From f4ea04c9cc8b9615b20d3a56e5eea98dcf53c86c Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Tue, 1 Oct 2019 12:25:45 +0200 Subject: [PATCH 01/59] Advanced ui actions 2 np (#46948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 move advanced_ui_actions plugin to NP * fix: 🐛 fix NP plugin configs * fix: 🐛 remove import from legacy platform --- .../lib/panel/panel_header/panel_header.tsx | 9 ++++++-- x-pack/.i18nrc.json | 2 +- x-pack/index.js | 2 -- .../plugins/advanced_ui_actions/index.ts | 16 -------------- .../public/np_ready/public/legacy.ts | 21 ------------------- .../advanced_ui_actions}/kibana.json | 4 ++-- .../public/can_inherit_time_range.test.ts | 2 +- .../public/can_inherit_time_range.ts | 4 ++-- .../public/custom_time_range_action.test.ts | 6 +++--- .../public/custom_time_range_action.tsx | 15 ++++++------- .../public/custom_time_range_badge.test.ts | 5 ++--- .../public/custom_time_range_badge.tsx | 9 ++------ .../public/customize_time_range_modal.tsx | 4 ++-- .../public/does_inherit_time_range.ts | 2 +- .../advanced_ui_actions}/public/index.ts | 2 +- .../advanced_ui_actions}/public/plugin.ts | 11 +++++++--- .../public/test_helpers/index.ts | 0 .../test_helpers/time_range_container.ts | 4 ++-- .../test_helpers/time_range_embeddable.ts | 4 ++-- .../time_range_embeddable_factory.ts | 4 ++-- .../advanced_ui_actions}/public/types.ts | 2 +- 21 files changed, 45 insertions(+), 83 deletions(-) delete mode 100644 x-pack/legacy/plugins/advanced_ui_actions/index.ts delete mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/legacy.ts rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/kibana.json (68%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/can_inherit_time_range.test.ts (95%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/can_inherit_time_range.ts (80%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/custom_time_range_action.test.ts (97%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/custom_time_range_action.tsx (84%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/custom_time_range_badge.test.ts (97%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/custom_time_range_badge.tsx (93%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/customize_time_range_modal.tsx (97%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/does_inherit_time_range.ts (90%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/index.ts (87%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/plugin.ts (88%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/test_helpers/index.ts (100%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/test_helpers/time_range_container.ts (90%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/test_helpers/time_range_embeddable.ts (84%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/test_helpers/time_range_embeddable_factory.ts (86%) rename x-pack/{legacy/plugins/advanced_ui_actions/public/np_ready => plugins/advanced_ui_actions}/public/types.ts (89%) diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 930b68dc884eff..1ad55aab5a449c 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -48,8 +48,13 @@ function renderBadges(badges: IAction[], embeddable: IEmbeddable) { )); } -function isVisualizeEmbeddable(embeddable: IEmbeddable | any): embeddable is any { - return embeddable.type === 'VISUALIZE_EMBEDDABLE_TYPE'; +const VISUALIZE_EMBEDDABLE_TYPE = 'visualization'; +type VisualizeEmbeddable = any; + +function isVisualizeEmbeddable( + embeddable: IEmbeddable | VisualizeEmbeddable +): embeddable is VisualizeEmbeddable { + return embeddable.type === VISUALIZE_EMBEDDABLE_TYPE; } export function PanelHeader({ diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index b92edfd06ffb1f..896c7ac7a259cb 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -2,7 +2,7 @@ "prefix": "xpack", "paths": { "xpack.actions": "legacy/plugins/actions", - "xpack.advancedUiActions": "legacy/plugins/advanced_ui_actions", + "xpack.advancedUiActions": "plugins/advanced_ui_actions", "xpack.alerting": "legacy/plugins/alerting", "xpack.apm": "legacy/plugins/apm", "xpack.beatsManagement": "legacy/plugins/beats_management", diff --git a/x-pack/index.js b/x-pack/index.js index 1aaae50d5a6130..10f969a21b68be 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -43,7 +43,6 @@ import { encryptedSavedObjects } from './legacy/plugins/encrypted_saved_objects' import { snapshotRestore } from './legacy/plugins/snapshot_restore'; import { actions } from './legacy/plugins/actions'; import { alerting } from './legacy/plugins/alerting'; -import { advancedUiActions } from './legacy/plugins/advanced_ui_actions'; import { lens } from './legacy/plugins/lens'; module.exports = function (kibana) { @@ -88,6 +87,5 @@ module.exports = function (kibana) { snapshotRestore(kibana), actions(kibana), alerting(kibana), - advancedUiActions(kibana), ]; }; diff --git a/x-pack/legacy/plugins/advanced_ui_actions/index.ts b/x-pack/legacy/plugins/advanced_ui_actions/index.ts deleted file mode 100644 index e7b54d8863456e..00000000000000 --- a/x-pack/legacy/plugins/advanced_ui_actions/index.ts +++ /dev/null @@ -1,16 +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 { resolve } from 'path'; - -export const advancedUiActions = (kibana: any) => - new kibana.Plugin({ - id: 'advanced_ui_actions', - publicDir: resolve(__dirname, 'public'), - uiExports: { - hacks: 'plugins/advanced_ui_actions/np_ready/public/legacy', - }, - }); diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/legacy.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/legacy.ts deleted file mode 100644 index 535e55d71d3494..00000000000000 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/legacy.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. - */ - -/* eslint-disable @kbn/eslint/no-restricted-paths */ -import { npSetup, npStart } from 'ui/new_platform'; -/* eslint-enable @kbn/eslint/no-restricted-paths */ - -import { plugin } from '.'; - -const pluginInstance = plugin({} as any); -export const setup = pluginInstance.setup(npSetup.core, { - embeddable: npSetup.plugins.embeddable, - uiActions: npSetup.plugins.uiActions, -}); -export const start = pluginInstance.start(npStart.core, { - embeddable: npStart.plugins.embeddable, - uiActions: npStart.plugins.uiActions, -}); diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/kibana.json b/x-pack/plugins/advanced_ui_actions/kibana.json similarity index 68% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/kibana.json rename to x-pack/plugins/advanced_ui_actions/kibana.json index fd2c7ad1130c1f..515c4749de2128 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/kibana.json +++ b/x-pack/plugins/advanced_ui_actions/kibana.json @@ -1,9 +1,9 @@ { - "id": "advanced_ui_actions", + "id": "advancedUiActions", "version": "kibana", "requiredPlugins": [ "embeddable", - "ui_actions" + "uiActions" ], "server": false, "ui": true diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.test.ts b/x-pack/plugins/advanced_ui_actions/public/can_inherit_time_range.test.ts similarity index 95% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.test.ts rename to x-pack/plugins/advanced_ui_actions/public/can_inherit_time_range.test.ts index a0a550da1d24c9..03c096c8c178f4 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.test.ts +++ b/x-pack/plugins/advanced_ui_actions/public/can_inherit_time_range.test.ts @@ -8,7 +8,7 @@ import { canInheritTimeRange } from './can_inherit_time_range'; import { HelloWorldEmbeddable, HelloWorldContainer, -} from '../../../../../../../src/plugins/embeddable/public/lib/test_samples'; +} from '../../../../src/plugins/embeddable/public/lib/test_samples'; /** eslint-enable */ import { TimeRangeEmbeddable, TimeRangeContainer } from './test_helpers'; diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.ts b/x-pack/plugins/advanced_ui_actions/public/can_inherit_time_range.ts similarity index 80% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.ts rename to x-pack/plugins/advanced_ui_actions/public/can_inherit_time_range.ts index 09d8d26998e95e..2294c9be40e17d 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.ts +++ b/x-pack/plugins/advanced_ui_actions/public/can_inherit_time_range.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Embeddable, IContainer, ContainerInput } from 'src/plugins/embeddable/public'; -import { TimeRange } from '../../../../../../../src/plugins/data/public'; +import { Embeddable, IContainer, ContainerInput } from '../../../../src/plugins/embeddable/public'; +import { TimeRange } from '../../../../src/plugins/data/public'; import { TimeRangeInput } from './custom_time_range_action'; interface ContainerTimeRangeInput extends ContainerInput { diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.test.ts b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.test.ts similarity index 97% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.test.ts rename to x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.test.ts index 1a5b26134ff5a3..bbdcf99495288b 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.test.ts +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.test.ts @@ -10,18 +10,18 @@ import { skip } from 'rxjs/operators'; import * as Rx from 'rxjs'; import { mount } from 'enzyme'; -import { EmbeddableFactory } from '../../../../../../../src/plugins/embeddable/public'; +import { EmbeddableFactory } from '../../../../src/plugins/embeddable/public'; import { TimeRangeEmbeddable, TimeRangeContainer, TIME_RANGE_EMBEDDABLE } from './test_helpers'; import { TimeRangeEmbeddableFactory } from './test_helpers/time_range_embeddable_factory'; import { CustomTimeRangeAction } from './custom_time_range_action'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { coreMock } from '../../../../src/core/public/mocks'; /* eslint-disable */ import { HelloWorldEmbeddableFactory, HELLO_WORLD_EMBEDDABLE_TYPE, HelloWorldEmbeddable, HelloWorldContainer, -} from '../../../../../../../src/plugins/embeddable/public/lib/test_samples'; +} from '../../../../src/plugins/embeddable/public/lib/test_samples'; /* eslint-enable */ import { nextTick } from 'test_utils/enzyme_helpers'; diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.tsx b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx similarity index 84% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.tsx rename to x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx index a9ab5edea4a25c..ca11fe91abdbf7 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx @@ -7,19 +7,13 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { IEmbeddable, Embeddable, EmbeddableInput } from 'src/plugins/embeddable/public'; -import { - IAction, - IncompatibleActionError, -} from '../../../../../../../src/plugins/ui_actions/public'; -import { TimeRange } from '../../../../../../../src/plugins/data/public'; -import { SEARCH_EMBEDDABLE_TYPE } from '../../../../../../../src/legacy/core_plugins/kibana/public/discover/embeddable/search_embeddable'; -import { VisualizeEmbeddable } from '../../../../../../../src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable'; -import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../../../../src/legacy/core_plugins/kibana/public/visualize/embeddable/constants'; - +import { IAction, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; +import { TimeRange } from '../../../../src/plugins/data/public'; import { CustomizeTimeRangeModal } from './customize_time_range_modal'; import { OpenModal, CommonlyUsedRange } from './types'; const CUSTOM_TIME_RANGE = 'CUSTOM_TIME_RANGE'; +const SEARCH_EMBEDDABLE_TYPE = 'search'; export interface TimeRangeInput extends EmbeddableInput { timeRange: TimeRange; @@ -31,6 +25,9 @@ function hasTimeRange( return (embeddable as Embeddable).getInput().timeRange !== undefined; } +const VISUALIZE_EMBEDDABLE_TYPE = 'visualization'; +type VisualizeEmbeddable = any; + function isVisualizeEmbeddable( embeddable: IEmbeddable | VisualizeEmbeddable ): embeddable is VisualizeEmbeddable { diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.test.ts b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.test.ts similarity index 97% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.test.ts rename to x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.test.ts index 9b13e5b03cf10d..c6046c02f0833d 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.test.ts +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.test.ts @@ -9,12 +9,11 @@ import { findTestSubject } from '@elastic/eui/lib/test'; import { skip } from 'rxjs/operators'; import * as Rx from 'rxjs'; import { mount } from 'enzyme'; - -import { EmbeddableFactory } from '../../../../../../../src/plugins/embeddable/public'; +import { EmbeddableFactory } from '../../../../src/plugins/embeddable/public'; import { TimeRangeEmbeddable, TimeRangeContainer, TIME_RANGE_EMBEDDABLE } from './test_helpers'; import { TimeRangeEmbeddableFactory } from './test_helpers/time_range_embeddable_factory'; import { CustomTimeRangeBadge } from './custom_time_range_badge'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { coreMock } from '../../../../src/core/public/mocks'; import { ReactElement } from 'react'; import { nextTick } from 'test_utils/enzyme_helpers'; diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.tsx b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.tsx similarity index 93% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.tsx rename to x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.tsx index 13fdbb17e1070f..78fe8e01e599e0 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.tsx @@ -6,14 +6,9 @@ import React from 'react'; import { prettyDuration, commonDurationRanges } from '@elastic/eui'; - import { IEmbeddable, Embeddable, EmbeddableInput } from 'src/plugins/embeddable/public'; -import { - IAction, - IncompatibleActionError, -} from '../../../../../../../src/plugins/ui_actions/public'; -import { TimeRange } from '../../../../../../../src/plugins/data/public'; - +import { IAction, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; +import { TimeRange } from '../../../../src/plugins/data/public'; import { CustomizeTimeRangeModal } from './customize_time_range_modal'; import { doesInheritTimeRange } from './does_inherit_time_range'; import { OpenModal, CommonlyUsedRange } from './types'; diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/customize_time_range_modal.tsx b/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx similarity index 97% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/customize_time_range_modal.tsx rename to x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx index 4dabf28538668e..90393f9f4ff6f1 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/customize_time_range_modal.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx @@ -19,8 +19,8 @@ import { EuiFlexItem, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Embeddable, IContainer, ContainerInput } from 'src/plugins/embeddable/public'; -import { TimeRange } from '../../../../../../../src/plugins/data/public'; +import { Embeddable, IContainer, ContainerInput } from '../../../../src/plugins/embeddable/public'; +import { TimeRange } from '../../../../src/plugins/data/public'; import { TimeRangeInput } from './custom_time_range_action'; import { doesInheritTimeRange } from './does_inherit_time_range'; import { CommonlyUsedRange } from './types'; diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/does_inherit_time_range.ts b/x-pack/plugins/advanced_ui_actions/public/does_inherit_time_range.ts similarity index 90% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/does_inherit_time_range.ts rename to x-pack/plugins/advanced_ui_actions/public/does_inherit_time_range.ts index 6b4033db345806..4cfe581b7eac55 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/does_inherit_time_range.ts +++ b/x-pack/plugins/advanced_ui_actions/public/does_inherit_time_range.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Embeddable, IContainer, ContainerInput } from 'src/plugins/embeddable/public'; +import { Embeddable, IContainer, ContainerInput } from '../../../../src/plugins/embeddable/public'; import { TimeRangeInput } from './custom_time_range_action'; export function doesInheritTimeRange(embeddable: Embeddable) { diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/index.ts b/x-pack/plugins/advanced_ui_actions/public/index.ts similarity index 87% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/index.ts rename to x-pack/plugins/advanced_ui_actions/public/index.ts index 5dd807ba2442d7..c11c1119a9b130 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from 'src/core/public'; +import { PluginInitializerContext } from '../../../../src/core/public'; import { AdvancedUiActionsPublicPlugin } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/plugin.ts b/x-pack/plugins/advanced_ui_actions/public/plugin.ts similarity index 88% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/plugin.ts rename to x-pack/plugins/advanced_ui_actions/public/plugin.ts index f1b87f6c694a19..fc106cc8ec26b9 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/plugin.ts +++ b/x-pack/plugins/advanced_ui_actions/public/plugin.ts @@ -4,14 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { IUiActionsStart, IUiActionsSetup } from 'src/plugins/ui_actions/public'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, +} from '../../../../src/core/public'; +import { IUiActionsStart, IUiActionsSetup } from '../../../../src/plugins/ui_actions/public'; import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER, Setup as EmbeddableSetup, Start as EmbeddableStart, -} from '../../../../../../../src/plugins/embeddable/public'; +} from '../../../../src/plugins/embeddable/public'; import { CustomTimeRangeAction } from './custom_time_range_action'; import { CustomTimeRangeBadge } from './custom_time_range_badge'; diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/index.ts b/x-pack/plugins/advanced_ui_actions/public/test_helpers/index.ts similarity index 100% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/index.ts rename to x-pack/plugins/advanced_ui_actions/public/test_helpers/index.ts diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_container.ts b/x-pack/plugins/advanced_ui_actions/public/test_helpers/time_range_container.ts similarity index 90% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_container.ts rename to x-pack/plugins/advanced_ui_actions/public/test_helpers/time_range_container.ts index a916f40160c591..657ecf1fcee99b 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_container.ts +++ b/x-pack/plugins/advanced_ui_actions/public/test_helpers/time_range_container.ts @@ -9,8 +9,8 @@ import { Container, ContainerOutput, GetEmbeddableFactory, -} from '../../../../../../../../src/plugins/embeddable/public'; -import { TimeRange } from '../../../../../../../../src/plugins/data/public'; +} from '../../../../../src/plugins/embeddable/public'; +import { TimeRange } from '../../../../../src/plugins/data/public'; /** * interfaces are not allowed to specify a sub-set of the required types until diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable.ts b/x-pack/plugins/advanced_ui_actions/public/test_helpers/time_range_embeddable.ts similarity index 84% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable.ts rename to x-pack/plugins/advanced_ui_actions/public/test_helpers/time_range_embeddable.ts index 0ca8a0ad9391f2..b768113f8b6c42 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable.ts +++ b/x-pack/plugins/advanced_ui_actions/public/test_helpers/time_range_embeddable.ts @@ -9,8 +9,8 @@ import { Embeddable, EmbeddableInput, IContainer, -} from '../../../../../../../../src/plugins/embeddable/public'; -import { TimeRange } from '../../../../../../../../src/plugins/data/public'; +} from '../../../../../src/plugins/embeddable/public'; +import { TimeRange } from '../../../../../src/plugins/data/public'; interface EmbeddableTimeRangeInput extends EmbeddableInput { timeRange: TimeRange; diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable_factory.ts b/x-pack/plugins/advanced_ui_actions/public/test_helpers/time_range_embeddable_factory.ts similarity index 86% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable_factory.ts rename to x-pack/plugins/advanced_ui_actions/public/test_helpers/time_range_embeddable_factory.ts index 225bf3420faa19..efbf7a3bd2dc65 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable_factory.ts +++ b/x-pack/plugins/advanced_ui_actions/public/test_helpers/time_range_embeddable_factory.ts @@ -8,8 +8,8 @@ import { EmbeddableInput, IContainer, EmbeddableFactory, -} from '../../../../../../../../src/plugins/embeddable/public'; -import { TimeRange } from '../../../../../../../../src/plugins/data/public'; +} from '../../../../../src/plugins/embeddable/public'; +import { TimeRange } from '../../../../../src/plugins/data/public'; import { TIME_RANGE_EMBEDDABLE, TimeRangeEmbeddable } from './time_range_embeddable'; interface EmbeddableTimeRangeInput extends EmbeddableInput { diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/types.ts b/x-pack/plugins/advanced_ui_actions/public/types.ts similarity index 89% rename from x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/types.ts rename to x-pack/plugins/advanced_ui_actions/public/types.ts index 626782ba372ce4..bbd7c5528276f1 100644 --- a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/types.ts +++ b/x-pack/plugins/advanced_ui_actions/public/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { OverlayRef } from 'src/core/public'; +import { OverlayRef } from '../../../../src/core/public'; export interface CommonlyUsedRange { from: string; From 1f7715e22347ac28ed396bca7a6b673468276318 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Tue, 1 Oct 2019 03:55:33 -0700 Subject: [PATCH 02/59] Revert "[APM] Fix agent config flyout state (#46950)" (#46961) This reverts commit 09ef1a0f0a8a38e27c3e2f9eb10e46c6161e47cf. --- .../app/Settings/AddSettings/AddSettingFlyout.tsx | 15 +-------------- .../Settings/AddSettings/AddSettingFlyoutBody.tsx | 6 +----- .../apm/public/components/app/Settings/index.tsx | 1 + 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AddSettings/AddSettingFlyout.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AddSettings/AddSettingFlyout.tsx index f7cbfcbb85ebb2..613ce572e17b5f 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AddSettings/AddSettingFlyout.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AddSettings/AddSettingFlyout.tsx @@ -18,7 +18,7 @@ import { EuiButtonEmpty, EuiCallOut } from '@elastic/eui'; -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { toastNotifications } from 'ui/notify'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -57,7 +57,6 @@ export function AddSettingsFlyout({ ? selectedConfig.settings.transaction_sample_rate.toString() : '' ); - const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher( () => callApmApi({ @@ -89,18 +88,6 @@ export function AddSettingsFlyout({ env.name === environment && (Boolean(selectedConfig) || env.available) ); - useEffect(() => { - if (selectedConfig) { - setEnvironment(selectedConfig.service.environment); - setServiceName(selectedConfig.service.name); - setSampleRate(selectedConfig.settings.transaction_sample_rate.toString()); - } else { - setEnvironment(ENVIRONMENT_NOT_DEFINED); - setServiceName(undefined); - setSampleRate(''); - } - }, [selectedConfig]); - if (!isOpen) { return null; } diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AddSettings/AddSettingFlyoutBody.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AddSettings/AddSettingFlyoutBody.tsx index 92a1452fae13b8..090f3fe0d5f912 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AddSettings/AddSettingFlyoutBody.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AddSettings/AddSettingFlyoutBody.tsx @@ -157,11 +157,7 @@ export function AddSettingFlyoutBody({ placeholder={selectPlaceholderLabel} isLoading={environmentStatus === 'loading'} options={environmentOptions} - value={ - selectedConfig - ? environment || ENVIRONMENT_NOT_DEFINED - : environment - } + value={environment} disabled={!serviceName || Boolean(selectedConfig)} onChange={e => { e.preventDefault(); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx index c8ba5759f7a5e2..b75d3cf6ff458c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx @@ -52,6 +52,7 @@ export function Settings() { ); const hasConfigurations = !isEmpty(data); + return ( <> Date: Tue, 1 Oct 2019 13:22:04 +0100 Subject: [PATCH 03/59] Removing New Visualization title on save (#46719) * Removing New Visualization title on save * Removing title from saved visualization --- .../public/visualize/saved_visualizations/_saved_vis.js | 5 +---- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/_saved_vis.js b/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/_saved_vis.js index fd13e458caeaae..f8adaed0bf584a 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/_saved_vis.js +++ b/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/_saved_vis.js @@ -26,7 +26,6 @@ */ import { VisProvider } from 'ui/vis'; -import { i18n } from '@kbn/i18n'; import { uiModules } from 'ui/modules'; import { updateOldState } from 'ui/vis/vis_update_state'; import { VisualizeConstants } from '../visualize_constants'; @@ -58,9 +57,7 @@ uiModules id: opts.id, indexPattern: opts.indexPattern, defaults: { - title: i18n.translate('kbn.visualize.defaultVisualizationTitle', { - defaultMessage: 'New Visualization', - }), + title: '', visState: (function () { if (!opts.type) return null; const def = {}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ba1cd13a1b9ffe..9b57ab465b707b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2426,7 +2426,6 @@ "kbn.visualize.badge.readOnly.text": "読み込み専用", "kbn.visualize.badge.readOnly.tooltip": "ビジュアライゼーションを保存できません", "kbn.visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage": "indexPattern または savedSearchId が必要です", - "kbn.visualize.defaultVisualizationTitle": "新規ビジュアライゼーション", "kbn.visualize.disabledLabVisualizationMessage": "ラボビジュアライゼーションを表示するには、高度な設定でラボモードをオンにしてください。", "kbn.visualize.disabledLabVisualizationTitle": "{title} はラボビジュアライゼーションです。", "kbn.visualize.editor.createBreadcrumb": "作成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 781043f6c7279d..3cd3ad3fdcd35e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2427,7 +2427,6 @@ "kbn.visualize.badge.readOnly.text": "只读", "kbn.visualize.badge.readOnly.tooltip": "无法保存可视化", "kbn.visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage": "必须提供 indexPattern 或 savedSearchId", - "kbn.visualize.defaultVisualizationTitle": "新建可视化", "kbn.visualize.disabledLabVisualizationMessage": "请在高级设置中打开实验室模式,以查看实验室可视化。", "kbn.visualize.disabledLabVisualizationTitle": "{title} 为实验室可视化。", "kbn.visualize.editor.createBreadcrumb": "创建", From dca6b3b93e3bb3df4f8ba473b201eea2415e5b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Tue, 1 Oct 2019 14:49:54 +0200 Subject: [PATCH 04/59] Increase breadcrumb `max` setting (#46595) --- src/core/public/chrome/ui/header/header_breadcrumbs.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx index f4b1c1d49cd27f..68eb6a54f48a3c 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx @@ -64,7 +64,11 @@ export class HeaderBreadcrumbs extends Component { public render() { return ( - + ); } From cb6aa078e421928e74afecc46a0f3813d1d91f4a Mon Sep 17 00:00:00 2001 From: Caroline Horn <549577+cchaos@users.noreply.github.com> Date: Tue, 1 Oct 2019 09:42:27 -0400 Subject: [PATCH 05/59] [Lens] Making field filters more obvious (#46615) * Using an EuiFacetButton for filter popover trigger * Added search icon and padding to field search * Add padding to sides of filter button --- .../editor_frame/_frame_layout.scss | 3 +- .../indexpattern_plugin/_datapanel.scss | 18 +- .../public/indexpattern_plugin/datapanel.tsx | 214 +++++++++--------- 3 files changed, 123 insertions(+), 112 deletions(-) diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/_frame_layout.scss b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/_frame_layout.scss index 7e7c402bbc7235..e3b91ee0674c83 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/_frame_layout.scss +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/_frame_layout.scss @@ -20,8 +20,9 @@ min-width: $lnsPanelMinWidth + $euiSizeXL; overflow: hidden; // Leave out bottom padding so the suggestions scrollbar stays flush to window edge + // Leave out left padding so the left sidebar's focus states are visible outside of content bounds // This also means needing to add same amount of margin to page content and suggestion items - padding: $euiSize $euiSize 0; + padding: $euiSize $euiSize 0 0; &:first-child { padding-left: $euiSize; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/_datapanel.scss b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/_datapanel.scss index cd2c9bff6b165e..4671d779833af7 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/_datapanel.scss +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/_datapanel.scss @@ -1,7 +1,9 @@ +@import '@elastic/eui/src/components/form/form_control_layout/mixins'; + .lnsInnerIndexPatternDataPanel { width: 100%; height: 100%; - padding: $euiSize 0 0 $euiSize; + padding: $euiSize $euiSize 0; } .lnsInnerIndexPatternDataPanel__header { @@ -24,7 +26,6 @@ .lnsInnerIndexPatternDataPanel__listWrapper { @include euiOverflowShadow; @include euiScrollBar; - margin-top: 2px; // form control shadow position: relative; flex-grow: 1; overflow: auto; @@ -37,3 +38,16 @@ left: 0; right: 0; } + +.lnsInnerIndexPatternDataPanel__filterButton { + width: 100%; + color: $euiColorPrimary; + padding-left: $euiSizeS; + padding-right: $euiSizeS; +} + +.lnsInnerIndexPatternDataPanel__textField { + @include euiFormControlLayoutPadding(1, 'right'); + @include euiFormControlLayoutPadding(1, 'left'); +} + diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx index 12e6150a789b3a..85996659620e7d 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx @@ -21,7 +21,8 @@ import { EuiText, EuiFormControlLayout, EuiSwitch, - EuiButtonIcon, + EuiFacetButton, + EuiIcon, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -352,120 +353,115 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ - - - - setLocalState(s => ({ ...localState, isTypeFilterOpen: false })) - } - button={ - { - setLocalState(s => ({ - ...s, - isTypeFilterOpen: !localState.isTypeFilterOpen, - })); - }} - data-test-subj="lnsIndexPatternFiltersToggle" - title={i18n.translate('xpack.lens.indexPatterns.toggleFiltersPopover', { - defaultMessage: 'Filters for index pattern', - })} - aria-label={i18n.translate( - 'xpack.lens.indexPatterns.toggleFiltersPopover', - { - defaultMessage: 'Filters for index pattern', - } - )} - /> - } - > - - {i18n.translate('xpack.lens.indexPatterns.filterByTypeLabel', { - defaultMessage: 'Filter by type', - })} - - ( - - setLocalState(s => ({ - ...s, - typeFilter: localState.typeFilter.includes(type) - ? localState.typeFilter.filter(t => t !== type) - : [...localState.typeFilter, type], - })) - } - > - {fieldTypeNames[type]} - - ))} - /> - - { - onToggleEmptyFields(); - }} - label={i18n.translate('xpack.lens.indexPatterns.toggleEmptyFieldsSwitch', { - defaultMessage: 'Only show fields with data', - })} - data-test-subj="lnsEmptyFilter" - /> - - - } - clear={{ - title: i18n.translate('xpack.lens.indexPatterns.clearFiltersLabel', { - defaultMessage: 'Clear name and type filters', - }), - 'aria-label': i18n.translate('xpack.lens.indexPatterns.clearFiltersLabel', { - defaultMessage: 'Clear name and type filters', - }), - onClick: () => { +
+ { + setLocalState(s => ({ + ...s, + nameFilter: '', + typeFilter: [], + })); + }, + }} + > + { + setLocalState({ ...localState, nameFilter: e.target.value }); + }} + aria-label={i18n.translate('xpack.lens.indexPatterns.filterByNameAriaLabel', { + defaultMessage: 'Search fields', + })} + /> + +
+
+ setLocalState(s => ({ ...localState, isTypeFilterOpen: false }))} + button={ + } + isSelected={localState.typeFilter.length ? true : false} + onClick={() => { setLocalState(s => ({ ...s, - nameFilter: '', - typeFilter: [], + isTypeFilterOpen: !localState.isTypeFilterOpen, })); - }, - }} - > - { - setLocalState({ ...localState, nameFilter: e.target.value }); }} - aria-label={i18n.translate('xpack.lens.indexPatterns.filterByNameAriaLabel', { - defaultMessage: 'Search fields', + > + + + } + > + + {i18n.translate('xpack.lens.indexPatterns.filterByTypeLabel', { + defaultMessage: 'Filter by type', + })} + + ( + + setLocalState(s => ({ + ...s, + typeFilter: localState.typeFilter.includes(type) + ? localState.typeFilter.filter(t => t !== type) + : [...localState.typeFilter, type], + })) + } + > + {fieldTypeNames[type]} + + ))} + /> + + { + onToggleEmptyFields(); + }} + label={i18n.translate('xpack.lens.indexPatterns.toggleEmptyFieldsSwitch', { + defaultMessage: 'Only show fields with data', })} + data-test-subj="lnsEmptyFilter" /> - - - + + +
{ From 16dc1f33b0712b6270e9915047db4f60a2c42739 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Tue, 1 Oct 2019 14:56:05 +0100 Subject: [PATCH 06/59] [SIEM] Update wording (#46923) * update title for events histogram * update case size for TLS table * remove redundant file --- .../hosts/events_over_time/translation.ts | 2 +- .../page/network/tls_table/translations.ts | 2 +- .../public/pages/hosts/hosts_navigations.tsx | 387 ------------------ 3 files changed, 2 insertions(+), 389 deletions(-) delete mode 100644 x-pack/legacy/plugins/siem/public/pages/hosts/hosts_navigations.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/events_over_time/translation.ts b/x-pack/legacy/plugins/siem/public/components/page/hosts/events_over_time/translation.ts index a2d7036f050368..5f68a1a1cae7d1 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/events_over_time/translation.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/events_over_time/translation.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const EVENT_COUNT_FREQUENCY_BY_ACTION = i18n.translate( 'xpack.siem.eventsOverTime.eventCountFrequencyByActionTitle', { - defaultMessage: 'Event count frequency by action', + defaultMessage: 'Event count by action', } ); diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts index d81ba115747f51..89d0f58684cbe2 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const TRANSPORT_LAYER_SECURITY = i18n.translate( 'xpack.siem.network.ipDetails.tlsTable.transportLayerSecurityTitle', { - defaultMessage: 'Transport layer security', + defaultMessage: 'Transport Layer Security', } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts_navigations.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts_navigations.tsx deleted file mode 100644 index 37283b7d4aa8ed..00000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts_navigations.tsx +++ /dev/null @@ -1,387 +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 { StaticIndexPattern } from 'ui/index_patterns'; -import { getOr, omit } from 'lodash/fp'; -import React from 'react'; -import { EuiSpacer } from '@elastic/eui'; -import * as i18n from './translations'; - -import { HostsTable, UncommonProcessTable } from '../../components/page/hosts'; - -import { HostsQuery } from '../../containers/hosts'; -import { AuthenticationTable } from '../../components/page/hosts/authentications_table'; -import { AnomaliesHostTable } from '../../components/ml/tables/anomalies_host_table'; -import { UncommonProcessesQuery } from '../../containers/uncommon_processes'; -import { InspectQuery, Refetch } from '../../store/inputs/model'; -import { NarrowDateRange } from '../../components/ml/types'; -import { hostsModel } from '../../store'; -import { manageQuery } from '../../components/page/manage_query'; -import { AuthenticationsQuery } from '../../containers/authentications'; -import { ESTermQuery } from '../../../common/typed_json'; -import { HostsTableType } from '../../store/hosts/model'; -import { StatefulEventsViewer } from '../../components/events_viewer'; -import { NavTab } from '../../components/navigation/types'; -import { EventsOverTimeQuery } from '../../containers/events/events_over_time'; -import { EventsOverTimeHistogram } from '../../components/page/hosts/events_over_time'; -import { UpdateDateRange } from '../../components/charts/common'; - -const getTabsOnHostsUrl = (tabName: HostsTableType) => `#/hosts/${tabName}`; -const getTabsOnHostDetailsUrl = (hostName: string, tabName: HostsTableType) => { - return `#/hosts/${hostName}/${tabName}`; -}; - -type KeyHostsNavTabWithoutMlPermission = HostsTableType.hosts & - HostsTableType.authentications & - HostsTableType.uncommonProcesses & - HostsTableType.events; - -type KeyHostsNavTabWithMlPermission = KeyHostsNavTabWithoutMlPermission & HostsTableType.anomalies; - -export type KeyHostsNavTab = KeyHostsNavTabWithoutMlPermission | KeyHostsNavTabWithMlPermission; - -type KeyHostDetailsNavTabWithoutMlPermission = HostsTableType.authentications & - HostsTableType.uncommonProcesses & - HostsTableType.events; - -type KeyHostDetailsNavTabWithMlPermission = KeyHostsNavTabWithoutMlPermission & - HostsTableType.anomalies; - -export type KeyHostDetailsNavTab = - | KeyHostDetailsNavTabWithoutMlPermission - | KeyHostDetailsNavTabWithMlPermission; - -export type HostsNavTab = Record; - -export const navTabsHosts = (hasMlUserPermissions: boolean): HostsNavTab => { - const hostsNavTabs = { - [HostsTableType.hosts]: { - id: HostsTableType.hosts, - name: i18n.NAVIGATION_ALL_HOSTS_TITLE, - href: getTabsOnHostsUrl(HostsTableType.hosts), - disabled: false, - urlKey: 'host', - }, - [HostsTableType.authentications]: { - id: HostsTableType.authentications, - name: i18n.NAVIGATION_AUTHENTICATIONS_TITLE, - href: getTabsOnHostsUrl(HostsTableType.authentications), - disabled: false, - urlKey: 'host', - }, - [HostsTableType.uncommonProcesses]: { - id: HostsTableType.uncommonProcesses, - name: i18n.NAVIGATION_UNCOMMON_PROCESSES_TITLE, - href: getTabsOnHostsUrl(HostsTableType.uncommonProcesses), - disabled: false, - urlKey: 'host', - }, - [HostsTableType.anomalies]: { - id: HostsTableType.anomalies, - name: i18n.NAVIGATION_ANOMALIES_TITLE, - href: getTabsOnHostsUrl(HostsTableType.anomalies), - disabled: false, - urlKey: 'host', - }, - [HostsTableType.events]: { - id: HostsTableType.events, - name: i18n.NAVIGATION_EVENTS_TITLE, - href: getTabsOnHostsUrl(HostsTableType.events), - disabled: false, - urlKey: 'host', - }, - }; - - return hasMlUserPermissions ? hostsNavTabs : omit([HostsTableType.anomalies], hostsNavTabs); -}; - -export const navTabsHostDetails = ( - hostName: string, - hasMlUserPermissions: boolean -): Record => { - const hostDetailsNavTabs = { - [HostsTableType.authentications]: { - id: HostsTableType.authentications, - name: i18n.NAVIGATION_AUTHENTICATIONS_TITLE, - href: getTabsOnHostDetailsUrl(hostName, HostsTableType.authentications), - disabled: false, - urlKey: 'host', - isDetailPage: true, - }, - [HostsTableType.uncommonProcesses]: { - id: HostsTableType.uncommonProcesses, - name: i18n.NAVIGATION_UNCOMMON_PROCESSES_TITLE, - href: getTabsOnHostDetailsUrl(hostName, HostsTableType.uncommonProcesses), - disabled: false, - urlKey: 'host', - isDetailPage: true, - }, - [HostsTableType.anomalies]: { - id: HostsTableType.anomalies, - name: i18n.NAVIGATION_ANOMALIES_TITLE, - href: getTabsOnHostDetailsUrl(hostName, HostsTableType.anomalies), - disabled: false, - urlKey: 'host', - isDetailPage: true, - }, - [HostsTableType.events]: { - id: HostsTableType.events, - name: i18n.NAVIGATION_EVENTS_TITLE, - href: getTabsOnHostDetailsUrl(hostName, HostsTableType.events), - disabled: false, - urlKey: 'host', - isDetailPage: true, - }, - }; - - return hasMlUserPermissions - ? hostDetailsNavTabs - : omit(HostsTableType.anomalies, hostDetailsNavTabs); -}; - -interface OwnProps { - type: hostsModel.HostsType; - startDate: number; - endDate: number; - filterQuery?: string | ESTermQuery; - kqlQueryExpression: string; -} -export type HostsComponentsQueryProps = OwnProps & { - deleteQuery?: ({ id }: { id: string }) => void; - indexPattern: StaticIndexPattern; - skip: boolean; - setQuery: ({ - id, - inspect, - loading, - refetch, - }: { - id: string; - inspect: InspectQuery | null; - loading: boolean; - refetch: Refetch; - }) => void; - updateDateRange?: UpdateDateRange; - filterQueryExpression?: string; - hostName?: string; -}; - -export type AnomaliesQueryTabBodyProps = OwnProps & { - skip: boolean; - narrowDateRange: NarrowDateRange; - hostName?: string; -}; - -const AuthenticationTableManage = manageQuery(AuthenticationTable); -const HostsTableManage = manageQuery(HostsTable); -const UncommonProcessTableManage = manageQuery(UncommonProcessTable); - -export const HostsQueryTabBody = ({ - deleteQuery, - endDate, - filterQuery, - indexPattern, - skip, - setQuery, - startDate, - type, -}: HostsComponentsQueryProps) => { - return ( - - {({ hosts, totalCount, loading, pageInfo, loadPage, id, inspect, isInspected, refetch }) => ( - - )} - - ); -}; - -export const AuthenticationsQueryTabBody = ({ - deleteQuery, - endDate, - filterQuery, - skip, - setQuery, - startDate, - type, -}: HostsComponentsQueryProps) => { - return ( - - {({ - authentications, - totalCount, - loading, - pageInfo, - loadPage, - id, - inspect, - isInspected, - refetch, - }) => { - return ( - - ); - }} - - ); -}; - -export const UncommonProcessTabBody = ({ - deleteQuery, - endDate, - filterQuery, - skip, - setQuery, - startDate, - type, -}: HostsComponentsQueryProps) => { - return ( - - {({ - uncommonProcesses, - totalCount, - loading, - pageInfo, - loadPage, - id, - inspect, - isInspected, - refetch, - }) => ( - - )} - - ); -}; - -export const AnomaliesTabBody = ({ - endDate, - skip, - startDate, - type, - narrowDateRange, - hostName, -}: AnomaliesQueryTabBodyProps) => { - return ( - - ); -}; -const EventsOverTimeManage = manageQuery(EventsOverTimeHistogram); - -export const EventsTabBody = ({ - endDate, - kqlQueryExpression, - startDate, - setQuery, - filterQuery, - updateDateRange = () => {}, -}: HostsComponentsQueryProps) => { - const HOSTS_PAGE_TIMELINE_ID = 'hosts-page'; - - return ( - <> - - {({ eventsOverTime, loading, id, inspect, refetch, totalCount }) => ( - - )} - - - - - ); -}; From 4d432f1d9c4512f5f7c223ce87fa5fb5ed4e5381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Tue, 1 Oct 2019 15:59:16 +0200 Subject: [PATCH 07/59] Update CODEOWNERS (#47029) --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 516f5e71577e6d..bea2f4e74297e8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,6 +13,8 @@ # APM /x-pack/legacy/plugins/apm/ @elastic/apm-ui +/x-pack/test/functional/apps/apm/ @elastic/apm-ui +/src/legacy/core_plugins/apm_oss/ @elastic/apm-ui # Beats /x-pack/legacy/plugins/beats_management/ @elastic/beats From c0465258d06b596991f45dcfadf49e25d29ff038 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Tue, 1 Oct 2019 16:59:37 +0300 Subject: [PATCH 08/59] Preparation for move core_plugins\kibana\common\field_formats into data plugin (#46921) * Preparation for move core_plugins\kibana\common\field_formats into data plugin Related to: #44973 * Fix PR Comments --- .../types/{boolean.js => boolean.ts} | 18 +++++--- .../content_types/html_content_type.ts | 24 +++++------ .../field_formats/content_types/index.ts | 4 +- .../content_types/text_content_type.ts | 12 +++--- .../common/field_formats/converters/custom.ts | 8 ++-- .../common/field_formats/field_format.test.ts | 6 +-- .../data/common/field_formats/field_format.ts | 43 +++++++++++-------- .../data/common/field_formats/types.ts | 7 +-- 8 files changed, 64 insertions(+), 58 deletions(-) rename src/legacy/core_plugins/kibana/common/field_formats/types/{boolean.js => boolean.ts} (82%) diff --git a/src/legacy/core_plugins/kibana/common/field_formats/types/boolean.js b/src/legacy/core_plugins/kibana/common/field_formats/types/boolean.ts similarity index 82% rename from src/legacy/core_plugins/kibana/common/field_formats/types/boolean.js rename to src/legacy/core_plugins/kibana/common/field_formats/types/boolean.ts index 0e1e1ecf058b9e..c232f65143d859 100644 --- a/src/legacy/core_plugins/kibana/common/field_formats/types/boolean.js +++ b/src/legacy/core_plugins/kibana/common/field_formats/types/boolean.ts @@ -17,11 +17,19 @@ * under the License. */ -import { asPrettyString } from '../../../../../../plugins/data/common/field_formats'; +import { + FieldFormat, + asPrettyString, + KBN_FIELD_TYPES, +} from '../../../../../../plugins/data/common'; -export function createBoolFormat(FieldFormat) { +export function createBoolFormat() { return class BoolFormat extends FieldFormat { - _convert(value) { + static id = 'boolean'; + static title = 'Boolean'; + static fieldType = [KBN_FIELD_TYPES.BOOLEAN, KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.STRING]; + + _convert(value: any): string { if (typeof value === 'string') { value = value.trim().toLowerCase(); } @@ -41,9 +49,5 @@ export function createBoolFormat(FieldFormat) { return asPrettyString(value); } } - - static id = 'boolean'; - static title = 'Boolean'; - static fieldType = ['boolean', 'number', 'string']; }; } diff --git a/src/plugins/data/common/field_formats/content_types/html_content_type.ts b/src/plugins/data/common/field_formats/content_types/html_content_type.ts index 65b7944c5978ac..6bad1ef2b56c73 100644 --- a/src/plugins/data/common/field_formats/content_types/html_content_type.ts +++ b/src/plugins/data/common/field_formats/content_types/html_content_type.ts @@ -17,17 +17,17 @@ * under the License. */ import { escape, isFunction } from 'lodash'; -import { FieldFormatConvert, IFieldFormat, HtmlConventTypeConvert } from '../types'; +import { FieldFormatConvert, IFieldFormat, HtmlContextTypeConvert } from '../types'; import { asPrettyString, getHighlightHtml } from '../utils'; -const CONTEXT_TYPE = 'html'; +export const HTML_CONTEXT_TYPE = 'html'; const getConvertFn = ( format: IFieldFormat, - fieldFormatConvert: FieldFormatConvert -): HtmlConventTypeConvert => { - const fallbackHtml: HtmlConventTypeConvert = (value, field, hit) => { + fieldFormatConvert: Partial +): HtmlContextTypeConvert => { + const fallbackHtml: HtmlContextTypeConvert = (value, field, hit) => { const formatted = escape(format.convert(value, 'text')); return !field || !hit || !hit.highlight || !hit.highlight[field.name] @@ -35,16 +35,16 @@ const getConvertFn = ( : getHighlightHtml(formatted, hit.highlight[field.name]); }; - return (fieldFormatConvert[CONTEXT_TYPE] || fallbackHtml) as HtmlConventTypeConvert; + return (fieldFormatConvert[HTML_CONTEXT_TYPE] || fallbackHtml) as HtmlContextTypeConvert; }; export const setup = ( format: IFieldFormat, - fieldFormatConvert: FieldFormatConvert -): FieldFormatConvert => { + fieldFormatConvert: Partial +): HtmlContextTypeConvert => { const convert = getConvertFn(format, fieldFormatConvert); - const recurse: HtmlConventTypeConvert = (value, field, hit, meta) => { + const recurse: HtmlContextTypeConvert = (value, field, hit, meta) => { if (value == null) { return asPrettyString(value); } @@ -63,11 +63,9 @@ export const setup = ( return subValues.join(',' + (useMultiLine ? '\n' : ' ')); }; - const wrap: HtmlConventTypeConvert = (value, field, hit, meta) => { + const wrap: HtmlContextTypeConvert = (value, field, hit, meta) => { return `${recurse(value, field, hit, meta)}`; }; - return { - [CONTEXT_TYPE]: wrap, - }; + return wrap; }; diff --git a/src/plugins/data/common/field_formats/content_types/index.ts b/src/plugins/data/common/field_formats/content_types/index.ts index b5d98a7bc83936..d391ba72d9f45d 100644 --- a/src/plugins/data/common/field_formats/content_types/index.ts +++ b/src/plugins/data/common/field_formats/content_types/index.ts @@ -17,5 +17,5 @@ * under the License. */ -export { setup as textContentTypeSetup } from './text_content_type'; -export { setup as htmlContentTypeSetup } from './html_content_type'; +export { setup as textContentTypeSetup, TEXT_CONTEXT_TYPE } from './text_content_type'; +export { setup as htmlContentTypeSetup, HTML_CONTEXT_TYPE } from './html_content_type'; diff --git a/src/plugins/data/common/field_formats/content_types/text_content_type.ts b/src/plugins/data/common/field_formats/content_types/text_content_type.ts index 40a450fae7067f..b0d3522f742a5b 100644 --- a/src/plugins/data/common/field_formats/content_types/text_content_type.ts +++ b/src/plugins/data/common/field_formats/content_types/text_content_type.ts @@ -22,15 +22,15 @@ import { IFieldFormat, FieldFormatConvert, TextContextTypeConvert } from '../typ import { asPrettyString } from '../utils'; -const CONTEXT_TYPE = 'text'; +export const TEXT_CONTEXT_TYPE = 'text'; -const getConvertFn = (fieldFormatConvert: FieldFormatConvert): TextContextTypeConvert => - (fieldFormatConvert[CONTEXT_TYPE] || asPrettyString) as TextContextTypeConvert; +const getConvertFn = (fieldFormatConvert: Partial): TextContextTypeConvert => + (fieldFormatConvert[TEXT_CONTEXT_TYPE] || asPrettyString) as TextContextTypeConvert; export const setup = ( format: IFieldFormat, - fieldFormatConvert: FieldFormatConvert -): FieldFormatConvert => { + fieldFormatConvert: Partial +): TextContextTypeConvert => { const convert = getConvertFn(fieldFormatConvert); const recurse: TextContextTypeConvert = value => { @@ -42,5 +42,5 @@ export const setup = ( return JSON.stringify(value.map(recurse)); }; - return { [CONTEXT_TYPE]: recurse }; + return recurse; }; diff --git a/src/plugins/data/common/field_formats/converters/custom.ts b/src/plugins/data/common/field_formats/converters/custom.ts index bc9b4211272281..3d562f97716be6 100644 --- a/src/plugins/data/common/field_formats/converters/custom.ts +++ b/src/plugins/data/common/field_formats/converters/custom.ts @@ -18,15 +18,13 @@ */ import { FieldFormat } from '../field_format'; -import { FieldFormatConvert } from '../types'; +import { FieldFormatConvertFunction } from '../types'; const ID = 'custom'; -export const createCustomFieldFormat = (convert: FieldFormatConvert) => +export const createCustomFieldFormat = (convert: FieldFormatConvertFunction) => class CustomFieldFormat extends FieldFormat { static id = ID; - public get _convert() { - return convert; - } + _convert = convert; }; diff --git a/src/plugins/data/common/field_formats/field_format.test.ts b/src/plugins/data/common/field_formats/field_format.test.ts index 8e66eac85eb3ce..e4a64e91d1fff4 100644 --- a/src/plugins/data/common/field_formats/field_format.test.ts +++ b/src/plugins/data/common/field_formats/field_format.test.ts @@ -23,7 +23,7 @@ import { FieldFormatConvert } from './types'; import { asPrettyString } from './utils/as_pretty_string'; const getTestFormat = ( - _convert: FieldFormatConvert = { + _convert: Partial = { text: (val: string) => asPrettyString(val), }, _params?: any @@ -32,9 +32,7 @@ const getTestFormat = ( static id = 'test-format'; static title = 'Test Format'; - public get _convert() { - return _convert; - } + _convert = _convert; })(_params); describe('FieldFormat class', () => { diff --git a/src/plugins/data/common/field_formats/field_format.ts b/src/plugins/data/common/field_formats/field_format.ts index 226631660b8a0f..cdf82cd9eb9d1a 100644 --- a/src/plugins/data/common/field_formats/field_format.ts +++ b/src/plugins/data/common/field_formats/field_format.ts @@ -21,10 +21,18 @@ import { isFunction, transform, size, cloneDeep, get, defaults } from 'lodash'; import { createCustomFieldFormat } from './converters/custom'; import { ContentType, FieldFormatConvert, FieldFormatConvertFunction } from './types'; -import { htmlContentTypeSetup, textContentTypeSetup } from './content_types'; +import { + htmlContentTypeSetup, + textContentTypeSetup, + TEXT_CONTEXT_TYPE, + HTML_CONTEXT_TYPE, +} from './content_types'; const DEFAULT_CONTEXT_TYPE = 'text'; +export const isFieldFormatConvertFn = (convert: any): convert is FieldFormatConvertFunction => + isFunction(convert); + export abstract class FieldFormat { /** * @property {string} - Field Format Id @@ -43,19 +51,19 @@ export abstract class FieldFormat { * @property {string} - Field Format Type * @private */ - static fieldType: string; + static fieldType: string | string[]; /** * @property {FieldFormatConvert} * @private */ - _convert: FieldFormatConvert = FieldFormat.setupContentType(this, get(this, '_convert', {})); + private convertObject: FieldFormatConvert | undefined; /** * @property {Function} - ref to child class * @private */ - type: any = this.constructor; + public type: any = this.constructor; constructor(public _params: any = {}) {} @@ -88,11 +96,11 @@ export abstract class FieldFormat { getConverterFor( contentType: ContentType = DEFAULT_CONTEXT_TYPE ): FieldFormatConvertFunction | null { - if (this._convert) { - return this._convert[contentType]; + if (!this.convertObject) { + this.convertObject = FieldFormat.setupContentType(this, get(this, '_convert')); } - return null; + return this.convertObject[contentType] || null; } /** @@ -160,32 +168,31 @@ export abstract class FieldFormat { } static from(convertFn: FieldFormatConvertFunction) { - return createCustomFieldFormat(FieldFormat.toConvertObject(convertFn)); + return createCustomFieldFormat(convertFn); } private static setupContentType( fieldFormat: IFieldFormat, - convert: FieldFormatConvert | FieldFormatConvertFunction + convert: Partial | FieldFormatConvertFunction = {} ): FieldFormatConvert { - const convertObject = FieldFormat.toConvertObject(convert); + const convertObject = isFieldFormatConvertFn(convert) + ? FieldFormat.toConvertObject(convert) + : convert; return { - ...textContentTypeSetup(fieldFormat, convertObject), - ...htmlContentTypeSetup(fieldFormat, convertObject), + [TEXT_CONTEXT_TYPE]: textContentTypeSetup(fieldFormat, convertObject), + [HTML_CONTEXT_TYPE]: htmlContentTypeSetup(fieldFormat, convertObject), }; } - private static toConvertObject( - convert: FieldFormatConvert | FieldFormatConvertFunction - ): FieldFormatConvert { - if (isFunction(convert)) { + private static toConvertObject(convert: FieldFormatConvertFunction): Partial { + if (isFieldFormatConvertFn(convert)) { return { - [DEFAULT_CONTEXT_TYPE]: convert, + [TEXT_CONTEXT_TYPE]: convert, }; } return convert; } } -export type FieldFormatConvert = { [key: string]: Function } | FieldFormatConvertFunction; export type IFieldFormat = PublicMethodsOf; diff --git a/src/plugins/data/common/field_formats/types.ts b/src/plugins/data/common/field_formats/types.ts index 39cf34b7103945..626bab297392b4 100644 --- a/src/plugins/data/common/field_formats/types.ts +++ b/src/plugins/data/common/field_formats/types.ts @@ -24,7 +24,7 @@ export type ContentType = 'html' | 'text'; export { IFieldFormat } from './field_format'; /** @internal **/ -export type HtmlConventTypeConvert = ( +export type HtmlContextTypeConvert = ( value: any, field?: any, hit?: Record, @@ -35,9 +35,10 @@ export type HtmlConventTypeConvert = ( export type TextContextTypeConvert = (value: any) => string; /** @internal **/ -export type FieldFormatConvertFunction = HtmlConventTypeConvert | TextContextTypeConvert; +export type FieldFormatConvertFunction = HtmlContextTypeConvert | TextContextTypeConvert; /** @internal **/ export interface FieldFormatConvert { - [key: string]: FieldFormatConvertFunction; + text: TextContextTypeConvert; + html: HtmlContextTypeConvert; } From a325611c407358b6ef90bcd1bef07e1edf704b0b Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 1 Oct 2019 19:11:58 +0500 Subject: [PATCH 09/59] [Uptime] added aria label description for ping over time chart (#46689) Fixes #35352 To Improve accessibility added aria-label to chart container to make it readable. --- .../charts/chart_wrapper/chart_wrapper.tsx | 20 +++++++++++-- .../functional/charts/snapshot_histogram.tsx | 28 +++++++++++++------ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_wrapper/chart_wrapper.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_wrapper/chart_wrapper.tsx index 44b3cfba5d764f..dd2853b60c87fe 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_wrapper/chart_wrapper.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_wrapper/chart_wrapper.tsx @@ -4,15 +4,30 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment } from 'react'; +import React, { FC, Fragment, HTMLAttributes } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingChart } from '@elastic/eui'; interface Props { + /** + * Height for the chart + */ height?: string; + /** + * if chart data source is still loading + */ loading?: boolean; + /** + * aria-label for accessibility + */ + 'aria-label'?: string; } -export const ChartWrapper: FC = ({ loading = false, height = '100%', children }) => { +export const ChartWrapper: FC = ({ + loading = false, + height = '100%', + children, + ...rest +}) => { const opacity = loading === true ? 0.3 : 1; return ( @@ -23,6 +38,7 @@ export const ChartWrapper: FC = ({ loading = false, height = '100%', chil opacity, transition: 'opacity 0.2s', }} + {...(rest as HTMLAttributes)} > {children}
diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/snapshot_histogram.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/snapshot_histogram.tsx index 6d16526f3ac476..54aa6f8cf16443 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/snapshot_histogram.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/snapshot_histogram.tsx @@ -16,8 +16,9 @@ import { } from '@elastic/charts'; import { EuiEmptyPrompt, EuiTitle, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { Fragment } from 'react'; +import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import moment from 'moment'; import { HistogramDataPoint } from '../../../../common/graphql/types'; import { getColorsMap } from './get_colors_map'; import { getChartDateLabel } from '../../../lib/helper'; @@ -44,7 +45,7 @@ export interface SnapshotHistogramProps { dangerColor: string; /** - * Height is needed, since by defauly charts takes height of 100% + * Height is needed, since by default charts takes height of 100% */ height?: string; } @@ -66,11 +67,11 @@ export const SnapshotHistogramComponent = ({ }: Props) => { if (!data || !data.histogram) /** - * TODO: the Fragment, EuiTitle, and EuiPanel should be extractec to a dumb component + * TODO: the Fragment, EuiTitle, and EuiPanel should be extracted to a dumb component * that we can reuse in the subsequent return statement at the bottom of this function. */ return ( - + <>
- + ); const { histogram } = data; const downMonitorsName = i18n.translate('xpack.uptime.snapshotHistogram.downMonitorsId', { @@ -114,7 +115,7 @@ export const SnapshotHistogramComponent = ({ }); const upSpecId = getSpecId(upMonitorsId); return ( - + <>
@@ -124,7 +125,18 @@ export const SnapshotHistogramComponent = ({ />
- +
-
+ ); }; From 68df39f758b4cacced4ca1e85795f31d50e779d0 Mon Sep 17 00:00:00 2001 From: igoristic Date: Tue, 1 Oct 2019 12:02:59 -0400 Subject: [PATCH 10/59] Issue 46223: Allow isCollectionEnabledUpdated to hang until data is available (#46279) * Fixed reason check * Continue render if reason is not null --- .../explanations/collection_enabled/collection_enabled.js | 4 ++-- .../public/lib/elasticsearch_settings/enabler.js | 2 +- .../plugins/monitoring/public/views/no_data/controller.js | 7 ++++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/x-pack/legacy/plugins/monitoring/public/components/no_data/explanations/collection_enabled/collection_enabled.js b/x-pack/legacy/plugins/monitoring/public/components/no_data/explanations/collection_enabled/collection_enabled.js index 48883e43e0ebf9..50b67cbda6f3de 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/no_data/explanations/collection_enabled/collection_enabled.js +++ b/x-pack/legacy/plugins/monitoring/public/components/no_data/explanations/collection_enabled/collection_enabled.js @@ -39,10 +39,10 @@ export class ExplainCollectionEnabled extends React.Component { const { enabler } = this.props; enabler.enableCollectionEnabled(); - // wait 19 seconds, show link to reload + // wait 22 seconds, show link to reload this.waitedTooLongTimer = setTimeout(() => { this.setState({ waitedTooLong: true }); - }, 19 * 1000); + }, 22 * 1000); } render() { diff --git a/x-pack/legacy/plugins/monitoring/public/lib/elasticsearch_settings/enabler.js b/x-pack/legacy/plugins/monitoring/public/lib/elasticsearch_settings/enabler.js index 8c25a79f12fb8d..f6679611441235 100644 --- a/x-pack/legacy/plugins/monitoring/public/lib/elasticsearch_settings/enabler.js +++ b/x-pack/legacy/plugins/monitoring/public/lib/elasticsearch_settings/enabler.js @@ -35,7 +35,7 @@ export class Enabler { async enableCollectionEnabled() { try { - this.updateModel({ isCollectionEnabledUpdating: true, isCollectionEnabledUpdated: false }); + this.updateModel({ isCollectionEnabledUpdating: true }); await this.$http.put('../api/monitoring/v1/elasticsearch_settings/set/collection_enabled'); this.updateModel({ isCollectionEnabledUpdated: true, diff --git a/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js b/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js index 5ad68268ed31fa..0fef0fbe125e2e 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js +++ b/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js @@ -72,7 +72,12 @@ export class NoDataController extends MonitoringViewBaseController { //Need to set updateModel after super since there is no `this` otherwise const { updateModel } = new ModelUpdater($scope, this); const enabler = new Enabler($http, updateModel); - $scope.$watch(() => this, () => this.render(enabler), true); + $scope.$watch(() => this, () => { + if (this.isCollectionEnabledUpdated && !this.reason) { + return; + } + this.render(enabler); + }, true); } getDefaultModel() { From 29c5d6755bbf49e6d927a373ec368a0120ec3233 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Tue, 1 Oct 2019 12:36:48 -0400 Subject: [PATCH 11/59] [Uptime] Fix flaky unit test snapshot (#46492) * Extract test helper function for reuse. * Modify unit test to avoid flaky behavior. * Fix outputted error string in test helper. * Add test file for test helper function. * run x-pack-intake job 40 times * Revert "run x-pack-intake job 40 times" This reverts commit 53b520c98f563e849afc01c92cdf1310bc6d8b14. --- ...asticsearch_monitor_states_adapter.test.ts | 25 +++++++++++++++++++ .../elasticsearch_monitors_adapter.test.ts | 7 +----- .../assert_close_to.test.ts.snap | 3 +++ .../helper/__test__/assert_close_to.test.ts | 21 ++++++++++++++++ .../server/lib/helper/assert_close_to.ts | 11 ++++++++ .../plugins/uptime/server/lib/helper/index.ts | 1 + 6 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 x-pack/legacy/plugins/uptime/server/lib/helper/__test__/__snapshots__/assert_close_to.test.ts.snap create mode 100644 x-pack/legacy/plugins/uptime/server/lib/helper/__test__/assert_close_to.test.ts create mode 100644 x-pack/legacy/plugins/uptime/server/lib/helper/assert_close_to.ts diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/elasticsearch_monitor_states_adapter.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/elasticsearch_monitor_states_adapter.test.ts index 67a66362e4cc50..1a90699090b29f 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/elasticsearch_monitor_states_adapter.test.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/elasticsearch_monitor_states_adapter.test.ts @@ -8,6 +8,8 @@ import { DatabaseAdapter } from '../../database'; import exampleFilter from './example_filter.json'; import monitorState from './monitor_states_docs.json'; import { ElasticsearchMonitorStatesAdapter } from '../elasticsearch_monitor_states_adapter'; +import { get, set } from 'lodash'; +import { assertCloseTo } from '../../../helper'; describe('ElasticsearchMonitorStatesAdapter', () => { let database: DatabaseAdapter; @@ -37,6 +39,7 @@ describe('ElasticsearchMonitorStatesAdapter', () => { }); it('applies an appropriate filter section to the query based on filters', async () => { + expect.assertions(3); const adapter = new ElasticsearchMonitorStatesAdapter(database); await adapter.legacyGetMonitorStates( {}, @@ -45,6 +48,28 @@ describe('ElasticsearchMonitorStatesAdapter', () => { JSON.stringify(exampleFilter), 'down' ); + expect(searchMock).toHaveBeenCalledTimes(3); + const fixedInterval = parseInt( + get( + searchMock.mock.calls[2][1], + 'body.aggs.by_id.aggs.histogram.date_histogram.fixed_interval', + '' + ).split('ms')[0], + 10 + ); + expect(fixedInterval).not.toBeNaN(); + /** + * This value can sometimes be off by 1 as a result of fuzzy calculation. + * + * It had no implications in practice, but from a test standpoint can cause flaky + * snapshot failures. + */ + assertCloseTo(fixedInterval, 36000, 100); + set( + searchMock.mock.calls[2][1], + 'body.aggs.by_id.aggs.histogram.date_histogram.fixed_interval', + '36000ms' + ); expect(searchMock.mock.calls).toMatchSnapshot(); }); }); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/elasticsearch_monitors_adapter.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/elasticsearch_monitors_adapter.test.ts index 9fce667adf613c..b21fb982bfb3bb 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/elasticsearch_monitors_adapter.test.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/elasticsearch_monitors_adapter.test.ts @@ -8,12 +8,7 @@ import { get, set } from 'lodash'; import { ElasticsearchMonitorsAdapter } from '../elasticsearch_monitors_adapter'; import { CountParams, CountResponse } from 'elasticsearch'; import mockChartsData from './monitor_charts_mock.json'; - -const assertCloseTo = (actual: number, expected: number, precision: number) => { - if (Math.abs(expected - actual) > precision) { - throw new Error(`expected [${actual}] to be within ${precision} of ${actual}`); - } -}; +import { assertCloseTo } from '../../../helper'; // FIXME: there are many untested functions in this adapter. They should be tested. describe('ElasticsearchMonitorsAdapter', () => { diff --git a/x-pack/legacy/plugins/uptime/server/lib/helper/__test__/__snapshots__/assert_close_to.test.ts.snap b/x-pack/legacy/plugins/uptime/server/lib/helper/__test__/__snapshots__/assert_close_to.test.ts.snap new file mode 100644 index 00000000000000..7dfb8c88be1ca4 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/helper/__test__/__snapshots__/assert_close_to.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`assertCloseTo throws an error when expected value is outside of precision range 1`] = `"expected [12500] to be within 100 of 10000"`; diff --git a/x-pack/legacy/plugins/uptime/server/lib/helper/__test__/assert_close_to.test.ts b/x-pack/legacy/plugins/uptime/server/lib/helper/__test__/assert_close_to.test.ts new file mode 100644 index 00000000000000..6ccedcf7153dc4 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/helper/__test__/assert_close_to.test.ts @@ -0,0 +1,21 @@ +/* + * 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 { assertCloseTo } from '../assert_close_to'; + +describe('assertCloseTo', () => { + it('does not throw an error when expected value is correct', () => { + assertCloseTo(10000, 10001, 100); + }); + + it('does not throw an error when expected value is under actual, but within precision threshold', () => { + assertCloseTo(10000, 9875, 300); + }); + + it('throws an error when expected value is outside of precision range', () => { + expect(() => assertCloseTo(10000, 12500, 100)).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/helper/assert_close_to.ts b/x-pack/legacy/plugins/uptime/server/lib/helper/assert_close_to.ts new file mode 100644 index 00000000000000..13b6f3688809c5 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/helper/assert_close_to.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const assertCloseTo = (actual: number, expected: number, precision: number) => { + if (Math.abs(expected - actual) > precision) { + throw new Error(`expected [${expected}] to be within ${precision} of ${actual}`); + } +}; diff --git a/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts b/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts index 19d5a3b00237df..a2a72825c6b98d 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts @@ -9,3 +9,4 @@ export { formatEsBucketsForHistogram } from './format_es_buckets_for_histogram'; export { getFilterClause } from './get_filter_clause'; export { getHistogramInterval } from './get_histogram_interval'; export { parseFilterQuery } from './parse_filter_query'; +export { assertCloseTo } from './assert_close_to'; From 073d1b07d97838614a0520164da1646a583eb3ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Tue, 1 Oct 2019 19:24:05 +0200 Subject: [PATCH 12/59] Removing 0 sufix if array contains only one element (#47036) --- .../__test__/DottedKeyValueTable.test.tsx | 27 +++++++++++++++++++ .../shared/DottedKeyValueTable/index.tsx | 4 ++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/apm/public/components/shared/DottedKeyValueTable/__test__/DottedKeyValueTable.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/DottedKeyValueTable/__test__/DottedKeyValueTable.test.tsx index deecd955293bb4..aebc86352a7745 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/DottedKeyValueTable/__test__/DottedKeyValueTable.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/DottedKeyValueTable/__test__/DottedKeyValueTable.test.tsx @@ -104,4 +104,31 @@ describe('DottedKeyValueTable', () => { 'top.name.last' ]); }); + + it('should not add 0 sufix if value is an array with one element', () => { + const data = { + a: { + b: { + c1: ['foo', 'bar'], + c2: ['foo'] + } + }, + b: { + c: ['foo'] + }, + c: { + d: ['foo', 'bar'] + } + }; + const output = render(); + + expect(getKeys(output)).toEqual([ + 'a.b.c1.0', + 'a.b.c1.1', + 'a.b.c2', + 'b.c', + 'c.d.0', + 'c.d.1' + ]); + }); }); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/DottedKeyValueTable/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/DottedKeyValueTable/index.tsx index baeea829f401f2..674df14c62eb0b 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/DottedKeyValueTable/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/DottedKeyValueTable/index.tsx @@ -34,10 +34,12 @@ export function pathify( item: StringMap, { maxDepth, parentKey = '', depth = 0 }: PathifyOptions ): PathifyResult { + const isArrayWithSingleValue = Array.isArray(item) && item.length === 1; return Object.keys(item) .sort() .reduce((pathified, key) => { - const currentKey = compact([parentKey, key]).join('.'); + const childKey = isArrayWithSingleValue ? '' : key; + const currentKey = compact([parentKey, childKey]).join('.'); if ((!maxDepth || depth + 1 <= maxDepth) && isObject(item[key])) { return { ...pathified, From 072f332385d0500659cf5966f5eae221ea4841af Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Tue, 1 Oct 2019 13:02:52 -0500 Subject: [PATCH 13/59] time picker to filter (#47055) --- src/legacy/core_plugins/kibana/ui_setting_defaults.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/legacy/core_plugins/kibana/ui_setting_defaults.js b/src/legacy/core_plugins/kibana/ui_setting_defaults.js index 8592242e7ff20c..191ae7309f46ff 100644 --- a/src/legacy/core_plugins/kibana/ui_setting_defaults.js +++ b/src/legacy/core_plugins/kibana/ui_setting_defaults.js @@ -811,7 +811,7 @@ export function getUiSettingDefaults() { }, 'timepicker:timeDefaults': { name: i18n.translate('kbn.advancedSettings.timepicker.timeDefaultsTitle', { - defaultMessage: 'Time picker defaults', + defaultMessage: 'Time filter defaults', }), value: `{ @@ -826,7 +826,7 @@ export function getUiSettingDefaults() { }, 'timepicker:refreshIntervalDefaults': { name: i18n.translate('kbn.advancedSettings.timepicker.refreshIntervalDefaultsTitle', { - defaultMessage: 'Time picker refresh interval', + defaultMessage: 'Time filter refresh interval', }), value: `{ @@ -841,7 +841,7 @@ export function getUiSettingDefaults() { }, 'timepicker:quickRanges': { name: i18n.translate('kbn.advancedSettings.timepicker.quickRangesTitle', { - defaultMessage: 'Time picker quick ranges', + defaultMessage: 'Time filter quick ranges', }), value: JSON.stringify([ { @@ -897,7 +897,7 @@ export function getUiSettingDefaults() { type: 'json', description: i18n.translate('kbn.advancedSettings.timepicker.quickRangesText', { defaultMessage: - 'The list of ranges to show in the Quick section of the time picker. This should be an array of objects, ' + + 'The list of ranges to show in the Quick section of the time filter. This should be an array of objects, ' + 'with each object containing "from", "to" (see {acceptedFormatsLink}), and ' + '"display" (the title to be displayed).', description: From de841f72808a66024d300c0f0fd10c8ef2411721 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Tue, 1 Oct 2019 15:04:00 -0400 Subject: [PATCH 14/59] [Lens] Default to stacked bar and reduce number of suggestions (#46721) --- .../editor_frame/_suggestion_panel.scss | 1 + x-pack/legacy/plugins/lens/public/index.scss | 1 + .../metric_visualization_plugin/index.scss | 9 + .../metric_expression.test.tsx | 60 ++---- .../metric_expression.tsx | 13 +- .../__snapshots__/xy_expression.test.tsx.snap | 7 - .../_xy_expression.scss | 3 +- .../xy_visualization_plugin/xy_expression.tsx | 4 +- .../xy_suggestions.test.ts | 195 +++++++++++------- .../xy_visualization_plugin/xy_suggestions.ts | 89 ++++---- .../xy_visualization.test.ts | 4 +- .../xy_visualization.tsx | 2 +- 12 files changed, 200 insertions(+), 188 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/index.scss diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/_suggestion_panel.scss b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/_suggestion_panel.scss index a44fe7ee68dd09..01a6954671809d 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/_suggestion_panel.scss +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/_suggestion_panel.scss @@ -39,6 +39,7 @@ } .lnsSuggestionPanel__chartWrapper { + display: flex; height: $lnsSuggestionHeight - $euiSize; pointer-events: none; margin: 0 $euiSizeS; diff --git a/x-pack/legacy/plugins/lens/public/index.scss b/x-pack/legacy/plugins/lens/public/index.scss index 9864690cff0936..f646b1ed0a9ae7 100644 --- a/x-pack/legacy/plugins/lens/public/index.scss +++ b/x-pack/legacy/plugins/lens/public/index.scss @@ -12,3 +12,4 @@ @import './editor_frame_plugin/index'; @import './indexpattern_plugin/index'; @import './xy_visualization_plugin/index'; +@import './metric_visualization_plugin/index'; diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/index.scss b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/index.scss new file mode 100644 index 00000000000000..23390315399971 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/index.scss @@ -0,0 +1,9 @@ +.lnsMetricExpression__container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + text-align: center; +} diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.test.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.test.tsx index ccda4fb3484ad0..f82def178261b0 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.test.tsx +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.test.tsx @@ -53,18 +53,8 @@ describe('metric_expression', () => { expect(shallow( x as FieldFormat} />)) .toMatchInlineSnapshot(`
{ /> ) ).toMatchInlineSnapshot(` - - -
- 10110 -
-
-
- `); + + +
+ 10110 +
+
+
+ `); }); }); }); diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.tsx index 6abaf7d63d54dd..68de585771a798 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.tsx +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.tsx @@ -106,18 +106,7 @@ export function MetricChart({ } return ( - +
{value} diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap index 048cbb0811432d..f9a5a73eda2702 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap @@ -2,7 +2,6 @@ exports[`xy_expression XYChart component it renders area 1`] = ` + ); @@ -177,7 +177,7 @@ export function XYChart({ data, args, formatFactory, timeZone }: XYChartRenderPr } return ( - + { expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Array [ - Object { - "seriesType": "area_stacked", - "splitAccessor": "aaa", - "x": "date", - "y": Array [ - "bytes", - ], - }, - ] - `); + Array [ + Object { + "seriesType": "bar_stacked", + "splitAccessor": "aaa", + "x": "date", + "y": Array [ + "bytes", + ], + }, + ] + `); }); test('does not suggest multiple splits', () => { @@ -161,18 +161,18 @@ describe('xy_suggestions', () => { expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Array [ - Object { - "seriesType": "area_stacked", - "splitAccessor": "product", - "x": "date", - "y": Array [ - "price", - "quantity", - ], - }, - ] - `); + Array [ + Object { + "seriesType": "bar_stacked", + "splitAccessor": "product", + "x": "date", + "y": Array [ + "price", + "quantity", + ], + }, + ] + `); }); test('uses datasource provided title if available', () => { @@ -232,7 +232,43 @@ describe('xy_suggestions', () => { expect(suggestion.hide).toBeFalsy(); }); - test('suggests an area chart for unchanged table and existing bar chart on non-ordinal x axis', () => { + test('only makes a seriesType suggestion for unchanged table without split', () => { + (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); + const currentState: XYState = { + isHorizontal: false, + legend: { isVisible: true, position: 'bottom' }, + preferredSeriesType: 'bar', + layers: [ + { + accessors: ['price'], + layerId: 'first', + seriesType: 'bar', + splitAccessor: 'dummyCol', + xAccessor: 'date', + }, + ], + }; + const suggestions = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), dateCol('date')], + layerId: 'first', + changeType: 'unchanged', + }, + state: currentState, + }); + + expect(suggestions).toHaveLength(1); + + expect(suggestions[0].state).toEqual({ + ...currentState, + preferredSeriesType: 'line', + layers: [{ ...currentState.layers[0], seriesType: 'line' }], + }); + expect(suggestions[0].title).toEqual('Line chart'); + }); + + test('suggests seriesType and stacking when there is a split', () => { const currentState: XYState = { isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, @@ -247,7 +283,7 @@ describe('xy_suggestions', () => { }, ], }; - const [suggestion, ...rest] = getSuggestions({ + const [seriesSuggestion, stackSuggestion, ...rest] = getSuggestions({ table: { isMultiRow: true, columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], @@ -257,14 +293,19 @@ describe('xy_suggestions', () => { state: currentState, }); - expect(rest).toHaveLength(1); - expect(suggestion.state).toEqual({ + expect(rest).toHaveLength(0); + expect(seriesSuggestion.state).toEqual({ + ...currentState, + preferredSeriesType: 'line', + layers: [{ ...currentState.layers[0], seriesType: 'line' }], + }); + expect(stackSuggestion.state).toEqual({ ...currentState, - preferredSeriesType: 'area', - layers: [{ ...currentState.layers[0], seriesType: 'area' }], + preferredSeriesType: 'bar_stacked', + layers: [{ ...currentState.layers[0], seriesType: 'bar_stacked' }], }); - expect(suggestion.previewIcon).toEqual('visArea'); - expect(suggestion.title).toEqual('Area chart'); + expect(seriesSuggestion.title).toEqual('Line chart'); + expect(stackSuggestion.title).toEqual('Stacked'); }); test('suggests a flipped chart for unchanged table and existing bar chart on ordinal x axis', () => { @@ -293,7 +334,7 @@ describe('xy_suggestions', () => { state: currentState, }); - expect(rest).toHaveLength(1); + expect(rest).toHaveLength(0); expect(suggestion.state).toEqual({ ...currentState, isHorizontal: true, @@ -301,48 +342,42 @@ describe('xy_suggestions', () => { expect(suggestion.title).toEqual('Flip'); }); - test('suggests a stacked chart for unchanged table and unstacked chart', () => { - (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); - (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); + test('suggests stacking for unchanged table that has a split', () => { const currentState: XYState = { isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ { - accessors: ['price', 'quantity'], + accessors: ['price'], layerId: 'first', seriesType: 'bar', - splitAccessor: 'dummyCol', + splitAccessor: 'date', xAccessor: 'product', }, ], }; - const suggestion = getSuggestions({ + const suggestions = getSuggestions({ table: { isMultiRow: true, - columns: [numCol('price'), numCol('quantity'), strCol('product')], + columns: [numCol('price'), dateCol('date'), strCol('product')], layerId: 'first', changeType: 'unchanged', }, state: currentState, - })[1]; + }); + + const suggestion = suggestions[suggestions.length - 1]; expect(suggestion.state).toEqual({ ...currentState, preferredSeriesType: 'bar_stacked', - layers: [ - { - ...currentState.layers[0], - seriesType: 'bar_stacked', - }, - ], + layers: [{ ...currentState.layers[0], seriesType: 'bar_stacked' }], }); expect(suggestion.title).toEqual('Stacked'); }); test('keeps column to dimension mappings on extended tables', () => { - (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); const currentState: XYState = { isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, @@ -431,17 +466,17 @@ describe('xy_suggestions', () => { }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Array [ - Object { - "seriesType": "bar_stacked", - "splitAccessor": "ddd", - "x": "quantity", - "y": Array [ - "price", - ], - }, - ] - `); + Array [ + Object { + "seriesType": "bar_stacked", + "splitAccessor": "ddd", + "x": "quantity", + "y": Array [ + "price", + ], + }, + ] + `); }); test('handles ip', () => { @@ -467,17 +502,17 @@ describe('xy_suggestions', () => { }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Array [ - Object { - "seriesType": "bar_stacked", - "splitAccessor": "ddd", - "x": "myip", - "y": Array [ - "quantity", - ], - }, - ] - `); + Array [ + Object { + "seriesType": "bar_stacked", + "splitAccessor": "ddd", + "x": "myip", + "y": Array [ + "quantity", + ], + }, + ] + `); }); test('handles unbucketed suggestions', () => { @@ -502,16 +537,16 @@ describe('xy_suggestions', () => { }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Array [ - Object { - "seriesType": "bar_stacked", - "splitAccessor": "eee", - "x": "mybool", - "y": Array [ - "num votes", - ], - }, - ] - `); + Array [ + Object { + "seriesType": "bar_stacked", + "splitAccessor": "eee", + "x": "mybool", + "y": Array [ + "num votes", + ], + }, + ] + `); }); }); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts index 1f3bc9d701114e..2f28e20ebd274a 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts @@ -187,7 +187,7 @@ function getSuggestionsForLayer( // if current state is using the same data, suggest same chart with different presentational configuration - if (xValue.operation.scale === 'ordinal') { + if (seriesType !== 'line' && xValue.operation.scale === 'ordinal') { // flip between horizontal/vertical for ordinal scales sameStateSuggestions.push( buildSuggestion({ @@ -198,36 +198,38 @@ function getSuggestionsForLayer( ); } else { // change chart type for interval or ratio scales on x axis - const newSeriesType = flipSeriesType(seriesType); + const newSeriesType = altSeriesType(seriesType); sameStateSuggestions.push( buildSuggestion({ ...options, seriesType: newSeriesType, - title: newSeriesType.startsWith('area') - ? i18n.translate('xpack.lens.xySuggestions.areaChartTitle', { - defaultMessage: 'Area chart', - }) - : i18n.translate('xpack.lens.xySuggestions.barChartTitle', { + title: newSeriesType.startsWith('bar') + ? i18n.translate('xpack.lens.xySuggestions.barChartTitle', { defaultMessage: 'Bar chart', + }) + : i18n.translate('xpack.lens.xySuggestions.lineChartTitle', { + defaultMessage: 'Line chart', }), }) ); } - // flip between stacked/unstacked - sameStateSuggestions.push( - buildSuggestion({ - ...options, - seriesType: toggleStackSeriesType(seriesType), - title: seriesType.endsWith('stacked') - ? i18n.translate('xpack.lens.xySuggestions.unstackedChartTitle', { - defaultMessage: 'Unstacked', - }) - : i18n.translate('xpack.lens.xySuggestions.stackedChartTitle', { - defaultMessage: 'Stacked', - }), - }) - ); + if (seriesType !== 'line' && splitBy) { + // flip between stacked/unstacked + sameStateSuggestions.push( + buildSuggestion({ + ...options, + seriesType: toggleStackSeriesType(seriesType), + title: seriesType.endsWith('stacked') + ? i18n.translate('xpack.lens.xySuggestions.unstackedChartTitle', { + defaultMessage: 'Unstacked', + }) + : i18n.translate('xpack.lens.xySuggestions.stackedChartTitle', { + defaultMessage: 'Stacked', + }), + }) + ); + } return sameStateSuggestions; } @@ -247,18 +249,21 @@ function toggleStackSeriesType(oldSeriesType: SeriesType) { } } -function flipSeriesType(oldSeriesType: SeriesType) { +// Until the area chart rendering bug is fixed, avoid suggesting area charts +// https://github.com/elastic/elastic-charts/issues/388 +function altSeriesType(oldSeriesType: SeriesType) { switch (oldSeriesType) { case 'area': - return 'bar'; + return 'line'; case 'area_stacked': return 'bar_stacked'; case 'bar': - return 'area'; + return 'line'; case 'bar_stacked': - return 'area_stacked'; + return 'line'; + case 'line': default: - return 'bar'; + return 'bar_stacked'; } } @@ -268,27 +273,25 @@ function getSeriesType( xValue: TableSuggestionColumn, changeType: TableChangeType ): SeriesType { - const defaultType = xValue.operation.dataType === 'date' ? 'area_stacked' : 'bar_stacked'; - const preferredSeriesType = (currentState && currentState.preferredSeriesType) || defaultType; - const isDateCompatible = - preferredSeriesType === 'area' || - preferredSeriesType === 'line' || - preferredSeriesType === 'area_stacked'; - - if (changeType !== 'initial') { - const oldLayer = getExistingLayer(currentState, layerId); - return ( - (oldLayer && oldLayer.seriesType) || - (currentState && currentState.preferredSeriesType) || - defaultType - ); + const defaultType = 'bar_stacked'; + + const oldLayer = getExistingLayer(currentState, layerId); + const oldLayerSeriesType = oldLayer ? oldLayer.seriesType : false; + + const closestSeriesType = + oldLayerSeriesType || (currentState && currentState.preferredSeriesType) || defaultType; + + // Attempt to keep the seriesType consistent on initial add of a layer + // Ordinal scales should always use a bar because there is no interpolation between buckets + if (xValue.operation.scale && xValue.operation.scale === 'ordinal') { + return closestSeriesType.startsWith('bar') ? closestSeriesType : defaultType; } - if (xValue.operation.dataType === 'date') { - return isDateCompatible ? preferredSeriesType : defaultType; + if (changeType === 'initial') { + return defaultType; } - return isDateCompatible ? defaultType : preferredSeriesType; + return closestSeriesType !== defaultType ? closestSeriesType : defaultType; } function getSuggestionTitle( diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts index 8d9092f63f59b3..8bc7b0c9116f70 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts @@ -56,7 +56,7 @@ describe('xy_visualization', () => { ], "layerId": "", "position": "top", - "seriesType": "bar", + "seriesType": "bar_stacked", "showGridlines": false, "splitAccessor": "test-id2", "xAccessor": "test-id3", @@ -66,7 +66,7 @@ describe('xy_visualization', () => { "isVisible": true, "position": "right", }, - "preferredSeriesType": "bar", + "preferredSeriesType": "bar_stacked", "title": "Empty XY Chart", } `); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index 14c35c21c73330..69cb93bb1903d0 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -18,7 +18,7 @@ import { toExpression, toPreviewExpression } from './to_expression'; import { generateId } from '../id_generator'; const defaultIcon = 'visBarVertical'; -const defaultSeriesType = 'bar'; +const defaultSeriesType = 'bar_stacked'; function getDescription(state?: State) { if (!state) { From 027476f9a7f9901e63ebd2c15fa13004a5a69510 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Tue, 1 Oct 2019 13:54:17 -0600 Subject: [PATCH 15/59] [SIEM][Detection Engine] Temporary re-indexer and starting folder (#47006) ## Summary Start the detection engine work by adding experimental backend re-indexer and placeholder ECS data. * Added README.md with instructions on how to setup alerts and actions * Added temporary re-indexer with painless scripts * Added example query to move the re-indexer towards. ### Checklist Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR. ~~- [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)~~ ~~- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)~~ ~~- [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~~ ~~- [ ] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios~~ ~~- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~~ ### For maintainers ~~- [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~ ~~- [ ] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~ --- x-pack/legacy/plugins/siem/index.ts | 9 + .../server/lib/detection_engine/README.md | 126 ++ .../alerts/build_events_query.ts | 88 + .../alerts/build_events_reindex.ts | 141 ++ .../alerts/signals_alert_type.ts | 82 + .../lib/detection_engine/signals_mapping.json | 1644 +++++++++++++++++ 6 files changed, 2090 insertions(+) create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/README.md create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_query.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_reindex.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/signals_alert_type.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/signals_mapping.json diff --git a/x-pack/legacy/plugins/siem/index.ts b/x-pack/legacy/plugins/siem/index.ts index 78fc0a52dc3d67..4c0997e1d6181f 100644 --- a/x-pack/legacy/plugins/siem/index.ts +++ b/x-pack/legacy/plugins/siem/index.ts @@ -23,6 +23,7 @@ import { DEFAULT_FROM, DEFAULT_TO, } from './common/constants'; +import { signalsAlertType } from './server/lib/detection_engine/alerts/signals_alert_type'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export function siem(kibana: any) { @@ -31,6 +32,11 @@ export function siem(kibana: any) { configPrefix: 'xpack.siem', publicDir: resolve(__dirname, 'public'), require: ['kibana', 'elasticsearch'], + // Uncomment these lines to turn on alerting and action for detection engine and comment the other + // require statement out. These are hidden behind feature flags at the moment so if you turn + // these on without the feature flags turned on then Kibana will crash since we are a legacy plugin + // and legacy plugins cannot have optional requirements. + // require: ['kibana', 'elasticsearch', 'alerting', 'actions'], uiExports: { app: { description: i18n.translate('xpack.siem.securityDescription', { @@ -115,6 +121,9 @@ export function siem(kibana: any) { mappings: savedObjectMappings, }, init(server: Server) { + if (server.plugins.alerting != null) { + server.plugins.alerting.registerType(signalsAlertType); + } server.injectUiAppVars('siem', async () => server.getInjectedUiAppVars('kibana')); initServerWithKibana(server); }, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/README.md b/x-pack/legacy/plugins/siem/server/lib/detection_engine/README.md new file mode 100644 index 00000000000000..063e7c1975b25b --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/README.md @@ -0,0 +1,126 @@ +Temporary README.md for developers working on the backend detection engine +for how to get started. + +See these two other pages for references: +https://github.com/elastic/kibana/blob/master/x-pack/legacy/plugins/alerting/README.md +https://github.com/elastic/kibana/tree/master/x-pack/legacy/plugins/actions + +Since there is no UI yet and a lot of backend areas that are not created, you +should install the kbn-action and kbn-alert project from here: +https://github.com/pmuellr/kbn-action + +Add your signal mappings into your Kibana instance manually by opening + +``` +x-pack/legacy/plugins/siem/server/lib/detection_engine/signals_mapping.json +``` + +And copying that to your DEV tools so it looks something like: +``` +PUT /.siem-signals-10-01-2019 +{ + "mappings": { + "dynamic": false, +... +``` + +We will solve the above issue here: +https://github.com/elastic/kibana/issues/47002 + +Add these lines to your `kibana.dev.yml` to turn on the feature toggles of alerting and actions: +``` +# Feature flag to turn on alerting +xpack.alerting.enabled: true + +# Feature flag to turn on actions which goes with alerting +xpack.actions.enabled: true + +# White list everything for ease of development (do not do in production) +xpack.actions.whitelistedHosts: ['*'] +``` + +Open `x-pack/legacy/plugins/siem/index.ts` and find these lines and add the require statement +while commenting out the other require statement: + +``` +// Uncomment these lines to turn on alerting and action for detection engine and comment the other +// require statement out. These are hidden behind feature flags at the moment so if you turn +// these on without the feature flags turned on then Kibana will crash since we are a legacy plugin +// and legacy plugins cannot have optional requirements. +// require: ['kibana', 'elasticsearch', 'alerting', 'actions'], +``` + +Restart Kibana and you should see alerting and actions starting up +``` +server log [22:05:22.277] [info][status][plugin:alerting@8.0.0] Status changed from uninitialized to green - Ready +server log [22:05:22.270] [info][status][plugin:actions@8.0.0] Status changed from uninitialized to green - Ready +``` + +Open a terminal and run + +```sh +kbn-alert ls-types +``` + +You should see the new alert type of: + +```ts +[ + { + "id": "siem.signals", + "name": "SIEM Signals" + } +] +``` + +Setup SIEM Alerts Log action through + +```ts +kbn-action create .server-log "SIEM Alerts Log" {} {} +{ + "id": "7edd7e98-9286-4fdb-a5c5-16de776bc7c7", + "actionTypeId": ".server-log", + "description": "SIEM Alerts Log", + "config": {} +} +``` + +Take note of the `id` GUID above and copy and paste that into a create alert like so + +```ts +kbn-alert create siem.signals 5m '{}' "[{group:default id:'7edd7e98-9286-4fdb-a5c5-16de776bc7c7' params:{message: 'SIEM Alert Fired'}}]" +``` + +You should get back a response like so +```ts +{ + "id": "908a6af1-ac63-4d52-a856-fc635a00db0f", + "alertTypeId": "siem.signals", + "interval": "5m", + "actions": [ + { + "group": "default", + "params": { + "message": "SIEM Alert Fired" + }, + "id": "7edd7e98-9286-4fdb-a5c5-16de776bc7c7" + } + ], + "alertTypeParams": {}, + "enabled": true, + "throttle": null, + "createdBy": "elastic", + "updatedBy": "elastic", + "apiKeyOwner": "elastic", + "scheduledTaskId": "4f401ca0-e402-11e9-94ed-051d758a6c79" +} +``` + +Every 5 minutes you should see this message in your terminal now: + +``` +server log [22:17:33.945] [info][alerting] SIEM Alert Fired +``` + +Add the `.siem-signals-10-01-2019` to your advanced SIEM settings to see any signals +created which should update once every 5 minutes at this point. \ No newline at end of file diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_query.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_query.ts new file mode 100644 index 00000000000000..6f780744b17b69 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_query.ts @@ -0,0 +1,88 @@ +/* + * 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. + */ + +// TODO: See build_events_reindex.ts for all the spots to make things "configurable" +// here but this is intended to replace the build_events_reindex.ts +export const buildEventsQuery = () => { + return { + allowNoIndices: true, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + ignoreUnavailable: true, + body: { + query: { + bool: { + filter: [ + { + bool: { + filter: [ + { + bool: { + should: [ + { + match_phrase: { + 'user.name': 'root', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + filter: [ + { + bool: { + should: [ + { + range: { + '@timestamp': { + gte: 1567317600000, + }, + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + range: { + '@timestamp': { + lte: 1569909599999, + }, + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + size: 26, + track_total_hits: true, + sort: [ + { + '@timestamp': 'desc', + }, + { + _doc: 'desc', + }, + ], + }, + }; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_reindex.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_reindex.ts new file mode 100644 index 00000000000000..84cedca0855e0d --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_reindex.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// TODO: Re-index is just a temporary solution in order to speed up development +// of any front end pieces. This should be replaced with a combination of the file +// build_events_query.ts and any scrolling/scaling solutions from that particular +// file. + +interface BuildEventsReIndexParams { + index: string[]; + from: number; + to: number; + signalsIndex: string; + maxDocs: number; + kqlFilter: {}; + severity: number; + description: string; + name: string; + timeDetected: number; + ruleRevision: number; + ruleId: string; + ruleType: string; + references: string[]; +} + +export const buildEventsReIndex = ({ + index, + from, + to, + signalsIndex, + maxDocs, + kqlFilter, + severity, + description, + name, + timeDetected, + ruleRevision, + ruleId, + ruleType, + references, +}: BuildEventsReIndexParams) => { + const indexPatterns = index.map(element => `"${element}"`).join(','); + const refs = references.map(element => `"${element}"`).join(','); + const filter = [ + kqlFilter, + { + bool: { + filter: [ + { + bool: { + should: [ + { + range: { + '@timestamp': { + gte: from, + }, + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + range: { + '@timestamp': { + lte: to, + }, + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + ]; + return { + body: { + source: { + index, + sort: [ + { + '@timestamp': 'desc', + }, + { + _doc: 'desc', + }, + ], + query: { + bool: { + filter: [ + ...filter, + { + match_all: {}, + }, + ], + }, + }, + }, + dest: { + index: signalsIndex, + }, + script: { + source: ` + String[] indexPatterns = new String[] {${indexPatterns}}; + String[] references = new String[] {${refs}}; + + def parent = [ + "id": ctx._id, + "type": "event", + "depth": 1 + ]; + + def signal = [ + "rule_revision": "${ruleRevision}", + "rule_id": "${ruleId}", + "rule_type": "${ruleType}", + "parent": parent, + "name": "${name}", + "severity": ${severity}, + "description": "${description}", + "time_detected": "${timeDetected}", + "index_patterns": indexPatterns, + "references": references + ]; + + ctx._source.signal = signal; + `, + lang: 'painless', + }, + max_docs: maxDocs, + }, + }; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/signals_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/signals_alert_type.ts new file mode 100644 index 00000000000000..d0b631f66d54d7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/signals_alert_type.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import { APP_ID } from '../../../../common/constants'; +import { AlertType, AlertExecutorOptions } from '../../../../../alerting'; + +// TODO: Remove this for the build_events_query call eventually +import { buildEventsReIndex } from './build_events_reindex'; + +// TODO: Comment this in and use this instead of the reIndex API +// once scrolling and other things are done with it. +// import { buildEventsQuery } from './build_events_query'; + +export const signalsAlertType: AlertType = { + id: `${APP_ID}.signals`, + name: 'SIEM Signals', + actionGroups: ['default'], + async executor({ services, params, state }: AlertExecutorOptions) { + // TODO: We need to swap out this arbitrary number of siem-signal id for an injected + // data driven instance id through passed in parameters. + const instance = services.alertInstanceFactory('siem-signals'); + + // TODO: Comment this in eventually and use the buildEventsQuery() + // for scrolling and other fun stuff instead of using the buildEventsReIndex() + // const query = buildEventsQuery(); + + // TODO: Turn these options being sent in into a template for the alert type + const reIndex = buildEventsReIndex({ + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + from: moment() + .subtract(5, 'minutes') + .valueOf(), + to: Date.now(), + signalsIndex: '.siem-signals-10-01-2019', + severity: 2, + description: 'User root activity', + name: 'User Rule', + timeDetected: Date.now(), + kqlFilter: { + bool: { + should: [ + { + match_phrase: { + 'user.name': 'root', + }, + }, + ], + minimum_should_match: 1, + }, + }, + maxDocs: 100, + ruleRevision: 1, + ruleId: '1', + ruleType: 'KQL', + references: ['https://www.elastic.co', 'https://example.com'], + }); + + try { + services.log(['info', 'SIEM'], 'Starting SIEM signal job'); + + // TODO: Comment this in eventually and use this for manual insertion of the + // signals instead of the ReIndex() api + // const result = await services.callCluster('search', query); + // eslint-disable-next-line + const result = await services.callCluster('reindex', reIndex); + + // TODO: Error handling here and writing of any errors that come back from ES by + services.log(['info', 'SIEM'], `Result of reindex: ${JSON.stringify(result, null, 2)}`); + } catch (err) { + // TODO: Error handling and writing of errors into a signal that has error + // handling/conditions + services.log(['error', 'SIEM'], `You encountered an error of: ${err.message}`); + } + + // Schedule the default action which is nothing if it's a plain signal. + instance.scheduleActions('default'); + }, +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals_mapping.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals_mapping.json new file mode 100644 index 00000000000000..4f1e07e2f5d762 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals_mapping.json @@ -0,0 +1,1644 @@ +{ + "mappings": { + "dynamic": false, + "properties": { + "@timestamp": { + "type": "date" + }, + "signal": { + "properties": { + "parent": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "depth": { + "type": "long" + } + } + }, + "time_detected": { + "type": "date" + }, + "rule_revision": { + "type": "long" + }, + "rule_id": { + "type": "keyword" + }, + "rule_type": { + "type": "keyword" + }, + "rule_query": { + "type": "keyword" + }, + "index_patterns": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "severity": { + "type": "long" + }, + "references": { + "type": "text" + }, + "error": { + "properties": { + "code": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "message": { + "type": "text", + "norms": false + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "agent": { + "properties": { + "ephemeral_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "hostname": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "client": { + "properties": { + "address": { + "type": "keyword", + "ignore_above": 1024 + }, + "bytes": { + "type": "long" + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "user": { + "properties": { + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "group": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "availability_zone": { + "type": "keyword", + "ignore_above": 1024 + }, + "instance": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "machine": { + "properties": { + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "project": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "provider": { + "type": "keyword", + "ignore_above": 1024 + }, + "region": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "container": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "image": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "tag": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "runtime": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "destination": { + "properties": { + "address": { + "type": "keyword", + "ignore_above": 1024 + }, + "bytes": { + "type": "long" + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "packets": { + "type": "long" + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "port": { + "type": "long" + }, + "user": { + "properties": { + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "group": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "ecs": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "error": { + "properties": { + "code": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "message": { + "type": "text", + "norms": false + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "event": { + "properties": { + "action": { + "type": "keyword", + "ignore_above": 1024 + }, + "category": { + "type": "keyword", + "ignore_above": 1024 + }, + "created": { + "type": "date" + }, + "dataset": { + "type": "keyword", + "ignore_above": 1024 + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "kind": { + "type": "keyword", + "ignore_above": 1024 + }, + "module": { + "type": "keyword", + "ignore_above": 1024 + }, + "origin": { + "type": "keyword", + "ignore_above": 1024 + }, + "original": { + "type": "keyword", + "ignore_above": 1024 + }, + "outcome": { + "type": "keyword", + "ignore_above": 1024 + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "file": { + "properties": { + "ctime": { + "type": "date" + }, + "device": { + "type": "keyword", + "ignore_above": 1024 + }, + "extension": { + "type": "keyword", + "ignore_above": 1024 + }, + "gid": { + "type": "keyword", + "ignore_above": 1024 + }, + "group": { + "type": "keyword", + "ignore_above": 1024 + }, + "inode": { + "type": "keyword", + "ignore_above": 1024 + }, + "mode": { + "type": "keyword", + "ignore_above": 1024 + }, + "mtime": { + "type": "date" + }, + "origin": { + "type": "keyword", + "fields": { + "raw": { + "type": "keyword", + "ignore_above": 1024 + } + }, + "ignore_above": 1024 + }, + "owner": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "selinux": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "level": { + "type": "keyword", + "ignore_above": 1024 + }, + "role": { + "type": "keyword", + "ignore_above": 1024 + }, + "user": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "setgid": { + "type": "boolean" + }, + "setuid": { + "type": "boolean" + }, + "size": { + "type": "long" + }, + "target_path": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "uid": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "group": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "properties": { + "blake2b_256": { + "type": "keyword", + "ignore_above": 1024 + }, + "blake2b_384": { + "type": "keyword", + "ignore_above": 1024 + }, + "blake2b_512": { + "type": "keyword", + "ignore_above": 1024 + }, + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha224": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha384": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha3_224": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha3_256": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha3_384": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha3_512": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha512": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha512_224": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha512_256": { + "type": "keyword", + "ignore_above": 1024 + }, + "xxh64": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "host": { + "properties": { + "architecture": { + "type": "keyword", + "ignore_above": 1024 + }, + "containerized": { + "type": "boolean" + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hostname": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "os": { + "properties": { + "build": { + "type": "keyword", + "ignore_above": 1024 + }, + "codename": { + "type": "keyword", + "ignore_above": 1024 + }, + "family": { + "type": "keyword", + "ignore_above": 1024 + }, + "full": { + "type": "keyword", + "ignore_above": 1024 + }, + "kernel": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "platform": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "user": { + "properties": { + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "group": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "bytes": { + "type": "long" + }, + "method": { + "type": "keyword", + "ignore_above": 1024 + }, + "referrer": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "bytes": { + "type": "long" + }, + "status_code": { + "type": "long" + } + } + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "type": "keyword", + "ignore_above": 1024 + }, + "original": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "message": { + "type": "text", + "norms": false + }, + "network": { + "properties": { + "application": { + "type": "keyword", + "ignore_above": 1024 + }, + "bytes": { + "type": "long" + }, + "community_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "direction": { + "type": "keyword", + "ignore_above": 1024 + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "packets": { + "type": "long" + }, + "protocol": { + "type": "keyword", + "ignore_above": 1024 + }, + "transport": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "observer": { + "properties": { + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hostname": { + "type": "keyword", + "ignore_above": 1024 + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "os": { + "properties": { + "family": { + "type": "keyword", + "ignore_above": 1024 + }, + "full": { + "type": "keyword", + "ignore_above": 1024 + }, + "kernel": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "platform": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "serial_number": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "vendor": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "organization": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "os": { + "properties": { + "family": { + "type": "keyword", + "ignore_above": 1024 + }, + "full": { + "type": "keyword", + "ignore_above": 1024 + }, + "kernel": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "platform": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "process": { + "properties": { + "args": { + "type": "keyword", + "ignore_above": 1024 + }, + "created": { + "type": "keyword", + "ignore_above": 1024 + }, + "entity_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "executable": { + "type": "keyword", + "ignore_above": 1024 + }, + "hash": { + "properties": { + "sha1": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + } + } + }, + "title": { + "type": "keyword", + "ignore_above": 1024 + }, + "working_directory": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "related": { + "properties": { + "ip": { + "type": "ip" + } + } + }, + "server": { + "properties": { + "address": { + "type": "keyword", + "ignore_above": 1024 + }, + "bytes": { + "type": "long" + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "user": { + "properties": { + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "group": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "state": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "source": { + "properties": { + "address": { + "type": "keyword", + "ignore_above": 1024 + }, + "bytes": { + "type": "long" + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "packets": { + "type": "long" + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "port": { + "type": "long" + }, + "user": { + "properties": { + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "group": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "tags": { + "type": "keyword", + "ignore_above": 1024 + }, + "url": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "fragment": { + "type": "keyword", + "ignore_above": 1024 + }, + "full": { + "type": "keyword", + "ignore_above": 1024 + }, + "original": { + "type": "keyword", + "ignore_above": 1024 + }, + "password": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "port": { + "type": "long" + }, + "query": { + "type": "keyword", + "ignore_above": 1024 + }, + "scheme": { + "type": "keyword", + "ignore_above": 1024 + }, + "username": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "user": { + "properties": { + "audit": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "effective": { + "properties": { + "group": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "entity_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "filesystem": { + "properties": { + "group": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "group": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "name_map": { + "type": "object" + }, + "saved": { + "properties": { + "group": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "selinux": { + "properties": { + "category": { + "type": "keyword", + "ignore_above": 1024 + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "level": { + "type": "keyword", + "ignore_above": 1024 + }, + "role": { + "type": "keyword", + "ignore_above": 1024 + }, + "user": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "terminal": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "original": { + "type": "keyword", + "ignore_above": 1024 + }, + "os": { + "properties": { + "family": { + "type": "keyword", + "ignore_above": 1024 + }, + "full": { + "type": "keyword", + "ignore_above": 1024 + }, + "kernel": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "platform": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + } +} From 1381e9a2efd54c34baee48dfaf7e2413e254ed16 Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Tue, 1 Oct 2019 17:46:20 -0500 Subject: [PATCH 16/59] Make check_core_api_changes script faster (#47068) --- src/dev/run_check_core_api_changes.ts | 78 +++++++++++++++------------ 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/src/dev/run_check_core_api_changes.ts b/src/dev/run_check_core_api_changes.ts index 4d0be7f3884665..d2c75c86ce7442 100644 --- a/src/dev/run_check_core_api_changes.ts +++ b/src/dev/run_check_core_api_changes.ts @@ -139,14 +139,51 @@ const runApiExtractor = ( return Extractor.invoke(config, options); }; -async function run(folder: string): Promise { +interface Options { + accept: boolean; + docs: boolean; + help: boolean; +} + +async function run( + folder: string, + { log, opts }: { log: ToolingLog; opts: Options } +): Promise { + log.info(`Core ${folder} API: checking for changes in API signature...`); + + const { apiReportChanged, succeeded } = runApiExtractor(log, folder, opts.accept); + + // If we're not accepting changes and there's a failure, exit. + if (!opts.accept && !succeeded) { + return false; + } + + // Attempt to generate docs even if api-extractor didn't succeed + if ((opts.accept && apiReportChanged) || opts.docs) { + try { + await renameExtractedApiPackageName(folder); + await runApiDocumenter(folder); + } catch (e) { + log.error(e); + return false; + } + log.info(`Core ${folder} API: updated documentation ✔`); + } + + // If the api signature changed or any errors or warnings occured, exit with an error + // NOTE: Because of https://github.com/Microsoft/web-build-tools/issues/1258 + // api-extractor will not return `succeeded: false` when the API changes. + return !apiReportChanged && succeeded; +} + +(async () => { const log = new ToolingLog({ level: 'info', writeTo: process.stdout, }); const extraFlags: string[] = []; - const opts = getopts(process.argv.slice(2), { + const opts = (getopts(process.argv.slice(2), { boolean: ['accept', 'docs', 'help'], default: { project: undefined, @@ -155,7 +192,7 @@ async function run(folder: string): Promise { extraFlags.push(name); return false; }, - }); + }) as any) as Options; if (extraFlags.length > 0) { for (const flag of extraFlags) { @@ -193,45 +230,18 @@ async function run(folder: string): Promise { return !(extraFlags.length > 0); } - log.info(`Core ${folder} API: checking for changes in API signature...`); - try { + log.info(`Core: Building types...`); await runBuildTypes(); } catch (e) { log.error(e); return false; } - const { apiReportChanged, succeeded } = runApiExtractor(log, folder, opts.accept); - - // If we're not accepting changes and there's a failure, exit. - if (!opts.accept && !succeeded) { - return false; - } - - // Attempt to generate docs even if api-extractor didn't succeed - if ((opts.accept && apiReportChanged) || opts.docs) { - try { - await renameExtractedApiPackageName(folder); - await runApiDocumenter(folder); - } catch (e) { - log.error(e); - return false; - } - log.info(`Core ${folder} API: updated documentation ✔`); - } - - // If the api signature changed or any errors or warnings occured, exit with an error - // NOTE: Because of https://github.com/Microsoft/web-build-tools/issues/1258 - // api-extractor will not return `succeeded: false` when the API changes. - return !apiReportChanged && succeeded; -} - -(async () => { - const publicSucceeded = await run('public'); - const serverSucceeded = await run('server'); + const folders = ['public', 'server']; + const results = await Promise.all(folders.map(folder => run(folder, { log, opts }))); - if (!publicSucceeded || !serverSucceeded) { + if (results.find(r => r === false) !== undefined) { process.exitCode = 1; } })(); From bb9b4c792de5a8fd176097cc04a49b30f347d9b4 Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Wed, 2 Oct 2019 10:04:00 +0200 Subject: [PATCH 17/59] [ML] Update data-test-subj attributes for action buttons and add ml namespace (#47032) This PR updates a couple ML data-test-subj attributes. --- .../components/job_actions/management.js | 18 ++++-- .../components/job_details/job_details.js | 30 ++++----- .../job_details/job_details_pane.js | 2 +- .../components/job_group/job_group.js | 2 +- .../components/jobs_list/jobs_list.js | 18 +++--- .../detector_title/detector_title.tsx | 2 +- .../influencers/influencers_select.tsx | 2 +- .../multi_metric_view/chart_grid.tsx | 2 +- .../components/population_view/chart_grid.tsx | 2 +- .../components/split_cards/split_cards.tsx | 16 +++-- .../components/split_field/by_field.tsx | 2 +- .../components/split_field/split_field.tsx | 4 +- .../time_range_step/time_range_picker.tsx | 2 +- .../services/machine_learning/job_table.ts | 63 ++++++------------- .../machine_learning/job_wizard_common.ts | 36 +++-------- .../job_wizard_multi_metric.ts | 14 ++--- .../machine_learning/job_wizard_population.ts | 24 ++++--- 17 files changed, 106 insertions(+), 133 deletions(-) diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_actions/management.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_actions/management.js index ba13b06821a1f2..7d97c8589e7d10 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_actions/management.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_actions/management.js @@ -40,7 +40,8 @@ export function actionsMenuContent(showEditJobFlyout, showDeleteJobModal, showSt onClick: (item) => { showStartDatafeedModal([item]); closeMenu(); - } + }, + 'data-test-subj': 'mlActionButtonStartDatafeed' }, { name: i18n.translate('xpack.ml.jobsList.managementActions.stopDatafeedLabel', { defaultMessage: 'Stop datafeed' @@ -54,7 +55,8 @@ export function actionsMenuContent(showEditJobFlyout, showDeleteJobModal, showSt onClick: (item) => { stopDatafeeds([item], refreshJobs); closeMenu(true); - } + }, + 'data-test-subj': 'mlActionButtonStopDatafeed' }, { name: i18n.translate('xpack.ml.jobsList.managementActions.closeJobLabel', { defaultMessage: 'Close job' @@ -68,7 +70,8 @@ export function actionsMenuContent(showEditJobFlyout, showDeleteJobModal, showSt onClick: (item) => { closeJobs([item], refreshJobs); closeMenu(true); - } + }, + 'data-test-subj': 'mlActionButtonCloseJob' }, { name: i18n.translate('xpack.ml.jobsList.managementActions.cloneJobLabel', { defaultMessage: 'Clone job' @@ -92,7 +95,8 @@ export function actionsMenuContent(showEditJobFlyout, showDeleteJobModal, showSt onClick: (item) => { cloneJob(item.id); closeMenu(true); - } + }, + 'data-test-subj': 'mlActionButtonCloneJob' }, { name: i18n.translate('xpack.ml.jobsList.managementActions.editJobLabel', { defaultMessage: 'Edit job' @@ -105,7 +109,8 @@ export function actionsMenuContent(showEditJobFlyout, showDeleteJobModal, showSt onClick: (item) => { showEditJobFlyout(item); closeMenu(); - } + }, + 'data-test-subj': 'mlActionButtonEditJob' }, { name: i18n.translate('xpack.ml.jobsList.managementActions.deleteJobLabel', { defaultMessage: 'Delete job' @@ -119,7 +124,8 @@ export function actionsMenuContent(showEditJobFlyout, showDeleteJobModal, showSt onClick: (item) => { showDeleteJobModal([item]); closeMenu(); - } + }, + 'data-test-subj': 'mlActionButtonDeleteJob' } ]; } diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_details/job_details.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_details/job_details.js index 97e7c1b72e5651..192310937c0e93 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_details/job_details.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_details/job_details.js @@ -51,7 +51,7 @@ class JobDetailsUI extends Component { const { job } = this.state; if (job === undefined) { return ( -
+
); @@ -77,35 +77,35 @@ class JobDetailsUI extends Component { const tabs = [{ id: 'job-settings', - 'data-test-subj': 'tab-job-settings', + 'data-test-subj': 'mlJobListTab-job-settings', name: intl.formatMessage({ id: 'xpack.ml.jobsList.jobDetails.tabs.jobSettingsLabel', defaultMessage: 'Job settings' }), - content: , + content: , time: job.open_time }, { id: 'job-config', - 'data-test-subj': 'tab-job-config', + 'data-test-subj': 'mlJobListTab-job-config', name: intl.formatMessage({ id: 'xpack.ml.jobsList.jobDetails.tabs.jobConfigLabel', defaultMessage: 'Job config' }), content: , }, { id: 'counts', - 'data-test-subj': 'tab-counts', + 'data-test-subj': 'mlJobListTab-counts', name: intl.formatMessage({ id: 'xpack.ml.jobsList.jobDetails.tabs.countsLabel', defaultMessage: 'Counts' }), - content: , + content: , }, { id: 'json', - 'data-test-subj': 'tab-json', + 'data-test-subj': 'mlJobListTab-json', name: intl.formatMessage({ id: 'xpack.ml.jobsList.jobDetails.tabs.jsonLabel', defaultMessage: 'JSON' @@ -113,7 +113,7 @@ class JobDetailsUI extends Component { content: , }, { id: 'job-messages', - 'data-test-subj': 'tab-job-messages', + 'data-test-subj': 'mlJobListTab-job-messages', name: intl.formatMessage({ id: 'xpack.ml.jobsList.jobDetails.tabs.jobMessagesLabel', defaultMessage: 'Job messages' @@ -126,17 +126,17 @@ class JobDetailsUI extends Component { // Datafeed should be at index 2 in tabs array for full details tabs.splice(2, 0, { id: 'datafeed', - 'data-test-subj': 'tab-datafeed', + 'data-test-subj': 'mlJobListTab-datafeed', name: intl.formatMessage({ id: 'xpack.ml.jobsList.jobDetails.tabs.datafeedLabel', defaultMessage: 'Datafeed' }), - content: , + content: , }); tabs.push({ id: 'datafeed-preview', - 'data-test-subj': 'tab-datafeed-preview', + 'data-test-subj': 'mlJobListTab-datafeed-preview', name: intl.formatMessage({ id: 'xpack.ml.jobsList.jobDetails.tabs.datafeedPreviewLabel', defaultMessage: 'Datafeed preview' @@ -144,7 +144,7 @@ class JobDetailsUI extends Component { content: , }, { id: 'forecasts', - 'data-test-subj': 'tab-forecasts', + 'data-test-subj': 'mlJobListTab-forecasts', name: intl.formatMessage({ id: 'xpack.ml.jobsList.jobDetails.tabs.forecastsLabel', defaultMessage: 'Forecasts' @@ -156,7 +156,7 @@ class JobDetailsUI extends Component { if (mlAnnotationsEnabled && showFullDetails) { tabs.push({ id: 'annotations', - 'data-test-subj': 'tab-annotations', + 'data-test-subj': 'mlJobListTab-annotations', name: intl.formatMessage({ id: 'xpack.ml.jobsList.jobDetails.tabs.annotationsLabel', defaultMessage: 'Annotations' @@ -171,7 +171,7 @@ class JobDetailsUI extends Component { } return ( -
+

{section.title}

-
+
{ section.items.map((item, i) => ()) } diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_group/job_group.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_group/job_group.js index 9ba7aba9dc8765..b0e10a975b8633 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_group/job_group.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_group/job_group.js @@ -16,7 +16,7 @@ export function JobGroup({ name }) { return (
), width: '3%' }, { field: 'id', - 'data-test-subj': 'id', + 'data-test-subj': 'mlJobListColumnId', name: intl.formatMessage({ id: 'xpack.ml.jobsList.idLabel', defaultMessage: 'ID' @@ -195,7 +195,7 @@ class JobsListUI extends Component { }), sortable: true, field: 'description', - 'data-test-subj': 'description', + 'data-test-subj': 'mlJobListColumnDescription', render: (description, item) => ( ), @@ -203,7 +203,7 @@ class JobsListUI extends Component { width: '20%' }, { field: 'processed_record_count', - 'data-test-subj': 'recordCount', + 'data-test-subj': 'mlJobListColumnRecordCount', name: intl.formatMessage({ id: 'xpack.ml.jobsList.processedRecordsLabel', defaultMessage: 'Processed records' @@ -215,7 +215,7 @@ class JobsListUI extends Component { width: '10%' }, { field: 'memory_status', - 'data-test-subj': 'memoryStatus', + 'data-test-subj': 'mlJobListColumnMemoryStatus', name: intl.formatMessage({ id: 'xpack.ml.jobsList.memoryStatusLabel', defaultMessage: 'Memory status' @@ -225,7 +225,7 @@ class JobsListUI extends Component { width: '5%' }, { field: 'jobState', - 'data-test-subj': 'jobState', + 'data-test-subj': 'mlJobListColumnJobState', name: intl.formatMessage({ id: 'xpack.ml.jobsList.jobStateLabel', defaultMessage: 'Job state' @@ -235,7 +235,7 @@ class JobsListUI extends Component { width: '8%' }, { field: 'datafeedState', - 'data-test-subj': 'datafeedState', + 'data-test-subj': 'mlJobListColumnDatafeedState', name: intl.formatMessage({ id: 'xpack.ml.jobsList.datafeedStateLabel', defaultMessage: 'Datafeed state' @@ -278,7 +278,7 @@ class JobsListUI extends Component { }), truncateText: false, field: 'latestTimestampSortValue', - 'data-test-subj': 'latestTimestamp', + 'data-test-subj': 'mlJobListColumnLatestTimestamp', sortable: true, render: (time, item) => ( @@ -355,7 +355,7 @@ class JobsListUI extends Component { sorting={sorting} hasActions={true} rowProps={item => ({ - 'data-test-subj': `row row-${item.id}` + 'data-test-subj': `mlJobListRow row-${item.id}` })} /> ); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/detector_title.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/detector_title.tsx index 040d980cefab71..feac3b30dfa3bd 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/detector_title.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/detector_title.tsx @@ -33,7 +33,7 @@ export const DetectorTitle: FC = ({ return ( - + {getTitle(agg, field, splitField)} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers_select.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers_select.tsx index 63f1936dd5f3e8..f03565fd3a0330 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers_select.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers_select.tsx @@ -35,7 +35,7 @@ export const InfluencersSelect: FC = ({ fields, changeHandler, selectedIn selectedOptions={selection} onChange={onChange} isClearable={false} - data-test-subj="influencerSelect" + data-test-subj="mlInfluencerSelect" /> ); }; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/chart_grid.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/chart_grid.tsx index cb2ca459da4fe7..e4dd46b159a6c4 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/chart_grid.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/chart_grid.tsx @@ -54,7 +54,7 @@ export const ChartGrid: FC = ({ > {aggFieldPairList.map((af, i) => ( - + = ({ return ( {aggFieldPairList.map((af, i) => ( - + diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/split_cards.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/split_cards.tsx index a3c13fce53a9b6..1b3eac2626e471 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/split_cards.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/split_cards.tsx @@ -70,10 +70,14 @@ export const SplitCards: FC = memo( }; return (
storePanels(ref, marginBottom)} style={style}> - +
{fieldName}
@@ -85,7 +89,7 @@ export const SplitCards: FC = memo( return ( - + {(fieldValues.length === 0 || numberOfDetectors === 0) && {children}} {fieldValues.length > 0 && numberOfDetectors > 0 && splitField !== null && ( @@ -93,7 +97,7 @@ export const SplitCards: FC = memo(
= memo(
{fieldValues[0]}
diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/by_field.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/by_field.tsx index 0acabe60b00497..63576bb23f2422 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/by_field.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/by_field.tsx @@ -58,7 +58,7 @@ export const ByFieldSelector: FC = ({ detectorIndex }) => { changeHandler={setByField} selectedField={byField} isClearable={true} - testSubject="byFieldSelect" + testSubject="mlByFieldSelect" placeholder={i18n.translate( 'xpack.ml.newJob.wizard.pickFieldsStep.populationField.placeholder', { diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field.tsx index 229072c314cc5d..37ffa25e0eabfe 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field.tsx @@ -50,9 +50,9 @@ export const SplitFieldSelector: FC = () => { isClearable={canClearSelection} testSubject={ isMultiMetricJobCreator(jc) - ? 'multiMetricSplitFieldSelect' + ? 'mlMultiMetricSplitFieldSelect' : isPopulationJobCreator(jc) - ? 'populationSplitFieldSelect' + ? 'mlPopulationSplitFieldSelect' : undefined } /> diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range_picker.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range_picker.tsx index b17b47308643c7..26140b9557e908 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range_picker.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range_picker.tsx @@ -56,7 +56,7 @@ export const TimeRangePicker: FC = ({ setTimeRange, timeRange }) => { return ( -
+
{ // click counts tab - await testSubjects.click(this.detailsSelector(jobId, 'tab-counts')); + await testSubjects.click(this.detailsSelector(jobId, 'mlJobListTab-counts')); const countsTable = await testSubjects.find( - this.detailsSelector(jobId, 'details-counts > counts') + this.detailsSelector(jobId, 'mlJobDetails-counts > mlJobRowDetailsSection-counts') ); const modelSizeStatsTable = await testSubjects.find( - this.detailsSelector(jobId, 'details-counts > modelSizeStats') + this.detailsSelector(jobId, 'mlJobDetails-counts > mlJobRowDetailsSection-modelSizeStats') ); // parse a table by reading each row @@ -142,7 +143,7 @@ export function MachineLearningJobTableProvider({ getService }: FtrProviderConte public async ensureDetailsOpen(jobId: string) { await retry.try(async () => { if (!(await testSubjects.exists(this.detailsSelector(jobId)))) { - await testSubjects.click(this.rowSelector(jobId, 'detailsToggle')); + await testSubjects.click(this.rowSelector(jobId, 'mlJobListRowDetailsToggle')); } await testSubjects.existOrFail(this.detailsSelector(jobId)); @@ -152,7 +153,7 @@ export function MachineLearningJobTableProvider({ getService }: FtrProviderConte public async ensureDetailsClosed(jobId: string) { await retry.try(async () => { if (await testSubjects.exists(this.detailsSelector(jobId))) { - await testSubjects.click(this.rowSelector(jobId, 'detailsToggle')); + await testSubjects.click(this.rowSelector(jobId, 'mlJobListRowDetailsToggle')); await testSubjects.missingOrFail(this.detailsSelector(jobId)); } }); @@ -214,40 +215,16 @@ export function MachineLearningJobTableProvider({ getService }: FtrProviderConte } public async clickActionsMenu(jobId: string) { - const jobRow = await testSubjects.find(this.rowSelector(jobId)); - const actionsCell = await jobRow.findByCssSelector(`[id=${jobId}-actions]`); - const actionsMenuButton = await actionsCell.findByTagName('button'); - - log.debug(`Clicking actions menu button for job id ${jobId}`); - await actionsMenuButton.click(); + await testSubjects.click(this.rowSelector(jobId, 'euiCollapsedItemActionsButton')); if (!(await find.existsByDisplayedByCssSelector('[class~=euiContextMenuPanel]'))) { throw new Error(`expected euiContextMenuPanel to exist`); } } - public async clickActionsMenuEntry(jobId: string, entryText: string) { - await this.clickActionsMenu(jobId); - const actionsMenuPanel = await find.byCssSelector('[class~=euiContextMenuPanel]'); - const actionButtons = await actionsMenuPanel.findAllByTagName('button'); - - const filteredButtons = []; - for (const button of actionButtons) { - if ((await button.getVisibleText()) === entryText) { - filteredButtons.push(button); - } - } - - if (!(filteredButtons.length === 1)) { - throw new Error( - `expected action button ${entryText} to exist exactly once, but found ${filteredButtons.length} matching buttons` - ); - } - log.debug(`Clicking action button ${entryText} for job id ${jobId}`); - await filteredButtons[0].click(); - } - public async clickCloneJobAction(jobId: string) { - await this.clickActionsMenuEntry(jobId, 'Clone job'); + await this.clickActionsMenu(jobId); + await testSubjects.click('mlActionButtonCloneJob'); + await testSubjects.existOrFail('~mlPageJobWizard'); } })(); } diff --git a/x-pack/test/functional/services/machine_learning/job_wizard_common.ts b/x-pack/test/functional/services/machine_learning/job_wizard_common.ts index f0ecb1289c9c8d..7c85115a42aa0f 100644 --- a/x-pack/test/functional/services/machine_learning/job_wizard_common.ts +++ b/x-pack/test/functional/services/machine_learning/job_wizard_common.ts @@ -230,11 +230,11 @@ export function MachineLearningJobWizardCommonProvider({ getService }: FtrProvid }, async assertInfluencerInputExists() { - await testSubjects.existOrFail('influencerSelect > comboBoxInput'); + await testSubjects.existOrFail('mlInfluencerSelect > comboBoxInput'); }, async getSelectedInfluencers(): Promise { - return await comboBox.getComboBoxSelectedOptions('influencerSelect > comboBoxInput'); + return await comboBox.getComboBoxSelectedOptions('mlInfluencerSelect > comboBoxInput'); }, async assertInfluencerSelection(influencers: string[]) { @@ -242,7 +242,7 @@ export function MachineLearningJobWizardCommonProvider({ getService }: FtrProvid }, async addInfluencer(influencer: string) { - await comboBox.setCustom('influencerSelect > comboBoxInput', influencer); + await comboBox.setCustom('mlInfluencerSelect > comboBoxInput', influencer); expect(await this.getSelectedInfluencers()).to.contain(influencer); }, @@ -256,31 +256,13 @@ export function MachineLearningJobWizardCommonProvider({ getService }: FtrProvid detectorPosition: number, chartType: string ) { - await testSubjects.existOrFail(`detector ${detectorPosition}`); - await testSubjects.existOrFail(`detector ${detectorPosition} > detectorTitle`); + await testSubjects.existOrFail(`mlDetector ${detectorPosition}`); + await testSubjects.existOrFail(`mlDetector ${detectorPosition} > mlDetectorTitle`); expect( - await testSubjects.getVisibleText(`detector ${detectorPosition} > detectorTitle`) + await testSubjects.getVisibleText(`mlDetector ${detectorPosition} > mlDetectorTitle`) ).to.eql(aggAndFieldIdentifier); - await this.assertAnomalyChartExists(chartType, `detector ${detectorPosition}`); - }, - - async assertDetectorSplitExists(splitField: string) { - await testSubjects.existOrFail(`dataSplit > dataSplitTitle ${splitField}`); - await testSubjects.existOrFail(`dataSplit > splitCard front`); - await testSubjects.existOrFail(`dataSplit > splitCard back`); - }, - - async assertDetectorSplitFrontCardTitle(frontCardTitle: string) { - expect( - await testSubjects.getVisibleText(`dataSplit > splitCard front > splitCardTitle`) - ).to.eql(frontCardTitle); - }, - - async assertDetectorSplitNumberOfBackCards(numberOfBackCards: number) { - expect(await testSubjects.findAll(`dataSplit > splitCard back`)).to.have.length( - numberOfBackCards - ); + await this.assertAnomalyChartExists(chartType, `mlDetector ${detectorPosition}`); }, async assertCreateJobButtonExists() { @@ -288,11 +270,11 @@ export function MachineLearningJobWizardCommonProvider({ getService }: FtrProvid }, async assertDateRangeSelectionExists() { - await testSubjects.existOrFail('jobWizardDateRange'); + await testSubjects.existOrFail('mlJobWizardDateRange'); }, async getSelectedDateRange() { - const dateRange = await testSubjects.find('jobWizardDateRange'); + const dateRange = await testSubjects.find('mlJobWizardDateRange'); const [startPicker, endPicker] = await dateRange.findAllByClassName('euiFieldText'); return { startDate: await startPicker.getAttribute('value'), diff --git a/x-pack/test/functional/services/machine_learning/job_wizard_multi_metric.ts b/x-pack/test/functional/services/machine_learning/job_wizard_multi_metric.ts index d57e92aaa34688..d9df6a9d682a76 100644 --- a/x-pack/test/functional/services/machine_learning/job_wizard_multi_metric.ts +++ b/x-pack/test/functional/services/machine_learning/job_wizard_multi_metric.ts @@ -13,35 +13,35 @@ export function MachineLearningJobWizardMultiMetricProvider({ getService }: FtrP return { async assertSplitFieldInputExists() { - await testSubjects.existOrFail('multiMetricSplitFieldSelect > comboBoxInput'); + await testSubjects.existOrFail('mlMultiMetricSplitFieldSelect > comboBoxInput'); }, async assertSplitFieldSelection(identifier: string) { const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( - 'multiMetricSplitFieldSelect > comboBoxInput' + 'mlMultiMetricSplitFieldSelect > comboBoxInput' ); expect(comboBoxSelectedOptions.length).to.eql(1); expect(comboBoxSelectedOptions[0]).to.eql(identifier); }, async selectSplitField(identifier: string) { - await comboBox.set('multiMetricSplitFieldSelect > comboBoxInput', identifier); + await comboBox.set('mlMultiMetricSplitFieldSelect > comboBoxInput', identifier); await this.assertSplitFieldSelection(identifier); }, async assertDetectorSplitExists(splitField: string) { - await testSubjects.existOrFail(`dataSplit > dataSplitTitle ${splitField}`); - await testSubjects.existOrFail(`dataSplit > splitCard front`); + await testSubjects.existOrFail(`mlDataSplit > mlDataSplitTitle ${splitField}`); + await testSubjects.existOrFail(`mlDataSplit > mlSplitCard front`); }, async assertDetectorSplitFrontCardTitle(frontCardTitle: string) { expect( - await testSubjects.getVisibleText(`dataSplit > splitCard front > splitCardTitle`) + await testSubjects.getVisibleText(`mlDataSplit > mlSplitCard front > mlSplitCardTitle`) ).to.eql(frontCardTitle); }, async assertDetectorSplitNumberOfBackCards(numberOfBackCards: number) { - expect(await testSubjects.findAll(`dataSplit > splitCard back`)).to.have.length( + expect(await testSubjects.findAll(`mlDataSplit > mlSplitCard back`)).to.have.length( numberOfBackCards ); }, diff --git a/x-pack/test/functional/services/machine_learning/job_wizard_population.ts b/x-pack/test/functional/services/machine_learning/job_wizard_population.ts index 2b0a8c6521bd17..892bdaf394936a 100644 --- a/x-pack/test/functional/services/machine_learning/job_wizard_population.ts +++ b/x-pack/test/functional/services/machine_learning/job_wizard_population.ts @@ -13,31 +13,31 @@ export function MachineLearningJobWizardPopulationProvider({ getService }: FtrPr return { async assertPopulationFieldInputExists() { - await testSubjects.existOrFail('populationSplitFieldSelect > comboBoxInput'); + await testSubjects.existOrFail('mlPopulationSplitFieldSelect > comboBoxInput'); }, async assertPopulationFieldSelection(identifier: string) { const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( - 'populationSplitFieldSelect > comboBoxInput' + 'mlPopulationSplitFieldSelect > comboBoxInput' ); expect(comboBoxSelectedOptions.length).to.eql(1); expect(comboBoxSelectedOptions[0]).to.eql(identifier); }, async selectPopulationField(identifier: string) { - await comboBox.set('populationSplitFieldSelect > comboBoxInput', identifier); + await comboBox.set('mlPopulationSplitFieldSelect > comboBoxInput', identifier); await this.assertPopulationFieldSelection(identifier); }, async assertDetectorSplitFieldInputExists(detectorPosition: number) { await testSubjects.existOrFail( - `detector ${detectorPosition} > byFieldSelect > comboBoxInput` + `mlDetector ${detectorPosition} > mlByFieldSelect > comboBoxInput` ); }, async assertDetectorSplitFieldSelection(detectorPosition: number, identifier: string) { const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( - `detector ${detectorPosition} > byFieldSelect > comboBoxInput` + `mlDetector ${detectorPosition} > mlByFieldSelect > comboBoxInput` ); expect(comboBoxSelectedOptions.length).to.eql(1); expect(comboBoxSelectedOptions[0]).to.eql(identifier); @@ -45,21 +45,23 @@ export function MachineLearningJobWizardPopulationProvider({ getService }: FtrPr async selectDetectorSplitField(detectorPosition: number, identifier: string) { await comboBox.set( - `detector ${detectorPosition} > byFieldSelect > comboBoxInput`, + `mlDetector ${detectorPosition} > mlByFieldSelect > comboBoxInput`, identifier ); await this.assertDetectorSplitFieldSelection(detectorPosition, identifier); }, async assertDetectorSplitExists(detectorPosition: number) { - await testSubjects.existOrFail(`detector ${detectorPosition} > dataSplit`); - await testSubjects.existOrFail(`detector ${detectorPosition} > dataSplit > splitCard front`); + await testSubjects.existOrFail(`mlDetector ${detectorPosition} > mlDataSplit`); + await testSubjects.existOrFail( + `mlDetector ${detectorPosition} > mlDataSplit > mlSplitCard front` + ); }, async assertDetectorSplitFrontCardTitle(detectorPosition: number, frontCardTitle: string) { expect( await testSubjects.getVisibleText( - `detector ${detectorPosition} > dataSplit > splitCard front > splitCardTitle` + `mlDetector ${detectorPosition} > mlDataSplit > mlSplitCard front > mlSplitCardTitle` ) ).to.eql(frontCardTitle); }, @@ -69,7 +71,9 @@ export function MachineLearningJobWizardPopulationProvider({ getService }: FtrPr numberOfBackCards: number ) { expect( - await testSubjects.findAll(`detector ${detectorPosition} > dataSplit > splitCard back`) + await testSubjects.findAll( + `mlDetector ${detectorPosition} > mlDataSplit > mlSplitCard back` + ) ).to.have.length(numberOfBackCards); }, }; From 03ccb43e312d980c8f7ced725e71a163419f2ba0 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Wed, 2 Oct 2019 10:11:05 +0200 Subject: [PATCH 18/59] [Discover] De-angularize side bar search field (#46679) * Add DiscoverFieldSearch react component * Adapt field_chooser.js * Add jest test * Adapt scss --- .../kibana/public/discover/_discover.scss | 21 ++- .../kibana/public/discover/_hacks.scss | 14 -- .../field_chooser/_field_chooser.scss | 9 + .../discover_field_search.test.tsx | 58 +++++++ .../field_chooser/discover_field_search.tsx | 91 ++++++++++ .../discover_field_search_directive.ts | 33 ++++ .../field_chooser/field_chooser.html | 164 ++++++++---------- .../components/field_chooser/field_chooser.js | 65 ++++--- .../kibana/public/discover/index.html | 27 +-- .../styles/_legacy/components/_sidebar.scss | 4 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 12 files changed, 328 insertions(+), 160 deletions(-) create mode 100644 src/legacy/core_plugins/kibana/public/discover/components/field_chooser/discover_field_search.test.tsx create mode 100644 src/legacy/core_plugins/kibana/public/discover/components/field_chooser/discover_field_search.tsx create mode 100644 src/legacy/core_plugins/kibana/public/discover/components/field_chooser/discover_field_search_directive.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/_discover.scss b/src/legacy/core_plugins/kibana/public/discover/_discover.scss index abf24241071c21..12cac1c89275b3 100644 --- a/src/legacy/core_plugins/kibana/public/discover/_discover.scss +++ b/src/legacy/core_plugins/kibana/public/discover/_discover.scss @@ -19,9 +19,15 @@ discover-app { margin-top: 5px; } -// SASSTODO: replace the padding-top value with a variable .dscFieldList--popular { - padding-top: 10px; + padding-top: $euiSizeS; +} + +.dscFieldList--selected, +.dscFieldList--unpopular, +.dscFieldList--popular { + padding-left: $euiSizeS; + padding-right: $euiSizeS; } // SASSTODO: replace the z-index value with a variable @@ -151,9 +157,16 @@ discover-app { } } -// SASSTODO: replace the padding value with a variable +.dscFieldSearch { + padding: $euiSizeS; +} + +.dscFieldFilter { + margin-top: $euiSizeS; +} + .dscFieldDetails { - padding: 10px; + padding: $euiSizeS; background-color: $euiColorLightestShade; color: $euiTextColor; } diff --git a/src/legacy/core_plugins/kibana/public/discover/_hacks.scss b/src/legacy/core_plugins/kibana/public/discover/_hacks.scss index cdc8e04dff5789..baf27bb9f82da1 100644 --- a/src/legacy/core_plugins/kibana/public/discover/_hacks.scss +++ b/src/legacy/core_plugins/kibana/public/discover/_hacks.scss @@ -3,18 +3,4 @@ overflow: hidden; } -// SASSTODO: these are Angular Bootstrap classes. Will be replaced by EUI -.dscFieldDetails { - .progress { - background-color: shade($euiColorLightestShade, 5%); - margin-bottom: 0; - border-radius: 0; - } - .progress-bar { - padding-left: 10px; - text-align: right; - line-height: 20px; - max-width: 100%; - } -} diff --git a/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/_field_chooser.scss b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/_field_chooser.scss index 1946cccd319a4d..22f53512be46b1 100644 --- a/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/_field_chooser.scss +++ b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/_field_chooser.scss @@ -1,3 +1,8 @@ +.dscFieldChooser { + padding-left: $euiSizeS !important; + padding-right: $euiSizeS !important; +} + .dscFieldChooser__toggle { color: $euiColorMediumShade; margin-left: $euiSizeS !important; @@ -15,3 +20,7 @@ .dscProgressBarTooltip__anchor { display: block; } + +.dscToggleFieldFilterButton { + min-height: $euiSizeXL; +} diff --git a/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/discover_field_search.test.tsx b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/discover_field_search.test.tsx new file mode 100644 index 00000000000000..cf853d798a8abb --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/discover_field_search.test.tsx @@ -0,0 +1,58 @@ +/* + * 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 React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +// @ts-ignore +import { findTestSubject } from '@elastic/eui/lib/test'; +import { DiscoverFieldSearch } from './discover_field_search'; + +describe('DiscoverFieldSearch', () => { + function mountComponent() { + const props = { + onChange: jest.fn(), + onShowFilter: jest.fn(), + showFilter: false, + value: 'test', + }; + const comp = mountWithIntl(); + const input = findTestSubject(comp, 'fieldFilterSearchInput'); + const btn = findTestSubject(comp, 'toggleFieldFilterButton'); + return { comp, input, btn, props }; + } + + test('enter value', () => { + const { input, props } = mountComponent(); + input.simulate('change', { target: { value: 'new filter' } }); + expect(props.onChange).toBeCalledTimes(1); + }); + + // this should work, but doesn't, have to do some research + test('click toggle filter button', () => { + const { btn, props } = mountComponent(); + btn.simulate('click'); + expect(props.onShowFilter).toBeCalledTimes(1); + }); + + test('change showFilter value should change button label', () => { + const { btn, comp } = mountComponent(); + const prevFilterBtnHTML = btn.html(); + comp.setProps({ showFilter: true }); + expect(btn.html()).not.toBe(prevFilterBtnHTML); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/discover_field_search.tsx b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/discover_field_search.tsx new file mode 100644 index 00000000000000..666ccf0acfc7a3 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/discover_field_search.tsx @@ -0,0 +1,91 @@ +/* + * 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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonIcon, EuiFieldSearch, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; + +export interface Props { + /** + * triggered on input of user into search field + */ + onChange: (field: string, value: string) => void; + /** + * triggered when the "additional filter btn" is clicked + */ + onShowFilter: () => void; + /** + * determines whether additional filter fields are displayed + */ + showFilter: boolean; + /** + * the input value of the user + */ + value?: string; +} + +/** + * Component is Discover's side bar to search of available fields + * Additionally there's a button displayed that allows the user to show/hide more filter fields + */ +export function DiscoverFieldSearch({ showFilter, onChange, onShowFilter, value }: Props) { + if (typeof value !== 'string') { + // at initial rendering value is undefined (angular related), this catches the warning + // should be removed once all is react + return null; + } + const filterBtnAriaLabel = showFilter + ? i18n.translate('kbn.discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel', { + defaultMessage: 'Hide field filter settings', + }) + : i18n.translate('kbn.discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel', { + defaultMessage: 'Show field filter settings', + }); + const searchPlaceholder = i18n.translate('kbn.discover.fieldChooser.searchPlaceHolder', { + defaultMessage: 'Search fields', + }); + + return ( + + + onChange('name', event.currentTarget.value)} + placeholder={searchPlaceholder} + value={value} + /> + + + + onShowFilter()} + size="m" + /> + + + + ); +} diff --git a/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/discover_field_search_directive.ts b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/discover_field_search_directive.ts new file mode 100644 index 00000000000000..baf8f3040d6b02 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/discover_field_search_directive.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ +// @ts-ignore +import { uiModules } from 'ui/modules'; +import { wrapInI18nContext } from 'ui/i18n'; +import { DiscoverFieldSearch } from './discover_field_search'; + +const app = uiModules.get('apps/discover'); + +app.directive('discoverFieldSearch', function(reactDirective: any) { + return reactDirective(wrapInI18nContext(DiscoverFieldSearch), [ + ['onChange', { watchDepth: 'reference' }], + ['onShowFilter', { watchDepth: 'reference' }], + ['showFilter', { watchDepth: 'value' }], + ['value', { watchDepth: 'value' }], + ]); +}); diff --git a/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/field_chooser.html b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/field_chooser.html index 96aa1582b5243e..2043dc44c147e7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/field_chooser.html +++ b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/field_chooser.html @@ -5,6 +5,75 @@ index-pattern-list="indexPatternList" > + -
- -
-
- -
{ - $scope.toggleFieldFilterButtonAriaLabel = $scope.$parent.showFilter - ? i18n.translate('kbn.discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel', { - defaultMessage: 'Hide field settings', - }) - : i18n.translate('kbn.discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel', { - defaultMessage: 'Show field settings', - }); - }); + + $scope.showFilter = false; + $scope.toggleShowFilter = () => $scope.showFilter = !$scope.showFilter; $scope.selectedIndexPattern = $scope.indexPatternList.find( (pattern) => pattern.id === $scope.indexPattern.id @@ -81,40 +76,28 @@ app.directive('discFieldChooser', function ($location, config, $route) { ], defaults: { missing: true, - type: 'any' + type: 'any', + name: '' }, boolOpts: [ { label: 'any', value: undefined }, { label: 'yes', value: true }, { label: 'no', value: false } ], - toggleVal: function (name, def) { - if (filter.vals[name] !== def) filter.vals[name] = def; - else filter.vals[name] = undefined; - }, reset: function () { filter.vals = _.clone(filter.defaults); }, - isFieldSelected: function (field) { - return field.display; - }, - isFieldFiltered: function (field) { - const matchFilter = (filter.vals.type === 'any' || field.type === filter.vals.type); - const isAggregatable = (filter.vals.aggregatable == null || field.aggregatable === filter.vals.aggregatable); - const isSearchable = (filter.vals.searchable == null || field.searchable === filter.vals.searchable); - const scriptedOrMissing = (!filter.vals.missing || field.scripted || field.rowCount > 0); - const matchName = (!filter.vals.name || field.name.indexOf(filter.vals.name) !== -1); - - return !field.display - && matchFilter - && isAggregatable - && isSearchable - && scriptedOrMissing - && matchName - ; + /** + * filter for fields that are displayed / selected for the data table + */ + isFieldFilteredAndDisplayed: function (field) { + return field.display && isFieldFiltered(field); }, - popularity: function (field) { - return field.count > 0; + /** + * filter for fields that are not displayed / selected for the data table + */ + isFieldFilteredAndNotDisplayed: function (field) { + return !field.display && isFieldFiltered(field) && field.type !== '_source'; }, getActive: function () { return _.some(filter.props, function (prop) { @@ -123,6 +106,20 @@ app.directive('discFieldChooser', function ($location, config, $route) { } }; + function isFieldFiltered(field) { + const matchFilter = (filter.vals.type === 'any' || field.type === filter.vals.type); + const isAggregatable = (filter.vals.aggregatable == null || field.aggregatable === filter.vals.aggregatable); + const isSearchable = (filter.vals.searchable == null || field.searchable === filter.vals.searchable); + const scriptedOrMissing = !filter.vals.missing || field.type === '_source' || field.scripted || field.rowCount > 0; + const matchName = (!filter.vals.name || field.name.indexOf(filter.vals.name) !== -1); + + return matchFilter && isAggregatable && isSearchable && scriptedOrMissing && matchName; + } + + $scope.setFilterValue = (name, value) => { + filter.vals[name] = value; + }; + // set the initial values to the defaults filter.reset(); diff --git a/src/legacy/core_plugins/kibana/public/discover/index.html b/src/legacy/core_plugins/kibana/public/discover/index.html index a9172a107e7ba5..4cce312accf5c4 100644 --- a/src/legacy/core_plugins/kibana/public/discover/index.html +++ b/src/legacy/core_plugins/kibana/public/discover/index.html @@ -28,19 +28,20 @@
diff --git a/src/legacy/ui/public/styles/_legacy/components/_sidebar.scss b/src/legacy/ui/public/styles/_legacy/components/_sidebar.scss index a21f8fed525e7d..571064a1f29c4e 100644 --- a/src/legacy/ui/public/styles/_legacy/components/_sidebar.scss +++ b/src/legacy/ui/public/styles/_legacy/components/_sidebar.scss @@ -1,8 +1,8 @@ // ONLY USED IN DISCOVER .sidebar-container { - padding-left: $euiSizeS !important; - padding-right: $euiSizeS !important; + padding-left: 0 !important; + padding-right: 0 !important; background-color: $euiColorLightestShade; border-right-color: transparent; border-bottom-color: transparent; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9b57ab465b707b..57632ab6f640f4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1535,7 +1535,6 @@ "kbn.discover.fieldChooser.fieldCalculator.fieldIsNotPresentInDocumentsErrorMessage": "このフィールドは Elasticsearch マッピングに表示されますが、ドキュメントテーブルの {hitsLength} 件のドキュメントには含まれません。可視化や検索は可能な場合があります。", "kbn.discover.fieldChooser.filter.aggregatableLabel": "集約可能", "kbn.discover.fieldChooser.filter.availableFieldsTitle": "利用可能なフィールド", - "kbn.discover.fieldChooser.filter.fieldNameLabel": "フィールド名", "kbn.discover.fieldChooser.filter.hideMissingFieldsLabel": "未入力のフィールドを非表示", "kbn.discover.fieldChooser.filter.indexAndFieldsSectionAriaLabel": "インデックスとフィールド", "kbn.discover.fieldChooser.filter.popularTitle": "人気", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3cd3ad3fdcd35e..606abe57875235 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1536,7 +1536,6 @@ "kbn.discover.fieldChooser.fieldCalculator.fieldIsNotPresentInDocumentsErrorMessage": "此字段在您的 Elasticsearch 映射中,但不在文档表中显示的 {hitsLength} 个文档中。您可能仍能够基于其进行可视化或搜索。", "kbn.discover.fieldChooser.filter.aggregatableLabel": "可聚合", "kbn.discover.fieldChooser.filter.availableFieldsTitle": "可用字段", - "kbn.discover.fieldChooser.filter.fieldNameLabel": "字段名称", "kbn.discover.fieldChooser.filter.hideMissingFieldsLabel": "隐藏缺失字段", "kbn.discover.fieldChooser.filter.indexAndFieldsSectionAriaLabel": "索引和字段", "kbn.discover.fieldChooser.filter.popularTitle": "常用", From 28c4bbfca6eea59161ca29688838d2ed3791de3b Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Wed, 2 Oct 2019 10:43:57 +0200 Subject: [PATCH 19/59] [ML] Job type page (#46933) * [ML] wip job types to react * [ML] delete angular job_type page * [ML] TS refactoring * [ML] restrict page width * [ML] refactor with CreateJobLinkCard * [ML] data-test-subj for functional tests * [ML] fix recognized results * [ML] missing i18n * [ML] add custom logo support for create job link card, change data recognizer layout * [ML] rename iconType prop * [ML] remove unused styles * [ML] fix page background * [ML] data recognizer wrappers * [ML] fix IE issue, IndexPatternSavedObject * [ML] fix callout * [ML] job type directive test * [ML] fix types --- x-pack/legacy/plugins/ml/public/_hacks.scss | 13 +- .../public/{breadcrumbs.js => breadcrumbs.ts} | 16 +- .../create_job_link_card.tsx | 82 ++++- .../data_recognizer/_data_recognizer.scss | 31 -- .../components/data_recognizer/_index.scss | 1 - .../data_recognizer/data_recognizer.d.ts | 9 +- .../data_recognizer/data_recognizer.js | 4 +- .../data_recognizer/recognized_result.js | 36 +- .../actions_panel/actions_panel.tsx | 13 +- x-pack/legacy/plugins/ml/public/index.scss | 1 - .../jobs/{breadcrumbs.js => breadcrumbs.ts} | 71 ++-- .../public/jobs/new_job/wizard/_wizard.scss | 53 --- .../ml/public/jobs/new_job/wizard/index.js | 1 - .../job_type/__tests__/job_type_controller.js | 46 --- .../new_job/wizard/steps/job_type/index.js | 9 - .../wizard/steps/job_type/job_type.html | 274 ---------------- .../steps/job_type/job_type_controller.js | 103 ------ .../ml/public/jobs/new_job_new/index.ts | 2 + .../pages/job_type/__test__/directive.js | 45 +++ .../new_job_new/pages/job_type/directive.tsx | 65 ++++ .../jobs/new_job_new/pages/job_type/page.tsx | 308 ++++++++++++++++++ .../jobs/new_job_new/pages/job_type/route.ts | 27 ++ .../jobs/new_job_new/pages/new_job/route.ts | 2 - .../plugins/ml/public/util/index_utils.js | 108 ------ .../plugins/ml/public/util/index_utils.ts | 110 +++++++ ...ently_accessed.js => recently_accessed.ts} | 12 +- 26 files changed, 703 insertions(+), 739 deletions(-) rename x-pack/legacy/plugins/ml/public/{breadcrumbs.js => breadcrumbs.ts} (76%) delete mode 100644 x-pack/legacy/plugins/ml/public/components/data_recognizer/_data_recognizer.scss delete mode 100644 x-pack/legacy/plugins/ml/public/components/data_recognizer/_index.scss rename x-pack/legacy/plugins/ml/public/jobs/{breadcrumbs.js => breadcrumbs.ts} (55%) delete mode 100644 x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/__tests__/job_type_controller.js delete mode 100644 x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/index.js delete mode 100644 x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html delete mode 100644 x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type_controller.js create mode 100644 x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/__test__/directive.js create mode 100644 x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/directive.tsx create mode 100644 x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/page.tsx create mode 100644 x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/route.ts delete mode 100644 x-pack/legacy/plugins/ml/public/util/index_utils.js create mode 100644 x-pack/legacy/plugins/ml/public/util/index_utils.ts rename x-pack/legacy/plugins/ml/public/util/{recently_accessed.js => recently_accessed.ts} (80%) diff --git a/x-pack/legacy/plugins/ml/public/_hacks.scss b/x-pack/legacy/plugins/ml/public/_hacks.scss index b0a8a43d23096d..39740360d8a840 100644 --- a/x-pack/legacy/plugins/ml/public/_hacks.scss +++ b/x-pack/legacy/plugins/ml/public/_hacks.scss @@ -1,21 +1,10 @@ .tab-datavisualizer_index_select, .tab-timeseriesexplorer, -.tab-explorer, -.tab-jobs { +.tab-explorer, { // Make all page background white until More of the pages use EuiPage to wrap in panel-like components background-color: $euiColorEmptyShade; } -.tab-jobs { - label { - display: inline-block; - } - - .validation-error { - margin-top: $euiSizeXS; - } -} - // ML specific bootstrap hacks .button-wrapper { display: inline; diff --git a/x-pack/legacy/plugins/ml/public/breadcrumbs.js b/x-pack/legacy/plugins/ml/public/breadcrumbs.ts similarity index 76% rename from x-pack/legacy/plugins/ml/public/breadcrumbs.js rename to x-pack/legacy/plugins/ml/public/breadcrumbs.ts index bdde734be7c1a3..ba4703d4818ff5 100644 --- a/x-pack/legacy/plugins/ml/public/breadcrumbs.js +++ b/x-pack/legacy/plugins/ml/public/breadcrumbs.ts @@ -8,28 +8,28 @@ import { i18n } from '@kbn/i18n'; export const ML_BREADCRUMB = Object.freeze({ text: i18n.translate('xpack.ml.machineLearningBreadcrumbLabel', { - defaultMessage: 'Machine Learning' + defaultMessage: 'Machine Learning', }), - href: '#/' + href: '#/', }); export const SETTINGS = Object.freeze({ text: i18n.translate('xpack.ml.settingsBreadcrumbLabel', { - defaultMessage: 'Settings' + defaultMessage: 'Settings', }), - href: '#/settings?' + href: '#/settings?', }); export const ANOMALY_DETECTION_BREADCRUMB = Object.freeze({ text: i18n.translate('xpack.ml.anomalyDetectionBreadcrumbLabel', { - defaultMessage: 'Anomaly Detection' + defaultMessage: 'Anomaly Detection', }), - href: '#/jobs?' + href: '#/jobs?', }); export const DATA_VISUALIZER_BREADCRUMB = Object.freeze({ text: i18n.translate('xpack.ml.datavisualizerBreadcrumbLabel', { - defaultMessage: 'Data Visualizer' + defaultMessage: 'Data Visualizer', }), - href: '#/datavisualizer?' + href: '#/datavisualizer?', }); diff --git a/x-pack/legacy/plugins/ml/public/components/create_job_link_card/create_job_link_card.tsx b/x-pack/legacy/plugins/ml/public/components/create_job_link_card/create_job_link_card.tsx index 6549df35ba381c..07a924caae7724 100644 --- a/x-pack/legacy/plugins/ml/public/components/create_job_link_card/create_job_link_card.tsx +++ b/x-pack/legacy/plugins/ml/public/components/create_job_link_card/create_job_link_card.tsx @@ -4,25 +4,77 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC } from 'react'; +import React, { FC, ReactElement } from 'react'; -import { EuiCard, EuiIcon, IconType } from '@elastic/eui'; +import { + EuiIcon, + IconType, + EuiText, + EuiTitle, + EuiFlexItem, + EuiFlexGroup, + EuiPanel, + EuiLink, +} from '@elastic/eui'; interface Props { - iconType: IconType; - title: string; - description: string; - onClick(): void; + icon: IconType | ReactElement; + iconAreaLabel?: string; + title: any; + description: any; + href?: string; + onClick?: () => void; + isDisabled?: boolean; + 'data-test-subj'?: string; } // Component for rendering a card which links to the Create Job page, displaying an // icon, card title, description and link. -export const CreateJobLinkCard: FC = ({ iconType, title, description, onClick }) => ( - } - title={title} - description={description} - onClick={onClick} - /> -); +export const CreateJobLinkCard: FC = ({ + icon, + iconAreaLabel, + title, + description, + onClick, + href, + isDisabled, + 'data-test-subj': dateTestSubj, +}) => { + const linkHrefAndOnClickProps = { + ...(href ? { href } : {}), + ...(onClick ? { onClick } : {}), + }; + return ( + + + + + {typeof icon === 'string' ? ( + + ) : ( + icon + )} + + + +

{title}

+
+ +

{description}

+
+
+
+
+
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/components/data_recognizer/_data_recognizer.scss b/x-pack/legacy/plugins/ml/public/components/data_recognizer/_data_recognizer.scss deleted file mode 100644 index b915be2ab84536..00000000000000 --- a/x-pack/legacy/plugins/ml/public/components/data_recognizer/_data_recognizer.scss +++ /dev/null @@ -1,31 +0,0 @@ -ml-data-recognizer { - .ml-data-recognizer-logo { - width: $euiSizeXL; - } -} - -// Moved here from /home since it's no longer being used there -.synopsis { - display: flex; - flex-grow: 1; - cursor: pointer; - - &:hover, - &:focus { - text-decoration: none; - - .synopsisTitle { - text-decoration: underline; - } - } -} - -.synopsisTitle { - font-size: $euiSize; - font-weight: normal; - color: $euiColorPrimary; -} - -.synopsisIcon { - padding-top: $euiSizeS; -} diff --git a/x-pack/legacy/plugins/ml/public/components/data_recognizer/_index.scss b/x-pack/legacy/plugins/ml/public/components/data_recognizer/_index.scss deleted file mode 100644 index 67cc4372ea6225..00000000000000 --- a/x-pack/legacy/plugins/ml/public/components/data_recognizer/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'data_recognizer'; \ No newline at end of file diff --git a/x-pack/legacy/plugins/ml/public/components/data_recognizer/data_recognizer.d.ts b/x-pack/legacy/plugins/ml/public/components/data_recognizer/data_recognizer.d.ts index c8a7bba2d189f5..e7d191a31e034e 100644 --- a/x-pack/legacy/plugins/ml/public/components/data_recognizer/data_recognizer.d.ts +++ b/x-pack/legacy/plugins/ml/public/components/data_recognizer/data_recognizer.d.ts @@ -7,9 +7,14 @@ import { FC } from 'react'; import { IndexPattern } from 'ui/index_patterns'; +import { SavedSearch } from 'src/legacy/core_plugins/kibana/public/discover/types'; declare const DataRecognizer: FC<{ indexPattern: IndexPattern; - results: any; - className: string; + savedSearch?: SavedSearch; + results: { + count: number; + onChange?: Function; + }; + className?: string; }>; diff --git a/x-pack/legacy/plugins/ml/public/components/data_recognizer/data_recognizer.js b/x-pack/legacy/plugins/ml/public/components/data_recognizer/data_recognizer.js index fd754ee5191046..b303ed9b7f008b 100644 --- a/x-pack/legacy/plugins/ml/public/components/data_recognizer/data_recognizer.js +++ b/x-pack/legacy/plugins/ml/public/components/data_recognizer/data_recognizer.js @@ -57,9 +57,9 @@ export class DataRecognizer extends Component { render() { return ( -
+ <> {this.state.results} -
+ ); } } diff --git a/x-pack/legacy/plugins/ml/public/components/data_recognizer/recognized_result.js b/x-pack/legacy/plugins/ml/public/components/data_recognizer/recognized_result.js index 60dc38f2291f89..6f511abf89e310 100644 --- a/x-pack/legacy/plugins/ml/public/components/data_recognizer/recognized_result.js +++ b/x-pack/legacy/plugins/ml/public/components/data_recognizer/recognized_result.js @@ -11,7 +11,9 @@ import PropTypes from 'prop-types'; import { EuiIcon, + EuiFlexItem } from '@elastic/eui'; +import { CreateJobLinkCard } from '../create_job_link_card'; export const RecognizedResult = ({ config, @@ -28,35 +30,23 @@ export const RecognizedResult = ({ // if a logo is available, use that, otherwise display the id // the logo should be a base64 encoded image or an eui icon if(config.logo && config.logo.icon) { - logo =
; + logo = ; } else if (config.logo && config.logo.src) { - logo =
; + logo = ; } else { logo =

{config.id}

; } return ( - + + + ); }; diff --git a/x-pack/legacy/plugins/ml/public/datavisualizer/index_based/components/actions_panel/actions_panel.tsx b/x-pack/legacy/plugins/ml/public/datavisualizer/index_based/components/actions_panel/actions_panel.tsx index 9a291cabf558f9..c8295a1e3d8db3 100644 --- a/x-pack/legacy/plugins/ml/public/datavisualizer/index_based/components/actions_panel/actions_panel.tsx +++ b/x-pack/legacy/plugins/ml/public/datavisualizer/index_based/components/actions_panel/actions_panel.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { IndexPattern } from 'ui/index_patterns'; -import { EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { EuiPanel, EuiSpacer, EuiText, EuiTitle, EuiFlexGroup } from '@elastic/eui'; import { useUiChromeContext } from '../../../../contexts/ui/use_ui_chrome_context'; import { CreateJobLinkCard } from '../../../../components/create_job_link_card'; @@ -63,11 +63,9 @@ export const ActionsPanel: FC = ({ indexPattern }) => {

- + + +
@@ -80,7 +78,7 @@ export const ActionsPanel: FC = ({ indexPattern }) => { = ({ indexPattern }) => { 'Use the full range of options to create a job for more advanced use cases', })} onClick={openAdvancedJobWizard} + href={`${basePath}/app/ml#/jobs/new_job/advanced?index=${indexPattern}`} /> ); diff --git a/x-pack/legacy/plugins/ml/public/index.scss b/x-pack/legacy/plugins/ml/public/index.scss index 4cab633d5fa569..a3fefb7b1fac86 100644 --- a/x-pack/legacy/plugins/ml/public/index.scss +++ b/x-pack/legacy/plugins/ml/public/index.scss @@ -36,7 +36,6 @@ @import 'components/chart_tooltip/index'; @import 'components/confirm_modal/index'; @import 'components/controls/index'; - @import 'components/data_recognizer/index'; @import 'components/documentation_help_link/index'; @import 'components/entity_cell/index'; @import 'components/field_title_bar/index'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/breadcrumbs.js b/x-pack/legacy/plugins/ml/public/jobs/breadcrumbs.ts similarity index 55% rename from x-pack/legacy/plugins/ml/public/jobs/breadcrumbs.js rename to x-pack/legacy/plugins/ml/public/jobs/breadcrumbs.ts index d066a524d70aab..35e9c3326a4ccf 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/breadcrumbs.js +++ b/x-pack/legacy/plugins/ml/public/jobs/breadcrumbs.ts @@ -4,12 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ - -import { ML_BREADCRUMB, DATA_VISUALIZER_BREADCRUMB, ANOMALY_DETECTION_BREADCRUMB } from '../breadcrumbs'; import { i18n } from '@kbn/i18n'; +import { Breadcrumb } from 'ui/chrome'; +import { + ANOMALY_DETECTION_BREADCRUMB, + DATA_VISUALIZER_BREADCRUMB, + ML_BREADCRUMB, +} from '../breadcrumbs'; - -export function getJobManagementBreadcrumbs() { +export function getJobManagementBreadcrumbs(): Breadcrumb[] { // Whilst top level nav menu with tabs remains, // use root ML breadcrumb. return [ @@ -17,93 +20,93 @@ export function getJobManagementBreadcrumbs() { ANOMALY_DETECTION_BREADCRUMB, { text: i18n.translate('xpack.ml.anomalyDetection.jobManagementLabel', { - defaultMessage: 'Job Management' + defaultMessage: 'Job Management', }), - href: '' - } + href: '', + }, ]; } -export function getCreateJobBreadcrumbs() { +export function getCreateJobBreadcrumbs(): Breadcrumb[] { return [ ML_BREADCRUMB, ANOMALY_DETECTION_BREADCRUMB, { text: i18n.translate('xpack.ml.jobsBreadcrumbs.createJobLabel', { - defaultMessage: 'Create job' + defaultMessage: 'Create job', }), - href: '#/jobs/new_job' - } + href: '#/jobs/new_job', + }, ]; } -export function getCreateSingleMetricJobBreadcrumbs() { +export function getCreateSingleMetricJobBreadcrumbs(): Breadcrumb[] { return [ ...getCreateJobBreadcrumbs(), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.singleMetricLabel', { - defaultMessage: 'Single metric' + defaultMessage: 'Single metric', }), - href: '' - } + href: '', + }, ]; } -export function getCreateMultiMetricJobBreadcrumbs() { +export function getCreateMultiMetricJobBreadcrumbs(): Breadcrumb[] { return [ ...getCreateJobBreadcrumbs(), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.multiMetricLabel', { - defaultMessage: 'Multi metric' + defaultMessage: 'Multi metric', }), - href: '' - } + href: '', + }, ]; } -export function getCreatePopulationJobBreadcrumbs() { +export function getCreatePopulationJobBreadcrumbs(): Breadcrumb[] { return [ ...getCreateJobBreadcrumbs(), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.populationLabel', { - defaultMessage: 'Population' + defaultMessage: 'Population', }), - href: '' - } + href: '', + }, ]; } -export function getAdvancedJobConfigurationBreadcrumbs() { +export function getAdvancedJobConfigurationBreadcrumbs(): Breadcrumb[] { return [ ...getCreateJobBreadcrumbs(), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.advancedConfigurationLabel', { - defaultMessage: 'Advanced configuration' + defaultMessage: 'Advanced configuration', }), - href: '' - } + href: '', + }, ]; } -export function getCreateRecognizerJobBreadcrumbs($routeParams) { +export function getCreateRecognizerJobBreadcrumbs($routeParams: any): Breadcrumb[] { return [ ...getCreateJobBreadcrumbs(), { text: $routeParams.id, - href: '' - } + href: '', + }, ]; } -export function getDataVisualizerIndexOrSearchBreadcrumbs() { +export function getDataVisualizerIndexOrSearchBreadcrumbs(): Breadcrumb[] { return [ ML_BREADCRUMB, DATA_VISUALIZER_BREADCRUMB, { text: i18n.translate('xpack.ml.jobsBreadcrumbs.selectIndexOrSearchLabel', { - defaultMessage: 'Select index or search' + defaultMessage: 'Select index or search', }), - href: '' - } + href: '', + }, ]; } diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/_wizard.scss b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/_wizard.scss index 7eadb9a8ce77a0..def24f6d6a7476 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/_wizard.scss +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/_wizard.scss @@ -1,56 +1,3 @@ -.job-type-gallery { - width: 100%; - padding-right: $euiSizeS; - padding-left: $euiSizeS; - background-color: $euiColorLightestShade; - flex: 1 0 auto; - - .job-types-content { - max-width: 1200px; // SASSTODO: Proper calc - margin-right: auto; - margin-left: auto; - } - - .synopsis { - display: flex; - flex-grow: 1; - - .synopsisTitle { - font-size: $euiFontSize; - font-weight: normal; - color: $euiColorPrimary; - } - - .synopsisIcon { - padding-top: $euiSizeS; - } - } - - .synopsis:hover { - text-decoration: none; - - .synopsisTitle { - text-decoration: underline; - } - } - - .euiFlexItem.disabled { - cursor: not-allowed; - } - - .synopsis.disabled { - pointer-events: none; - - .synopsisTitle { - color: $euiColorDarkShade; - } - } - - .index-warning { - border: $euiBorderThin; - } -} - .index-or-saved-search-selection { .kuiBarSection .kuiButtonGroup { display: none; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/index.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/index.js index c2fb4646306692..61ce488f69014b 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/index.js +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/index.js @@ -9,5 +9,4 @@ // SASS TODO: Import wizard.scss instead // import 'plugins/kibana/visualize/wizard/wizard.less'; import './steps/index_or_search'; -import './steps/job_type'; import 'plugins/ml/components/data_recognizer'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/__tests__/job_type_controller.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/__tests__/job_type_controller.js deleted file mode 100644 index f8fd13b2ae36e1..00000000000000 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/__tests__/job_type_controller.js +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - - -import ngMock from 'ng_mock'; -import expect from '@kbn/expect'; -import sinon from 'sinon'; - -// Import this way to be able to stub/mock functions later on in the tests using sinon. -import * as indexUtils from 'plugins/ml/util/index_utils'; - -describe('ML - Job Type Controller', () => { - beforeEach(() => { - ngMock.module('kibana'); - }); - - it('Initialize Job Type Controller', (done) => { - const stub = sinon.stub(indexUtils, 'timeBasedIndexCheck').callsFake(() => false); - ngMock.inject(function ($rootScope, $controller, $route) { - // Set up the $route current props required for the tests. - $route.current = { - locals: { - indexPattern: { - id: 'test_id', - title: 'test_pattern' - }, - savedSearch: {} - } - }; - - const scope = $rootScope.$new(); - - expect(() => { - $controller('MlNewJobStepJobType', { $scope: scope }); - }).to.not.throwError(); - - expect(scope.indexWarningTitle).to.eql('Index pattern test_pattern is not time based'); - stub.restore(); - done(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/index.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/index.js deleted file mode 100644 index 9a9fa7e73b9f1e..00000000000000 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/index.js +++ /dev/null @@ -1,9 +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 './job_type_controller'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html deleted file mode 100644 index 1dc3aea215d93a..00000000000000 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html +++ /dev/null @@ -1,274 +0,0 @@ - - - -
- -
-

-
- -
-
-
-
- - - - - - - {{indexWarningTitle}} -
-
-

- -
- -

-
-
-
-
-
- -
-
-

-

-

-
-
- -
-
- -
-

-

-
- -
- - - -
- -
-

-

-
-
- - - -
- -
-
diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type_controller.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type_controller.js deleted file mode 100644 index df7768ee9f0c84..00000000000000 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type_controller.js +++ /dev/null @@ -1,103 +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. - */ - - - -/* - * Controller for the second step in the Create Job wizard, allowing - * the user to select the type of job they wish to create. - */ - -import uiRoutes from 'ui/routes'; -import { i18n } from '@kbn/i18n'; -import { checkLicenseExpired } from 'plugins/ml/license/check_license'; -import { checkCreateJobsPrivilege } from 'plugins/ml/privilege/check_privilege'; -import { getCreateJobBreadcrumbs } from 'plugins/ml/jobs/breadcrumbs'; -import { SearchItemsProvider } from 'plugins/ml/jobs/new_job/utils/new_job_utils'; -import { loadCurrentIndexPattern, loadCurrentSavedSearch, timeBasedIndexCheck } from 'plugins/ml/util/index_utils'; -import { addItemToRecentlyAccessed } from 'plugins/ml/util/recently_accessed'; -import { checkMlNodesAvailable } from 'plugins/ml/ml_nodes_check/check_ml_nodes'; -import template from './job_type.html'; -import { timefilter } from 'ui/timefilter'; - -uiRoutes - .when('/jobs/new_job/step/job_type', { - template, - k7Breadcrumbs: getCreateJobBreadcrumbs, - resolve: { - CheckLicense: checkLicenseExpired, - privileges: checkCreateJobsPrivilege, - indexPattern: loadCurrentIndexPattern, - savedSearch: loadCurrentSavedSearch, - checkMlNodesAvailable, - } - }); - - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -module.controller('MlNewJobStepJobType', - function ($scope, Private) { - - timefilter.disableTimeRangeSelector(); // remove time picker from top of page - timefilter.disableAutoRefreshSelector(); // remove time picker from top of page - - const createSearchItems = Private(SearchItemsProvider); - const { - indexPattern, - savedSearch } = createSearchItems(); - - // check to see that the index pattern is time based. - // if it isn't, display a warning and disable all links - $scope.indexWarningTitle = ''; - $scope.isTimeBasedIndex = timeBasedIndexCheck(indexPattern); - if ($scope.isTimeBasedIndex === false) { - $scope.indexWarningTitle = (savedSearch.id === undefined) ? - i18n.translate('xpack.ml.newJob.wizard.jobType.indexPatternNotTimeBasedMessage', { - defaultMessage: 'Index pattern {indexPatternTitle} is not time based', - values: { indexPatternTitle: indexPattern.title } - }) - : i18n.translate('xpack.ml.newJob.wizard.jobType.indexPatternFromSavedSearchNotTimeBasedMessage', { - defaultMessage: '{savedSearchTitle} uses index pattern {indexPatternTitle} which is not time based', - values: { - savedSearchTitle: savedSearch.title, - indexPatternTitle: indexPattern.title - } - }); - } - - $scope.indexPattern = indexPattern; - $scope.savedSearch = savedSearch; - $scope.recognizerResults = { - count: 0, - onChange() { - $scope.$applyAsync(); - } - }; - - $scope.pageTitleLabel = (savedSearch.id !== undefined) ? - i18n.translate('xpack.ml.newJob.wizard.jobType.savedSearchPageTitleLabel', { - defaultMessage: 'saved search {savedSearchTitle}', - values: { savedSearchTitle: savedSearch.title } - }) - : i18n.translate('xpack.ml.newJob.wizard.jobType.indexPatternPageTitleLabel', { - defaultMessage: 'index pattern {indexPatternTitle}', - values: { indexPatternTitle: indexPattern.title } - }); - - $scope.getUrl = function (basePath) { - return (savedSearch.id === undefined) ? `${basePath}?index=${indexPattern.id}` : - `${basePath}?savedSearchId=${savedSearch.id}`; - }; - - $scope.addSelectionToRecentlyAccessed = function () { - const title = (savedSearch.id === undefined) ? indexPattern.title : savedSearch.title; - const url = $scope.getUrl(''); - addItemToRecentlyAccessed('jobs/new_job/datavisualizer', title, url); - }; - - }); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts index d3feaf087524c8..2366f2c655000d 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts @@ -6,3 +6,5 @@ import './pages/new_job/route'; import './pages/new_job/directive'; +import './pages/job_type/route'; +import './pages/job_type/directive'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/__test__/directive.js b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/__test__/directive.js new file mode 100644 index 00000000000000..5be526f2eb2c02 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/__test__/directive.js @@ -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 ngMock from 'ng_mock'; +import expect from '@kbn/expect'; +import sinon from 'sinon'; + +// Import this way to be able to stub/mock functions later on in the tests using sinon. +import * as indexUtils from 'plugins/ml/util/index_utils'; + +describe('ML - Job Type Directive', () => { + let $scope; + let $compile; + let $element; + + beforeEach(ngMock.module('kibana')); + beforeEach(() => { + ngMock.inject(function ($injector) { + $compile = $injector.get('$compile'); + const $rootScope = $injector.get('$rootScope'); + $scope = $rootScope.$new(); + }); + }); + + afterEach(() => { + $scope.$destroy(); + }); + + it('Initialize Job Type Directive', done => { + sinon.stub(indexUtils, 'timeBasedIndexCheck').callsFake(() => false); + ngMock.inject(function () { + expect(() => { + $element = $compile('')($scope); + }).to.not.throwError(); + + // directive has scope: false + const scope = $element.isolateScope(); + expect(scope).to.eql(undefined); + done(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/directive.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/directive.tsx new file mode 100644 index 00000000000000..4ad689a943160c --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/directive.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +// @ts-ignore +import { uiModules } from 'ui/modules'; +const module = uiModules.get('apps/ml', ['react']); +import { timefilter } from 'ui/timefilter'; +import { IndexPatterns } from 'ui/index_patterns'; + +import { I18nContext } from 'ui/i18n'; +import { IPrivate } from 'ui/private'; +import { InjectorService } from '../../../../../common/types/angular'; + +import { SearchItemsProvider } from '../../../new_job/utils/new_job_utils'; +import { Page } from './page'; + +import { KibanaContext, KibanaConfigTypeFix } from '../../../../contexts/kibana'; + +module.directive('mlJobTypePage', ($injector: InjectorService) => { + return { + scope: {}, + restrict: 'E', + link: async (scope: ng.IScope, element: ng.IAugmentedJQuery) => { + // remove time picker from top of page + timefilter.disableTimeRangeSelector(); + timefilter.disableAutoRefreshSelector(); + + const indexPatterns = $injector.get('indexPatterns'); + const kbnBaseUrl = $injector.get('kbnBaseUrl'); + const kibanaConfig = $injector.get('config'); + const Private = $injector.get('Private'); + + const createSearchItems = Private(SearchItemsProvider); + const { indexPattern, savedSearch, combinedQuery } = createSearchItems(); + const kibanaContext = { + combinedQuery, + currentIndexPattern: indexPattern, + currentSavedSearch: savedSearch, + indexPatterns, + kbnBaseUrl, + kibanaConfig, + }; + + ReactDOM.render( + + + {React.createElement(Page)} + + , + element[0] + ); + + element.on('$destroy', () => { + ReactDOM.unmountComponentAtNode(element[0]); + scope.$destroy(); + }); + }, + }; +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/page.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/page.tsx new file mode 100644 index 00000000000000..4991039ffa2886 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/page.tsx @@ -0,0 +1,308 @@ +/* + * 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, { FC, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiPage, + EuiPageBody, + EuiTitle, + EuiSpacer, + EuiCallOut, + EuiText, + EuiFlexGrid, + EuiFlexItem, + EuiLink, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useKibanaContext } from '../../../../contexts/kibana'; +import { DataRecognizer } from '../../../../components/data_recognizer'; +import { addItemToRecentlyAccessed } from '../../../../util/recently_accessed'; +import { timeBasedIndexCheck } from '../../../../util/index_utils'; +import { CreateJobLinkCard } from '../../../../components/create_job_link_card'; + +export const Page: FC = () => { + const kibanaContext = useKibanaContext(); + const [recognizerResultsCount, setRecognizerResultsCount] = useState(0); + + const { currentSavedSearch, currentIndexPattern } = kibanaContext; + + const isTimeBasedIndex = timeBasedIndexCheck(currentIndexPattern); + const indexWarningTitle = + !isTimeBasedIndex && currentSavedSearch.id === undefined + ? i18n.translate('xpack.ml.newJob.wizard.jobType.indexPatternNotTimeBasedMessage', { + defaultMessage: 'Index pattern {indexPatternTitle} is not time based', + values: { indexPatternTitle: currentIndexPattern.title }, + }) + : i18n.translate( + 'xpack.ml.newJob.wizard.jobType.indexPatternFromSavedSearchNotTimeBasedMessage', + { + defaultMessage: + '{savedSearchTitle} uses index pattern {indexPatternTitle} which is not time based', + values: { + savedSearchTitle: currentSavedSearch.title, + indexPatternTitle: currentIndexPattern.title, + }, + } + ); + const pageTitleLabel = + currentSavedSearch.id !== undefined + ? i18n.translate('xpack.ml.newJob.wizard.jobType.savedSearchPageTitleLabel', { + defaultMessage: 'saved search {savedSearchTitle}', + values: { savedSearchTitle: currentSavedSearch.title }, + }) + : i18n.translate('xpack.ml.newJob.wizard.jobType.indexPatternPageTitleLabel', { + defaultMessage: 'index pattern {indexPatternTitle}', + values: { indexPatternTitle: currentIndexPattern.title }, + }); + + const recognizerResults = { + count: 0, + onChange() { + setRecognizerResultsCount(recognizerResults.count); + }, + }; + + const getUrl = (basePath: string) => { + return currentSavedSearch.id === undefined + ? `${basePath}?index=${currentIndexPattern.id}` + : `${basePath}?savedSearchId=${currentSavedSearch.id}`; + }; + + const addSelectionToRecentlyAccessed = () => { + const title = + currentSavedSearch.id === undefined ? currentIndexPattern.title : currentSavedSearch.title; + const url = getUrl(''); + addItemToRecentlyAccessed('jobs/new_job/datavisualizer', title, url); + + window.location.href = getUrl('#jobs/new_job/datavisualizer'); + }; + + const jobTypes = [ + { + href: getUrl('#jobs/new_job/single_metric'), + icon: { + type: 'createSingleMetricJob', + ariaLabel: i18n.translate('xpack.ml.newJob.wizard.jobType.singleMetricAriaLabel', { + defaultMessage: 'Single metric job', + }), + }, + title: i18n.translate('xpack.ml.newJob.wizard.jobType.singleMetricTitle', { + defaultMessage: 'Single metric', + }), + description: i18n.translate('xpack.ml.newJob.wizard.jobType.singleMetricDescription', { + defaultMessage: 'Detect anomalies in a single time series.', + }), + id: 'mlJobTypeLinkSingleMetricJob', + }, + { + href: getUrl('#jobs/new_job/multi_metric'), + icon: { + type: 'createMultiMetricJob', + ariaLabel: i18n.translate('xpack.ml.newJob.wizard.jobType.multiMetricAriaLabel', { + defaultMessage: 'Multi metric job', + }), + }, + title: i18n.translate('xpack.ml.newJob.wizard.jobType.multiMetricTitle', { + defaultMessage: 'Multi metric', + }), + description: i18n.translate('xpack.ml.newJob.wizard.jobType.multiMetricDescription', { + defaultMessage: + 'Detect anomalies in multiple metrics by splitting a time series by a categorical field.', + }), + id: 'mlJobTypeLinkMultiMetricJob', + }, + { + href: getUrl('#jobs/new_job/population'), + icon: { + type: 'createPopulationJob', + ariaLabel: i18n.translate('xpack.ml.newJob.wizard.jobType.populationAriaLabel', { + defaultMessage: 'Population job', + }), + }, + title: i18n.translate('xpack.ml.newJob.wizard.jobType.populationTitle', { + defaultMessage: 'Population', + }), + description: i18n.translate('xpack.ml.newJob.wizard.jobType.populationDescription', { + defaultMessage: + 'Detect activity that is unusual compared to the behavior of the population.', + }), + id: 'mlJobTypeLinkPopulationJob', + }, + { + href: getUrl('#jobs/new_job/advanced'), + icon: { + type: 'createAdvancedJob', + ariaLabel: i18n.translate('xpack.ml.newJob.wizard.jobType.advancedAriaLabel', { + defaultMessage: 'Advanced job', + }), + }, + title: i18n.translate('xpack.ml.newJob.wizard.jobType.advancedTitle', { + defaultMessage: 'Advanced', + }), + description: i18n.translate('xpack.ml.newJob.wizard.jobType.advancedDescription', { + defaultMessage: + 'Use the full range of options to create a job for more advanced use cases.', + }), + id: 'mlJobTypeLinkAdvancedJob', + }, + ]; + + return ( + + + +

+ +

+
+ + + {isTimeBasedIndex === false && ( + <> + + +
+ + + +
+ + + )} + + + + + +

+ +

+
+ +

+ +

+
+ + + + + {jobTypes.map(({ href, icon, title, description, id }) => ( + + + + ))} + + + + + + +

+ +

+
+ +

+ +

+
+ + + + + + + } + description={ + + } + onClick={addSelectionToRecentlyAccessed} + href={getUrl('#jobs/new_job/datavisualizer')} + /> + + +
+
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/route.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/route.ts new file mode 100644 index 00000000000000..b61424998705d6 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/route.ts @@ -0,0 +1,27 @@ +/* + * 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 uiRoutes from 'ui/routes'; + +// @ts-ignore +import { checkMlNodesAvailable } from 'plugins/ml/ml_nodes_check/check_ml_nodes'; +// @ts-ignore +import { checkLicenseExpired } from '../../../../license/check_license'; +import { checkCreateJobsPrivilege } from '../../../../privilege/check_privilege'; +import { loadCurrentIndexPattern, loadCurrentSavedSearch } from '../../../../util/index_utils'; +import { getCreateJobBreadcrumbs } from '../../../breadcrumbs'; + +uiRoutes.when('/jobs/new_job/step/job_type', { + template: '', + k7Breadcrumbs: getCreateJobBreadcrumbs, + resolve: { + CheckLicense: checkLicenseExpired, + privileges: checkCreateJobsPrivilege, + indexPattern: loadCurrentIndexPattern, + savedSearch: loadCurrentSavedSearch, + checkMlNodesAvailable, + }, +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts index cdca3a810fcdd8..08f05e6884bb35 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts @@ -9,14 +9,12 @@ import uiRoutes from 'ui/routes'; // @ts-ignore import { checkFullLicense } from '../../../../license/check_license'; import { checkGetJobsPrivilege } from '../../../../privilege/check_privilege'; -// @ts-ignore import { loadCurrentIndexPattern, loadCurrentSavedSearch } from '../../../../util/index_utils'; import { getCreateSingleMetricJobBreadcrumbs, getCreateMultiMetricJobBreadcrumbs, getCreatePopulationJobBreadcrumbs, - // @ts-ignore } from '../../../breadcrumbs'; import { Route } from '../../../../../common/types/kibana'; diff --git a/x-pack/legacy/plugins/ml/public/util/index_utils.js b/x-pack/legacy/plugins/ml/public/util/index_utils.js deleted file mode 100644 index dfc6a7735616a6..00000000000000 --- a/x-pack/legacy/plugins/ml/public/util/index_utils.js +++ /dev/null @@ -1,108 +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 { toastNotifications } from 'ui/notify'; -import { SavedObjectsClientProvider } from 'ui/saved_objects'; -import { i18n } from '@kbn/i18n'; - -let indexPatternCache = []; -let fullIndexPatterns = []; -let currentIndexPattern = null; -let currentSavedSearch = null; - -export let refreshIndexPatterns = null; - -export function loadIndexPatterns(Private, indexPatterns) { - fullIndexPatterns = indexPatterns; - const savedObjectsClient = Private(SavedObjectsClientProvider); - return savedObjectsClient.find({ - type: 'index-pattern', - fields: ['id', 'title', 'type', 'fields'], - perPage: 10000 - }).then((response) => { - indexPatternCache = response.savedObjects; - - if (refreshIndexPatterns === null) { - refreshIndexPatterns = () => { - return new Promise((resolve, reject) => { - loadIndexPatterns(Private, indexPatterns) - .then((resp) => { - resolve(resp); - }) - .catch((error) => { - reject(error); - }); - }); - }; - } - - return indexPatternCache; - }); -} - -export function getIndexPatterns() { - return indexPatternCache; -} - -export function getIndexPatternNames() { - return indexPatternCache.map(i => (i.attributes && i.attributes.title)); -} - -export function getIndexPatternIdFromName(name) { - for (let j = 0; j < indexPatternCache.length; j++) { - if (indexPatternCache[j].get('title') === name) { - return indexPatternCache[j].id; - } - } - return name; -} - -export function loadCurrentIndexPattern(indexPatterns, $route) { - fullIndexPatterns = indexPatterns; - currentIndexPattern = fullIndexPatterns.get($route.current.params.index); - return currentIndexPattern; -} - -export function getIndexPatternById(id) { - return fullIndexPatterns.get(id); -} - -export function loadCurrentSavedSearch(savedSearches, $route) { - currentSavedSearch = savedSearches.get($route.current.params.savedSearchId); - return currentSavedSearch; -} - -export function getCurrentIndexPattern() { - return currentIndexPattern; -} - -export function getCurrentSavedSearch() { - return currentSavedSearch; -} - -// returns true if the index passed in is time based -// an optional flag will trigger the display a notification at the top of the page -// warning that the index is not time based -export function timeBasedIndexCheck(indexPattern, showNotification = false) { - if (indexPattern.isTimeBased() === false) { - if (showNotification) { - toastNotifications.addWarning({ - title: i18n.translate('xpack.ml.indexPatternNotBasedOnTimeSeriesNotificationTitle', { - defaultMessage: 'The index pattern {indexPatternTitle} is not based on a time series', - values: { indexPatternTitle: indexPattern.title } - }), - text: i18n.translate('xpack.ml.indexPatternNotBasedOnTimeSeriesNotificationDescription', { - defaultMessage: 'Anomaly detection only runs over time-based indices' - }), - }); - } - return false; - } else { - return true; - } -} diff --git a/x-pack/legacy/plugins/ml/public/util/index_utils.ts b/x-pack/legacy/plugins/ml/public/util/index_utils.ts new file mode 100644 index 00000000000000..41dd13555726c4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/util/index_utils.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { toastNotifications } from 'ui/notify'; +import { i18n } from '@kbn/i18n'; +import { IndexPattern, IndexPatterns } from 'ui/index_patterns'; +import { SavedObjectAttributes, SimpleSavedObject } from 'kibana/public'; +import chrome from 'ui/chrome'; +import { SavedSearchLoader } from '../../../../../../src/legacy/core_plugins/kibana/public/discover/types'; +import { setup as data } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; + +type IndexPatternSavedObject = SimpleSavedObject; + +let indexPatternCache: IndexPatternSavedObject[] = []; +let fullIndexPatterns: IndexPatterns | null = null; + +export let refreshIndexPatterns: (() => Promise) | null = null; + +export function loadIndexPatterns() { + fullIndexPatterns = data.indexPatterns.indexPatterns; + const savedObjectsClient = chrome.getSavedObjectsClient(); + return savedObjectsClient + .find({ + type: 'index-pattern', + fields: ['id', 'title', 'type', 'fields'], + perPage: 10000, + }) + .then(response => { + indexPatternCache = response.savedObjects; + if (refreshIndexPatterns === null) { + refreshIndexPatterns = () => { + return new Promise((resolve, reject) => { + loadIndexPatterns() + .then(resp => { + resolve(resp); + }) + .catch(error => { + reject(error); + }); + }); + }; + } + + return indexPatternCache; + }); +} + +export function getIndexPatterns() { + return indexPatternCache; +} + +export function getIndexPatternNames() { + return indexPatternCache.map(i => i.attributes && i.attributes.title); +} + +export function getIndexPatternIdFromName(name: string) { + for (let j = 0; j < indexPatternCache.length; j++) { + if (indexPatternCache[j].get('title') === name) { + return indexPatternCache[j].id; + } + } + return name; +} + +export function loadCurrentIndexPattern(indexPatterns: IndexPatterns, $route: Record) { + fullIndexPatterns = indexPatterns; + return fullIndexPatterns.get($route.current.params.index); +} + +export function getIndexPatternById(id: string): IndexPattern { + if (fullIndexPatterns !== null) { + return fullIndexPatterns.get(id); + } else { + throw new Error('Index patterns are not initialized!'); + } +} + +export function loadCurrentSavedSearch( + savedSearches: SavedSearchLoader, + $route: Record +) { + return savedSearches.get($route.current.params.savedSearchId); +} + +/** + * Returns true if the index passed in is time based + * an optional flag will trigger the display a notification at the top of the page + * warning that the index is not time based + */ +export function timeBasedIndexCheck(indexPattern: IndexPattern, showNotification = false) { + if (!indexPattern.isTimeBased()) { + if (showNotification) { + toastNotifications.addWarning({ + title: i18n.translate('xpack.ml.indexPatternNotBasedOnTimeSeriesNotificationTitle', { + defaultMessage: 'The index pattern {indexPatternTitle} is not based on a time series', + values: { indexPatternTitle: indexPattern.title }, + }), + text: i18n.translate('xpack.ml.indexPatternNotBasedOnTimeSeriesNotificationDescription', { + defaultMessage: 'Anomaly detection only runs over time-based indices', + }), + }); + } + return false; + } else { + return true; + } +} diff --git a/x-pack/legacy/plugins/ml/public/util/recently_accessed.js b/x-pack/legacy/plugins/ml/public/util/recently_accessed.ts similarity index 80% rename from x-pack/legacy/plugins/ml/public/util/recently_accessed.js rename to x-pack/legacy/plugins/ml/public/util/recently_accessed.ts index b642be7d1226a1..9a3d3089dff2bf 100644 --- a/x-pack/legacy/plugins/ml/public/util/recently_accessed.js +++ b/x-pack/legacy/plugins/ml/public/util/recently_accessed.ts @@ -4,38 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ - - // utility functions for managing which links get added to kibana's recently accessed list import { recentlyAccessed } from 'ui/persisted_log'; import { i18n } from '@kbn/i18n'; -export function addItemToRecentlyAccessed(page, itemId, url) { +export function addItemToRecentlyAccessed(page: string, itemId: string, url: string) { let pageLabel = ''; let id = `ml-job-${itemId}`; switch (page) { case 'explorer': pageLabel = i18n.translate('xpack.ml.anomalyExplorerPageLabel', { - defaultMessage: 'Anomaly Explorer' + defaultMessage: 'Anomaly Explorer', }); break; case 'timeseriesexplorer': pageLabel = i18n.translate('xpack.ml.singleMetricViewerPageLabel', { - defaultMessage: 'Single Metric Viewer' + defaultMessage: 'Single Metric Viewer', }); break; case 'jobs/new_job/datavisualizer': pageLabel = i18n.translate('xpack.ml.dataVisualizerPageLabel', { - defaultMessage: 'Data Visualizer' + defaultMessage: 'Data Visualizer', }); id = `ml-datavisualizer-${itemId}`; break; default: + // eslint-disable-next-line no-console console.error('addItemToRecentlyAccessed - No page specified'); return; - break; } url = `ml#/${page}/${url}`; From 7279f064ca331be8a080c198c5de926cd78579e9 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Wed, 2 Oct 2019 02:00:58 -0700 Subject: [PATCH 20/59] [ML] Remove deprecated force parameter. (#46361) The force parameter was removed from start actions in elastic/elasticsearch#46414. This reflects the change in the UI API calls. --- .../ml/server/client/elasticsearch_ml.js | 5 +---- .../ml/server/models/data_frame/transforms.ts | 20 +++++++++---------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js index ae38eeada3124e..2a186988a750d6 100644 --- a/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js +++ b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js @@ -287,14 +287,11 @@ export const elasticsearchJsPlugin = (Client, config, components) => { ml.startDataFrameTransform = ca({ urls: [ { - fmt: '/_data_frame/transforms/<%=transformId%>/_start?&force=<%=force%>', + fmt: '/_data_frame/transforms/<%=transformId%>/_start', req: { transformId: { type: 'string' }, - force: { - type: 'boolean' - } } } ], diff --git a/x-pack/legacy/plugins/ml/server/models/data_frame/transforms.ts b/x-pack/legacy/plugins/ml/server/models/data_frame/transforms.ts index 392fb4191eba04..f5bce55763498e 100644 --- a/x-pack/legacy/plugins/ml/server/models/data_frame/transforms.ts +++ b/x-pack/legacy/plugins/ml/server/models/data_frame/transforms.ts @@ -19,9 +19,13 @@ enum TRANSFORM_ACTIONS { DELETE = 'delete', } -interface StartStopOptions { +interface StartTransformOptions { transformId: DataFrameTransformId; - force: boolean; +} + +interface StopTransformOptions { + transformId: DataFrameTransformId; + force?: boolean; waitForCompletion?: boolean; } @@ -30,11 +34,11 @@ export function transformServiceProvider(callWithRequest: callWithRequestType) { return callWithRequest('ml.deleteDataFrameTransform', { transformId }); } - async function stopTransform(options: StartStopOptions) { + async function stopTransform(options: StopTransformOptions) { return callWithRequest('ml.stopDataFrameTransform', options); } - async function startTransform(options: StartStopOptions) { + async function startTransform(options: StartTransformOptions) { return callWithRequest('ml.startDataFrameTransform', options); } @@ -86,13 +90,7 @@ export function transformServiceProvider(callWithRequest: callWithRequestType) { for (const transformInfo of transformsInfo) { const transformId = transformInfo.id; try { - await startTransform({ - transformId, - force: - transformInfo.state !== undefined - ? transformInfo.state === DATA_FRAME_TRANSFORM_STATE.FAILED - : false, - }); + await startTransform({ transformId }); results[transformId] = { success: true }; } catch (e) { if (isRequestTimeout(e)) { From 1a452cdf9bfe5df507eec8a11881b6e83acf873a Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Wed, 2 Oct 2019 10:30:36 +0100 Subject: [PATCH 21/59] Removing default title from saving a new search in Discover (#47031) --- .../kibana/public/discover/saved_searches/_saved_search.js | 5 +---- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/legacy/core_plugins/kibana/public/discover/saved_searches/_saved_search.js b/src/legacy/core_plugins/kibana/public/discover/saved_searches/_saved_search.js index ebf830dfae6426..eed6bcad0ec5d4 100644 --- a/src/legacy/core_plugins/kibana/public/discover/saved_searches/_saved_search.js +++ b/src/legacy/core_plugins/kibana/public/discover/saved_searches/_saved_search.js @@ -18,7 +18,6 @@ */ import 'ui/notify'; -import { i18n } from '@kbn/i18n'; import { uiModules } from 'ui/modules'; import { createLegacyClass } from 'ui/utils/legacy_class'; import { SavedObjectProvider } from 'ui/saved_objects/saved_object'; @@ -38,9 +37,7 @@ module.factory('SavedSearch', function (Private) { id: id, defaults: { - title: i18n.translate('kbn.discover.savedSearch.newSavedSearchTitle', { - defaultMessage: 'New Saved Search', - }), + title: '', description: '', columns: [], hits: 0, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 57632ab6f640f4..16a338e2039821 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1580,7 +1580,6 @@ "kbn.discover.notifications.savedSearchTitle": "検索「{savedSearchTitle}」が保存されました。", "kbn.discover.painlessError.painlessScriptedFieldErrorMessage": "Painless スクリプトのフィールド「{script}」のエラー.", "kbn.discover.rootBreadcrumb": "ディスカバリ", - "kbn.discover.savedSearch.newSavedSearchTitle": "新しく保存された検索", "kbn.discover.savedSearch.savedObjectName": "保存された検索", "kbn.discover.scaledToDescription": "{bucketIntervalDescription} にスケーリング済み", "kbn.discover.searchingTitle": "検索中", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 606abe57875235..d9988b34b272d9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1581,7 +1581,6 @@ "kbn.discover.notifications.savedSearchTitle": "搜索 “{savedSearchTitle}” 已保存", "kbn.discover.painlessError.painlessScriptedFieldErrorMessage": "Painless 脚本字段 “{script}” 有错误。", "kbn.discover.rootBreadcrumb": "Discover", - "kbn.discover.savedSearch.newSavedSearchTitle": "新保存的搜索", "kbn.discover.savedSearch.savedObjectName": "已保存搜索", "kbn.discover.scaledToDescription": "已缩放至 {bucketIntervalDescription}", "kbn.discover.searchingTitle": "正在搜索", From d8a576536200b0c3f942ba876c1697c68576c824 Mon Sep 17 00:00:00 2001 From: Maryia Lapata Date: Wed, 2 Oct 2019 13:48:39 +0300 Subject: [PATCH 22/59] [Vis: Default Editor] Prevent disabling of the only metrics agg (#46575) * Prevent disabling of the only metrics agg * Add unit tests * Fix when aggs with different schema name --- .../editors/default/components/agg.test.tsx | 13 ++++++++++ .../vis/editors/default/components/agg.tsx | 4 +++ .../editors/default/components/agg_group.tsx | 16 +++++++++--- .../components/agg_group_helper.test.ts | 25 ++++++++++++++++++- .../default/components/agg_group_helper.tsx | 20 +++++++++------ .../ui/public/vis/editors/default/sidebar.js | 10 ++++++++ 6 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/legacy/ui/public/vis/editors/default/components/agg.test.tsx b/src/legacy/ui/public/vis/editors/default/components/agg.test.tsx index 8f49c93b5c152e..b87eb3f5fb303c 100644 --- a/src/legacy/ui/public/vis/editors/default/components/agg.test.tsx +++ b/src/legacy/ui/public/vis/editors/default/components/agg.test.tsx @@ -61,6 +61,7 @@ describe('DefaultEditorAgg component', () => { dragHandleProps: null, formIsTouched: false, groupName: AggGroupNames.Metrics, + isDisabled: false, isDraggable: false, isLastBucket: false, isRemovable: false, @@ -200,6 +201,18 @@ describe('DefaultEditorAgg component', () => { expect(defaultProps.onToggleEnableAgg).toBeCalledWith(defaultProps.agg, false); }); + it('should disable the disableAggregation button', () => { + defaultProps.isDisabled = true; + defaultProps.isRemovable = true; + const comp = mount(); + + expect( + comp + .find('EuiButtonIcon[data-test-subj="toggleDisableAggregationBtn disable"]') + .prop('disabled') + ).toBeTruthy(); + }); + it('should enable agg', () => { defaultProps.agg.enabled = false; const comp = mount(); diff --git a/src/legacy/ui/public/vis/editors/default/components/agg.tsx b/src/legacy/ui/public/vis/editors/default/components/agg.tsx index e8b4839c8f85e1..345c9254ff6c15 100644 --- a/src/legacy/ui/public/vis/editors/default/components/agg.tsx +++ b/src/legacy/ui/public/vis/editors/default/components/agg.tsx @@ -37,6 +37,7 @@ export interface DefaultEditorAggProps extends DefaultEditorAggCommonProps { aggIndex: number; aggIsTooLow: boolean; dragHandleProps: {} | null; + isDisabled: boolean; isDraggable: boolean; isLastBucket: boolean; isRemovable: boolean; @@ -49,6 +50,7 @@ function DefaultEditorAgg({ dragHandleProps, formIsTouched, groupName, + isDisabled, isDraggable, isLastBucket, isRemovable, @@ -142,6 +144,7 @@ function DefaultEditorAgg({ actionIcons.push({ id: 'disableAggregation', color: 'text', + disabled: isDisabled, type: 'eye', onClick: () => onToggleEnableAgg(agg, false), tooltip: i18n.translate('common.ui.vis.editors.agg.disableAggButtonTooltip', { @@ -205,6 +208,7 @@ function DefaultEditorAgg({ return ( void; + addSchema: (schemas: Schema) => void; reorderAggs: (group: AggConfig[]) => void; } @@ -77,6 +82,10 @@ function DefaultEditorAggGroup({ const isGroupValid = Object.values(aggsState).every(item => item.valid); const isAllAggsTouched = isInvalidAggsTouched(aggsState); + const isMetricAggregationDisabled = useMemo( + () => groupName === AggGroupNames.Metrics && getEnabledMetricAggsCount(group) === 1, + [groupName, group] + ); useEffect(() => { // when isAllAggsTouched is true, it means that all invalid aggs are touched and we will set ngModel's touched to true @@ -155,6 +164,7 @@ function DefaultEditorAggGroup({ isDraggable={stats.count > 1} isLastBucket={groupName === AggGroupNames.Buckets && index === group.length - 1} isRemovable={isAggRemovable(agg, group)} + isDisabled={agg.schema.name === 'metric' && isMetricAggregationDisabled} lastParentPipelineAggTitle={lastParentPipelineAggTitle} metricAggs={metricAggs} state={state} diff --git a/src/legacy/ui/public/vis/editors/default/components/agg_group_helper.test.ts b/src/legacy/ui/public/vis/editors/default/components/agg_group_helper.test.ts index b2dac344950bd4..6bb27d4a0c14eb 100644 --- a/src/legacy/ui/public/vis/editors/default/components/agg_group_helper.test.ts +++ b/src/legacy/ui/public/vis/editors/default/components/agg_group_helper.test.ts @@ -18,7 +18,12 @@ */ import { AggConfig } from '../../../../agg_types/agg_config'; -import { isAggRemovable, calcAggIsTooLow, isInvalidAggsTouched } from './agg_group_helper'; +import { + isAggRemovable, + calcAggIsTooLow, + isInvalidAggsTouched, + getEnabledMetricAggsCount, +} from './agg_group_helper'; import { AggsState } from './agg_group_state'; describe('DefaultEditorGroup helpers', () => { @@ -46,6 +51,7 @@ describe('DefaultEditorGroup helpers', () => { } as AggConfig, ]; }); + describe('isAggRemovable', () => { it('should return true when the number of aggs with the same schema is above the min', () => { const isRemovable = isAggRemovable(group[0], group); @@ -60,6 +66,23 @@ describe('DefaultEditorGroup helpers', () => { }); }); + describe('getEnabledMetricAggsCount', () => { + it('should return 1 when there is the only enabled agg', () => { + group[0].enabled = true; + const enabledAggs = getEnabledMetricAggsCount(group); + + expect(enabledAggs).toBe(1); + }); + + it('should return 2 when there are multiple enabled aggs', () => { + group[0].enabled = true; + group[1].enabled = true; + const enabledAggs = getEnabledMetricAggsCount(group); + + expect(enabledAggs).toBe(2); + }); + }); + describe('calcAggIsTooLow', () => { it('should return false when agg.schema.mustBeFirst has falsy value', () => { const isRemovable = calcAggIsTooLow(group[1], 0, group); diff --git a/src/legacy/ui/public/vis/editors/default/components/agg_group_helper.tsx b/src/legacy/ui/public/vis/editors/default/components/agg_group_helper.tsx index 5bfb2cdfb48d8b..847aa0b87d2d30 100644 --- a/src/legacy/ui/public/vis/editors/default/components/agg_group_helper.tsx +++ b/src/legacy/ui/public/vis/editors/default/components/agg_group_helper.tsx @@ -17,22 +17,28 @@ * under the License. */ -import { findIndex, reduce, isEmpty } from 'lodash'; +import { findIndex, isEmpty } from 'lodash'; import { AggConfig } from '../../../../agg_types/agg_config'; import { AggsState } from './agg_group_state'; const isAggRemovable = (agg: AggConfig, group: AggConfig[]) => { - const metricCount = reduce( - group, - (count, aggregation: AggConfig) => { - return aggregation.schema.name === agg.schema.name ? ++count : count; - }, + const metricCount = group.reduce( + (count, aggregation: AggConfig) => + aggregation.schema.name === agg.schema.name ? ++count : count, 0 ); // make sure the the number of these aggs is above the min return metricCount > agg.schema.min; }; +const getEnabledMetricAggsCount = (group: AggConfig[]) => { + return group.reduce( + (count, aggregation: AggConfig) => + aggregation.schema.name === 'metric' && aggregation.enabled ? ++count : count, + 0 + ); +}; + const calcAggIsTooLow = (agg: AggConfig, aggIndex: number, group: AggConfig[]) => { if (!agg.schema.mustBeFirst) { return false; @@ -59,4 +65,4 @@ function isInvalidAggsTouched(aggsState: AggsState) { return invalidAggs.every(agg => agg.touched); } -export { isAggRemovable, calcAggIsTooLow, isInvalidAggsTouched }; +export { isAggRemovable, calcAggIsTooLow, isInvalidAggsTouched, getEnabledMetricAggsCount }; diff --git a/src/legacy/ui/public/vis/editors/default/sidebar.js b/src/legacy/ui/public/vis/editors/default/sidebar.js index ee33e0093f42b8..92cb99c56038db 100644 --- a/src/legacy/ui/public/vis/editors/default/sidebar.js +++ b/src/legacy/ui/public/vis/editors/default/sidebar.js @@ -24,6 +24,8 @@ import 'ui/directives/css_truncate'; import { uiModules } from '../../../modules'; import sidebarTemplate from './sidebar.html'; import { move } from '../../../utils/collection'; +import { AggGroupNames } from './agg_groups'; +import { getEnabledMetricAggsCount } from './components/agg_group_helper'; uiModules.get('app/visualize').directive('visEditorSidebar', function () { return { @@ -76,6 +78,14 @@ uiModules.get('app/visualize').directive('visEditorSidebar', function () { } aggs.splice(index, 1); + + if (agg.schema.group === AggGroupNames.Metrics) { + const metrics = $scope.state.aggs.bySchemaGroup(AggGroupNames.Metrics); + + if (getEnabledMetricAggsCount(metrics) === 0) { + metrics.find(aggregation => aggregation.schema.name === 'metric').enabled = true; + } + } }; $scope.onToggleEnableAgg = (agg, isEnable) => { From 2b06c0227e943aea21e73f1077d0d89fb3ccd926 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 2 Oct 2019 14:20:10 +0200 Subject: [PATCH 23/59] [Graph] Reactify visualization (#46799) --- x-pack/legacy/plugins/graph/public/_main.scss | 79 +------ .../public/angular/templates/_graph.scss | 47 ----- .../graph/public/angular/templates/index.html | 66 +----- x-pack/legacy/plugins/graph/public/app.js | 35 +--- .../graph/public/components/_index.scss | 1 + .../graph_visualization.test.tsx.snap | 173 ++++++++++++++++ .../_graph_visualization.scss | 60 ++++++ .../graph_visualization/_index.scss | 1 + .../graph_visualization.test.tsx | 164 +++++++++++++++ .../graph_visualization.tsx | 194 ++++++++++++++++++ .../components/graph_visualization/index.ts | 7 + .../components/legacy_icon/_legacy_icon.scss | 19 +- .../components/legacy_icon/legacy_icon.tsx | 7 +- .../graph/public/helpers/style_choices.ts | 5 +- .../services/persistence/deserialize.test.ts | 2 +- .../graph/public/types/workspace_state.ts | 2 + 16 files changed, 640 insertions(+), 222 deletions(-) create mode 100644 x-pack/legacy/plugins/graph/public/components/graph_visualization/__snapshots__/graph_visualization.test.tsx.snap create mode 100644 x-pack/legacy/plugins/graph/public/components/graph_visualization/_graph_visualization.scss create mode 100644 x-pack/legacy/plugins/graph/public/components/graph_visualization/_index.scss create mode 100644 x-pack/legacy/plugins/graph/public/components/graph_visualization/graph_visualization.test.tsx create mode 100644 x-pack/legacy/plugins/graph/public/components/graph_visualization/graph_visualization.tsx create mode 100644 x-pack/legacy/plugins/graph/public/components/graph_visualization/index.ts diff --git a/x-pack/legacy/plugins/graph/public/_main.scss b/x-pack/legacy/plugins/graph/public/_main.scss index 7e70ccd6f35d8b..2559b7d1aba5ca 100644 --- a/x-pack/legacy/plugins/graph/public/_main.scss +++ b/x-pack/legacy/plugins/graph/public/_main.scss @@ -1,32 +1,3 @@ -/** - * Nodes - */ - -.gphNode-disabled{ - opacity:0.3; -} - -.gphNode__circle { - fill: $euiColorMediumShade; - - // SASSTODO: Can't definitively change modifier class - // because it's not easy to tell what's a class and what's - // part of the data object since they are named the same - &.selectedNode { - stroke-width: $euiSizeXS; - stroke: transparentize($euiColorPrimary, .25); - } -} - -.gphNode__text { - fill: $euiColorDarkestShade; - - &--lowOpacity{ - fill-opacity: 0.5; - } -} - - /** * Forms */ @@ -35,64 +6,16 @@ margin-bottom: $euiSizeS; } -.gphColorPicker__color, -.gphIconPicker__icon { +.gphColorPicker__color { margin: $euiSizeXS; cursor: pointer; - &.selectedNode, &:hover, &:focus { transform: scale(1.4); } } -.gphIconPicker__icon { - opacity: .7; - - &.selectedNode, - &:hover, - &:focus { - opacity: 1; - } -} - -.gphIndexSelect{ - max-width: $euiSizeL * 10; - margin-right: $euiSizeXS; - - &-unselected { - @include euiFocusRing; - } -} - -.gphAddButton { - background: $euiColorPrimary; - color: $euiColorEmptyShade; - border-radius: 50%; - font-size: $euiFontSizeXS; - margin: 2px $euiSizeS 0 $euiSizeXS; - @include size(26px); // same as svg - - &:hover:not(:disabled) { - background: shadeOrTint($euiColorPrimary, 10%, 10%); - cursor: pointer; - } - - &:disabled { - background: $euiColorMediumShade; - cursor: not-allowed; - } - - &-focus { - @include euiFocusRing; - } -} - -.gphFieldList { - min-width: $euiSizeXL * 10; -} - /** * Utilities */ diff --git a/x-pack/legacy/plugins/graph/public/angular/templates/_graph.scss b/x-pack/legacy/plugins/graph/public/angular/templates/_graph.scss index 20ca704b9282ba..0f2bf90a10d4f3 100644 --- a/x-pack/legacy/plugins/graph/public/angular/templates/_graph.scss +++ b/x-pack/legacy/plugins/graph/public/angular/templates/_graph.scss @@ -27,53 +27,6 @@ flex: 1; } -.gphGraph { - // SASSTODO: Can't definitively change child class - // because it's not easy to tell what's a class and what's - // part of the data object since they are named the same - .edge { - fill: $euiColorMediumShade; - stroke: $euiColorMediumShade; - stroke-width: 2; - stroke-opacity: 0.5; - - &:hover { - stroke-opacity: 0.95; - cursor: pointer; - } - } - - .edge.selectedEdge { - stroke: $euiColorDarkShade; - stroke-opacity: 0.95; - } - - .edge.inferredEdge { - stroke-dasharray: 5,5; - } -} - -.gphNode__label { - @include gphSvgText; - cursor: pointer; -} - -.gphNode__label--html { - @include euiTextTruncate; - text-align: center; -} - -.gphNode__markerCircle { - fill: $euiColorDarkShade; - stroke: $euiColorEmptyShade; -} - -.gphNode__markerText { - @include gphSvgText; - font-size: $euiSizeS - 2px; - fill: $euiColorEmptyShade; -} - .gphGraph__menus { margin: $euiSizeS; } diff --git a/x-pack/legacy/plugins/graph/public/angular/templates/index.html b/x-pack/legacy/plugins/graph/public/angular/templates/index.html index 52f901f7d169ed..267e7564fb8302 100644 --- a/x-pack/legacy/plugins/graph/public/angular/templates/index.html +++ b/x-pack/legacy/plugins/graph/public/angular/templates/index.html @@ -21,65 +21,13 @@ dispatch="reduxDispatch" > -
- - - - - - - - {{n.icon.code}} - - - - - {{n.label}} - - - - -

{{n.label}}

- -
- - - - {{n.numChildren}} - - - -
-
-
- - +
+ + + + +
+ + +
+
+ + + + + + + + + + `; exports[`QueryBarInput Should pass the query language to the language switcher 1`] = ` - + - -
+ -
-
- + - + -
- - - - -
- - - - + aria-controls="kbnTypeahead__items" + aria-expanded={false} + aria-haspopup="true" + aria-owns="kbnTypeahead__items" + onMouseDown={[Function]} + onMouseUp={[Function]} + onTouchEnd={[Function]} + onTouchStart={[Function]} + role="combobox" + style={ + Object { + "position": "relative", } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="popover" - isOpen={false} - ownFocus={true} - panelPaddingSize="m" - withTitle={true} + } + > +
- -
+ } + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="kbnTypeahead__items" + aria-label="You are on search box of Another Screen page. Start typing to search and filter the test" + autoComplete="off" + autoFocus={true} + compressed={false} + data-test-subj="queryInput" + fullWidth={true} + inputRef={[Function]} + isLoading={false} + onChange={[Function]} + onClick={[Function]} onKeyDown={[Function]} - onMouseDown={[Function]} - onMouseUp={[Function]} - onTouchEnd={[Function]} - onTouchStart={[Function]} + onKeyUp={[Function]} + placeholder="Search" + role="textbox" + spellCheck={false} + type="text" + value="response:200" > -
+ } + compressed={false} + fullWidth={true} + isLoading={false} > - -
+ - + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="popover" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + withTitle={true} > - - - Lucene - - - - - -
-
- -
-
-
-
-
-
-
- -
-
-
+
+ + + +
+ + + + + + + + + + + + + + + + + + + `; exports[`QueryBarInput Should render the given query 1`] = ` - + - -
+ -
-
- + - + -
- - - - -
- - - - + aria-controls="kbnTypeahead__items" + aria-expanded={false} + aria-haspopup="true" + aria-owns="kbnTypeahead__items" + onMouseDown={[Function]} + onMouseUp={[Function]} + onTouchEnd={[Function]} + onTouchStart={[Function]} + role="combobox" + style={ + Object { + "position": "relative", } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="popover" - isOpen={false} - ownFocus={true} - panelPaddingSize="m" - withTitle={true} + } + > +
- -
+ } + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="kbnTypeahead__items" + aria-label="You are on search box of Another Screen page. Start typing to search and filter the test" + autoComplete="off" + autoFocus={true} + compressed={false} + data-test-subj="queryInput" + fullWidth={true} + inputRef={[Function]} + isLoading={false} + onChange={[Function]} + onClick={[Function]} onKeyDown={[Function]} - onMouseDown={[Function]} - onMouseUp={[Function]} - onTouchEnd={[Function]} - onTouchStart={[Function]} + onKeyUp={[Function]} + placeholder="Search" + role="textbox" + spellCheck={false} + type="text" + value="response:200" > -
+ } + compressed={false} + fullWidth={true} + isLoading={false} > - -
+ + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="popover" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + withTitle={true} > - - - KQL - - - - - -
-
- -
-
-
-
-
-
-
- -
-
-
+
+ + + +
+ + + + + + + + + + + + + + + + + + + `; diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.test.tsx b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.test.tsx index e66d71b9b08b4b..a66fb682063ece 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.test.tsx +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.test.tsx @@ -25,12 +25,14 @@ import { import { EuiFieldText } from '@elastic/eui'; import React from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { QueryLanguageSwitcher } from './language_switcher'; import { QueryBarInput, QueryBarInputUI } from './query_bar_input'; import { coreMock } from '../../../../../../../core/public/mocks'; const startMock = coreMock.createStart(); import { IndexPattern } from '../../../index'; +import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; +import { I18nProvider } from '@kbn/i18n/react'; +import { mount } from 'enzyme'; const noop = () => { return; @@ -78,64 +80,67 @@ const mockIndexPattern = { ], } as IndexPattern; +function wrapQueryBarInputInContext(testProps: any, store?: any) { + const defaultOptions = { + screenTitle: 'Another Screen', + intl: null as any, + }; + + const services = { + appName: testProps.appName || 'test', + uiSettings: startMock.uiSettings, + savedObjects: startMock.savedObjects, + notifications: startMock.notifications, + http: startMock.http, + store: store || createMockStorage(), + }; + + return ( + + + + + + ); +} + describe('QueryBarInput', () => { beforeEach(() => { jest.clearAllMocks(); }); it('Should render the given query', () => { - const component = mountWithIntl( - + const component = mount( + wrapQueryBarInputInContext({ + query: kqlQuery, + onSubmit: noop, + indexPatterns: [mockIndexPattern], + }) ); expect(component).toMatchSnapshot(); }); it('Should pass the query language to the language switcher', () => { - const component = mountWithIntl( - + const component = mount( + wrapQueryBarInputInContext({ + query: luceneQuery, + onSubmit: noop, + indexPatterns: [mockIndexPattern], + }) ); expect(component).toMatchSnapshot(); }); it('Should disable autoFocus on EuiFieldText when disableAutoFocus prop is true', () => { - const component = mountWithIntl( - + const component = mount( + wrapQueryBarInputInContext({ + query: kqlQuery, + onSubmit: noop, + indexPatterns: [mockIndexPattern], + disableAutoFocus: true, + }) ); expect(component).toMatchSnapshot(); @@ -144,43 +149,32 @@ describe('QueryBarInput', () => { it('Should create a unique PersistedLog based on the appName and query language', () => { mockPersistedLogFactory.mockClear(); - mountWithIntl( - + mount( + wrapQueryBarInputInContext({ + query: kqlQuery, + onSubmit: noop, + indexPatterns: [mockIndexPattern], + disableAutoFocus: true, + appName: 'discover', + }) ); - expect(mockPersistedLogFactory.mock.calls[0][0]).toBe('typeahead:discover-kuery'); }); it("On language selection, should store the user's preference in localstorage and reset the query", () => { const mockStorage = createMockStorage(); const mockCallback = jest.fn(); - - const component = mountWithIntl( - + const component = mount( + wrapQueryBarInputInContext( + { + query: kqlQuery, + onSubmit: mockCallback, + indexPatterns: [mockIndexPattern], + disableAutoFocus: true, + appName: 'discover', + }, + mockStorage + ) ); component @@ -194,23 +188,16 @@ describe('QueryBarInput', () => { it('Should call onSubmit when the user hits enter inside the query bar', () => { const mockCallback = jest.fn(); - const component = mountWithIntl( - + const component = mount( + wrapQueryBarInputInContext({ + query: kqlQuery, + onSubmit: mockCallback, + indexPatterns: [mockIndexPattern], + disableAutoFocus: true, + }) ); - const instance = component.instance() as QueryBarInputUI; + const instance = component.find('QueryBarInputUI').instance() as QueryBarInputUI; const input = instance.inputRef; const inputWrapper = component.find(EuiFieldText).find('input'); inputWrapper.simulate('keyDown', { target: input, keyCode: 13, key: 'Enter', metaKey: true }); @@ -220,23 +207,17 @@ describe('QueryBarInput', () => { }); it('Should use PersistedLog for recent search suggestions', async () => { - const component = mountWithIntl( - + const component = mount( + wrapQueryBarInputInContext({ + query: kqlQuery, + onSubmit: noop, + indexPatterns: [mockIndexPattern], + disableAutoFocus: true, + persistedLog: mockPersistedLog, + }) ); - const instance = component.instance() as QueryBarInputUI; + const instance = component.find('QueryBarInputUI').instance() as QueryBarInputUI; const input = instance.inputRef; const inputWrapper = component.find(EuiFieldText).find('input'); inputWrapper.simulate('keyDown', { target: input, keyCode: 13, key: 'Enter', metaKey: true }); @@ -250,22 +231,15 @@ describe('QueryBarInput', () => { it('Should accept index pattern strings and fetch the full object', () => { mockFetchIndexPatterns.mockClear(); - - mountWithIntl( - + mount( + wrapQueryBarInputInContext({ + query: kqlQuery, + onSubmit: noop, + indexPatterns: ['logstash-*'], + disableAutoFocus: true, + }) ); + expect(mockFetchIndexPatterns).toHaveBeenCalledWith( startMock.savedObjects.client, ['logstash-*'], diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.tsx b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.tsx index 7a972a6068f6f6..6c91da7c28135f 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.tsx +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.tsx @@ -23,19 +23,17 @@ import React from 'react'; import { EuiFieldText, EuiOutsideClickDetector, PopoverAnchorPosition } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; -import { debounce, compact, isEqual, omit } from 'lodash'; +import { debounce, compact, isEqual } from 'lodash'; import { PersistedLog } from 'ui/persisted_log'; -import { Storage } from 'ui/storage'; -import { npStart } from 'ui/new_platform'; -import { - UiSettingsClientContract, - SavedObjectsClientContract, - HttpServiceBase, -} from 'src/core/public'; + import { AutocompleteSuggestion, AutocompleteSuggestionType, } from '../../../../../../../plugins/data/public'; +import { + withKibana, + KibanaReactContextValue, +} from '../../../../../../../plugins/kibana_react/public'; import { IndexPattern, StaticIndexPattern } from '../../../index_patterns'; import { Query } from '../index'; import { fromUser, matchPairs, toUser } from '../lib'; @@ -43,21 +41,13 @@ import { QueryLanguageSwitcher } from './language_switcher'; import { SuggestionsComponent } from './typeahead/suggestions_component'; import { getQueryLog } from '../lib/get_query_log'; import { fetchIndexPatterns } from '../lib/fetch_index_patterns'; - -// todo: related to https://github.com/elastic/kibana/pull/45762/files -// Will be refactored after merge of related PR -const getAutocompleteProvider = (language: string) => - npStart.plugins.data.autocomplete.getProvider(language); +import { IDataPluginServices } from '../../../types'; interface Props { - uiSettings: UiSettingsClientContract; - indexPatterns: Array; - savedObjectsClient: SavedObjectsClientContract; - http: HttpServiceBase; - store: Storage; + kibana: KibanaReactContextValue; intl: InjectedIntl; + indexPatterns: Array; query: Query; - appName: string; disableAutoFocus?: boolean; screenTitle?: string; prepend?: React.ReactNode; @@ -67,6 +57,7 @@ interface Props { languageSwitcherPopoverAnchorPosition?: PopoverAnchorPosition; onChange?: (query: Query) => void; onSubmit?: (query: Query) => void; + dataTestSubj?: string; } interface State { @@ -107,6 +98,7 @@ export class QueryBarInputUI extends Component { public inputRef: HTMLInputElement | null = null; private persistedLog: PersistedLog | undefined; + private services = this.props.kibana.services; private componentIsUnmounting = false; private getQueryString = () => { @@ -122,9 +114,9 @@ export class QueryBarInputUI extends Component { ) as IndexPattern[]; const objectPatternsFromStrings = (await fetchIndexPatterns( - this.props.savedObjectsClient, + this.services.savedObjects!.client, stringPatterns, - this.props.uiSettings + this.services.uiSettings! )) as IndexPattern[]; this.setState({ @@ -137,13 +129,13 @@ export class QueryBarInputUI extends Component { return; } - const uiSettings = this.props.uiSettings; + const uiSettings = this.services.uiSettings; const language = this.props.query.language; const queryString = this.getQueryString(); const recentSearchSuggestions = this.getRecentSearchSuggestions(queryString); - const autocompleteProvider = getAutocompleteProvider(language); + const autocompleteProvider = this.services.autocomplete.getProvider(language); if ( !autocompleteProvider || !Array.isArray(this.state.indexPatterns) || @@ -369,11 +361,11 @@ export class QueryBarInputUI extends Component { // Send telemetry info every time the user opts in or out of kuery // As a result it is important this function only ever gets called in the // UI component's change handler. - this.props.http.post('/api/kibana/kql_opt_in_telemetry', { + this.services.http.post('/api/kibana/kql_opt_in_telemetry', { body: JSON.stringify({ opt_in: language === 'kuery' }), }); - this.props.store.set('kibana.userQueryLanguage', language); + this.services.store.set('kibana.userQueryLanguage', language); const newQuery = { query: '', language }; this.onChange(newQuery); @@ -406,7 +398,7 @@ export class QueryBarInputUI extends Component { this.persistedLog = this.props.persistedLog ? this.props.persistedLog - : getQueryLog(this.props.uiSettings, this.props.appName, this.props.query.language); + : getQueryLog(this.services.uiSettings, this.services.appName, this.props.query.language); this.fetchIndexPatterns().then(this.updateSuggestions); } @@ -419,7 +411,7 @@ export class QueryBarInputUI extends Component { this.persistedLog = this.props.persistedLog ? this.props.persistedLog - : getQueryLog(this.props.uiSettings, this.props.appName, this.props.query.language); + : getQueryLog(this.services.uiSettings, this.services.appName, this.props.query.language); if (!isEqual(prevProps.indexPatterns, this.props.indexPatterns)) { this.fetchIndexPatterns().then(this.updateSuggestions); @@ -446,24 +438,6 @@ export class QueryBarInputUI extends Component { } public render() { - const rest = omit(this.props, [ - 'indexPatterns', - 'intl', - 'query', - 'appName', - 'disableAutoFocus', - 'screenTitle', - 'prepend', - 'store', - 'persistedLog', - 'bubbleSubmitEvent', - 'languageSwitcherPopoverAnchorPosition', - 'onChange', - 'onSubmit', - 'uiSettings', - 'savedObjectsClient', - ]); - return (
{ }, { previouslyTranslatedPageTitle: this.props.screenTitle, - pageType: this.props.appName, + pageType: this.services.appName, } ) : undefined } type="text" - data-test-subj="queryInput" aria-autocomplete="list" aria-controls="kbnTypeahead__items" aria-activedescendant={ @@ -529,7 +502,7 @@ export class QueryBarInputUI extends Component { onSelectLanguage={this.onSelectLanguage} /> } - {...rest} + data-test-subj={this.props.dataTestSubj || 'queryInput'} />
@@ -548,4 +521,4 @@ export class QueryBarInputUI extends Component { } } -export const QueryBarInput = injectI18n(QueryBarInputUI); +export const QueryBarInput = injectI18n(withKibana(QueryBarInputUI)); diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.test.tsx b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.test.tsx index 0926af7b30ef78..337bb9f4861c3e 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.test.tsx +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.test.tsx @@ -21,7 +21,6 @@ import { mockPersistedLogFactory } from './query_bar_input.test.mocks'; import React from 'react'; import { mount } from 'enzyme'; -import './query_bar_top_row.test.mocks'; import { QueryBarTopRow } from './query_bar_top_row'; import { IndexPattern } from '../../../index'; @@ -97,42 +96,49 @@ const mockIndexPattern = { ], } as IndexPattern; -describe('QueryBarTopRowTopRow', () => { - const QUERY_INPUT_SELECTOR = 'InjectIntl(QueryBarInputUI)'; - const TIMEPICKER_SELECTOR = 'EuiSuperDatePicker'; +function wrapQueryBarTopRowInContext(testProps: any) { + const defaultOptions = { + screenTitle: 'Another Screen', + onSubmit: noop, + onChange: noop, + intl: null as any, + }; + const services = { + appName: 'discover', uiSettings: startMock.uiSettings, savedObjects: startMock.savedObjects, notifications: startMock.notifications, http: startMock.http, - }; - const defaultOptions = { - appName: 'discover', - screenTitle: 'Another Screen', - onSubmit: noop, - onChange: noop, store: createMockStorage(), - intl: null as any, }; + return ( + + + + + + ); +} + +describe('QueryBarTopRowTopRow', () => { + const QUERY_INPUT_SELECTOR = 'QueryBarInputUI'; + const TIMEPICKER_SELECTOR = 'EuiSuperDatePicker'; + beforeEach(() => { jest.clearAllMocks(); }); it('Should render the given query', () => { const component = mount( - - - - - + wrapQueryBarTopRowInContext({ + query: kqlQuery, + screenTitle: 'Another Screen', + isDirty: false, + indexPatterns: [mockIndexPattern], + timeHistory: timefilterSetupMock.history, + }) ); expect(component.find(QUERY_INPUT_SELECTOR).length).toBe(1); @@ -141,19 +147,14 @@ describe('QueryBarTopRowTopRow', () => { it('Should create a unique PersistedLog based on the appName and query language', () => { mount( - - - - - + wrapQueryBarTopRowInContext({ + query: kqlQuery, + screenTitle: 'Another Screen', + indexPatterns: [mockIndexPattern], + timeHistory: timefilterSetupMock.history, + disableAutoFocus: true, + isDirty: false, + }) ); expect(mockPersistedLogFactory.mock.calls[0][0]).toBe('typeahead:discover-kuery'); @@ -161,15 +162,10 @@ describe('QueryBarTopRowTopRow', () => { it('Should render only timepicker when no options provided', () => { const component = mount( - - - - - + wrapQueryBarTopRowInContext({ + isDirty: false, + timeHistory: timefilterSetupMock.history, + }) ); expect(component.find(QUERY_INPUT_SELECTOR).length).toBe(0); @@ -178,16 +174,11 @@ describe('QueryBarTopRowTopRow', () => { it('Should not show timepicker when asked', () => { const component = mount( - - - - - + wrapQueryBarTopRowInContext({ + showDatePicker: false, + timeHistory: timefilterSetupMock.history, + isDirty: false, + }) ); expect(component.find(QUERY_INPUT_SELECTOR).length).toBe(0); @@ -196,19 +187,14 @@ describe('QueryBarTopRowTopRow', () => { it('Should render timepicker with options', () => { const component = mount( - - - - - + wrapQueryBarTopRowInContext({ + isDirty: false, + screenTitle: 'Another Screen', + showDatePicker: true, + dateRangeFrom: 'now-7d', + dateRangeTo: 'now', + timeHistory: timefilterSetupMock.history, + }) ); expect(component.find(QUERY_INPUT_SELECTOR).length).toBe(0); @@ -217,19 +203,16 @@ describe('QueryBarTopRowTopRow', () => { it('Should render only query input bar', () => { const component = mount( - - - - - + wrapQueryBarTopRowInContext({ + query: kqlQuery, + indexPatterns: [mockIndexPattern], + isDirty: false, + screenTitle: 'Another Screen', + showDatePicker: false, + dateRangeFrom: 'now-7d', + dateRangeTo: 'now', + timeHistory: timefilterSetupMock.history, + }) ); expect(component.find(QUERY_INPUT_SELECTOR).length).toBe(1); @@ -238,20 +221,15 @@ describe('QueryBarTopRowTopRow', () => { it('Should NOT render query input bar if disabled', () => { const component = mount( - - - - - + wrapQueryBarTopRowInContext({ + query: kqlQuery, + isDirty: false, + screenTitle: 'Another Screen', + indexPatterns: [mockIndexPattern], + showQueryInput: false, + showDatePicker: false, + timeHistory: timefilterSetupMock.history, + }) ); expect(component.find(QUERY_INPUT_SELECTOR).length).toBe(0); @@ -260,17 +238,12 @@ describe('QueryBarTopRowTopRow', () => { it('Should NOT render query input bar if missing options', () => { const component = mount( - - - - - + wrapQueryBarTopRowInContext({ + isDirty: false, + screenTitle: 'Another Screen', + showDatePicker: false, + timeHistory: timefilterSetupMock.history, + }) ); expect(component.find(QUERY_INPUT_SELECTOR).length).toBe(0); diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.tsx b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.tsx index c25b596973174d..6895c9ecd018cb 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.tsx +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.tsx @@ -21,7 +21,6 @@ import { doesKueryExpressionHaveLuceneSyntaxError } from '@kbn/es-query'; import classNames from 'classnames'; import React, { useState, useEffect } from 'react'; -import { Storage } from 'ui/storage'; import { documentationLinks } from 'ui/documentation_links'; import { PersistedLog } from 'ui/persisted_log'; @@ -30,6 +29,7 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLink, EuiSuperDatePicker } fro import { EuiSuperUpdateButton } from '@elastic/eui'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import { Toast } from 'src/core/public'; +import { TimeRange } from 'src/plugins/data/public'; import { useKibana } from '../../../../../../../plugins/kibana_react/public'; import { IndexPattern } from '../../../index_patterns'; @@ -37,21 +37,15 @@ import { QueryBarInput } from './query_bar_input'; import { getQueryLog } from '../lib/get_query_log'; import { Query } from '../index'; import { TimeHistoryContract } from '../../../timefilter'; - -interface DateRange { - from: string; - to: string; -} +import { IDataPluginServices } from '../../../types'; interface Props { query?: Query; - onSubmit: (payload: { dateRange: DateRange; query?: Query }) => void; - onChange: (payload: { dateRange: DateRange; query?: Query }) => void; + onSubmit: (payload: { dateRange: TimeRange; query?: Query }) => void; + onChange: (payload: { dateRange: TimeRange; query?: Query }) => void; disableAutoFocus?: boolean; - appName: string; screenTitle?: string; indexPatterns?: Array; - store?: Storage; intl: InjectedIntl; prepend?: React.ReactNode; showQueryInput?: boolean; @@ -70,15 +64,15 @@ interface Props { function QueryBarTopRowUI(props: Props) { const [isDateRangeInvalid, setIsDateRangeInvalid] = useState(false); - const kibana = useKibana(); - const { uiSettings, http, notifications, savedObjects } = kibana.services; + const kibana = useKibana(); + const { uiSettings, notifications, store, appName } = kibana.services; const queryLanguage = props.query && props.query.language; let persistedLog: PersistedLog | undefined; useEffect(() => { if (!props.query) return; - persistedLog = getQueryLog(uiSettings!, props.appName, props.query.language); + persistedLog = getQueryLog(uiSettings!, appName, props.query.language); }, [queryLanguage]); function onClickSubmitButton(event: React.MouseEvent) { @@ -131,7 +125,7 @@ function QueryBarTopRowUI(props: Props) { } } - function onSubmit({ query, dateRange }: { query?: Query; dateRange: DateRange }) { + function onSubmit({ query, dateRange }: { query?: Query; dateRange: TimeRange }) { handleLuceneSyntaxWarning(); if (props.timeHistory) { @@ -153,19 +147,14 @@ function QueryBarTopRowUI(props: Props) { return ( ); @@ -176,7 +165,7 @@ function QueryBarTopRowUI(props: Props) { } function shouldRenderQueryInput(): boolean { - return Boolean(props.showQueryInput && props.indexPatterns && props.query && props.store); + return Boolean(props.showQueryInput && props.indexPatterns && props.query && store); } function renderUpdateButton() { @@ -251,7 +240,7 @@ function QueryBarTopRowUI(props: Props) { function handleLuceneSyntaxWarning() { if (!props.query) return; - const { intl, store } = props; + const { intl } = props; const { query, language } = props.query; if ( language === 'kuery' && @@ -300,8 +289,8 @@ function QueryBarTopRowUI(props: Props) { } function onLuceneSyntaxWarningOptOut(toast: Toast) { - if (!props.store) return; - props.store.set('kibana.luceneSyntaxWarningOptOut', true); + if (!store) return; + store.set('kibana.luceneSyntaxWarningOptOut', true); notifications!.toasts.remove(toast); } diff --git a/src/legacy/core_plugins/data/public/search/search_bar/components/create_search_bar.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/create_search_bar.tsx new file mode 100644 index 00000000000000..add49e47971d34 --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/create_search_bar.tsx @@ -0,0 +1,93 @@ +/* + * 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 React from 'react'; +import { Filter } from '@kbn/es-query'; +import { CoreStart } from 'src/core/public'; +import { Storage } from 'ui/storage'; +import { AutocompletePublicPluginStart } from 'src/plugins/data/public'; +import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public'; +import { TimefilterSetup } from '../../../timefilter'; +import { FilterManager, SearchBar } from '../../../'; +import { SearchBarOwnProps } from '.'; + +interface StatefulSearchBarDeps { + core: CoreStart; + store: Storage; + timefilter: TimefilterSetup; + filterManager: FilterManager; + autocomplete: AutocompletePublicPluginStart; +} + +export type StatetfulSearchBarProps = SearchBarOwnProps & { + appName: string; +}; + +const defaultFiltersUpdated = (filterManager: FilterManager) => { + return (filters: Filter[]) => { + filterManager.setFilters(filters); + }; +}; + +const defaultOnRefreshChange = (timefilter: TimefilterSetup) => { + return (options: { isPaused: boolean; refreshInterval: number }) => { + timefilter.timefilter.setRefreshInterval({ + value: options.refreshInterval, + pause: options.isPaused, + }); + }; +}; + +export function createSearchBar({ + core, + store, + timefilter, + filterManager, + autocomplete, +}: StatefulSearchBarDeps) { + // App name should come from the core application service. + // Until it's available, we'll ask the user to provide it for the pre-wired component. + return (props: StatetfulSearchBarProps) => { + const timeRange = timefilter.timefilter.getTime(); + const refreshInterval = timefilter.timefilter.getRefreshInterval(); + + return ( + + + + ); + }; +} diff --git a/src/legacy/core_plugins/data/public/search/search_bar/components/index.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/index.tsx index 24ffa939a746e9..accaac163acfcf 100644 --- a/src/legacy/core_plugins/data/public/search/search_bar/components/index.tsx +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/index.tsx @@ -18,3 +18,4 @@ */ export * from './search_bar'; +export * from './create_search_bar'; diff --git a/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx index 7d48977fab8a53..73e81a38572c39 100644 --- a/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx @@ -18,14 +18,17 @@ */ import React from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { SearchBar } from './search_bar'; import { IndexPattern } from '../../../index_patterns'; +import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; +import { I18nProvider } from '@kbn/i18n/react'; + import { coreMock } from '../../../../../../../../src/core/public/mocks'; const startMock = coreMock.createStart(); import { timefilterServiceMock } from '../../../timefilter/timefilter_service.mock'; +import { mount } from 'enzyme'; const timefilterSetupMock = timefilterServiceMock.createSetupContract(); jest.mock('../../../../../data/public', () => { @@ -41,13 +44,6 @@ jest.mock('../../../query/query_bar', () => { }; }); -jest.mock('ui/notify', () => ({ - toastNotifications: { - addSuccess: () => {}, - addDanger: () => {}, - }, -})); - const noop = jest.fn(); const createMockWebStorage = () => ({ @@ -87,26 +83,44 @@ const kqlQuery = { language: 'kuery', }; -describe('SearchBar', () => { - const SEARCH_BAR_ROOT = '.globalQueryBar'; - const FILTER_BAR = '.filterBar'; - const QUERY_BAR = '.queryBar'; - - const options = { +function wrapSearchBarInContext(testProps: any) { + const defaultOptions = { appName: 'test', - savedObjects: startMock.savedObjects, - notifications: startMock.notifications, timeHistory: timefilterSetupMock.history, intl: null as any, }; + const services = { + uiSettings: startMock.uiSettings, + savedObjects: startMock.savedObjects, + notifications: startMock.notifications, + http: startMock.http, + store: createMockStorage(), + }; + + return ( + + + + + + ); +} + +describe('SearchBar', () => { + const SEARCH_BAR_ROOT = '.globalQueryBar'; + const FILTER_BAR = '.filterBar'; + const QUERY_BAR = '.queryBar'; + beforeEach(() => { jest.clearAllMocks(); }); it('Should render query bar when no options provided (in reality - timepicker)', () => { - const component = mountWithIntl( - + const component = mount( + wrapSearchBarInContext({ + indexPatterns: [mockIndexPattern], + }) ); expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); @@ -115,12 +129,11 @@ describe('SearchBar', () => { }); it('Should render empty when timepicker is off and no options provided', () => { - const component = mountWithIntl( - + const component = mount( + wrapSearchBarInContext({ + indexPatterns: [mockIndexPattern], + showDatePicker: false, + }) ); expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); @@ -129,14 +142,13 @@ describe('SearchBar', () => { }); it('Should render filter bar, when required fields are provided', () => { - const component = mountWithIntl( - + const component = mount( + wrapSearchBarInContext({ + indexPatterns: [mockIndexPattern], + showDatePicker: false, + onFiltersUpdated: noop, + filters: [], + }) ); expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); @@ -145,15 +157,14 @@ describe('SearchBar', () => { }); it('Should NOT render filter bar, if disabled', () => { - const component = mountWithIntl( - + const component = mount( + wrapSearchBarInContext({ + indexPatterns: [mockIndexPattern], + showFilterBar: false, + filters: [], + onFiltersUpdated: noop, + showDatePicker: false, + }) ); expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); @@ -162,15 +173,13 @@ describe('SearchBar', () => { }); it('Should render query bar, when required fields are provided', () => { - const component = mountWithIntl( - + const component = mount( + wrapSearchBarInContext({ + indexPatterns: [mockIndexPattern], + screenTitle: 'test screen', + onQuerySubmit: noop, + query: kqlQuery, + }) ); expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); @@ -179,16 +188,14 @@ describe('SearchBar', () => { }); it('Should NOT render query bar, if disabled', () => { - const component = mountWithIntl( - + const component = mount( + wrapSearchBarInContext({ + indexPatterns: [mockIndexPattern], + screenTitle: 'test screen', + onQuerySubmit: noop, + query: kqlQuery, + showQueryBar: false, + }) ); expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); @@ -197,17 +204,15 @@ describe('SearchBar', () => { }); it('Should render query bar and filter bar', () => { - const component = mountWithIntl( - + const component = mount( + wrapSearchBarInContext({ + indexPatterns: [mockIndexPattern], + screenTitle: 'test screen', + onQuerySubmit: noop, + query: kqlQuery, + filters: [], + onFiltersUpdated: noop, + }) ); expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); diff --git a/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx index 6c73fa3614cc3c..ed2a6638aba114 100644 --- a/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx @@ -22,10 +22,9 @@ import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import classNames from 'classnames'; import React, { Component } from 'react'; import ResizeObserver from 'resize-observer-polyfill'; -import { Storage } from 'ui/storage'; import { get, isEqual } from 'lodash'; -import { CoreStart } from 'src/core/public'; +import { TimeRange } from 'src/plugins/data/common/types'; import { IndexPattern, Query, FilterBar } from '../../../../../data/public'; import { QueryBarTopRow } from '../../../query'; import { SavedQuery, SavedQueryAttributes } from '../index'; @@ -34,54 +33,52 @@ import { SavedQueryManagementComponent } from './saved_query_management/saved_qu import { SavedQueryService } from '../lib/saved_query_service'; import { createSavedQueryService } from '../lib/saved_query_service'; import { TimeHistoryContract } from '../../../timefilter'; - -interface DateRange { - from: string; - to: string; -} - -/** - * NgReact lib requires that changes to the props need to be made in the directive config as well - * See [search_bar\directive\index.js] file - */ -export interface SearchBarProps { - appName: string; +import { + withKibana, + KibanaReactContextValue, +} from '../../../../../../../plugins/kibana_react/public'; +import { IDataPluginServices } from '../../../types'; + +interface SearchBarInjectedDeps { + kibana: KibanaReactContextValue; intl: InjectedIntl; - indexPatterns?: IndexPattern[]; - - // Query bar - showQueryBar?: boolean; - showQueryInput?: boolean; - screenTitle?: string; - store?: Storage; - query?: Query; - savedQuery?: SavedQuery; - onQuerySubmit?: (payload: { dateRange: DateRange; query?: Query }) => void; timeHistory: TimeHistoryContract; // Filter bar - showFilterBar?: boolean; - filters?: Filter[]; onFiltersUpdated?: (filters: Filter[]) => void; + filters?: Filter[]; // Date picker - showDatePicker?: boolean; dateRangeFrom?: string; dateRangeTo?: string; // Autorefresh + onRefreshChange?: (options: { isPaused: boolean; refreshInterval: number }) => void; isRefreshPaused?: boolean; refreshInterval?: number; +} + +export interface SearchBarOwnProps { + indexPatterns?: IndexPattern[]; + customSubmitButton?: React.ReactNode; + screenTitle?: string; + + // Togglers + showQueryBar?: boolean; + showQueryInput?: boolean; + showFilterBar?: boolean; + showDatePicker?: boolean; showAutoRefreshOnly?: boolean; showSaveQuery?: boolean; - onRefreshChange?: (options: { isPaused: boolean; refreshInterval: number }) => void; + + // Query bar - should be in SearchBarInjectedDeps + query?: Query; + savedQuery?: SavedQuery; + onQuerySubmit?: (payload: { dateRange: TimeRange; query?: Query }) => void; onSaved?: (savedQuery: SavedQuery) => void; onSavedQueryUpdated?: (savedQuery: SavedQuery) => void; onClearSavedQuery?: () => void; - customSubmitButton?: React.ReactNode; - - // TODO: deprecate - savedObjects: CoreStart['savedObjects']; - notifications: CoreStart['notifications']; } +export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps; + interface State { isFiltersVisible: boolean; showSaveQueryModal: boolean; @@ -102,7 +99,7 @@ class SearchBarUI extends Component { }; private savedQueryService!: SavedQueryService; - + private services = this.props.kibana.services; public filterBarRef: Element | null = null; public filterBarWrapperRef: Element | null = null; @@ -253,7 +250,7 @@ class SearchBarUI extends Component { response = await this.savedQueryService.saveQuery(savedQueryAttributes); } - this.props.notifications.toasts.addSuccess( + this.services.notifications.toasts.addSuccess( `Your query "${response.attributes.title}" was saved` ); @@ -266,7 +263,7 @@ class SearchBarUI extends Component { this.props.onSaved(response); } } catch (error) { - this.props.notifications.toasts.addDanger( + this.services.notifications.toasts.addDanger( `An error occured while saving your query: ${error.message}` ); throw error; @@ -285,7 +282,7 @@ class SearchBarUI extends Component { }); }; - public onQueryBarChange = (queryAndDateRange: { dateRange: DateRange; query?: Query }) => { + public onQueryBarChange = (queryAndDateRange: { dateRange: TimeRange; query?: Query }) => { this.setState({ query: queryAndDateRange.query, dateRangeFrom: queryAndDateRange.dateRange.from, @@ -293,7 +290,7 @@ class SearchBarUI extends Component { }); }; - public onQueryBarSubmit = (queryAndDateRange: { dateRange?: DateRange; query?: Query }) => { + public onQueryBarSubmit = (queryAndDateRange: { dateRange?: TimeRange; query?: Query }) => { this.setState( { query: queryAndDateRange.query, @@ -337,8 +334,8 @@ class SearchBarUI extends Component { this.setFilterBarHeight(); this.ro.observe(this.filterBarRef); } - if (this.props.savedObjects) { - this.savedQueryService = createSavedQueryService(this.props.savedObjects!.client); + if (this.services.savedObjects) { + this.savedQueryService = createSavedQueryService(this.services.savedObjects.client); } } @@ -370,9 +367,7 @@ class SearchBarUI extends Component { query={this.state.query} screenTitle={this.props.screenTitle} onSubmit={this.onQueryBarSubmit} - appName={this.props.appName} indexPatterns={this.props.indexPatterns} - store={this.props.store} prepend={this.props.showFilterBar ? savedQueryManagement : undefined} showDatePicker={this.props.showDatePicker} dateRangeFrom={this.state.dateRangeFrom} @@ -449,4 +444,4 @@ class SearchBarUI extends Component { } } -export const SearchBar = injectI18n(SearchBarUI); +export const SearchBar = injectI18n(withKibana(SearchBarUI)); diff --git a/src/legacy/core_plugins/data/public/shim/legacy_dependencies_plugin.ts b/src/legacy/core_plugins/data/public/shim/legacy_dependencies_plugin.ts index 4289d56b33c605..126754388f13f2 100644 --- a/src/legacy/core_plugins/data/public/shim/legacy_dependencies_plugin.ts +++ b/src/legacy/core_plugins/data/public/shim/legacy_dependencies_plugin.ts @@ -18,7 +18,8 @@ */ import chrome from 'ui/chrome'; -import { CoreStart, Plugin } from '../../../../../../src/core/public'; +import { Storage } from 'ui/storage'; +import { Plugin } from '../../../../../../src/core/public'; import { initLegacyModule } from './legacy_module'; /** @internal */ @@ -26,6 +27,10 @@ export interface LegacyDependenciesPluginSetup { savedObjectsClient: any; } +export interface LegacyDependenciesPluginStart { + storage: Storage; +} + export class LegacyDependenciesPlugin implements Plugin { public setup() { initLegacyModule(); @@ -35,7 +40,9 @@ export class LegacyDependenciesPlugin implements Plugin { } as LegacyDependenciesPluginSetup; } - public start(core: CoreStart) { - // nothing to do here yet + public start() { + return { + storage: new Storage(window.localStorage), + } as LegacyDependenciesPluginStart; } } diff --git a/src/legacy/core_plugins/data/public/timefilter/get_time.ts b/src/legacy/core_plugins/data/public/timefilter/get_time.ts index e54725dd9ba486..18a43d789714d7 100644 --- a/src/legacy/core_plugins/data/public/timefilter/get_time.ts +++ b/src/legacy/core_plugins/data/public/timefilter/get_time.ts @@ -18,8 +18,8 @@ */ import dateMath from '@elastic/datemath'; -import { Field, IndexPattern } from 'ui/index_patterns'; import { TimeRange } from 'src/plugins/data/public'; +import { IndexPattern, Field } from '../index_patterns'; interface CalculateBoundsOptions { forceNow?: Date; diff --git a/src/legacy/core_plugins/data/public/timefilter/timefilter.test.mocks.ts b/src/legacy/core_plugins/data/public/timefilter/timefilter.test.mocks.ts deleted file mode 100644 index 7354916c3fc359..00000000000000 --- a/src/legacy/core_plugins/data/public/timefilter/timefilter.test.mocks.ts +++ /dev/null @@ -1,28 +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 { chromeServiceMock } from '../../../../../core/public/mocks'; - -jest.doMock('ui/new_platform', () => ({ - npStart: { - core: { - chrome: chromeServiceMock.createStartContract(), - }, - }, -})); diff --git a/src/legacy/core_plugins/data/public/timefilter/timefilter.ts b/src/legacy/core_plugins/data/public/timefilter/timefilter.ts index c08ea9043da927..64129ea2af5ffb 100644 --- a/src/legacy/core_plugins/data/public/timefilter/timefilter.ts +++ b/src/legacy/core_plugins/data/public/timefilter/timefilter.ts @@ -25,7 +25,7 @@ import { IndexPattern, TimeHistoryContract } from '../index'; import { areRefreshIntervalsDifferent, areTimeRangesDifferent } from './lib/diff_time_picker_vals'; import { parseQueryString } from './lib/parse_querystring'; import { calculateBounds, getTime } from './get_time'; -import { TimefilterConfig, InputTimeRange } from './types'; +import { TimefilterConfig, InputTimeRange, TimeRangeBounds } from './types'; export class Timefilter { // Fired when isTimeRangeSelectorEnabled \ isAutoRefreshSelectorEnabled are toggled @@ -148,19 +148,19 @@ export class Timefilter { return getTime(indexPattern, timeRange ? timeRange : this._time, this.getForceNow()); }; - public getBounds = () => { + public getBounds(): TimeRangeBounds { return this.calculateBounds(this._time); - }; + } - public calculateBounds = (timeRange: TimeRange) => { + public calculateBounds(timeRange: TimeRange): TimeRangeBounds { return calculateBounds(timeRange, { forceNow: this.getForceNow() }); - }; + } - public getActiveBounds = () => { + public getActiveBounds(): TimeRangeBounds | undefined { if (this.isTimeRangeSelectorEnabled) { return this.getBounds(); } - }; + } /** * Show the time bounds selector part of the time filter diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.test.mocks.tsx b/src/legacy/core_plugins/data/public/types.ts similarity index 63% rename from src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.test.mocks.tsx rename to src/legacy/core_plugins/data/public/types.ts index 585fad0e058b73..4b7a5c1402ea72 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.test.mocks.tsx +++ b/src/legacy/core_plugins/data/public/types.ts @@ -17,16 +17,15 @@ * under the License. */ -import { - fatalErrorsServiceMock, - notificationServiceMock, -} from '../../../../../../../core/public/mocks'; +import { UiSettingsClientContract, CoreStart } from 'src/core/public'; +import { AutocompletePublicPluginStart } from 'src/plugins/data/public'; -jest.doMock('ui/new_platform', () => ({ - npSetup: { - core: { - fatalErrors: fatalErrorsServiceMock.createSetupContract(), - notifications: notificationServiceMock.createSetupContract(), - }, - }, -})); +export interface IDataPluginServices extends Partial { + appName: string; + uiSettings: UiSettingsClientContract; + savedObjects: CoreStart['savedObjects']; + notifications: CoreStart['notifications']; + http: CoreStart['http']; + store: Storage; + autocomplete: AutocompletePublicPluginStart; +} diff --git a/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.test.tsx b/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.test.tsx index 39bf299cd8d125..21c5cef4ae9254 100644 --- a/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.test.tsx @@ -22,12 +22,20 @@ import { TopNavMenu } from './top_nav_menu'; import { TopNavMenuData } from './top_nav_menu_data'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { coreMock } from '../../../../../core/public/mocks'; -const startMock = coreMock.createStart(); - import { timefilterServiceMock } from '../../../../core_plugins/data/public/timefilter/timefilter_service.mock'; const timefilterSetupMock = timefilterServiceMock.createSetupContract(); +jest.mock('ui/new_platform'); + +jest.mock('../../../../../../src/legacy/core_plugins/data/public/legacy', () => ({ + start: { + ui: { + SearchBar: () => {}, + }, + }, + setup: {}, +})); + jest.mock('../../../../core_plugins/data/public', () => { return { SearchBar: () =>
, @@ -57,34 +65,26 @@ describe('TopNavMenu', () => { ]; it('Should render nothing when no config is provided', () => { - const component = shallowWithIntl(); + const component = shallowWithIntl(); expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(0); }); it('Should render 1 menu item', () => { - const component = shallowWithIntl(); + const component = shallowWithIntl(); expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(1); expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(0); }); it('Should render multiple menu items', () => { - const component = shallowWithIntl(); + const component = shallowWithIntl(); expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(menuItems.length); expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(0); }); it('Should render search bar', () => { const component = shallowWithIntl( - + ); expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); diff --git a/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.tsx b/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.tsx index c99c71f97e1afe..aec91c2aa6bc64 100644 --- a/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.tsx +++ b/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.tsx @@ -21,27 +21,16 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { I18nProvider } from '@kbn/i18n/react'; -import { UiSettingsClientContract, CoreStart } from 'src/core/public'; + import { TopNavMenuData } from './top_nav_menu_data'; import { TopNavMenuItem } from './top_nav_menu_item'; -import { KibanaContextProvider } from '../../../../../plugins/kibana_react/public'; -import { - SearchBar, - SearchBarProps, - TimeHistoryContract, -} from '../../../../core_plugins/data/public'; +import { SearchBarProps } from '../../../../core_plugins/data/public'; +import { start as data } from '../../../data/public/legacy'; type Props = Partial & { - name: string; + appName: string; config?: TopNavMenuData[]; showSearchBar?: boolean; - - // Search Bar dependencies - uiSettings?: UiSettingsClientContract; - savedObjects?: CoreStart['savedObjects']; - notifications?: CoreStart['notifications']; - timeHistory?: TimeHistoryContract; - http?: CoreStart['http']; }; /* @@ -54,9 +43,11 @@ type Props = Partial & { **/ export function TopNavMenu(props: Props) { + const { SearchBar } = data.ui; + const { config, showSearchBar, ...searchBarProps } = props; function renderItems() { - if (!props.config) return; - return props.config.map((menuItem: TopNavMenuData, i: number) => { + if (!config) return; + return config.map((menuItem: TopNavMenuData, i: number) => { return ( @@ -67,53 +58,8 @@ export function TopNavMenu(props: Props) { function renderSearchBar() { // Validate presense of all required fields - if ( - !props.showSearchBar || - !props.savedObjects || - !props.http || - !props.notifications || - !props.timeHistory - ) - return; - return ( - - - - ); + if (!showSearchBar) return; + return ; } function renderLayout() { 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 9d2f68ddb74465..5bcb2961c42de2 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 @@ -22,7 +22,7 @@ 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', () => { +jest.mock('../../data/public/legacy', () => { return {}; }); diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js index a6c52d8760666f..389a84babae87b 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js @@ -34,6 +34,7 @@ import { extractIndexPatterns } from '../../common/extract_index_patterns'; import { npStart } from 'ui/new_platform'; import { Storage } from 'ui/storage'; import { CoreStartContextProvider } from '../contexts/query_input_bar_context'; +import { KibanaContextProvider } from '../../../../../plugins/kibana_react/public'; const localStorage = new Storage(window.localStorage); import { timefilter } from 'ui/timefilter'; @@ -163,38 +164,48 @@ export class VisEditor extends Component { const { model } = this.state; if (model) { + //TODO: Remove CoreStartContextProvider, KibanaContextProvider should be raised to the top of the plugin. return ( -
-
- + +
+
+ +
+ +
+ + + +
- -
- - - -
-
+ ); } diff --git a/src/legacy/ui/public/kbn_top_nav/kbn_top_nav.js b/src/legacy/ui/public/kbn_top_nav/kbn_top_nav.js index f2faeee75810ee..79365eb5cf1cc5 100644 --- a/src/legacy/ui/public/kbn_top_nav/kbn_top_nav.js +++ b/src/legacy/ui/public/kbn_top_nav/kbn_top_nav.js @@ -21,9 +21,6 @@ import 'ngreact'; import { wrapInI18nContext } from 'ui/i18n'; import { uiModules } from 'ui/modules'; import { TopNavMenu } from '../../../core_plugins/kibana_react/public'; -import { Storage } from 'ui/storage'; -import { npStart } from 'ui/new_platform'; -import { start as data } from '../../../core_plugins/data/public/legacy'; const module = uiModules.get('kibana'); @@ -43,25 +40,10 @@ module.directive('kbnTopNav', () => { // of the config array's disableButton function return value changes. child.setAttribute('disabled-buttons', 'disabledButtons'); - // Pass in storage - const localStorage = new Storage(window.localStorage); - child.setAttribute('http', 'http'); - child.setAttribute('store', 'store'); - child.setAttribute('time-history', 'timeHistory'); - child.setAttribute('ui-settings', 'uiSettings'); - child.setAttribute('saved-objects', 'savedObjects'); - child.setAttribute('notifications', 'notifications'); - // Append helper directive elem.append(child); const linkFn = ($scope, _, $attr) => { - $scope.store = localStorage; - $scope.http = npStart.core.http; - $scope.uiSettings = npStart.core.uiSettings; - $scope.savedObjects = npStart.core.savedObjects; - $scope.notifications = npStart.core.notifications; - $scope.timeHistory = data.timefilter.history; // Watch config changes $scope.$watch(() => { @@ -95,20 +77,12 @@ module.directive('kbnTopNavHelper', (reactDirective) => { return reactDirective( wrapInI18nContext(TopNavMenu), [ - ['name', { watchDepth: 'reference' }], ['config', { watchDepth: 'value' }], ['disabledButtons', { watchDepth: 'reference' }], ['query', { watchDepth: 'reference' }], ['savedQuery', { watchDepth: 'reference' }], - ['store', { watchDepth: 'reference' }], - ['uiSettings', { watchDepth: 'reference' }], - ['savedObjects', { watchDepth: 'reference' }], - ['notifications', { watchDepth: 'reference' }], ['intl', { watchDepth: 'reference' }], - ['timeHistory', { watchDepth: 'reference' }], - ['store', { watchDepth: 'reference' }], - ['http', { watchDepth: 'reference' }], ['onQuerySubmit', { watchDepth: 'reference' }], ['onFiltersUpdated', { watchDepth: 'reference' }], diff --git a/src/legacy/ui/public/vis/editors/default/controls/filter.tsx b/src/legacy/ui/public/vis/editors/default/controls/filter.tsx index 779d5acf5b411e..cceaf86b5d85ca 100644 --- a/src/legacy/ui/public/vis/editors/default/controls/filter.tsx +++ b/src/legacy/ui/public/vis/editors/default/controls/filter.tsx @@ -24,6 +24,7 @@ import { Query, QueryBarInput } from 'plugins/data'; import { AggConfig } from '../../..'; import { npStart } from '../../../../new_platform'; import { Storage } from '../../../../storage'; +import { KibanaContextProvider } from '../../../../../../../plugins/kibana_react/public'; const localStorage = new Storage(window.localStorage); interface FilterRowProps { @@ -82,6 +83,7 @@ function FilterRow({
); + // TODO: KibanaContextProvider should be raised to the top of the vis plugin return ( - onChangeValue(id, query, customLabel)} - disableAutoFocus={!autoFocus} - data-test-subj={dataTestSubj} - bubbleSubmitEvent={true} - languageSwitcherPopoverAnchorPosition="leftDown" - store={localStorage} - uiSettings={npStart.core.uiSettings} - http={npStart.core.http} - savedObjectsClient={npStart.core.savedObjects.client} - /> + + onChangeValue(id, query, customLabel)} + disableAutoFocus={!autoFocus} + dataTestSubj={dataTestSubj} + bubbleSubmitEvent={true} + languageSwitcherPopoverAnchorPosition="leftDown" + /> + {showCustomLabel ? ( ; +export type AutocompletePublicPluginStart = Pick; /** @public **/ export type AutocompleteProvider = (args: { diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 110ca3e68a97e1..91b94e09607ee0 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -25,6 +25,7 @@ export function plugin(initializerContext: PluginInitializerContext) { } export { DataPublicPlugin as Plugin }; +export { DataPublicPluginSetup, DataPublicPluginStart } from './types'; export * from '../common'; export * from './autocomplete_provider'; diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index d1670ccb645dba..eb316477673605 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -19,14 +19,7 @@ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public'; import { AutocompleteProviderRegister } from './autocomplete_provider'; - -export interface DataPublicPluginSetup { - autocomplete: Pick; -} - -export interface DataPublicPluginStart { - autocomplete: Pick; -} +import { DataPublicPluginSetup, DataPublicPluginStart } from './types'; export class DataPublicPlugin implements Plugin { private readonly autocomplete = new AutocompleteProviderRegister(); diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index ea160d34dc1548..23308304b8ff8e 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -18,3 +18,12 @@ */ export * from './autocomplete_provider/types'; + +import { AutocompletePublicPluginSetup, AutocompletePublicPluginStart } from '.'; +export interface DataPublicPluginSetup { + autocomplete: AutocompletePublicPluginSetup; +} + +export interface DataPublicPluginStart { + autocomplete: AutocompletePublicPluginStart; +} diff --git a/x-pack/legacy/plugins/graph/public/angular/templates/index.html b/x-pack/legacy/plugins/graph/public/angular/templates/index.html index 267e7564fb8302..3ed9b390c6a787 100644 --- a/x-pack/legacy/plugins/graph/public/angular/templates/index.html +++ b/x-pack/legacy/plugins/graph/public/angular/templates/index.html @@ -11,14 +11,13 @@ current-index-pattern="selectedIndex" on-index-pattern-selected="uiSelectIndex" on-query-submit="submit" - saved-objects="pluginDependencies.savedObjects" - ui-settings="pluginDependencies.uiSettings" - http="pluginDependencies.http" - overlays="pluginDependencies.overlays" is-loading="loading" initial-query="initialQuery" state="reduxState" dispatch="reduxDispatch" + autocomplete-start="autocompleteStart" + core-start="coreStart" + store="store" >
diff --git a/x-pack/legacy/plugins/graph/public/app.js b/x-pack/legacy/plugins/graph/public/app.js index 32fd24b8bed6d8..aa8f0be6231dfc 100644 --- a/x-pack/legacy/plugins/graph/public/app.js +++ b/x-pack/legacy/plugins/graph/public/app.js @@ -32,6 +32,7 @@ import { npStart } from 'ui/new_platform'; import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_registry'; import { capabilities } from 'ui/capabilities'; import { showSaveModal } from 'ui/saved_objects/show_saved_object_save_modal'; +import { Storage } from 'ui/storage'; import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; @@ -114,11 +115,10 @@ app.directive('graphApp', function (reactDirective) { ['isLoading', { watchDepth: 'reference' }], ['onIndexPatternSelected', { watchDepth: 'reference' }], ['onQuerySubmit', { watchDepth: 'reference' }], - ['savedObjects', { watchDepth: 'reference' }], - ['uiSettings', { watchDepth: 'reference' }], - ['http', { watchDepth: 'reference' }], ['initialQuery', { watchDepth: 'reference' }], - ['overlays', { watchDepth: 'reference' }] + ['autocompleteStart', { watchDepth: 'reference' }], + ['coreStart', { watchDepth: 'reference' }], + ['store', { watchDepth: 'reference' }] ]); }); @@ -302,8 +302,10 @@ app.controller('graphuiPlugin', function ( } }; - $scope.pluginDependencies = npStart.core; + $scope.store = new Storage(window.localStorage); + $scope.coreStart = npStart.core; + $scope.autocompleteStart = npStart.plugins.data.autocomplete; $scope.loading = false; const updateScope = () => { diff --git a/x-pack/legacy/plugins/graph/public/components/app.tsx b/x-pack/legacy/plugins/graph/public/components/app.tsx index 7e75a13bb39e3b..907e7e4cecdcd5 100644 --- a/x-pack/legacy/plugins/graph/public/components/app.tsx +++ b/x-pack/legacy/plugins/graph/public/components/app.tsx @@ -6,22 +6,40 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; +import { Storage } from 'ui/storage'; +import { CoreStart } from 'kibana/public'; +import { AutocompletePublicPluginStart } from 'src/plugins/data/public'; import { FieldManagerProps, FieldManager } from './field_manager'; import { SearchBarProps, SearchBar } from './search_bar'; -export interface GraphAppProps extends FieldManagerProps, SearchBarProps {} +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; + +export interface GraphAppProps extends FieldManagerProps, SearchBarProps { + coreStart: CoreStart; + autocompleteStart: AutocompletePublicPluginStart; + store: Storage; +} export function GraphApp(props: GraphAppProps) { return ( -
- - - - - - - - -
+ +
+ + + + + + + + +
+
); } diff --git a/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx b/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx index eb97d63a333951..80b1c3c3439427 100644 --- a/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx @@ -4,32 +4,54 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SearchBar } from './search_bar'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { SearchBar, SearchBarProps } from './search_bar'; import React, { ReactElement } from 'react'; import { CoreStart } from 'src/core/public'; import { act } from 'react-dom/test-utils'; import { IndexPattern, QueryBarInput } from 'src/legacy/core_plugins/data/public'; +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { I18nProvider } from '@kbn/i18n/react'; + jest.mock('ui/new_platform'); + import { openSourceModal } from '../services/source_modal'; +import { mount } from 'enzyme'; jest.mock('../services/source_modal', () => ({ openSourceModal: jest.fn() })); +function wrapSearchBarInContext(testProps: SearchBarProps) { + const services = { + uiSettings: { + get: (key: string) => { + return 10; + }, + } as CoreStart['uiSettings'], + savedObjects: {} as CoreStart['savedObjects'], + notifications: {} as CoreStart['notifications'], + http: {} as CoreStart['http'], + overlays: {} as CoreStart['overlays'], + }; + + return ( + + + + + + ); +} + describe('search_bar', () => { it('should render search bar and submit queryies', () => { const querySubmit = jest.fn(); - const instance = shallowWithIntl( - {}} - onQuerySubmit={querySubmit} - savedObjects={{} as CoreStart['savedObjects']} - uiSettings={{} as CoreStart['uiSettings']} - http={{} as CoreStart['http']} - overlays={{} as CoreStart['overlays']} - currentIndexPattern={{ title: 'Testpattern' } as IndexPattern} - /> + const instance = mount( + wrapSearchBarInContext({ + isLoading: false, + onIndexPatternSelected: () => {}, + onQuerySubmit: querySubmit, + currentIndexPattern: { title: 'Testpattern' } as IndexPattern, + }) ); act(() => { instance.find(QueryBarInput).prop('onChange')!({ language: 'lucene', query: 'testQuery' }); @@ -44,17 +66,13 @@ describe('search_bar', () => { it('should translate kql query into JSON dsl', () => { const querySubmit = jest.fn(); - const instance = shallowWithIntl( - {}} - onQuerySubmit={querySubmit} - savedObjects={{} as CoreStart['savedObjects']} - uiSettings={{} as CoreStart['uiSettings']} - http={{} as CoreStart['http']} - overlays={{} as CoreStart['overlays']} - currentIndexPattern={{ title: 'Testpattern', fields: [{ name: 'test' }] } as IndexPattern} - /> + const instance = mount( + wrapSearchBarInContext({ + isLoading: false, + onIndexPatternSelected: () => {}, + onQuerySubmit: querySubmit, + currentIndexPattern: { title: 'Testpattern', fields: [{ name: 'test' }] } as IndexPattern, + }) ); act(() => { instance.find(QueryBarInput).prop('onChange')!({ language: 'kuery', query: 'test: abc' }); @@ -72,17 +90,14 @@ describe('search_bar', () => { it('should open index pattern picker', () => { const indexPatternSelected = jest.fn(); - const instance = shallowWithIntl( - {}} - savedObjects={{} as CoreStart['savedObjects']} - uiSettings={{} as CoreStart['uiSettings']} - http={{} as CoreStart['http']} - overlays={{} as CoreStart['overlays']} - currentIndexPattern={{ title: 'Testpattern' } as IndexPattern} - /> + + const instance = mount( + wrapSearchBarInContext({ + isLoading: false, + onIndexPatternSelected: indexPatternSelected, + onQuerySubmit: () => {}, + currentIndexPattern: { title: 'Testpattern' } as IndexPattern, + }) ); // pick the button component out of the tree because diff --git a/x-pack/legacy/plugins/graph/public/components/search_bar.tsx b/x-pack/legacy/plugins/graph/public/components/search_bar.tsx index 358d7d23d9ed4c..226f6f829d8a44 100644 --- a/x-pack/legacy/plugins/graph/public/components/search_bar.tsx +++ b/x-pack/legacy/plugins/graph/public/components/search_bar.tsx @@ -7,11 +7,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import React, { useState } from 'react'; -import { Storage } from 'ui/storage'; -import { CoreStart } from 'src/core/public'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; +import { IDataPluginServices } from 'src/legacy/core_plugins/data/public/types'; import { QueryBarInput, Query, @@ -19,8 +18,7 @@ import { } from '../../../../../../src/legacy/core_plugins/data/public'; import { IndexPatternSavedObject } from '../types/app_state'; import { openSourceModal } from '../services/source_modal'; - -const localStorage = new Storage(window.localStorage); +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; export interface SearchBarProps { isLoading: boolean; @@ -28,10 +26,6 @@ export interface SearchBarProps { initialQuery?: string; onIndexPatternSelected: (indexPattern: IndexPatternSavedObject) => void; onQuerySubmit: (query: string) => void; - savedObjects: CoreStart['savedObjects']; - uiSettings: CoreStart['uiSettings']; - http: CoreStart['http']; - overlays: CoreStart['overlays']; } function queryToString(query: Query, indexPattern: IndexPattern) { @@ -56,12 +50,13 @@ export function SearchBar(props: SearchBarProps) { onQuerySubmit, isLoading, onIndexPatternSelected, - uiSettings, - savedObjects, - http, initialQuery, } = props; const [query, setQuery] = useState({ language: 'kuery', query: initialQuery || '' }); + const kibana = useKibana(); + const { overlays, uiSettings, savedObjects } = kibana.services; + if (!overlays) return null; + return (
{ - openSourceModal(props, onIndexPatternSelected); + openSourceModal( + { + overlays, + savedObjects, + uiSettings, + }, + onIndexPatternSelected + ); }} > {currentIndexPattern 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 1e5d9eb39c578f..a8df5eafe71ffb 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 @@ -11,9 +11,11 @@ import { Storage } from 'ui/storage'; import { Document, SavedObjectStore } from '../persistence'; import { mount } from 'enzyme'; import { QueryBarTopRow } from '../../../../../../src/legacy/core_plugins/data/public/query/query_bar'; -import { SavedObjectsClientContract } from 'src/core/public'; +import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; import { coreMock } from 'src/core/public/mocks'; +const dataStartMock = dataPluginMock.createStartContract(); + jest.mock('../../../../../../src/legacy/core_plugins/data/public/query/query_bar', () => ({ QueryBarTopRow: jest.fn(() => null), })); @@ -37,16 +39,17 @@ describe('Lens App', () => { function makeDefaultArgs(): jest.Mocked<{ editorFrame: EditorFrameInstance; + data: typeof dataStartMock; core: typeof core; store: Storage; docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; - savedObjectsClient: SavedObjectsClientContract; }> { return ({ editorFrame: createMockFrame(), core, + data: dataStartMock, store: { get: jest.fn(), }, @@ -59,12 +62,12 @@ describe('Lens App', () => { savedObjectsClient: jest.fn(), } as unknown) as jest.Mocked<{ editorFrame: EditorFrameInstance; + data: typeof dataStartMock; core: typeof core; store: Storage; docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; - savedObjectsClient: SavedObjectsClientContract; }>; } diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index 3e157fc394d309..9c484e19789e95 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n'; import { EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Storage } from 'ui/storage'; import { CoreStart } from 'src/core/public'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; import { Query } from '../../../../../../src/legacy/core_plugins/data/public'; import { QueryBarTopRow } from '../../../../../../src/legacy/core_plugins/data/public/query/query_bar'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; @@ -51,6 +52,7 @@ function isLocalStateDirty( export function App({ editorFrame, + data, core, store, docId, @@ -58,6 +60,7 @@ export function App({ redirectTo, }: { editorFrame: EditorFrameInstance; + data: DataPublicPluginStart; core: CoreStart; store: Storage; docId?: string; @@ -156,10 +159,10 @@ export function App({
@@ -224,9 +227,7 @@ export function App({ setState({ ...state, localQueryBarState }); }} isDirty={isLocalStateDirty(state.localQueryBarState, state.query, state.dateRange)} - appName={'lens'} indexPatterns={state.indexPatternTitles} - store={store} showDatePicker={true} showQueryInput={true} query={state.localQueryBarState.query} diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx index 9504e0b6e17522..5e81785132616d 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx @@ -11,6 +11,7 @@ import chrome from 'ui/chrome'; import { Storage } from 'ui/storage'; import { CoreSetup, CoreStart } from 'src/core/public'; import { npSetup, npStart } from 'ui/new_platform'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; import { editorFrameSetup, editorFrameStart, editorFrameStop } from '../editor_frame_plugin'; import { indexPatternDatasourceSetup, indexPatternDatasourceStop } from '../indexpattern_plugin'; import { SavedObjectIndexStore } from '../persistence'; @@ -23,6 +24,9 @@ import { import { App } from './app'; import { EditorFrameInstance } from '../types'; +export interface LensPluginStartDependencies { + data: DataPublicPluginStart; +} export class AppPlugin { private instance: EditorFrameInstance | null = null; private store: SavedObjectIndexStore | null = null; @@ -45,7 +49,7 @@ export class AppPlugin { editorFrameSetupInterface.registerVisualization(metricVisualization); } - start(core: CoreStart) { + start(core: CoreStart, { data }: LensPluginStartDependencies) { if (this.store === null) { throw new Error('Start lifecycle called before setup lifecycle'); } @@ -60,6 +64,7 @@ export class AppPlugin { return ( app.setup(npSetup.core); -export const appStart = () => app.start(npStart.core); +export const appStart = () => app.start(npStart.core, { data: npStart.plugins.data }); export const appStop = () => app.stop(); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx index 32da61a95beb8c..63c6398e93997d 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx @@ -80,16 +80,7 @@ export const filterRatioOperation: OperationDefinition { + paramEditor: ({ state, setState, currentColumn, layerId }) => { const [hasDenominator, setDenominator] = useState( !isEqual(currentColumn.params.denominator, initialQuery) ); @@ -102,14 +93,8 @@ export const filterRatioOperation: OperationDefinition { setState( updateColumnParam({ @@ -168,14 +153,8 @@ export const filterRatioOperation: OperationDefinition {hasDenominator ? ( { setState( updateColumnParam({ diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap index 49439fa9d64e62..4c9ef61478ab48 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap @@ -1,103 +1,119 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`LayerPanel is rendered 1`] = ` - - - - - - - - - - + + + + + +

+ layer 1 +

+
+
+
+ +
+ -

- layer 1 -

- - - - + + +

+ + source prop1 + + + + you get one chance to set me + +

+
+
+
+
- - + + + + + + - -

- - source prop1 - - - - you get one chance to set me - -

-
-
+ /> + +
- -
-
- - - - - - - - -
-
- - - - + + + + `; exports[`LayerPanel should render empty panel when selectedLayer is null 1`] = `""`; diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js index c534583ddb58fe..f736f87dc46e16 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js @@ -21,13 +21,11 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { indexPatternService } from '../../../kibana_services'; -import { Storage } from 'ui/storage'; -import { SearchBar } from 'plugins/data'; +import { start as data } from '../../../../../../../../src/legacy/core_plugins/data/public/legacy'; +const { SearchBar } = data.ui; import { npStart } from 'ui/new_platform'; -import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public'; -const localStorage = new Storage(window.localStorage); export class FilterEditor extends Component { state = { @@ -93,35 +91,26 @@ export class FilterEditor extends Component { anchorPosition="leftCenter" >
- - - - - } - /> - + + + + } + />
); diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js index 74ec80c0765e84..fb09ed342b8d35 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js @@ -14,11 +14,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { SearchBar } from 'plugins/data'; -import { Storage } from 'ui/storage'; import { npStart } from 'ui/new_platform'; -import { KibanaContextProvider } from '../../../../../../../../../src/plugins/kibana_react/public'; - -const localStorage = new Storage(window.localStorage); export class WhereExpression extends Component { @@ -80,35 +76,25 @@ export class WhereExpression extends Component { defaultMessage="Use a query to narrow right source." /> - - - - - - } - /> - + + + + } + />
); diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.js b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.js index f7e242a82b89d0..9efbfe45da29c0 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.js @@ -29,6 +29,13 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; + +import { Storage } from 'ui/storage'; +const localStorage = new Storage(window.localStorage); + +// This import will eventually become a dependency injected by the fully deangularized NP plugin. +import { npStart } from 'ui/new_platform'; export class LayerPanel extends React.Component { @@ -144,75 +151,84 @@ export class LayerPanel extends React.Component { } return ( - - - - - + + + + + + + + + + +

{this.state.displayName}

+
+
+
+ +
+ - - - - - - -

{this.state.displayName}

-
-
- - -
- - - - {this._renderSourceProperties()} - - -
- + + + {this._renderSourceProperties()} + +
+
+
-
-
+
+
- + - + - + - {this._renderFilterSection()} + {this._renderFilterSection()} - {this._renderJoinSection()} + {this._renderJoinSection()} - + +
-
- - - - + + + + + ); } } From 2f80cc5fb3e0ec69bb12d78eac32893964dad10a Mon Sep 17 00:00:00 2001 From: Caroline Horn <549577+cchaos@users.noreply.github.com> Date: Wed, 2 Oct 2019 09:13:00 -0400 Subject: [PATCH 26/59] [Lens] Field item hovers, empty state graphic, and operation not applicable callout (#46864) 1. Better hover and focus states of field items 2. Added the empty state graphic 3. Fixed #46753 --- .../assets/lens_app_graphic_dark_2x.png | Bin 0 -> 82733 bytes .../assets/lens_app_graphic_light_2x.png | Bin 0 -> 94444 bytes .../editor_frame/editor_frame.tsx | 1 + .../editor_frame/suggestion_panel.tsx | 2 +- .../editor_frame/workspace_panel.test.tsx | 19 ++++-- .../editor_frame/workspace_panel.tsx | 33 +++++++-- .../indexpattern_plugin/_datapanel.scss | 9 ++- .../indexpattern_plugin/_field_item.scss | 63 ++++++++++++++---- .../public/indexpattern_plugin/datapanel.tsx | 24 ++++--- .../dimension_panel/dimension_panel.test.tsx | 8 +-- .../dimension_panel/popover_editor.tsx | 19 ++---- .../indexpattern_plugin/field_icon.test.tsx | 2 +- .../public/indexpattern_plugin/field_icon.tsx | 2 +- .../public/indexpattern_plugin/field_item.tsx | 32 ++++++--- .../indexpattern_suggestions.test.tsx | 4 +- .../operations/definitions/date_histogram.tsx | 4 +- .../definitions/filter_ratio.test.tsx | 2 +- .../operations/definitions/filter_ratio.tsx | 4 +- .../operations/definitions/terms.tsx | 2 +- .../operations/operations.test.ts | 2 +- .../indexpattern_plugin/state_helpers.test.ts | 8 +-- 21 files changed, 163 insertions(+), 77 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/assets/lens_app_graphic_dark_2x.png create mode 100644 x-pack/legacy/plugins/lens/public/assets/lens_app_graphic_light_2x.png diff --git a/x-pack/legacy/plugins/lens/public/assets/lens_app_graphic_dark_2x.png b/x-pack/legacy/plugins/lens/public/assets/lens_app_graphic_dark_2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2c2c71b82180a7574427c284cfffa91771a2296a GIT binary patch literal 82733 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

D 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

s4&bcDbHgys`Q8|Ssnb#9qa?* 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 ) diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx index 0b252729a99667..e6511f5e7bd98a 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx @@ -266,7 +266,7 @@ export function SuggestionPanel({ }} > {i18n.translate('xpack.lens.sugegstion.confirmSuggestionLabel', { - defaultMessage: 'Confirm and reload suggestions', + defaultMessage: 'Reload suggestions', })} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx index 86a0e5c8a833ac..fc224db743dca1 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx @@ -5,7 +5,6 @@ */ import React from 'react'; - import { ExpressionRendererProps } from '../../../../../../../src/legacy/core_plugins/expressions/public'; import { Visualization, FramePublicAPI, TableSuggestion } from '../../types'; import { @@ -20,6 +19,7 @@ import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; import { DragDrop, ChildDragDropProvider } from '../../drag_drop'; import { Ast } from '@kbn/interpreter/common'; +import { coreMock } from 'src/core/public/mocks'; const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); @@ -59,10 +59,11 @@ describe('workspace_panel', () => { visualizationState={{}} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} + core={coreMock.createSetup()} /> ); - expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(1); + expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2); expect(instance.find(expressionRendererMock)).toHaveLength(0); }); @@ -80,10 +81,11 @@ describe('workspace_panel', () => { visualizationState={{}} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} + core={coreMock.createSetup()} /> ); - expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(1); + expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2); expect(instance.find(expressionRendererMock)).toHaveLength(0); }); @@ -101,10 +103,11 @@ describe('workspace_panel', () => { visualizationState={{}} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} + core={coreMock.createSetup()} /> ); - expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(1); + expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2); expect(instance.find(expressionRendererMock)).toHaveLength(0); }); @@ -136,6 +139,7 @@ describe('workspace_panel', () => { visualizationState={{}} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} + core={coreMock.createSetup()} /> ); @@ -230,6 +234,7 @@ describe('workspace_panel', () => { visualizationState={{}} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} + core={coreMock.createSetup()} /> ); @@ -307,6 +312,7 @@ describe('workspace_panel', () => { visualizationState={{}} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} + core={coreMock.createSetup()} /> ); @@ -355,6 +361,7 @@ describe('workspace_panel', () => { visualizationState={{}} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} + core={coreMock.createSetup()} /> ); @@ -394,6 +401,7 @@ describe('workspace_panel', () => { visualizationState={{}} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} + core={coreMock.createSetup()} /> ); @@ -438,6 +446,7 @@ describe('workspace_panel', () => { visualizationState={{}} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} + core={coreMock.createSetup()} /> ); @@ -485,6 +494,7 @@ describe('workspace_panel', () => { visualizationState={{}} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} + core={coreMock.createSetup()} /> ); @@ -542,6 +552,7 @@ describe('workspace_panel', () => { visualizationState={{}} dispatch={mockDispatch} ExpressionRenderer={expressionRendererMock} + core={coreMock.createSetup()} /> ); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx index 314b10796435bc..6d0ab402a29710 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx @@ -6,8 +6,9 @@ import React, { useState, useEffect, useMemo, useContext } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem, EuiImage, EuiText } from '@elastic/eui'; import { toExpression } from '@kbn/interpreter/common'; +import { CoreStart, CoreSetup } from 'src/core/public'; import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public'; import { Action } from './state_management'; import { Datasource, Visualization, FramePublicAPI } from '../../types'; @@ -32,6 +33,7 @@ export interface WorkspacePanelProps { framePublicAPI: FramePublicAPI; dispatch: (action: Action) => void; ExpressionRenderer: ExpressionRenderer; + core: CoreStart | CoreSetup; } export const WorkspacePanel = debouncedComponent(InnerWorkspacePanel); @@ -46,8 +48,14 @@ export function InnerWorkspacePanel({ datasourceStates, framePublicAPI, dispatch, + core, ExpressionRenderer: ExpressionRendererComponent, }: WorkspacePanelProps) { + const IS_DARK_THEME = core.uiSettings.get('theme:darkMode'); + const emptyStateGraphicURL = IS_DARK_THEME + ? '/plugins/lens/assets/lens_app_graphic_dark_2x.png' + : '/plugins/lens/assets/lens_app_graphic_light_2x.png'; + const dragDropContext = useContext(DragContext); const suggestionForDraggedField = useMemo(() => { @@ -87,12 +95,25 @@ export function InnerWorkspacePanel({ function renderEmptyWorkspace() { return ( -

- +

+ +

+ -

+

+ +

+ ); } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/_datapanel.scss b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/_datapanel.scss index 4671d779833af7..24ccef4c081aa6 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/_datapanel.scss +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/_datapanel.scss @@ -23,9 +23,14 @@ flex-grow: 0; } +/** + * 1. Don't cut off the shadow of the field items + */ + .lnsInnerIndexPatternDataPanel__listWrapper { @include euiOverflowShadow; @include euiScrollBar; + margin-left: -$euiSize; /* 1 */ position: relative; flex-grow: 1; overflow: auto; @@ -35,8 +40,8 @@ padding-top: $euiSizeS; position: absolute; top: 0; - left: 0; - right: 0; + left: $euiSize; /* 1 */ + right: $euiSizeXS; /* 1 */ } .lnsInnerIndexPatternDataPanel__filterButton { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/_field_item.scss b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/_field_item.scss index 9eab50dd139d8a..54f9a3787466d3 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/_field_item.scss +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/_field_item.scss @@ -1,27 +1,61 @@ .lnsFieldItem { @include euiFontSizeS; - background: $euiColorEmptyShade; + background: lightOrDarkTheme($euiColorEmptyShade, $euiColorLightestShade); border-radius: $euiBorderRadius; margin-bottom: $euiSizeXS; - transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance; - - &:hover { - @include euiBottomShadowMedium; - z-index: 2; - cursor: grab; - } } -.lnsFieldItem--missing { - background: $euiColorLightestShade; +.lnsFieldItem__popoverAnchor:hover, +.lnsFieldItem__popoverAnchor:focus, +.lnsFieldItem__popoverAnchor:focus-within { + @include euiBottomShadowMedium; + border-radius: $euiBorderRadius; + z-index: 2; } -.lnsFieldItem__name { - margin-left: $euiSizeXS; +.lnsFieldItem--missing { + background: lightOrDarkTheme(transparentize($euiColorMediumShade, .9), $euiColorEmptyShade); + color: $euiColorDarkShade; } .lnsFieldItem__info { + border-radius: $euiBorderRadius - 1px; padding: $euiSizeS; + display: flex; + align-items: flex-start; + transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance, + background-color $euiAnimSpeedFast $euiAnimSlightResistance; // sass-lint:disable-line indentation + + .lnsFieldItem__name { + margin-left: $euiSizeXS; + flex-grow: 1; + } + + .lnsFieldListPanel__fieldIcon, + .lnsFieldItem__infoIcon { + flex-shrink: 0; + } + + .lnsFieldListPanel__fieldIcon { + margin-top: 2px; + } + + .lnsFieldItem__infoIcon { + visibility: hidden; + } + + &:hover, + &:focus { + cursor: grab; + + .lnsFieldItem__infoIcon { + visibility: visible; + } + } +} + +.lnsFieldItem__info-isOpen { + @include euiFocusRing; } .lnsFieldItem__topValue { @@ -45,3 +79,8 @@ min-width: 260px; max-width: 300px; } + +.lnsFieldItem__popoverButtonGroup { + // Enforce lowercase for buttons or else some browsers inherit all caps from popover title + text-transform: none; +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx index 85996659620e7d..da42113b4e7b4d 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx @@ -492,16 +492,20 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ })} {!localState.isLoading && paginatedFields.length === 0 && ( - - {showEmptyFields - ? i18n.translate('xpack.lens.indexPatterns.hiddenFieldsLabel', { - defaultMessage: - 'No fields have data with the current filters. You can show fields without data using the filters above.', - }) - : i18n.translate('xpack.lens.indexPatterns.noFieldsLabel', { - defaultMessage: 'No fields can be visualized from {title}', - values: { title: currentIndexPattern.title }, - })} + +

+ + {showEmptyFields + ? i18n.translate('xpack.lens.indexPatterns.hiddenFieldsLabel', { + defaultMessage: + 'No fields have data with the current filters. You can show fields without data using the filters above.', + }) + : i18n.translate('xpack.lens.indexPatterns.noFieldsLabel', { + defaultMessage: 'No fields in {title} can be visualized.', + values: { title: currentIndexPattern.title }, + })} + +

)}
diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx index 33567b20f364db..fecf2792da1c7f 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx @@ -96,7 +96,7 @@ describe('IndexPatternDimensionPanel', () => { columnOrder: ['col1'], columns: { col1: { - label: 'Date Histogram of timestamp', + label: 'Date histogram of timestamp', dataType: 'date', isBucketed: true, @@ -280,7 +280,7 @@ describe('IndexPatternDimensionPanel', () => { 'Incompatible' ); - expect(options.find(({ name }) => name === 'Date Histogram')!['data-test-subj']).toContain( + expect(options.find(({ name }) => name === 'Date histogram')!['data-test-subj']).toContain( 'Incompatible' ); }); @@ -822,7 +822,7 @@ describe('IndexPatternDimensionPanel', () => { .find(EuiSideNav) .prop('items')[0] .items.map(({ name }) => name) - ).toEqual(['Unique count', 'Average', 'Count', 'Filter Ratio', 'Maximum', 'Minimum', 'Sum']); + ).toEqual(['Unique count', 'Average', 'Count', 'Filter ratio', 'Maximum', 'Minimum', 'Sum']); }); it('should add a column on selection of a field', () => { @@ -973,7 +973,7 @@ describe('IndexPatternDimensionPanel', () => { columnOrder: ['col1'], columns: { col1: { - label: 'Date Histogram of timestamp', + label: 'Date histogram of timestamp', dataType: 'date', isBucketed: true, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx index ec2c153931bd14..494a0ae9fb1e35 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx @@ -330,28 +330,21 @@ export function PopoverEditor(props: PopoverEditorProps) { -

- -

-
+ iconType="sortUp" + /> )} {incompatibleSelectedOperationType && !selectedColumn && ( )} {!incompatibleSelectedOperationType && ParamEditor && ( diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx index 8fd72a790b38e5..85c1deb0ea7e1a 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx @@ -48,7 +48,7 @@ describe('FieldIcon', () => { `); }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx index d68ee0b82f28ec..f1e8db04860a7d 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx @@ -17,7 +17,7 @@ function getIconForDataType(dataType: string) { const icons: Partial>> = { boolean: 'invert', date: 'calendar', - ip: 'storage', + ip: 'ip', }; return icons[dataType] || ICON_TYPES.find(t => t === dataType) || 'empty'; } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx index 87ef874dece66b..62591bdf1e0815 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx @@ -18,6 +18,7 @@ import { EuiButtonGroup, EuiPopoverFooter, EuiPopoverTitle, + EuiIconTip, } from '@elastic/eui'; import { Chart, @@ -144,6 +145,7 @@ export function FieldItem(props: FieldItemProps) { return ( ('.application') || undefined} button={ @@ -157,7 +159,7 @@ export function FieldItem(props: FieldItemProps) { >
{ togglePopover(); @@ -167,12 +169,9 @@ export function FieldItem(props: FieldItemProps) { togglePopover(); } }} - title={i18n.translate('xpack.lens.indexPattern.fieldStatsButton', { - defaultMessage: 'Click or Enter for more information about {fieldName}', - values: { fieldName: field.name }, - })} - aria-label={i18n.translate('xpack.lens.indexPattern.fieldStatsButton', { - defaultMessage: 'Click or Enter for more information about {fieldName}', + aria-label={i18n.translate('xpack.lens.indexPattern.fieldStatsButtonAriaLabel', { + defaultMessage: + 'Click or press Enter for information about {fieldName}. Or, drag field into visualization.', values: { fieldName: field.name }, })} > @@ -181,6 +180,18 @@ export function FieldItem(props: FieldItemProps) { {wrappableHighlightableFieldName} + +
@@ -260,7 +271,8 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { if (histogram && histogram.buckets.length && topValues && topValues.buckets.length) { title = ( {i18n.translate('xpack.lens.indexPattern.fieldTopValuesLabel', { - defaultMessage: 'Top Values', + defaultMessage: 'Top values', })} ); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.test.tsx index 79847223631498..841d59b602ee8c 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.test.tsx @@ -1037,7 +1037,7 @@ describe('IndexPattern Data Source suggestions', () => { { columnId: 'col2', operation: { - label: 'Date Histogram of timestamp', + label: 'Date histogram of timestamp', dataType: 'date', isBucketed: true, scale: 'interval', @@ -1113,7 +1113,7 @@ describe('IndexPattern Data Source suggestions', () => { { columnId: 'newCol', operation: { - label: 'Date Histogram of timestamp', + label: 'Date histogram of timestamp', dataType: 'date', isBucketed: true, scale: 'interval', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx index 9558a141ad7a00..ba48a3c2f20325 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx @@ -31,7 +31,7 @@ const FixedEuiRange = (EuiRange as unknown) as React.ComponentType< function ofName(name: string) { return i18n.translate('xpack.lens.indexPattern.dateHistogramOf', { - defaultMessage: 'Date Histogram of {name}', + defaultMessage: 'Date histogram of {name}', values: { name }, }); } @@ -51,7 +51,7 @@ export interface DateHistogramIndexPatternColumn extends FieldBasedIndexPatternC export const dateHistogramOperation: OperationDefinition = { type: 'date_histogram', displayName: i18n.translate('xpack.lens.indexPattern.dateHistogram', { - defaultMessage: 'Date Histogram', + defaultMessage: 'Date histogram', }), getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { if ( diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.test.tsx index fb12910b7517d2..7c01a34fca2db4 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.test.tsx @@ -44,7 +44,7 @@ describe('filter_ratio', () => { columnOrder: ['col1'], columns: { col1: { - label: 'Filter Ratio', + label: 'Filter ratio', dataType: 'number', isBucketed: false, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx index 63c6398e93997d..a6689210be858b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx @@ -18,7 +18,7 @@ import { OperationDefinition } from '.'; import { BaseIndexPatternColumn } from './column_types'; const filterRatioLabel = i18n.translate('xpack.lens.indexPattern.filterRatio', { - defaultMessage: 'Filter Ratio', + defaultMessage: 'Filter ratio', }); export interface FilterRatioIndexPatternColumn extends BaseIndexPatternColumn { @@ -35,7 +35,7 @@ export const filterRatioOperation: OperationDefinition { return { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.tsx index 31f70eb6fb3bba..b26608086bd690 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.tsx @@ -51,7 +51,7 @@ export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { export const termsOperation: OperationDefinition = { type: 'terms', displayName: i18n.translate('xpack.lens.indexPattern.terms', { - defaultMessage: 'Top Values', + defaultMessage: 'Top values', }), getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { if ( diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.test.ts index 736a6f712d3468..6307a8f0f68d16 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.test.ts @@ -154,7 +154,7 @@ describe('getOperationTypesForField', () => { columnOrder: ['col1'], columns: { col1: { - label: 'Date Histogram of timestamp', + label: 'Date histogram of timestamp', dataType: 'date', isBucketed: true, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts index 9023173ab95dfe..1b98fa2b300054 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts @@ -367,7 +367,7 @@ describe('state_helpers', () => { expect( getColumnOrder({ col1: { - label: 'Top Values of category', + label: 'Top values of category', dataType: 'string', isBucketed: true, @@ -392,7 +392,7 @@ describe('state_helpers', () => { sourceField: 'bytes', }, col3: { - label: 'Date Histogram of timestamp', + label: 'Date histogram of timestamp', dataType: 'date', isBucketed: true, @@ -411,7 +411,7 @@ describe('state_helpers', () => { expect( getColumnOrder({ col1: { - label: 'Top Values of category', + label: 'Top values of category', dataType: 'string', isBucketed: true, @@ -438,7 +438,7 @@ describe('state_helpers', () => { suggestedPriority: 0, }, col3: { - label: 'Date Histogram of timestamp', + label: 'Date histogram of timestamp', dataType: 'date', isBucketed: true, From 68f20dcc5bac644a52e53f7cb5724964df0f6ed5 Mon Sep 17 00:00:00 2001 From: Caroline Horn <549577+cchaos@users.noreply.github.com> Date: Wed, 2 Oct 2019 09:39:05 -0400 Subject: [PATCH 27/59] [Maps] More compressed forms (#47043) * Adjusting vector size selectors * Degrees * Button group for symbol type * i18n and snaps * Input with popoper and icons * Improving layer settings styles * Column layout * Changing step * Fixed up the Go To and Draw popovers * Removing unecessary css * Improving source settings styles * Improving design to avoid panel inside panel * Remove unused translations * Update x-pack/legacy/plugins/maps/public/components/metrics_editor.js Co-Authored-By: Caroline Horn <549577+cchaos@users.noreply.github.com> * Update x-pack/legacy/plugins/maps/public/components/_index.scss Co-Authored-By: Caroline Horn <549577+cchaos@users.noreply.github.com> * Oh snap --- .../geometry_filter_form.test.js.snap | 72 ++++++----- .../maps/public/components/_index.scss | 20 ++++ .../public/components/geometry_filter_form.js | 23 ++-- .../maps/public/components/metric_editor.js | 9 +- .../maps/public/components/metrics_editor.js | 37 +++--- .../layer_panel/_index.scss | 2 +- .../layer_settings/layer_settings.js | 62 ++++++---- .../set_view_control/set_view_control.js | 32 +++-- .../__snapshots__/tools_control.test.js.snap | 8 +- .../tools_control/tools_control.js | 80 ++++++------- .../update_source_editor.js | 2 +- .../vector_style_symbol_editor.test.js.snap | 112 +++++++++--------- .../components/vector/color/_color_stops.scss | 2 +- .../static_orientation_selection.js | 1 + .../vector/size/size_range_selector.js | 7 +- .../vector/size/static_size_selection.js | 5 + .../vector/vector_style_symbol_editor.js | 62 +++++----- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 19 files changed, 303 insertions(+), 237 deletions(-) diff --git a/x-pack/legacy/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap b/x-pack/legacy/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap index 5e9500f65d167f..053863c68775f6 100644 --- a/x-pack/legacy/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap @@ -2,9 +2,6 @@ exports[`should not render relation select when geo field is geo_point 1`] = ` - - + - Create filter - + + Create filter + + `; exports[`should not show "within" relation when filter geometry is not closed 1`] = ` - - + - Create filter - + + Create filter + + `; exports[`should render relation select when geo field is geo_shape 1`] = ` - - + - Create filter - + + Create filter + + `; diff --git a/x-pack/legacy/plugins/maps/public/components/_index.scss b/x-pack/legacy/plugins/maps/public/components/_index.scss index 3e13cd3755dd33..6f180b840b877c 100644 --- a/x-pack/legacy/plugins/maps/public/components/_index.scss +++ b/x-pack/legacy/plugins/maps/public/components/_index.scss @@ -2,6 +2,26 @@ margin-bottom: $euiSizeS; } +.mapMetricEditorPanel__metricEditor { + padding: $euiSizeM 0; + border-top: $euiBorderThin; + + &:first-child { + padding-top: 0; + border-top: none; + } + + &:last-child { + margin-bottom: $euiSizeM; + border-bottom: 1px solid $euiColorLightShade; + } +} + +.mapMetricEditorPanel__metricRemoveButton { + padding-top: $euiSizeM; + text-align: right; +} + .mapGeometryFilter__geoFieldSuperSelect { height: $euiSizeL * 2; } diff --git a/x-pack/legacy/plugins/maps/public/components/geometry_filter_form.js b/x-pack/legacy/plugins/maps/public/components/geometry_filter_form.js index 2c518a856f2390..10410f4d79e5b5 100644 --- a/x-pack/legacy/plugins/maps/public/components/geometry_filter_form.js +++ b/x-pack/legacy/plugins/maps/public/components/geometry_filter_form.js @@ -16,6 +16,7 @@ import { EuiButton, EuiSelect, EuiSpacer, + EuiTextAlign, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ES_GEO_FIELD_TYPE, ES_SPATIAL_RELATIONS } from '../../common/constants'; @@ -154,8 +155,6 @@ export class GeometryFilterForm extends Component { }); return ( - - - {this.props.buttonLabel} - + + + + + {this.props.buttonLabel} + + ); } diff --git a/x-pack/legacy/plugins/maps/public/components/metric_editor.js b/x-pack/legacy/plugins/maps/public/components/metric_editor.js index f2f309d0512132..65f345925fd84d 100644 --- a/x-pack/legacy/plugins/maps/public/components/metric_editor.js +++ b/x-pack/legacy/plugins/maps/public/components/metric_editor.js @@ -43,7 +43,7 @@ export function MetricEditor({ fields, metricsFilter, metric, onChange, removeBu label={i18n.translate('xpack.maps.metricsEditor.selectFieldLabel', { defaultMessage: 'Field', })} - display="rowCompressed" + display="columnCompressed" > {fieldSelect} - {labelInput} + {removeButton} ); } diff --git a/x-pack/legacy/plugins/maps/public/components/metrics_editor.js b/x-pack/legacy/plugins/maps/public/components/metrics_editor.js index ebb3904a447991..53d078369cf89f 100644 --- a/x-pack/legacy/plugins/maps/public/components/metrics_editor.js +++ b/x-pack/legacy/plugins/maps/public/components/metrics_editor.js @@ -8,7 +8,7 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButtonIcon, EuiButtonEmpty, EuiPanel, EuiSpacer, EuiTextAlign } from '@elastic/eui'; +import { EuiButtonEmpty, EuiSpacer, EuiTextAlign } from '@elastic/eui'; import { MetricEditor } from './metric_editor'; export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics, metricsFilter }) { @@ -25,23 +25,26 @@ export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics, let removeButton; if (index > 0) { removeButton = ( - +
+ + + +
); } return ( - +
- +
); }); } @@ -80,7 +83,7 @@ export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics, return ( - {renderMetrics()} +
{renderMetrics()}
{renderAddMetricButton()}
diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/_index.scss b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/_index.scss index 5412a05e7dd7b8..b219f59476ce9f 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/_index.scss +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/_index.scss @@ -1,3 +1,3 @@ @import './layer_panel'; @import './filter_editor/filter_editor'; -@import './join_editor/resources/join'; +@import './join_editor/resources/join'; \ No newline at end of file diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.js b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.js index c0dbb1ec8fcbab..5be842f949871b 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.js @@ -36,7 +36,9 @@ export function LayerSettings(props) { }; const onAlphaChange = alpha => { - props.updateAlpha(props.layerId, alpha); + const alphaDecimal = alpha / 100; + + props.updateAlpha(props.layerId, alphaDecimal); }; const onApplyGlobalQueryChange = event => { @@ -47,18 +49,21 @@ export function LayerSettings(props) { return ( ); }; @@ -67,9 +72,9 @@ export function LayerSettings(props) { return ( @@ -77,23 +82,28 @@ export function LayerSettings(props) { }; const renderAlphaSlider = () => { + const alphaPercent = Math.round(props.alpha * 100); + return ( ); @@ -103,15 +113,23 @@ export function LayerSettings(props) { const layerSupportsGlobalQuery = props.layer.getIndexPatternIds().length; const applyGlobalQueryCheckbox = ( - + display="columnCompressedSwitch" + > + +
); if (layerSupportsGlobalQuery) { @@ -146,8 +164,6 @@ export function LayerSettings(props) { {renderLabel()} {renderZoomSliders()} {renderAlphaSlider()} - - {renderApplyGlobalQueryCheckbox()} diff --git a/x-pack/legacy/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.js b/x-pack/legacy/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.js index f1d055d357b24f..05ec23d10795a4 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.js @@ -13,6 +13,8 @@ import { EuiFieldNumber, EuiButtonIcon, EuiPopover, + EuiTextAlign, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -72,7 +74,7 @@ export class SetViewControl extends Component { return { isInvalid, component: ( - + + {latFormRow} {lonFormRow} {zoomFormRow} - - - + + + + + + + ); } @@ -149,6 +158,7 @@ export class SetViewControl extends Component { return ( , "id": 1, - "title": "Draw shape to filter data", + "title": "Draw shape", }, Object { "content": , "id": 2, - "title": "Draw bounds to filter data", + "title": "Draw bounds", }, ] } @@ -166,7 +166,7 @@ exports[`renders 1`] = ` onSubmit={[Function]} />, "id": 1, - "title": "Draw shape to filter data", + "title": "Draw shape", }, Object { "content": , "id": 2, - "title": "Draw bounds to filter data", + "title": "Draw bounds", }, ] } diff --git a/x-pack/legacy/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js b/x-pack/legacy/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js index 722c615ea43242..ea6ffe3ba14355 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js @@ -26,10 +26,17 @@ const DRAW_BOUNDS_LABEL = i18n.translate('xpack.maps.toolbarOverlay.drawBoundsLa defaultMessage: 'Draw bounds to filter data', }); -export class ToolsControl extends Component { +const DRAW_SHAPE_LABEL_SHORT = i18n.translate('xpack.maps.toolbarOverlay.drawShapeLabelShort', { + defaultMessage: 'Draw shape', +}); + +const DRAW_BOUNDS_LABEL_SHORT = i18n.translate('xpack.maps.toolbarOverlay.drawBoundsLabelShort', { + defaultMessage: 'Draw bounds', +}); +export class ToolsControl extends Component { state = { - isPopoverOpen: false + isPopoverOpen: false, }; _togglePopover = () => { @@ -42,18 +49,18 @@ export class ToolsControl extends Component { this.setState({ isPopoverOpen: false }); }; - _initiateShapeDraw = (options) => { + _initiateShapeDraw = options => { this.props.initiateDraw({ drawType: DRAW_TYPE.POLYGON, - ...options + ...options, }); this._closePopover(); - } + }; - _initiateBoundsDraw = (options) => { + _initiateBoundsDraw = options => { this.props.initiateDraw({ drawType: DRAW_TYPE.BOUNDS, - ...options + ...options, }); this._closePopover(); }; @@ -68,48 +75,50 @@ export class ToolsControl extends Component { items: [ { name: DRAW_SHAPE_LABEL, - panel: 1 + panel: 1, }, { name: DRAW_BOUNDS_LABEL, - panel: 2 - } - ] + panel: 2, + }, + ], }, { id: 1, - title: DRAW_SHAPE_LABEL, + title: DRAW_SHAPE_LABEL_SHORT, content: ( - ) + ), }, { id: 2, - title: DRAW_BOUNDS_LABEL, + title: DRAW_BOUNDS_LABEL_SHORT, content: ( - ) - } + ), + }, ]; } @@ -141,10 +150,7 @@ export class ToolsControl extends Component { withTitle anchorPosition="leftUp" > - + ); @@ -154,15 +160,9 @@ export class ToolsControl extends Component { return ( + {toolsPopoverButton} - {toolsPopoverButton} - - - + - + {this._renderMetricsEditor()} ); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/__snapshots__/vector_style_symbol_editor.test.js.snap b/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/__snapshots__/vector_style_symbol_editor.test.js.snap index f83240e3f70d01..97706169d98bf7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/__snapshots__/vector_style_symbol_editor.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/__snapshots__/vector_style_symbol_editor.test.js.snap @@ -1,34 +1,34 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Should render icon select when symbolized as Icon 1`] = ` - - + + hasEmptyLabelSpace={false} + label="Symbol type" + labelType="label" + > + + @@ -60,37 +60,37 @@ exports[`Should render icon select when symbolized as Icon 1`] = ` } singleSelection={true} /> - + `; exports[`Should render symbol select when symbolized as Circle 1`] = ` - - + - + hasEmptyLabelSpace={false} + label="Symbol type" + labelType="label" + > + + + `; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/color/_color_stops.scss b/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/color/_color_stops.scss index 7d6c6ede0d3307..eab7896650772b 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/color/_color_stops.scss +++ b/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/color/_color_stops.scss @@ -1,6 +1,6 @@ .mapColorStop { position: relative; - padding-right: $euiSizeXL + $euiSizeXS; + padding-right: $euiSizeXL + $euiSizeS; & + & { margin-top: $euiSizeS; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/orientation/static_orientation_selection.js b/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/orientation/static_orientation_selection.js index 8a335822038876..b5529c69874598 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/orientation/static_orientation_selection.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/orientation/static_orientation_selection.js @@ -23,6 +23,7 @@ export function StaticOrientationSelection({ onChange, styleOptions }) { showInput showLabels compressed + append="°" /> ); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/size/size_range_selector.js b/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/size/size_range_selector.js index fd1ce0ffe89a88..31b9b4f5ad6498 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/size/size_range_selector.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/size/size_range_selector.js @@ -8,6 +8,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { ValidatedDualRange } from 'ui/validated_range'; import { DEFAULT_MIN_SIZE, DEFAULT_MAX_SIZE } from '../../../vector_style_defaults'; +import { i18n } from '@kbn/i18n'; export function SizeRangeSelector({ minSize, maxSize, onChange, ...rest }) { const onSizeChange = ([min, max]) => { @@ -23,10 +24,14 @@ export function SizeRangeSelector({ minSize, maxSize, onChange, ...rest }) { max={DEFAULT_MAX_SIZE} step={1} value={[minSize, maxSize]} - showInput + showInput="inputWithPopover" showRange onChange={onSizeChange} allowEmptyRange={false} + append={i18n.translate('xpack.maps.vector.dualSize.unitLabel', { + defaultMessage: 'px', + description: 'Shorthand for pixel', + })} {...rest} /> ); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/size/static_size_selection.js b/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/size/static_size_selection.js index 507f81ade93092..38f8fe53d17488 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/size/static_size_selection.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/size/static_size_selection.js @@ -8,6 +8,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { staticSizeShape } from '../style_option_shapes'; import { ValidatedRange } from '../../../../../components/validated_range'; +import { i18n } from '@kbn/i18n'; export function StaticSizeSelection({ onChange, styleOptions }) { const onSizeChange = size => { @@ -23,6 +24,10 @@ export function StaticSizeSelection({ onChange, styleOptions }) { showInput showLabels compressed + append={i18n.translate('xpack.maps.vector.size.unitLabel', { + defaultMessage: 'px', + description: 'Shorthand for pixel', + })} /> ); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/vector_style_symbol_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/vector_style_symbol_editor.js index 0fcbe7653c3deb..268b1f39255b94 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/vector_style_symbol_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/components/vector/vector_style_symbol_editor.js @@ -10,7 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiSelect, + EuiButtonGroup, EuiSpacer, EuiComboBox, } from '@elastic/eui'; @@ -21,14 +21,14 @@ import { SymbolIcon } from './legend/symbol_icon'; const SYMBOLIZE_AS_OPTIONS = [ { - value: SYMBOLIZE_AS_CIRCLE, - text: i18n.translate('xpack.maps.vector.symbolAs.circleLabel', { + id: SYMBOLIZE_AS_CIRCLE, + label: i18n.translate('xpack.maps.vector.symbolAs.circleLabel', { defaultMessage: 'circle marker', }), }, { - value: SYMBOLIZE_AS_ICON, - text: i18n.translate('xpack.maps.vector.symbolAs.IconLabel', { + id: SYMBOLIZE_AS_ICON, + label: i18n.translate('xpack.maps.vector.symbolAs.IconLabel', { defaultMessage: 'icon', }), }, @@ -41,26 +41,27 @@ export function VectorStyleSymbolEditor({ isDarkMode, }) { const renderSymbolizeAsSelect = () => { - const selectedOption = SYMBOLIZE_AS_OPTIONS.find(({ value }) => { - return value === styleOptions.symbolizeAs; + const selectedOption = SYMBOLIZE_AS_OPTIONS.find(({ id }) => { + return id === styleOptions.symbolizeAs; }); - const onSymbolizeAsChange = e => { + const onSymbolizeAsChange = optionId => { const styleDescriptor = { options: { ...styleOptions, - symbolizeAs: e.target.value, + symbolizeAs: optionId, }, }; handlePropertyChange('symbol', styleDescriptor); }; return ( - ); }; @@ -113,28 +114,23 @@ export function VectorStyleSymbolEditor({ ); }; - const renderFormRowContent = () => { - if (styleOptions.symbolizeAs === SYMBOLIZE_AS_CIRCLE) { - return renderSymbolizeAsSelect(); - } - - return ( - + return ( + + {renderSymbolizeAsSelect()} - - {renderSymbolSelect()} - - ); - }; + - return ( - - {renderFormRowContent()} - + {styleOptions.symbolizeAs !== SYMBOLIZE_AS_CIRCLE && ( + + + {renderSymbolSelect()} + + )} + ); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 16a338e2039821..a821a66076bae9 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5872,9 +5872,7 @@ "xpack.maps.source.wms.attributionText": "属性 URL にはテキストが必要です", "xpack.maps.style.customColorRampLabel": "カスタマカラーランプ", "xpack.maps.toolbarOverlay.drawBounds.initialGeometryLabel": "境界", - "xpack.maps.toolbarOverlay.drawBounds.onSubmitButtonLabel": "境界を描く", "xpack.maps.toolbarOverlay.drawShape.initialGeometryLabel": "図形", - "xpack.maps.toolbarOverlay.drawShape.onSubmitButtonLabel": "図形を描く", "xpack.maps.tooltip.geometryFilterForm.createFilterButtonLabel": "フィルターを作成", "xpack.maps.tooltip.pageNumerText": "{total} 個中 {pageNumber} 個の機能", "xpack.maps.tooltip.showGeometryFilterViewLinkLabel": "ジオメトリでフィルタリング", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d9988b34b272d9..1f856731d43e19 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5875,9 +5875,7 @@ "xpack.maps.source.wms.attributionText": "属性 url 必须附带文本", "xpack.maps.style.customColorRampLabel": "定制颜色渐变", "xpack.maps.toolbarOverlay.drawBounds.initialGeometryLabel": "边界", - "xpack.maps.toolbarOverlay.drawBounds.onSubmitButtonLabel": "绘制边界", "xpack.maps.toolbarOverlay.drawShape.initialGeometryLabel": "形状", - "xpack.maps.toolbarOverlay.drawShape.onSubmitButtonLabel": "绘制形状", "xpack.maps.tooltip.geometryFilterForm.createFilterButtonLabel": "创建筛选", "xpack.maps.tooltip.pageNumerText": "第 {pageNumber} 项功能,总计 {total} 项", "xpack.maps.tooltip.showGeometryFilterViewLinkLabel": "按几何筛选", From d3fb7b8d3c6cebda01830e67dd988ffe62648c04 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Wed, 2 Oct 2019 10:29:45 -0400 Subject: [PATCH 28/59] [SR] render alert icon in policy table if last snapshot failed (#46960) --- .../public/app/sections/home/_home.scss | 17 ++++++++- .../policy_list/policy_table/policy_table.tsx | 36 ++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/_home.scss b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/_home.scss index c714222daa98b5..741ee76985937e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/_home.scss +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/_home.scss @@ -28,4 +28,19 @@ .euiToolTipAnchor { display: flex; } -} \ No newline at end of file +} + +/* + * Wraps long snapshot name with ellipsis when it is rendered with an icon + */ +.snapshotRestorePolicyTableSnapshotFailureContainer { + max-width: 200px; + > .euiFlexItem:last-child { + min-width: 0; + .euiText { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } +} diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx index a47b670177ca83..19239a282eb296 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx @@ -14,6 +14,8 @@ import { EuiToolTip, EuiButtonIcon, EuiLoadingSpinner, + EuiText, + EuiIcon, } from '@elastic/eui'; import { SlmPolicy } from '../../../../../../common/types'; @@ -94,8 +96,40 @@ export const PolicyTable: React.FunctionComponent = ({ name: i18n.translate('xpack.snapshotRestore.policyList.table.snapshotNameColumnTitle', { defaultMessage: 'Snapshot name', }), - truncateText: true, sortable: true, + render: ( + snapshotName: SlmPolicy['snapshotName'], + { lastFailure, lastSuccess }: SlmPolicy + ) => { + // Alert user if last snapshot failed + if (lastSuccess && lastFailure && lastFailure.time > lastSuccess.time) { + return ( + + + + + + + + {snapshotName} + + + ); + } + return snapshotName; + }, }, { field: 'repository', From 58c1f879f7763fb46af6f2644040ca1f39b2976f Mon Sep 17 00:00:00 2001 From: Michael Marcialis Date: Wed, 2 Oct 2019 11:06:14 -0400 Subject: [PATCH 29/59] [SIEM] Table Styles & Markup Tweaks (#46300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * restore conditional space before AS number * touchup table widths and text * adjust datepicker width * refactor matchMedia; set bp to above mbp rez * timeline table body refactor, first pass * TruncatableText: rm “width”, added “truncated” * cleanup imports * cleanup styles * rm size prop * swap out div? prob need to fix ref * restore truncation in timeline * think i have text overflow and tooltips happy now * light clean up * single overflow scrolling element * use polished for hex in rgba needs * simplify body markup * events table header poc * close button fixes * improve sort indicator position * drag handle updates * fix fields browser positioning * apply aria roles * fix blown out table width * localize sorting onClick to header text * correct key placement * prevent hover and click for unsortable and add btn * rm btn for non aggregatable col headers * change width/height prop names to avoid html attr * fix loading alignment * account for action cell width when one action * clean up trGroup organization * imports cleanup * fix types and skeleton rows/cells poc * new skeleton row comp * fix column heads not dragging * supplement row indentation * move widths out of styled components for perf * inline dynamic width * account for inline styles with dnd * cleanup * tweak in-page events table styling for consistency * cleanup * make compressed for consistency * cleanup * update jest tests + change matchMedia to css mq * fix missing grab cursor in IE11 * fix action td group width in IE11 * fix columns menu positioning in IE11 * fix collapsing notes in timelines table in IE11 * decouple from DroppableWrapper to prevent issues * update snapshots * more specific selector * rm show prop * add truncate to shouldComponentUpdate * bulk up `HeaderPanel` unit tests * correct conditional styles and add some more tests * improve SkeletonRow unit tests * change for loop to map * switch from pure to React.memo * make SkeletonRow cellCount dynamic * rm comments * fix buttons not being draggable for col headers * fix for safari position sticky + overflow auto bug * correct type errors * correct field browser overlap * missing semicolon --- .../drag_and_drop/draggable_wrapper.test.tsx | 9 +- .../drag_and_drop/draggable_wrapper.tsx | 96 +- .../drag_and_drop/droppable_wrapper.tsx | 13 +- .../components/drag_and_drop/helpers.ts | 9 - .../draggables/field_badge/index.tsx | 67 +- .../components/event_details/columns.tsx | 29 +- .../event_details/event_details.tsx | 10 +- .../events_viewer/events_viewer.test.tsx | 2 +- .../events_viewer/events_viewer.tsx | 40 +- .../events_viewer_header.test.tsx | 79 -- .../events_viewer/events_viewer_header.tsx | 51 - .../field_renderers/field_renderers.tsx | 9 +- .../fields_browser/category_columns.tsx | 7 +- .../fields_browser/field_browser.tsx | 33 +- .../components/fields_browser/field_items.tsx | 24 +- .../components/fields_browser/field_name.tsx | 3 +- .../components/fields_browser/index.test.tsx | 2 +- .../components/fields_browser/index.tsx | 39 +- .../components/fields_browser/translations.ts | 2 +- .../filters_global/filters_global.tsx | 4 +- .../public/components/flyout/button/index.tsx | 9 +- .../public/components/formatted_ip/index.tsx | 41 +- .../__snapshots__/header_panel.test.tsx.snap | 13 - .../__snapshots__/index.test.tsx.snap | 9 + .../header_panel/header_panel.test.tsx | 22 - .../components/header_panel/header_panel.tsx | 77 -- .../components/header_panel/index.test.tsx | 160 +++ .../public/components/header_panel/index.tsx | 82 +- .../siem/public/components/loader/index.tsx | 12 +- .../components/ml/tables/translations.ts | 2 +- .../components/notes/note_cards/index.tsx | 32 +- .../components/open_timeline/index.test.tsx | 2 +- .../open_timeline/timelines_table/index.tsx | 12 +- .../open_timeline/title_row/index.test.tsx | 2 +- .../components/page/add_to_kql/index.tsx | 1 - .../page/hosts/hosts_table/columns.tsx | 1 + .../page/hosts/hosts_table/index.test.tsx | 2 +- .../page/hosts/hosts_table/translations.ts | 2 +- .../uncommon_process_table/index.test.tsx | 10 +- .../hosts/uncommon_process_table/index.tsx | 6 +- .../uncommon_process_table/translations.ts | 8 +- .../page/network/domains_table/index.test.tsx | 2 +- .../network/domains_table/translations.ts | 2 +- .../network_top_n_flow_table/columns.tsx | 19 +- .../page/network/users_table/index.test.tsx | 2 +- .../page/network/users_table/translations.ts | 2 +- .../siem/public/components/pin/index.tsx | 29 +- .../__snapshots__/index.test.tsx.snap | 2 +- .../components/resize_handle/index.test.tsx | 65 +- .../public/components/resize_handle/index.tsx | 46 +- .../resize_handle/styled_handles.tsx | 18 - .../__snapshots__/index.test.tsx.snap | 7 + .../components/skeleton_row/index.test.tsx | 58 + .../public/components/skeleton_row/index.tsx | 67 ++ .../siem/public/components/tables/helpers.tsx | 8 +- .../timeline/body/actions/index.tsx | 138 +-- .../__snapshots__/index.test.tsx.snap | 1070 +++++++---------- .../body/column_headers/actions/index.tsx | 87 +- .../body/column_headers/common/styles.tsx | 2 - .../header/__snapshots__/index.test.tsx.snap | 30 +- .../body/column_headers/header/index.test.tsx | 57 +- .../body/column_headers/header/index.tsx | 210 ++-- .../header_tooltip_content/index.tsx | 13 +- .../body/column_headers/index.test.tsx | 4 - .../timeline/body/column_headers/index.tsx | 268 ++--- .../body/data_driven_columns/index.tsx | 108 +- .../body/events/event_column_view.tsx | 92 +- .../components/timeline/body/events/index.tsx | 79 +- .../timeline/body/events/stateful_event.tsx | 88 +- .../body/events/stateful_event_child.tsx | 58 +- .../components/timeline/body/helpers.ts | 3 +- .../components/timeline/body/index.test.tsx | 7 +- .../public/components/timeline/body/index.tsx | 93 +- .../get_row_renderer.test.tsx.snap | 10 +- .../plain_row_renderer.test.tsx.snap | 10 +- .../generic_row_renderer.test.tsx.snap | 508 ++++---- .../renderers/auditd/generic_row_renderer.tsx | 11 +- .../body/renderers/column_renderer.ts | 8 +- .../body/renderers/empty_column_renderer.tsx | 18 +- .../body/renderers/formatted_field.test.tsx | 26 +- .../body/renderers/formatted_field.tsx | 49 +- .../timeline/body/renderers/helpers.tsx | 9 - .../netflow_row_renderer.test.tsx.snap | 346 +++--- .../netflow/netflow_row_renderer.tsx | 22 +- .../body/renderers/plain_column_renderer.tsx | 69 +- .../body/renderers/plain_row_renderer.tsx | 4 +- .../timeline/body/renderers/row_renderer.tsx | 12 +- .../suricata_row_renderer.test.tsx.snap | 926 +++++++------- .../suricata/suricata_row_renderer.tsx | 6 +- .../generic_row_renderer.test.tsx.snap | 380 +++--- .../renderers/system/generic_row_renderer.tsx | 11 +- .../zeek_row_renderer.test.tsx.snap | 926 +++++++------- .../body/renderers/zeek/zeek_row_renderer.tsx | 5 +- .../timeline/data_providers/index.tsx | 4 +- .../provider_item_and_drag_drop.tsx | 4 +- .../timeline/properties/helpers.tsx | 50 +- .../components/timeline/properties/styles.tsx | 16 - .../public/components/timeline/styles.tsx | 325 +++++ .../components/timeline/timeline.test.tsx | 4 +- .../public/components/timeline/timeline.tsx | 3 +- .../__snapshots__/index.test.tsx.snap | 12 +- .../truncatable_text/index.test.tsx | 24 +- .../components/truncatable_text/index.tsx | 21 +- .../siem/public/pages/network/network.tsx | 59 +- 104 files changed, 3715 insertions(+), 3930 deletions(-) delete mode 100644 x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer_header.test.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer_header.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/header_panel/__snapshots__/header_panel.test.tsx.snap create mode 100644 x-pack/legacy/plugins/siem/public/components/header_panel/__snapshots__/index.test.tsx.snap delete mode 100644 x-pack/legacy/plugins/siem/public/components/header_panel/header_panel.test.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/header_panel/header_panel.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/header_panel/index.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/skeleton_row/__snapshots__/index.test.tsx.snap create mode 100644 x-pack/legacy/plugins/siem/public/components/skeleton_row/index.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/skeleton_row/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.test.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.test.tsx index 60c466459794e2..d9b78836b450e4 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.test.tsx @@ -12,7 +12,6 @@ import { MockedProvider } from 'react-apollo/test-utils'; import { mockBrowserFields, mocksSource } from '../../containers/source/mock'; import { TestProviders } from '../../mock'; import { mockDataProviders } from '../timeline/data_providers/mock/mock_data_providers'; - import { DragDropContextWrapper } from './drag_drop_context_wrapper'; import { DraggableWrapper } from './draggable_wrapper'; @@ -50,14 +49,12 @@ describe('DraggableWrapper', () => { }); describe('text truncation styling', () => { - test('it applies text truncation styling when a width IS specified (implicit: and the user is not dragging)', () => { - const width = '100px'; - + test('it applies text truncation styling when truncate IS specified (implicit: and the user is not dragging)', () => { const wrapper = mount( - message} /> + message} truncate /> @@ -68,7 +65,7 @@ describe('DraggableWrapper', () => { ); }); - test('it does NOT apply text truncation styling when a width is NOT specified', () => { + test('it does NOT apply text truncation styling when truncate is NOT specified', () => { const wrapper = mount( diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx index 9ddfc82dd88a5a..0755ef0e5592cf 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx @@ -20,7 +20,6 @@ import { dragAndDropActions } from '../../store/drag_and_drop'; import { DataProvider } from '../timeline/data_providers/data_provider'; import { STATEFUL_EVENT_CSS_CLASS_NAME } from '../timeline/helpers'; import { TruncatableText } from '../truncatable_text'; - import { getDraggableId, getDroppableId } from './helpers'; // As right now, we do not know what we want there, we will keep it as a placeholder @@ -29,17 +28,14 @@ export const DragEffects = styled.div``; DragEffects.displayName = 'DragEffects'; const Wrapper = styled.div` - .euiPageBody & { - display: inline-block; - } + display: inline-block; + max-width: 100%; `; Wrapper.displayName = 'Wrapper'; const ProviderContainer = styled.div<{ isDragging: boolean }>` ${({ theme, isDragging }) => css` - // ALL DRAGGABLES - &, &::before, &::after { @@ -47,23 +43,13 @@ const ProviderContainer = styled.div<{ isDragging: boolean }>` color ${theme.eui.euiAnimSpeedFast} ease; } - .euiBadge, - .euiBadge__text { - cursor: grab; - } - - // PAGE DRAGGABLES - ${!isDragging && ` - .euiPageBody & { - z-index: ${theme.eui.euiZLevel0} !important; - } - - { + & { border-radius: 2px; padding: 0 4px 0 8px; position: relative; + z-index: ${theme.eui.euiZLevel0} !important; &::before { background-image: linear-gradient( @@ -86,6 +72,14 @@ const ProviderContainer = styled.div<{ isDragging: boolean }>` } } + &:hover { + &, + & .euiBadge, + & .euiBadge__text { + cursor: move; //Fallback for IE11 + cursor: grab; + } + } .${STATEFUL_EVENT_CSS_CLASS_NAME}:hover &, tr:hover & { @@ -112,7 +106,8 @@ const ProviderContainer = styled.div<{ isDragging: boolean }>` background-color: ${theme.eui.euiColorPrimary}; &, - & a { + & a, + & a:hover { color: ${theme.eui.euiColorEmptyShade}; } @@ -131,9 +126,10 @@ const ProviderContainer = styled.div<{ isDragging: boolean }>` ${isDragging && ` - .euiPageBody & { + & { z-index: ${theme.eui.euiZLevel9} !important; - `} + } + `} `} `; @@ -147,7 +143,7 @@ interface OwnProps { provided: DraggableProvided, state: DraggableStateSnapshot ) => React.ReactNode; - width?: string; + truncate?: boolean; } interface DispatchProps { @@ -166,10 +162,10 @@ type Props = OwnProps & DispatchProps; * data provider associated with the item being dropped */ class DraggableWrapperComponent extends React.Component { - public shouldComponentUpdate = ({ dataProvider, render, width }: Props) => + public shouldComponentUpdate = ({ dataProvider, render, truncate }: Props) => isEqual(dataProvider, this.props.dataProvider) && render !== this.props.render && - width === this.props.width + truncate === this.props.truncate ? false : true; @@ -186,7 +182,7 @@ class DraggableWrapperComponent extends React.Component { } public render() { - const { dataProvider, render, width } = this.props; + const { dataProvider, render, truncate } = this.props; return ( @@ -198,34 +194,28 @@ class DraggableWrapperComponent extends React.Component { index={0} key={getDraggableId(dataProvider.id)} > - {(provided, snapshot) => { - return ( - - {width != null && !snapshot.isDragging ? ( - - {render(dataProvider, provided, snapshot)} - - ) : ( - - {render(dataProvider, provided, snapshot)} - - )} - - ); - }} + {(provided, snapshot) => ( + + {truncate && !snapshot.isDragging ? ( + + {render(dataProvider, provided, snapshot)} + + ) : ( + + {render(dataProvider, provided, snapshot)} + + )} + + )} {droppableProvided.placeholder} diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/droppable_wrapper.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/droppable_wrapper.tsx index af28e74df0f5f1..c0ab5a939bc4da 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/droppable_wrapper.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/droppable_wrapper.tsx @@ -4,13 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { rgba } from 'polished'; import * as React from 'react'; import { Droppable } from 'react-beautiful-dnd'; import { pure } from 'recompose'; import styled from 'styled-components'; -import { THIRTY_PERCENT_ALPHA_HEX_SUFFIX, TWENTY_PERCENT_ALPHA_HEX_SUFFIX } from './helpers'; - interface Props { children?: React.ReactNode; droppableId: string; @@ -34,22 +33,22 @@ const ReactDndDropTarget = styled.div<{ isDraggingOver: boolean; height: string ? ` .drop-and-provider-timeline { &:hover { - background-color: ${props.theme.eui.euiColorSuccess}${THIRTY_PERCENT_ALPHA_HEX_SUFFIX}; + background-color: ${rgba(props.theme.eui.euiColorSuccess, 0.3)}; } } .drop-and-provider-timeline:hover { - background-color: ${props.theme.eui.euiColorSuccess}${THIRTY_PERCENT_ALPHA_HEX_SUFFIX}; + background-color: ${rgba(props.theme.eui.euiColorSuccess, 0.3)}; } > div.timeline-drop-area-empty { color: ${props.theme.eui.euiColorSuccess} - background-color: ${props.theme.eui.euiColorSuccess}${TWENTY_PERCENT_ALPHA_HEX_SUFFIX}; + background-color: ${rgba(props.theme.eui.euiColorSuccess, 0.2)}; & .euiTextColor--subdued { color: ${props.theme.eui.euiColorSuccess}; } } > div.timeline-drop-area { - background-color: ${props.theme.eui.euiColorSuccess}${TWENTY_PERCENT_ALPHA_HEX_SUFFIX}; + background-color: ${rgba(props.theme.eui.euiColorSuccess, 0.2)}; .provider-item-filter-container div:first-child{ // Override dragNdrop beautiful so we do not have our droppable moving around for no good reason transform: none !important; @@ -86,7 +85,6 @@ const ReactDndDropTarget = styled.div<{ isDraggingOver: boolean; height: string } } `; - ReactDndDropTarget.displayName = 'ReactDndDropTarget'; export const DroppableWrapper = pure( @@ -118,5 +116,4 @@ export const DroppableWrapper = pure( ) ); - DroppableWrapper.displayName = 'DroppableWrapper'; diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/helpers.ts b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/helpers.ts index dce7b84a2128bd..415970474db4c3 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/helpers.ts @@ -224,12 +224,3 @@ export const DRAG_TYPE_FIELD = 'drag-type-field'; /** This class is added to the document body while dragging */ export const IS_DRAGGING_CLASS_NAME = 'is-dragging'; - -/** A hex alpha channel suffix representing 10% for the `AA` in `#RRGGBBAA` */ -export const TEN_PERCENT_ALPHA_HEX_SUFFIX = '1A'; - -/** A hex alpha channel suffix representing 20% for the `AA` in `#RRGGBBAA` */ -export const TWENTY_PERCENT_ALPHA_HEX_SUFFIX = '33'; - -/** A hex alpha channel suffix representing 30% for the `AA` in `#RRGGBBAA` */ -export const THIRTY_PERCENT_ALPHA_HEX_SUFFIX = '4d'; diff --git a/x-pack/legacy/plugins/siem/public/components/draggables/field_badge/index.tsx b/x-pack/legacy/plugins/siem/public/components/draggables/field_badge/index.tsx index f80afea9a98e95..faf65338b43378 100644 --- a/x-pack/legacy/plugins/siem/public/components/draggables/field_badge/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/draggables/field_badge/index.tsx @@ -4,54 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiBadge, - EuiFlexGroup, - EuiFlexItem, - // @ts-ignore - EuiHighlight, -} from '@elastic/eui'; +import { rgba } from 'polished'; import * as React from 'react'; import { pure } from 'recompose'; -import styled from 'styled-components'; - -const FieldBadgeFlexGroup = styled(EuiFlexGroup)` - height: 38px; -`; - -FieldBadgeFlexGroup.displayName = 'FieldBadgeFlexGroup'; - -const FieldBadgeFlexItem = styled(EuiFlexItem)` - font-weight: bold; -`; - -FieldBadgeFlexItem.displayName = 'FieldBadgeFlexItem'; - -/** - * The name of a (draggable) field - */ -export const FieldNameContainer = styled.div` - padding: 5px; - &:hover { - transition: background-color 0.7s ease; - background-color: #000; - color: #fff; - } +import styled, { css } from 'styled-components'; + +const Field = styled.div` + ${({ theme }) => css` + background-color: ${theme.eui.euiColorEmptyShade}; + border: ${theme.eui.euiBorderThin}; + box-shadow: 0 2px 2px -1px ${rgba(theme.eui.euiColorMediumShade, 0.3)}, + 0 1px 5px -2px ${rgba(theme.eui.euiColorMediumShade, 0.3)}; + font-size: ${theme.eui.euiFontSizeXS}; + font-weight: ${theme.eui.euiFontWeightSemiBold}; + line-height: ${theme.eui.euiLineHeight}; + padding: ${theme.eui.paddingSizes.xs}; + `} `; - -FieldNameContainer.displayName = 'FieldNameContainer'; +Field.displayName = 'Field'; /** * Renders a field (e.g. `event.action`) as a draggable badge */ -export const DraggableFieldBadge = pure<{ fieldId: string }>(({ fieldId }) => ( - - - - {fieldId} - - - -)); +// Passing the styles directly to the component because the width is +// being calculated and is recommended by Styled Components for performance +// https://github.com/styled-components/styled-components/issues/134#issuecomment-312415291 +export const DraggableFieldBadge = pure<{ fieldId: string; fieldWidth?: string }>( + ({ fieldId, fieldWidth }) => ( + + {fieldId} + + ) +); DraggableFieldBadge.displayName = 'DraggableFieldBadge'; diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/columns.tsx b/x-pack/legacy/plugins/siem/public/components/event_details/columns.tsx index 0013e40afa9c43..d835d2c6219312 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/event_details/columns.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Draggable } from 'react-beautiful-dnd'; import { EuiCheckbox, EuiFlexGroup, @@ -15,30 +14,30 @@ import { EuiToolTip, } from '@elastic/eui'; import * as React from 'react'; +import { Draggable } from 'react-beautiful-dnd'; import styled from 'styled-components'; import { BrowserFields } from '../../containers/source'; -import { ColumnHeader } from '../timeline/body/column_headers/column_header'; -import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers'; -import { DragEffects } from '../drag_and_drop/draggable_wrapper'; -import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/helpers'; -import { DefaultDraggable } from '../draggables'; import { ToStringArray } from '../../graphql/types'; +import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; +import { DragEffects } from '../drag_and_drop/draggable_wrapper'; import { DroppableWrapper } from '../drag_and_drop/droppable_wrapper'; +import { getDroppableId, getDraggableFieldId, DRAG_TYPE_FIELD } from '../drag_and_drop/helpers'; +import { DefaultDraggable } from '../draggables'; import { DraggableFieldBadge } from '../draggables/field_badge'; -import { FormattedFieldValue } from '../timeline/body/renderers/formatted_field'; +import { EVENT_DURATION_FIELD_NAME } from '../duration'; import { FieldName } from '../fields_browser/field_name'; -import { getIconFromType, getExampleText, getColumnsWithTimestamp } from './helpers'; -import { getDroppableId, getDraggableFieldId, DRAG_TYPE_FIELD } from '../drag_and_drop/helpers'; -import { OnUpdateColumns } from '../timeline/events'; import { SelectableText } from '../selectable_text'; -import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; -import { WithHoverActions } from '../with_hover_actions'; - -import * as i18n from './translations'; import { OverflowField } from '../tables/helpers'; +import { ColumnHeader } from '../timeline/body/column_headers/column_header'; +import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/helpers'; import { DATE_FIELD_TYPE, MESSAGE_FIELD_NAME } from '../timeline/body/renderers/constants'; -import { EVENT_DURATION_FIELD_NAME } from '../duration'; +import { FormattedFieldValue } from '../timeline/body/renderers/formatted_field'; +import { OnUpdateColumns } from '../timeline/events'; +import { WithHoverActions } from '../with_hover_actions'; +import { getIconFromType, getExampleText, getColumnsWithTimestamp } from './helpers'; +import * as i18n from './translations'; import { EventFieldsData } from './types'; const HoverActionsContainer = styled(EuiPanel)` diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/event_details.tsx b/x-pack/legacy/plugins/siem/public/components/event_details/event_details.tsx index 821ec2048d5ad0..f77c703f064f6c 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/event_details.tsx +++ b/x-pack/legacy/plugins/siem/public/components/event_details/event_details.tsx @@ -9,14 +9,12 @@ import React from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../containers/source'; -import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { DetailItem } from '../../graphql/types'; +import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { OnUpdateColumns } from '../timeline/events'; - import { EventFieldsBrowser } from './event_fields_browser'; import { JsonView } from './json_view'; import * as i18n from './translations'; -import { useTimelineWidthContext } from '../timeline/timeline_context'; export type View = 'table-view' | 'json-view'; @@ -32,9 +30,8 @@ interface Props { toggleColumn: (column: ColumnHeader) => void; } -const Details = styled.div<{ width: number }>` +const Details = styled.div` user-select: none; - width: ${({ width }) => `${width}px`}; `; Details.displayName = 'Details'; @@ -51,7 +48,6 @@ export const EventDetails = React.memo( timelineId, toggleColumn, }) => { - const width = useTimelineWidthContext(); const tabs: EuiTabbedContentTab[] = [ { id: 'table-view', @@ -76,7 +72,7 @@ export const EventDetails = React.memo( ]; return ( -
+
{ expect( wrapper - .find(`[data-test-subj="subtitle"]`) + .find(`[data-test-subj="header-panel-subtitle"]`) .first() .text() ).toEqual('Showing: 12 events'); diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx index 62e77aac15fd62..4299657e36dabc 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx @@ -4,17 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiPanel } from '@elastic/eui'; +import { EuiPanel } from '@elastic/eui'; import { getOr, isEmpty } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; import { StaticIndexPattern } from 'ui/index_patterns'; -import { AutoSizer } from '../auto_sizer'; import { BrowserFields } from '../../containers/source'; import { TimelineQuery } from '../../containers/timeline'; import { Direction } from '../../graphql/types'; import { KqlMode } from '../../store/timeline/model'; +import { AutoSizer } from '../auto_sizer'; +import { HeaderPanel } from '../header_panel'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { defaultHeaders } from '../timeline/body/column_headers/default_headers'; import { Sort } from '../timeline/body/sort'; @@ -23,29 +24,18 @@ import { DataProvider } from '../timeline/data_providers/data_provider'; import { OnChangeItemsPerPage } from '../timeline/events'; import { Footer, footerHeight } from '../timeline/footer'; import { combineQueries } from '../timeline/helpers'; +import { TimelineRefetch } from '../timeline/refetch_timeline'; import { isCompactFooter } from '../timeline/timeline'; import { ManageTimelineContext } from '../timeline/timeline_context'; - -import { EventsViewerHeader } from './events_viewer_header'; -import { TimelineRefetch } from '../timeline/refetch_timeline'; +import * as i18n from './translations'; const DEFAULT_EVENTS_VIEWER_HEIGHT = 500; const WrappedByAutoSizer = styled.div` width: 100%; `; // required by AutoSizer - WrappedByAutoSizer.displayName = 'WrappedByAutoSizer'; -const EventsViewerContainer = styled(EuiFlexGroup)` - overflow: hidden; - padding: 0 10px 0 12px; - user-select: none; - width: 100%; -`; - -EventsViewerContainer.displayName = 'EventsViewerContainer'; - interface Props { browserFields: BrowserFields; columns: ColumnHeader[]; @@ -103,18 +93,14 @@ export const EventsViewer = React.memo( {({ measureRef, content: { width = 0 } }) => ( - + <>
+ {combinedQueries != null ? ( c.id)} @@ -138,10 +124,13 @@ export const EventsViewer = React.memo( totalCount = 0, }) => ( <> -
( loading={loading} refetch={refetch} /> + ( sort={sort} toggleColumn={toggleColumn} /> +
( )} ) : null} - + )} @@ -211,5 +202,4 @@ export const EventsViewer = React.memo( prevProps.start === nextProps.start && prevProps.sort === nextProps.sort ); - EventsViewer.displayName = 'EventsViewer'; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer_header.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer_header.test.tsx deleted file mode 100644 index 84e7248869b2f4..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer_header.test.tsx +++ /dev/null @@ -1,79 +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 { mount } from 'enzyme'; -import React from 'react'; - -import { TestProviders } from '../../mock'; -import '../../mock/ui_settings'; - -import { EventsViewerHeader } from './events_viewer_header'; - -jest.mock('../../lib/settings/use_kibana_ui_setting'); - -const totalCount = 30; - -describe('EventsViewerHeader', () => { - test('it renders the expected title', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper - .find('[data-test-subj="panel_headline_title"]') - .first() - .text() - ).toEqual('Events'); - }); - - test('it renders a transparent inspect button when showInspect is false', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper - .find(`[data-test-subj="transparent-inspect-container"]`) - .first() - .exists() - ).toBe(true); - }); - - test('it renders an opaque inspect button when showInspect is true', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper - .find(`[data-test-subj="opaque-inspect-container"]`) - .first() - .exists() - ).toBe(true); - }); - - test('it renders the expected totalCount', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper - .find(`[data-test-subj="subtitle"]`) - .first() - .text() - ).toEqual(`Showing: ${totalCount} events`); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer_header.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer_header.tsx deleted file mode 100644 index 048b92e91efd23..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer_header.tsx +++ /dev/null @@ -1,51 +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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React from 'react'; - -import { HeaderPanel } from '../header_panel'; -import { InspectButton } from '../inspect'; - -import * as i18n from './translations'; - -interface Props { - id: string; - showInspect: boolean; - totalCount: number; -} - -export const EventsViewerHeader = React.memo(({ id, showInspect, totalCount }) => { - return ( - - - - - - - - - - ); -}); - -EventsViewerHeader.displayName = 'EventsViewerHeader'; diff --git a/x-pack/legacy/plugins/siem/public/components/field_renderers/field_renderers.tsx b/x-pack/legacy/plugins/siem/public/components/field_renderers/field_renderers.tsx index 7478cbc055d8f7..c7912777c653b9 100644 --- a/x-pack/legacy/plugins/siem/public/components/field_renderers/field_renderers.tsx +++ b/x-pack/legacy/plugins/siem/public/components/field_renderers/field_renderers.tsx @@ -5,11 +5,11 @@ */ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPopover, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { getOr } from 'lodash/fp'; import React, { Fragment, useState } from 'react'; - -import { FormattedMessage } from '@kbn/i18n/react'; import { pure } from 'recompose'; + import { AutonomousSystem, FlowTarget, @@ -17,14 +17,13 @@ import { IpOverviewData, Overview, } from '../../graphql/types'; +import { escapeDataProviderId } from '../drag_and_drop/helpers'; import { DefaultDraggable } from '../draggables'; import { getEmptyTagValue } from '../empty_value'; import { FormattedDate } from '../formatted_date'; import { HostDetailsLink, ReputationLink, VirusTotalLink, WhoIsLink } from '../links'; - -import * as i18n from '../page/network/ip_overview/translations'; -import { escapeDataProviderId } from '../drag_and_drop/helpers'; import { Spacer } from '../page'; +import * as i18n from '../page/network/ip_overview/translations'; export const IpOverviewId = 'ip-overview'; diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx index 8fab6ec14688a8..2581fba75da1e5 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx @@ -18,13 +18,12 @@ import styled from 'styled-components'; import { BrowserFields } from '../../containers/source'; import { getColumnsWithTimestamp } from '../event_details/helpers'; +import { CountBadge } from '../page'; import { OnUpdateColumns } from '../timeline/events'; +import { TimelineContext } from '../timeline/timeline_context'; import { WithHoverActions } from '../with_hover_actions'; - -import * as i18n from './translations'; -import { CountBadge } from '../page'; import { LoadingSpinner, getCategoryPaneCategoryClassName, getFieldCount } from './helpers'; -import { TimelineContext } from '../timeline/timeline_context'; +import * as i18n from './translations'; const CategoryName = styled.span<{ bold: boolean }>` font-weight: ${({ bold }) => (bold ? 'bold' : 'normal')}; diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx index f356c250eae5c9..17785ff582a3c2 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx @@ -4,14 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { noop } from 'lodash/fp'; import { EuiFlexGroup, EuiFlexItem, EuiOutsideClickDetector } from '@elastic/eui'; +import { noop } from 'lodash/fp'; import * as React from 'react'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { BrowserFields } from '../../containers/source'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; - +import { CategoriesPane } from './categories_pane'; +import { FieldsPane } from './fields_pane'; +import { Header } from './header'; import { CATEGORY_PANE_WIDTH, FIELDS_PANE_WIDTH, @@ -21,26 +23,25 @@ import { PANES_FLEX_GROUP_WIDTH, } from './helpers'; import { FieldBrowserProps, OnFieldSelected, OnHideFieldBrowser } from './types'; -import { Header } from './header'; -import { CategoriesPane } from './categories_pane'; -import { FieldsPane } from './fields_pane'; + const FieldsBrowserContainer = styled.div<{ width: number }>` - background-color: ${props => props.theme.eui.euiColorLightestShade}; - border: 1px solid ${({ theme }) => theme.eui.euiColorMediumShade}; - border-radius: 4px; - padding: 8px 8px 16px 8px; - position: absolute; - top: 25px; - ${({ width }) => `width: ${width}px`}; - z-index: 9990; + ${({ theme, width }) => css` + background-color: ${theme.eui.euiColorLightestShade}; + border: ${theme.eui.euiBorderWidthThin} solid ${theme.eui.euiColorMediumShade}; + border-radius: ${theme.eui.euiBorderRadius}; + left: 0; + padding: ${theme.eui.paddingSizes.s} ${theme.eui.paddingSizes.s} ${theme.eui.paddingSizes.m}; + position: absolute; + top: calc(100% + ${theme.eui.euiSize}); + width: ${width}px; + z-index: 9990; + `} `; - FieldsBrowserContainer.displayName = 'FieldsBrowserContainer'; const PanesFlexGroup = styled(EuiFlexGroup)` width: ${PANES_FLEX_GROUP_WIDTH}px; `; - PanesFlexGroup.displayName = 'PanesFlexGroup'; type Props = Pick< diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_items.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_items.tsx index 5cb96246de7d17..e909a983deedb1 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_items.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_items.tsx @@ -11,22 +11,20 @@ import { Draggable } from 'react-beautiful-dnd'; import styled from 'styled-components'; import { BrowserField, BrowserFields } from '../../containers/source'; -import { DraggableFieldBadge } from '../draggables/field_badge'; import { DragEffects } from '../drag_and_drop/draggable_wrapper'; import { DroppableWrapper } from '../drag_and_drop/droppable_wrapper'; -import { getColumnsWithTimestamp, getExampleText, getIconFromType } from '../event_details/helpers'; import { getDraggableFieldId, getDroppableId, DRAG_TYPE_FIELD } from '../drag_and_drop/helpers'; +import { DraggableFieldBadge } from '../draggables/field_badge'; import { getEmptyValue } from '../empty_value'; -import { OnUpdateColumns } from '../timeline/events'; +import { getColumnsWithTimestamp, getExampleText, getIconFromType } from '../event_details/helpers'; import { SelectableText } from '../selectable_text'; +import { ColumnHeader } from '../timeline/body/column_headers/column_header'; +import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/helpers'; +import { OnUpdateColumns } from '../timeline/events'; import { TruncatableText } from '../truncatable_text'; - import { FieldName } from './field_name'; - import * as i18n from './translations'; -import { ColumnHeader } from '../timeline/body/column_headers/column_header'; -import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/helpers'; -import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers'; const TypeIcon = styled(EuiIcon)` margin-left: 5px; @@ -179,11 +177,11 @@ export const getFieldColumns = () => [ field: 'description', name: i18n.DESCRIPTION, render: (description: string) => ( - - - {description} - - + + + <>{description} + + ), sortable: true, truncateText: true, diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.tsx index a998c057661eab..9c2cf2cb0e0b28 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.tsx @@ -21,11 +21,10 @@ import styled, { css } from 'styled-components'; import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { OnUpdateColumns } from '../timeline/events'; +import { TimelineContext } from '../timeline/timeline_context'; import { WithHoverActions } from '../with_hover_actions'; - import { LoadingSpinner } from './helpers'; import * as i18n from './translations'; -import { TimelineContext } from '../timeline/timeline_context'; /** * The name of a (draggable) field diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.test.tsx index bcff95dc8358e2..4c9c1fc4147ab7 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.test.tsx @@ -38,7 +38,7 @@ describe('StatefulFieldsBrowser', () => { .find('[data-test-subj="show-field-browser"]') .first() .text() - ).toEqual('Fields'); + ).toEqual('Columns'); }); describe('toggleShow', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx index 29c2c4a2c4e0b1..69720c76cab803 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx @@ -4,24 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ActionCreator } from 'typescript-fsa'; -import { connect } from 'react-redux'; -import { EuiButton, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { noop } from 'lodash/fp'; import * as React from 'react'; +import { connect } from 'react-redux'; import styled from 'styled-components'; +import { ActionCreator } from 'typescript-fsa'; import { BrowserFields } from '../../containers/source'; +import { timelineActions } from '../../store/actions'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { DEFAULT_CATEGORY_NAME } from '../timeline/body/column_headers/default_headers'; import { OnUpdateColumns } from '../timeline/events'; - import { FieldsBrowser } from './field_browser'; -import { FieldBrowserProps } from './types'; import { filterBrowserFieldsByFieldName, mergeBrowserFieldsWithDefaultCategory } from './helpers'; - import * as i18n from './translations'; -import { timelineActions } from '../../store/actions'; +import { FieldBrowserProps } from './types'; const fieldsButtonClassName = 'fields-button'; @@ -41,17 +39,8 @@ interface State { show: boolean; } -const FieldsBrowserButtonContainer = styled.div<{ show: boolean }>` - ${({ show }) => (show ? 'position: absolute;' : '')} - - .${fieldsButtonClassName} { - border-color: ${({ theme }) => theme.eui.euiColorLightShade}; - color: ${({ theme }) => theme.eui.euiColorDarkestShade}; - font-size: 14px; - margin: 1px 5px 2px 0; - ${({ show }) => (show ? 'position: absolute;' : '')} - ${({ show }) => (show ? 'top: -15px;' : '')} - } +const FieldsBrowserButtonContainer = styled.div` + position: relative; `; FieldsBrowserButtonContainer.displayName = 'FieldsBrowserButtonContainer'; @@ -124,28 +113,26 @@ export class StatefulFieldsBrowserComponent extends React.PureComponent< return ( <> - + {isEventViewer ? ( ) : ( - {i18n.FIELDS} - + )} diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/translations.ts b/x-pack/legacy/plugins/siem/public/components/fields_browser/translations.ts index 9446fe1b61bda7..5365fd05b9f750 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/translations.ts @@ -37,7 +37,7 @@ export const FIELD = i18n.translate('xpack.siem.fieldBrowser.fieldLabel', { }); export const FIELDS = i18n.translate('xpack.siem.fieldBrowser.fieldsTitle', { - defaultMessage: 'Fields', + defaultMessage: 'Columns', }); export const FIELDS_COUNT = (totalCount: number) => diff --git a/x-pack/legacy/plugins/siem/public/components/filters_global/filters_global.tsx b/x-pack/legacy/plugins/siem/public/components/filters_global/filters_global.tsx index 2283c8c84137f5..0e052fd4196116 100644 --- a/x-pack/legacy/plugins/siem/public/components/filters_global/filters_global.tsx +++ b/x-pack/legacy/plugins/siem/public/components/filters_global/filters_global.tsx @@ -64,9 +64,9 @@ export const FiltersGlobal = pure(({ children }) => ( {({ style, isSticky }) => (
)); - StarIcon.displayName = 'StarIcon'; export const Description = pure<{ @@ -100,7 +89,6 @@ export const Description = pure<{ )); - Description.displayName = 'Description'; export const Name = pure<{ timelineId: string; title: string; updateTitle: UpdateTitle }>( @@ -117,7 +105,6 @@ export const Name = pure<{ timelineId: string; title: string; updateTitle: Updat ) ); - Name.displayName = 'Name'; export const NewTimeline = pure<{ @@ -138,7 +125,6 @@ export const NewTimeline = pure<{ {i18n.NEW_TIMELINE} )); - NewTimeline.displayName = 'NewTimeline'; interface NotesButtonProps { @@ -156,25 +142,6 @@ interface NotesButtonProps { const getNewNoteId = (): string => uuid.v4(); -const NotesButtonIcon = styled(EuiButtonIcon)` - svg { - height: 19px; - width: 19px; - } -`; - -const NotesIcon = pure<{ count: number }>(({ count }) => ( - 0 ? 'primary' : 'subdued'} - data-test-subj="timeline-notes-icon" - size="m" - iconType="editorComment" - /> -)); - -NotesIcon.displayName = 'NotesIcon'; - const LargeNotesButton = pure<{ noteIds: string[]; text?: string; toggleShowNotes: () => void }>( ({ noteIds, text, toggleShowNotes }) => ( ) ); - LargeNotesButton.displayName = 'LargeNotesButton'; const SmallNotesButton = pure<{ noteIds: string[]; toggleShowNotes: () => void }>( ({ noteIds, toggleShowNotes }) => ( - toggleShowNotes()} - role="button" - > - - + /> ) ); - SmallNotesButton.displayName = 'SmallNotesButton'; /** @@ -254,7 +218,6 @@ const NotesButtonComponent = pure( ) ); - NotesButtonComponent.displayName = 'NotesButtonComponent'; export const NotesButton = pure( @@ -298,5 +261,4 @@ export const NotesButton = pure( ) ); - NotesButton.displayName = 'NotesButton'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/properties/styles.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/properties/styles.tsx index 81060a10b0d49d..3444875282ae75 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/properties/styles.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/properties/styles.tsx @@ -19,7 +19,6 @@ export const TimelineProperties = styled.div` justify-content: space-between; user-select: none; `; - TimelineProperties.displayName = 'TimelineProperties'; export const DatePicker = styled(EuiFlexItem)<{ width: number }>` @@ -29,14 +28,12 @@ export const DatePicker = styled(EuiFlexItem)<{ width: number }>` width: auto; } `; - DatePicker.displayName = 'DatePicker'; export const NameField = styled(EuiFieldText)` width: 150px; margin-right: 5px; `; - NameField.displayName = 'NameField'; export const DescriptionContainer = styled.div` @@ -44,33 +41,22 @@ export const DescriptionContainer = styled.div` margin-right: 5px; min-width: 150px; `; - DescriptionContainer.displayName = 'DescriptionContainer'; -export const SmallNotesButtonContainer = styled.div` - cursor: pointer; - width: 35px; -`; - -SmallNotesButtonContainer.displayName = 'SmallNotesButtonContainer'; - export const ButtonContainer = styled.div<{ animate: boolean }>` animation: ${fadeInEffect} ${({ animate }) => (animate ? '0.3s' : '0s')}; `; - ButtonContainer.displayName = 'ButtonContainer'; export const LabelText = styled.div` margin-left: 10px; `; - LabelText.displayName = 'LabelText'; export const StyledStar = styled(EuiIcon)` margin-right: 5px; cursor: pointer; `; - StyledStar.displayName = 'StyledStar'; export const Facet = styled.div` @@ -88,11 +74,9 @@ export const Facet = styled.div` padding-right: 8px; user-select: none; `; - Facet.displayName = 'Facet'; export const LockIconContainer = styled(EuiFlexItem)` margin-right: 2px; `; - LockIconContainer.displayName = 'LockIconContainer'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx new file mode 100644 index 00000000000000..86c470ef4d3a56 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx @@ -0,0 +1,325 @@ +/* + * 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 { EuiLoadingSpinner } from '@elastic/eui'; +import { rgba } from 'polished'; +import styled, { css } from 'styled-components'; + +/** + * OFFSET PIXEL VALUES + */ + +export const OFFSET_SCROLLBAR = 17; + +/** + * TIMELINE BODY + */ + +export const TimelineBody = styled.div.attrs({ + className: 'siemTimeline__body', +})<{ bodyHeight: number }>` + ${({ bodyHeight, theme }) => css` + height: ${bodyHeight + 'px'}; + overflow: auto; + scrollbar-width: thin; + + &::-webkit-scrollbar { + height: ${theme.eui.euiScrollBar}; + width: ${theme.eui.euiScrollBar}; + } + + &::-webkit-scrollbar-thumb { + background-clip: content-box; + background-color: ${rgba(theme.eui.euiColorDarkShade, 0.5)}; + border: ${theme.eui.euiScrollBarCorner} solid transparent; + } + + &::-webkit-scrollbar-corner, + &::-webkit-scrollbar-track { + background-color: transparent; + } + `} +`; +TimelineBody.displayName = 'TimelineBody'; + +/** + * EVENTS TABLE + */ + +export const EventsTable = styled.div.attrs({ + className: 'siemEventsTable', + role: 'table', +})``; +EventsTable.displayName = 'EventsTable'; + +/* EVENTS HEAD */ + +export const EventsThead = styled.div.attrs({ + className: 'siemEventsTable__thead', + role: 'rowgroup', +})` + ${({ theme }) => css` + background-color: ${theme.eui.euiColorEmptyShade}; + border-bottom: ${theme.eui.euiBorderWidthThick} solid ${theme.eui.euiColorLightShade}; + position: sticky; + top: 0; + z-index: ${theme.eui.euiZLevel1}; + `} +`; +EventsThead.displayName = 'EventsThead'; + +export const EventsTrHeader = styled.div.attrs({ + className: 'siemEventsTable__trHeader', + role: 'row', +})` + display: flex; +`; +EventsTrHeader.displayName = 'EventsTrHeader'; + +export const EventsThGroupActions = styled.div.attrs({ + className: 'siemEventsTable__thGroupActions', +})<{ actionsColumnWidth: number }>` + display: flex; + flex: 0 0 ${({ actionsColumnWidth }) => actionsColumnWidth + 'px'}; + justify-content: space-between; + min-width: 0; +`; +EventsThGroupActions.displayName = 'EventsThGroupActions'; + +export const EventsThGroupData = styled.div.attrs({ + className: 'siemEventsTable__thGroupData', +})` + display: flex; +`; +EventsThGroupData.displayName = 'EventsThGroupData'; + +export const EventsTh = styled.div.attrs({ + className: 'siemEventsTable__th', + role: 'columnheader', +})<{ isDragging?: boolean; position?: string }>` + align-items: center; + display: flex; + flex-shrink: 0; + min-width: 0; + position: ${({ position }) => position}; + + .siemEventsTable__thGroupActions &:first-child:last-child { + flex: 1; + } + + .siemEventsTable__thGroupData &:hover { + background-color: ${({ theme }) => theme.eui.euiTableHoverColor}; + cursor: move; //Fallback for IE11 + cursor: grab; + } +`; +EventsTh.displayName = 'EventsTh'; + +export const EventsThContent = styled.div.attrs({ + className: 'siemEventsTable__thContent', +})<{ textAlign?: string }>` + ${({ textAlign, theme }) => css` + font-size: ${theme.eui.euiFontSizeXS}; + font-weight: ${theme.eui.euiFontWeightSemiBold}; + line-height: ${theme.eui.euiLineHeight}; + min-width: 0; + padding: ${theme.eui.paddingSizes.xs}; + text-align: ${textAlign}; + width: 100%; //Using width: 100% instead of flex: 1 and max-width: 100% for IE11 + `} +`; +EventsThContent.displayName = 'EventsThContent'; + +/* EVENTS BODY */ + +export const EventsTbody = styled.div.attrs({ + className: 'siemEventsTable__tbody', + role: 'rowgroup', +})` + overflow-x: hidden; +`; +EventsTbody.displayName = 'EventsTbody'; + +export const EventsTrGroup = styled.div.attrs({ + className: 'siemEventsTable__trGroup', +})<{ className?: string }>` + ${({ theme }) => css` + border-bottom: ${theme.eui.euiBorderWidthThin} solid ${theme.eui.euiColorLightShade}; + + &:hover { + background-color: ${theme.eui.euiTableHoverColor}; + } + `} +`; +EventsTrGroup.displayName = 'EventsTrGroup'; + +export const EventsTrData = styled.div.attrs({ + className: 'siemEventsTable__trData', + role: 'row', +})` + display: flex; +`; +EventsTrData.displayName = 'EventsTrData'; + +export const EventsTrSupplement = styled.div.attrs({ + className: 'siemEventsTable__trSupplement', +})<{ className: string }>` + ${({ theme }) => css` + font-size: ${theme.eui.euiFontSizeXS}; + line-height: ${theme.eui.euiLineHeight}; + padding: 0 ${theme.eui.paddingSizes.xs} 0 ${theme.eui.paddingSizes.xl}; + `} +`; +EventsTrSupplement.displayName = 'EventsTrSupplement'; + +export const EventsTdGroupActions = styled.div.attrs({ + className: 'siemEventsTable__tdGroupActions', +})<{ actionsColumnWidth: number }>` + display: flex; + justify-content: space-between; + flex: 0 0 ${({ actionsColumnWidth }) => actionsColumnWidth + 'px'}; + min-width: 0; +`; +EventsTdGroupActions.displayName = 'EventsTdGroupActions'; + +export const EventsTdGroupData = styled.div.attrs({ + className: 'siemEventsTable__tdGroupData', +})` + display: flex; +`; +EventsTdGroupData.displayName = 'EventsTdGroupData'; + +export const EventsTd = styled.div.attrs({ + className: 'siemEventsTable__td', + role: 'cell', +})` + align-items: center; + display: flex; + flex-shrink: 0; + min-width: 0; + + .siemEventsTable__tdGroupActions &:first-child:last-child { + flex: 1; + } +`; +EventsTd.displayName = 'EventsTd'; + +export const EventsTdContent = styled.div.attrs({ + className: 'siemEventsTable__tdContent', +})<{ textAlign?: string }>` + ${({ textAlign, theme }) => css` + font-size: ${theme.eui.euiFontSizeXS}; + line-height: ${theme.eui.euiLineHeight}; + min-width: 0; + padding: ${theme.eui.paddingSizes.xs}; + text-align: ${textAlign}; + width: 100%; //Using width: 100% instead of flex: 1 and max-width: 100% for IE11 + `} +`; +EventsTdContent.displayName = 'EventsTdContent'; + +/** + * EVENTS HEADING + */ + +export const EventsHeading = styled.div.attrs({ + className: 'siemEventsHeading', +})<{ isLoading: boolean }>` + align-items: center; + display: flex; + + &:hover { + cursor: ${({ isLoading }) => (isLoading ? 'wait' : 'grab')}; + } +`; +EventsHeading.displayName = 'EventsHeading'; + +export const EventsHeadingTitleButton = styled.button.attrs({ + className: 'siemEventsHeading__title siemEventsHeading__title--aggregatable', + type: 'button', +})` + ${({ theme }) => css` + align-items: center; + display: flex; + font-weight: inherit; + min-width: 0; + + &:hover, + &:focus { + color: ${theme.eui.euiColorPrimary}; + text-decoration: underline; + } + + &:hover { + cursor: pointer; + } + + & > * + * { + margin-left: ${theme.eui.euiSizeXS}; + } + `} +`; +EventsHeadingTitleButton.displayName = 'EventsHeadingTitleButton'; + +export const EventsHeadingTitleSpan = styled.span.attrs({ + className: 'siemEventsHeading__title siemEventsHeading__title--notAggregatable', +})` + min-width: 0; +`; +EventsHeadingTitleSpan.displayName = 'EventsHeadingTitleSpan'; + +export const EventsHeadingExtra = styled.div.attrs({ + className: 'siemEventsHeading__extra', +})<{ className?: string }>` + ${({ theme }) => css` + margin-left: auto; + + &.siemEventsHeading__extra--close { + opacity: 0; + transition: all ${theme.eui.euiAnimSpeedNormal} ease; + visibility: hidden; + + .siemEventsTable__th:hover & { + opacity: 1; + visibility: visible; + } + } + `} +`; +EventsHeadingExtra.displayName = 'EventsHeadingExtra'; + +export const EventsHeadingHandle = styled.div.attrs({ + className: 'siemEventsHeading__handle', +})` + ${({ theme }) => css` + background-color: ${theme.eui.euiBorderColor}; + height: 100%; + opacity: 0; + transition: all ${theme.eui.euiAnimSpeedNormal} ease; + visibility: hidden; + width: ${theme.eui.euiBorderWidthThick}; + + .siemEventsTable__thead:hover & { + opacity: 1; + visibility: visible; + } + + &:hover { + background-color: ${theme.eui.euiColorPrimary}; + cursor: col-resize; + } + `} +`; +EventsHeadingHandle.displayName = 'EventsHeadingHandle'; + +/** + * EVENTS LOADING + */ + +export const EventsLoading = styled(EuiLoadingSpinner)` + margin: ${({ theme }) => theme.eui.euiSizeXS}; + vertical-align: top; +`; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx index 2617f9a957dd24..85986d2ed471c1 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx @@ -113,7 +113,7 @@ describe('Timeline', () => { expect(wrapper.find('[data-test-subj="timelineHeader"]').exists()).toEqual(true); }); - test('it renders the timeline body', () => { + test('it renders the timeline table', () => { const wrapper = mount( @@ -148,7 +148,7 @@ describe('Timeline', () => { ); - expect(wrapper.find('[data-test-subj="horizontal-scroll"]').exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(true); }); test('it does NOT render the paging footer when you do NOT have any data providers', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx index ded0209cef35d0..5101d557923698 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx @@ -15,7 +15,6 @@ import { TimelineQuery } from '../../containers/timeline'; import { Direction } from '../../graphql/types'; import { KqlMode } from '../../store/timeline/model'; import { AutoSizer } from '../auto_sizer'; - import { ColumnHeader } from './body/column_headers/column_header'; import { defaultHeaders } from './body/column_headers/default_headers'; import { Sort } from './body/sort'; @@ -30,12 +29,12 @@ import { OnToggleDataProviderEnabled, OnToggleDataProviderExcluded, } from './events'; +import { TimelineKqlFetch } from './fetch_kql_timeline'; import { Footer, footerHeight } from './footer'; import { TimelineHeader } from './header'; import { calculateBodyHeight, combineQueries } from './helpers'; import { TimelineRefetch } from './refetch_timeline'; import { ManageTimelineContext } from './timeline_context'; -import { TimelineKqlFetch } from './fetch_kql_timeline'; const WrappedByAutoSizer = styled.div` width: 100%; diff --git a/x-pack/legacy/plugins/siem/public/components/truncatable_text/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/truncatable_text/__snapshots__/index.test.tsx.snap index d17982cd26ae24..23b930c7a114b6 100644 --- a/x-pack/legacy/plugins/siem/public/components/truncatable_text/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/truncatable_text/__snapshots__/index.test.tsx.snap @@ -1,17 +1,19 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`TruncatableText renders correctly against snapshot 1`] = ` -.c0 { +.c0, +.c0 * { + display: inline-block; + max-width: 100%; overflow: hidden; text-overflow: ellipsis; + vertical-align: top; white-space: nowrap; - width: 50px; } - Hiding in plain sight - + `; diff --git a/x-pack/legacy/plugins/siem/public/components/truncatable_text/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/truncatable_text/index.test.tsx index c1bc485e21f2ab..51ffdb43fd4677 100644 --- a/x-pack/legacy/plugins/siem/public/components/truncatable_text/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/truncatable_text/index.test.tsx @@ -12,42 +12,26 @@ import * as React from 'react'; import { TruncatableText } from '.'; describe('TruncatableText', () => { - const width = '50px'; - test('renders correctly against snapshot', () => { - const wrapper = shallow( - {'Hiding in plain sight'} - ); + const wrapper = shallow({'Hiding in plain sight'}); expect(toJson(wrapper)).toMatchSnapshot(); }); test('it adds the hidden overflow style', () => { - const wrapper = mount( - {'Hiding in plain sight'} - ); + const wrapper = mount({'Hiding in plain sight'}); expect(wrapper).toHaveStyleRule('overflow', 'hidden'); }); test('it adds the ellipsis text-overflow style', () => { - const wrapper = mount({'Dramatic pause'}); + const wrapper = mount({'Dramatic pause'}); expect(wrapper).toHaveStyleRule('text-overflow', 'ellipsis'); }); test('it adds the nowrap white-space style', () => { - const wrapper = mount( - {'Who stopped the beats?'} - ); + const wrapper = mount({'Who stopped the beats?'}); expect(wrapper).toHaveStyleRule('white-space', 'nowrap'); }); - - test('it forwards the width prop as a style', () => { - const wrapper = mount( - {'width or without you'} - ); - - expect(wrapper).toHaveStyleRule('width', width); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/truncatable_text/index.tsx b/x-pack/legacy/plugins/siem/public/components/truncatable_text/index.tsx index 7cf91ce35cd43b..ff8307666275d1 100644 --- a/x-pack/legacy/plugins/siem/public/components/truncatable_text/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/truncatable_text/index.tsx @@ -4,21 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiText } from '@elastic/eui'; import styled from 'styled-components'; /** * Applies CSS styling to enable text to be truncated with an ellipsis. * Example: "Don't leave me hanging..." * - * Width is required, because CSS will not truncate the text unless a width is - * specified. + * Note: Requires a parent container with a defined width or max-width. */ -export const TruncatableText = styled(EuiText)<{ width: string }>` - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - width: ${({ width }) => width}; -`; +export const TruncatableText = styled.span` + &, + & * { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; + white-space: nowrap; + } +`; TruncatableText.displayName = 'TruncatableText'; diff --git a/x-pack/legacy/plugins/siem/public/pages/network/network.tsx b/x-pack/legacy/plugins/siem/public/pages/network/network.tsx index 3ee0cd23d56437..de75edfb33a4a9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/network/network.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/network/network.tsx @@ -5,42 +5,51 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { getOr } from 'lodash/fp'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { connect } from 'react-redux'; +import { RouteComponentProps } from 'react-router-dom'; import { StickyContainer } from 'react-sticky'; - +import styled, { css } from 'styled-components'; import { ActionCreator } from 'typescript-fsa'; -import { RouteComponentProps } from 'react-router-dom'; + +import { EmbeddedMap } from '../../components/embeddables/embedded_map'; import { FiltersGlobal } from '../../components/filters_global'; import { HeaderPage } from '../../components/header_page'; import { LastEventTime } from '../../components/last_event_time'; +import { AnomaliesNetworkTable } from '../../components/ml/tables/anomalies_network_table'; +import { scoreIntervalToDateTime } from '../../components/ml/score/score_interval_to_datetime'; import { manageQuery } from '../../components/page/manage_query'; import { KpiNetworkComponent, NetworkTopNFlowTable } from '../../components/page/network'; import { NetworkDnsTable } from '../../components/page/network/network_dns_table'; import { GlobalTime } from '../../containers/global_time'; import { KpiNetworkQuery } from '../../containers/kpi_network'; +import { NetworkFilter } from '../../containers/network'; import { NetworkDnsQuery } from '../../containers/network_dns'; import { NetworkTopNFlowQuery } from '../../containers/network_top_n_flow'; import { indicesExistOrDataTemporarilyUnavailable, WithSource } from '../../containers/source'; import { FlowTargetNew, LastEventIndexKey } from '../../graphql/types'; import { networkModel, networkSelectors, State } from '../../store'; - -import { NetworkKql } from './kql'; -import { NetworkEmptyPage } from './network_empty_page'; -import * as i18n from './translations'; -import { AnomaliesNetworkTable } from '../../components/ml/tables/anomalies_network_table'; -import { scoreIntervalToDateTime } from '../../components/ml/score/score_interval_to_datetime'; import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions'; import { InputsModelId } from '../../store/inputs/constants'; -import { EmbeddedMap } from '../../components/embeddables/embedded_map'; -import { NetworkFilter } from '../../containers/network'; import { SpyRoute } from '../../utils/route/spy_routes'; +import { NetworkKql } from './kql'; +import { NetworkEmptyPage } from './network_empty_page'; +import * as i18n from './translations'; const NetworkTopNFlowTableManage = manageQuery(NetworkTopNFlowTable); const NetworkDnsTableManage = manageQuery(NetworkDnsTable); const KpiNetworkComponentManage = manageQuery(KpiNetworkComponent); + +const ConditionalFlexGroup = styled(EuiFlexGroup)` + ${({ theme }) => css` + @media only screen and (min-width: 1441px) { + flex-direction: row; + } + `} +`; +ConditionalFlexGroup.displayName = 'ConditionalFlexGroup'; + interface NetworkComponentReduxProps { filterQuery: string; queryExpression: string; @@ -52,27 +61,6 @@ interface NetworkComponentReduxProps { } type NetworkComponentProps = NetworkComponentReduxProps & Partial>; -const mediaMatch = window.matchMedia( - 'screen and (min-width: ' + euiLightVars.euiBreakpoints.xl + ')' -); -const getFlexDirectionByMediaMatch = (): 'row' | 'column' => { - const { matches } = mediaMatch; - return matches ? 'row' : 'column'; -}; -export const getFlexDirection = () => { - const [display, setDisplay] = useState(getFlexDirectionByMediaMatch()); - - useEffect(() => { - const setFromEvent = () => setDisplay(getFlexDirectionByMediaMatch()); - window.addEventListener('resize', setFromEvent); - - return () => { - window.removeEventListener('resize', setFromEvent); - }; - }, []); - - return display; -}; const NetworkComponent = React.memo( ({ filterQuery, queryExpression, setAbsoluteRangeDatePicker }) => ( @@ -108,6 +96,7 @@ const NetworkComponent = React.memo( /> )} + ( - + ( )} - + From 51d734e9b8eea586be9e80dac3e5cd74323e3873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Wed, 2 Oct 2019 17:28:57 +0200 Subject: [PATCH 30/59] Changing status code colors on trace summary (#47114) --- .../HttpInfoSummaryItem.test.tsx | 29 +++++++++++++------ .../Summary/HttpInfoSummaryItem/index.tsx | 22 +++++++------- 2 files changed, 32 insertions(+), 19 deletions(-) rename x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/{ => __test__}/HttpInfoSummaryItem.test.tsx (75%) diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/HttpInfoSummaryItem.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/__test__/HttpInfoSummaryItem.test.tsx similarity index 75% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/HttpInfoSummaryItem.test.tsx rename to x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/__test__/HttpInfoSummaryItem.test.tsx index e87c16b9fd100a..7edc7eab3b3851 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/HttpInfoSummaryItem.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/__test__/HttpInfoSummaryItem.test.tsx @@ -6,16 +6,16 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; -import { palettes } from '@elastic/eui'; -import { HttpInfoSummaryItem } from './'; -import * as exampleTransactions from '../__fixtures__/transactions'; +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { HttpInfoSummaryItem } from '../'; +import * as exampleTransactions from '../../__fixtures__/transactions'; describe('HttpInfoSummaryItem', () => { describe('render', () => { const transaction = exampleTransactions.httpOk; const url = 'https://example.com'; const method = 'get'; - const props = { transaction, url, method, status: 200 }; + const props = { transaction, url, method, status: 100 }; it('renders', () => { expect(() => @@ -23,12 +23,23 @@ describe('HttpInfoSummaryItem', () => { ).not.toThrowError(); }); - describe('with status code 200', () => { + describe('with status code 100', () => { it('shows a success color', () => { const wrapper = mount(); expect(wrapper.find('HttpStatusBadge EuiBadge').prop('color')).toEqual( - palettes.euiPaletteForStatus.colors[0] + theme.euiColorDarkShade + ); + }); + }); + + describe('with status code 200', () => { + it('shows a success color', () => { + const p = { ...props, status: 200 }; + const wrapper = mount(); + + expect(wrapper.find('HttpStatusBadge EuiBadge').prop('color')).toEqual( + theme.euiColorSecondary ); }); }); @@ -40,7 +51,7 @@ describe('HttpInfoSummaryItem', () => { const wrapper = mount(); expect(wrapper.find('HttpStatusBadge EuiBadge').prop('color')).toEqual( - palettes.euiPaletteForStatus.colors[4] + theme.euiColorDarkShade ); }); }); @@ -52,7 +63,7 @@ describe('HttpInfoSummaryItem', () => { const wrapper = mount(); expect(wrapper.find('HttpStatusBadge EuiBadge').prop('color')).toEqual( - palettes.euiPaletteForStatus.colors[9] + theme.euiColorWarning ); }); }); @@ -64,7 +75,7 @@ describe('HttpInfoSummaryItem', () => { const wrapper = mount(); expect(wrapper.find('HttpStatusBadge EuiBadge').prop('color')).toEqual( - palettes.euiPaletteForStatus.colors[9] + theme.euiColorDanger ); }); }); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/index.tsx index d433dae75a1aee..5396b57fc27a6f 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/index.tsx @@ -6,24 +6,26 @@ import React from 'react'; import { EuiToolTip, EuiBadge } from '@elastic/eui'; +import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; -import { palettes } from '@elastic/eui'; import { units, px, truncate, unit } from '../../../../style/variables'; import { statusCodes } from './statusCodes'; -const statusColors = { - success: palettes.euiPaletteForStatus.colors[0], - warning: palettes.euiPaletteForStatus.colors[4], - error: palettes.euiPaletteForStatus.colors[9] -}; +const { + euiColorDarkShade, + euiColorSecondary, + euiColorWarning, + euiColorDanger +} = theme; function getStatusColor(status: number) { const colors: { [key: string]: string } = { - 2: statusColors.success, - 3: statusColors.warning, - 4: statusColors.error, - 5: statusColors.error + 1: euiColorDarkShade, + 2: euiColorSecondary, + 3: euiColorDarkShade, + 4: euiColorWarning, + 5: euiColorDanger }; return colors[status.toString().substr(0, 1)] || 'default'; From 0bfa7ca5c6412d277d165abb69fb8f211ff9329e Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 2 Oct 2019 12:05:02 -0400 Subject: [PATCH 31/59] Support space-specific default routes (#44678) --- config/kibana.yml | 4 - docs/setup/settings.asciidoc | 4 - .../advanced_settings.test.js.snap | 30 ++ .../field/__snapshots__/field.test.js.snap | 419 ++++++++++++++++++ .../settings/components/field/field.js | 15 +- .../settings/components/field/field.test.js | 30 ++ .../lib/__tests__/to_editable_config.test.js | 18 +- .../settings/lib/to_editable_config.js | 4 + .../kibana/ui_setting_defaults.js | 18 + src/legacy/server/config/schema.js | 2 +- .../server/config/transform_deprecations.js | 1 + .../config/transform_deprecations.test.js | 48 +- src/legacy/server/http/index.js | 9 +- .../default_route_provider.test.ts | 87 ++++ .../http/setup_default_route_provider.ts | 74 ++++ src/legacy/server/kbn_server.d.ts | 1 + .../privilege_space_table.tsx | 2 +- .../space_selector.tsx | 2 +- .../legacy/plugins/spaces/common/constants.ts | 5 + x-pack/legacy/plugins/spaces/common/index.ts | 2 +- .../spaces_url_parser.test.ts.snap | 0 .../lib/spaces_url_parser.test.ts | 2 +- .../lib/spaces_url_parser.ts | 2 +- x-pack/legacy/plugins/spaces/index.ts | 6 +- .../spaces/public/components/space_avatar.tsx | 4 +- .../legacy/plugins/spaces/public/lib/index.ts | 1 + .../lib}/space_attributes.test.ts | 0 .../lib}/space_attributes.ts | 4 +- .../spaces/public/lib/spaces_manager.ts | 35 +- .../customize_space_avatar.tsx | 2 +- .../spaces/public/views/management/index.tsx | 4 +- .../public/views/management/page_routes.tsx | 12 +- .../public/views/nav_control/nav_control.tsx | 4 +- .../public/views/space_selector/index.tsx | 4 +- .../spaces/server/lib/get_active_space.ts | 2 +- .../on_post_auth_interceptor.test.ts | 13 +- .../on_post_auth_interceptor.ts | 12 +- .../on_request_interceptor.ts | 2 +- .../spaces/server/new_platform/plugin.ts | 8 - .../spaces_service/spaces_service.test.ts | 2 +- .../spaces_service/spaces_service.ts | 2 +- .../api/__fixtures__/create_test_handler.ts | 6 +- .../spaces/server/routes/api/v1/index.ts | 36 -- .../server/routes/api/v1/spaces.test.ts | 93 ---- .../spaces/server/routes/api/v1/spaces.ts | 51 --- .../spaces/server/routes/views/enter_space.ts | 24 + .../spaces/server/routes/views/index.ts | 1 + .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../functional/apps/spaces/enter_space.ts | 60 +++ x-pack/test/functional/apps/spaces/index.ts | 1 + .../es_archives/spaces/enter_space/data.json | 83 ++++ .../spaces/enter_space/mappings.json | 287 ++++++++++++ .../page_objects/space_selector_page.js | 10 +- .../common/suites/select.ts | 125 ------ .../security_and_spaces/apis/index.ts | 1 - .../security_and_spaces/apis/select.ts | 341 -------------- .../spaces_only/apis/index.ts | 1 - .../spaces_only/apis/select.ts | 74 ---- 59 files changed, 1251 insertions(+), 843 deletions(-) create mode 100644 src/legacy/server/http/integration_tests/default_route_provider.test.ts create mode 100644 src/legacy/server/http/setup_default_route_provider.ts rename x-pack/legacy/plugins/spaces/{server => common}/lib/__snapshots__/spaces_url_parser.test.ts.snap (100%) rename x-pack/legacy/plugins/spaces/{server => common}/lib/spaces_url_parser.test.ts (97%) rename x-pack/legacy/plugins/spaces/{server => common}/lib/spaces_url_parser.ts (95%) rename x-pack/legacy/plugins/spaces/{common => public/lib}/space_attributes.test.ts (100%) rename x-pack/legacy/plugins/spaces/{common => public/lib}/space_attributes.ts (94%) delete mode 100644 x-pack/legacy/plugins/spaces/server/routes/api/v1/index.ts delete mode 100644 x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.test.ts delete mode 100644 x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.ts create mode 100644 x-pack/legacy/plugins/spaces/server/routes/views/enter_space.ts create mode 100644 x-pack/test/functional/apps/spaces/enter_space.ts create mode 100644 x-pack/test/functional/es_archives/spaces/enter_space/data.json create mode 100644 x-pack/test/functional/es_archives/spaces/enter_space/mappings.json delete mode 100644 x-pack/test/spaces_api_integration/common/suites/select.ts delete mode 100644 x-pack/test/spaces_api_integration/security_and_spaces/apis/select.ts delete mode 100644 x-pack/test/spaces_api_integration/spaces_only/apis/select.ts diff --git a/config/kibana.yml b/config/kibana.yml index 7d49fb37e03208..9525a6423d90a0 100644 --- a/config/kibana.yml +++ b/config/kibana.yml @@ -18,10 +18,6 @@ # default to `true` starting in Kibana 7.0. #server.rewriteBasePath: false -# Specifies the default route when opening Kibana. You can use this setting to modify -# the landing page when opening Kibana. -#server.defaultRoute: /app/kibana - # The maximum payload size in bytes for incoming server requests. #server.maxPayloadBytes: 1048576 diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 7f9034c48e232f..5b3db22a39ea64 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -256,10 +256,6 @@ deprecation warning at startup. This setting cannot end in a slash (`/`). `server.customResponseHeaders:`:: *Default: `{}`* Header names and values to send on all responses to the client from the Kibana server. -[[server-default]]`server.defaultRoute:`:: *Default: "/app/kibana"* This setting -specifies the default route when opening Kibana. You can use this setting to -modify the landing page when opening Kibana. Supported on {ece}. - `server.host:`:: *Default: "localhost"* This setting specifies the host of the back end server. diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap index a3af9fcc884e5c..8c1db0c33e0b02 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap @@ -70,6 +70,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "array", + "validation": undefined, "value": undefined, }, Object { @@ -88,6 +89,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "boolean", + "validation": undefined, "value": undefined, }, ], @@ -108,6 +110,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "string", + "validation": undefined, "value": undefined, }, Object { @@ -126,6 +129,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "image", + "validation": undefined, "value": undefined, }, Object { @@ -146,6 +150,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "json", + "validation": undefined, "value": undefined, }, Object { @@ -164,6 +169,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "number", + "validation": undefined, "value": undefined, }, Object { @@ -186,6 +192,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "select", + "validation": undefined, "value": undefined, }, Object { @@ -204,6 +211,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "string", + "validation": undefined, "value": undefined, }, Object { @@ -222,6 +230,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "json", + "validation": undefined, "value": undefined, }, Object { @@ -240,6 +249,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "markdown", + "validation": undefined, "value": undefined, }, Object { @@ -258,6 +268,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "number", + "validation": undefined, "value": undefined, }, Object { @@ -280,6 +291,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "select", + "validation": undefined, "value": undefined, }, Object { @@ -298,6 +310,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "string", + "validation": undefined, "value": undefined, }, ], @@ -342,6 +355,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "array", + "validation": undefined, "value": undefined, }, Object { @@ -360,6 +374,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "boolean", + "validation": undefined, "value": undefined, }, ], @@ -380,6 +395,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "string", + "validation": undefined, "value": undefined, }, Object { @@ -398,6 +414,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "image", + "validation": undefined, "value": undefined, }, Object { @@ -418,6 +435,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "json", + "validation": undefined, "value": undefined, }, Object { @@ -436,6 +454,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "number", + "validation": undefined, "value": undefined, }, Object { @@ -458,6 +477,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "select", + "validation": undefined, "value": undefined, }, Object { @@ -476,6 +496,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "string", + "validation": undefined, "value": undefined, }, Object { @@ -494,6 +515,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "json", + "validation": undefined, "value": undefined, }, Object { @@ -512,6 +534,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "markdown", + "validation": undefined, "value": undefined, }, Object { @@ -530,6 +553,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "number", + "validation": undefined, "value": undefined, }, Object { @@ -552,6 +576,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "select", + "validation": undefined, "value": undefined, }, Object { @@ -570,6 +595,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "readonly": false, "requiresPageReload": false, "type": "string", + "validation": undefined, "value": undefined, }, ], @@ -689,6 +715,7 @@ exports[`AdvancedSettings should render read-only when saving is disabled 1`] = "readonly": false, "requiresPageReload": false, "type": "string", + "validation": undefined, "value": undefined, }, ], @@ -731,6 +758,7 @@ exports[`AdvancedSettings should render read-only when saving is disabled 1`] = "readonly": false, "requiresPageReload": false, "type": "string", + "validation": undefined, "value": undefined, }, ], @@ -868,6 +896,7 @@ exports[`AdvancedSettings should render specific setting if given setting key 1` "readonly": false, "requiresPageReload": false, "type": "string", + "validation": undefined, "value": undefined, }, ], @@ -910,6 +939,7 @@ exports[`AdvancedSettings should render specific setting if given setting key 1` "readonly": false, "requiresPageReload": false, "type": "string", + "validation": undefined, "value": undefined, }, ], diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap index 1a5039bbb96f84..eb8454f64e7ba7 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap @@ -3707,3 +3707,422 @@ exports[`Field for string setting should render user value if there is user valu /> `; + +exports[`Field for stringWithValidation setting should render as read only if saving is disabled 1`] = ` + + + +
+ + } + fullWidth={false} + gutterSize="l" + idAria="string:test-validation:setting-aria" + title={ +

+ String test validation setting + +

+ } + titleSize="xs" + > + + + + + + + +`; + +exports[`Field for stringWithValidation setting should render as read only with help text if overridden 1`] = ` + + + +
+ + + + + + foo-default + , + } + } + /> + + + + + } + fullWidth={false} + gutterSize="l" + idAria="string:test-validation:setting-aria" + title={ +

+ String test validation setting + +

+ } + titleSize="xs" + > + + + + } + isInvalid={false} + label="string:test-validation:setting" + labelType="label" + > + + + + + + +`; + +exports[`Field for stringWithValidation setting should render custom setting icon if it is custom 1`] = ` + + + +
+ + } + fullWidth={false} + gutterSize="l" + idAria="string:test-validation:setting-aria" + title={ +

+ String test validation setting + + } + type="asterisk" + /> +

+ } + titleSize="xs" + > + + + + + + + +`; + +exports[`Field for stringWithValidation setting should render default value if there is no user value set 1`] = ` + + + +
+ + } + fullWidth={false} + gutterSize="l" + idAria="string:test-validation:setting-aria" + title={ +

+ String test validation setting + +

+ } + titleSize="xs" + > + + + + + + + +`; + +exports[`Field for stringWithValidation setting should render user value if there is user value is set 1`] = ` + + + +
+ + + + + + foo-default + , + } + } + /> + + + + + } + fullWidth={false} + gutterSize="l" + idAria="string:test-validation:setting-aria" + title={ +

+ String test validation setting + +

+ } + titleSize="xs" + > + + + + + +     + + + } + isInvalid={false} + label="string:test-validation:setting" + labelType="label" + > + + + + + + +`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js index f431a862fb4c8b..c0b1188950126c 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js @@ -166,7 +166,7 @@ class FieldUI extends PureComponent { onFieldChange = (e) => { const value = e.target.value; - const { type } = this.props.setting; + const { type, validation } = this.props.setting; const { unsavedValue } = this.state; let newUnsavedValue = undefined; @@ -181,8 +181,21 @@ class FieldUI extends PureComponent { default: newUnsavedValue = value; } + + let isInvalid = false; + let error = undefined; + + if (validation && validation.regex) { + if (!validation.regex.test(newUnsavedValue)) { + error = validation.message; + isInvalid = true; + } + } + this.setState({ unsavedValue: newUnsavedValue, + isInvalid, + error }); } diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js index 81cda09eaf0da5..0a2886d0d0287e 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js @@ -143,6 +143,22 @@ const settings = { isOverridden: false, options: null, }, + stringWithValidation: { + name: 'string:test-validation:setting', + ariaName: 'string test validation setting', + displayName: 'String test validation setting', + description: 'Description for String test validation setting', + type: 'string', + validation: { + regex: new RegExp('/^foo'), + message: 'must start with "foo"' + }, + value: undefined, + defVal: 'foo-default', + isCustom: false, + isOverridden: false, + options: null, + } }; const userValues = { array: ['user', 'value'], @@ -153,6 +169,10 @@ const userValues = { number: 10, select: 'banana', string: 'foo', + stringWithValidation: 'fooUserValue' +}; +const invalidUserValues = { + stringWithValidation: 'invalidUserValue' }; const save = jest.fn(() => Promise.resolve()); const clear = jest.fn(() => Promise.resolve()); @@ -392,6 +412,16 @@ describe('Field', () => { const userValue = userValues[type]; const fieldUserValue = type === 'array' ? userValue.join(', ') : userValue; + if (setting.validation) { + const invalidUserValue = invalidUserValues[type]; + it('should display an error when validation fails', async () => { + component.instance().onFieldChange({ target: { value: invalidUserValue } }); + component.update(); + const errorMessage = component.find('.euiFormErrorText').text(); + expect(errorMessage).toEqual(setting.validation.message); + }); + } + it('should be able to change value and cancel', async () => { component.instance().onFieldChange({ target: { value: fieldUserValue } }); component.update(); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/__tests__/to_editable_config.test.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/__tests__/to_editable_config.test.js index ad1ba30ece4b19..555aab8c2b5ffa 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/__tests__/to_editable_config.test.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/__tests__/to_editable_config.test.js @@ -43,7 +43,7 @@ describe('Settings', function () { def = { value: 'the original', description: 'the one and only', - options: 'all the options' + options: 'all the options', }; }); @@ -76,6 +76,18 @@ describe('Settings', function () { expect(invoke({ def }).type).to.equal('array'); }); }); + + describe('that contains a validation object', function () { + it('constructs a validation regex with message', function () { + def.validation = { + regexString: '^foo', + message: 'must start with "foo"' + }; + const result = invoke({ def }); + expect(result.validation.regex).to.be.a(RegExp); + expect(result.validation.message).to.equal('must start with "foo"'); + }); + }); }); describe('when not given a setting definition object', function () { @@ -94,6 +106,10 @@ describe('Settings', function () { it('sets options to undefined', function () { expect(invoke().options).to.be.undefined; }); + + it('sets validation to undefined', function () { + expect(invoke().validation).to.be.undefined; + }); }); }); }); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js index b557c880c496b5..4c3b87e5120920 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js @@ -43,6 +43,10 @@ export function toEditableConfig({ def, name, value, isCustom, isOverridden }) { defVal: def.value, type: getValType(def, value), description: def.description, + validation: def.validation ? { + regex: new RegExp(def.validation.regexString), + message: def.validation.message + } : undefined, options: def.options, optionLabels: def.optionLabels, requiresPageReload: !!def.requiresPageReload, diff --git a/src/legacy/core_plugins/kibana/ui_setting_defaults.js b/src/legacy/core_plugins/kibana/ui_setting_defaults.js index 191ae7309f46ff..7a152526339560 100644 --- a/src/legacy/core_plugins/kibana/ui_setting_defaults.js +++ b/src/legacy/core_plugins/kibana/ui_setting_defaults.js @@ -55,6 +55,24 @@ export function getUiSettingDefaults() { 'buildNum': { readonly: true }, + 'defaultRoute': { + name: i18n.translate('kbn.advancedSettings.defaultRoute.defaultRouteTitle', { + defaultMessage: 'Default route', + }), + value: '/app/kibana', + validation: { + regexString: '^\/', + message: i18n.translate('kbn.advancedSettings.defaultRoute.defaultRouteValidationMessage', { + defaultMessage: 'The route must start with a slash ("/")', + }), + }, + description: + i18n.translate('kbn.advancedSettings.defaultRoute.defaultRouteText', { + defaultMessage: 'This setting specifies the default route when opening Kibana. ' + + 'You can use this setting to modify the landing page when opening Kibana. ' + + 'The route must start with a slash ("/").', + }), + }, 'query:queryString:options': { name: i18n.translate('kbn.advancedSettings.query.queryStringOptionsTitle', { defaultMessage: 'Query string options', diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index 919653bc941f46..2b91eafd45caab 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -78,7 +78,7 @@ export default () => Joi.object({ server: Joi.object({ uuid: Joi.string().guid().default(), name: Joi.string().default(os.hostname()), - defaultRoute: Joi.string().default('/app/kibana').regex(/^\//, `start with a slash`), + defaultRoute: Joi.string().regex(/^\//, `start with a slash`), customResponseHeaders: Joi.object().unknown(true).default({}), xsrf: Joi.object({ disableProtection: Joi.boolean().default(false), diff --git a/src/legacy/server/config/transform_deprecations.js b/src/legacy/server/config/transform_deprecations.js index 7cac17a88fe64b..8be880074f9fd3 100644 --- a/src/legacy/server/config/transform_deprecations.js +++ b/src/legacy/server/config/transform_deprecations.js @@ -95,6 +95,7 @@ const cspRules = (settings, log) => { const deprecations = [ //server + rename('server.defaultRoute', 'uiSettings.overrides.defaultRoute'), unused('server.xsrf.token'), unused('uiSettings.enabled'), rename('optimize.lazy', 'optimize.watch'), diff --git a/src/legacy/server/config/transform_deprecations.test.js b/src/legacy/server/config/transform_deprecations.test.js index 38044357f230df..4094443ac0006a 100644 --- a/src/legacy/server/config/transform_deprecations.test.js +++ b/src/legacy/server/config/transform_deprecations.test.js @@ -62,6 +62,24 @@ describe('server/config', function () { }); }); + describe('server.defaultRoute', () => { + it('renames to uiSettings.overrides.defaultRoute when specified', () => { + const settings = { + server: { + defaultRoute: '/app/foo', + }, + }; + + expect(transformDeprecations(settings)).toEqual({ + uiSettings: { + overrides: { + defaultRoute: '/app/foo' + } + } + }); + }); + }); + describe('csp.rules', () => { describe('with nonce source', () => { it('logs a warning', () => { @@ -74,20 +92,18 @@ describe('server/config', function () { const log = jest.fn(); transformDeprecations(settings, log); expect(log.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "csp.rules no longer supports the {nonce} syntax. Replacing with 'self' in script-src", - ], - ] - `); + Array [ + Array [ + "csp.rules no longer supports the {nonce} syntax. Replacing with 'self' in script-src", + ], + ] + `); }); it('replaces a nonce', () => { expect( - transformDeprecations( - { csp: { rules: [`script-src 'nonce-{nonce}'`] } }, - jest.fn() - ).csp.rules + transformDeprecations({ csp: { rules: [`script-src 'nonce-{nonce}'`] } }, jest.fn()).csp + .rules ).toEqual([`script-src 'self'`]); expect( transformDeprecations( @@ -158,12 +174,12 @@ describe('server/config', function () { const log = jest.fn(); transformDeprecations({ csp: { rules: [`script-src 'unsafe-eval'`] } }, log); expect(log.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "csp.rules must contain the 'self' source. Automatically adding to script-src.", - ], - ] - `); + Array [ + Array [ + "csp.rules must contain the 'self' source. Automatically adding to script-src.", + ], + ] + `); }); it('adds self', () => { diff --git a/src/legacy/server/http/index.js b/src/legacy/server/http/index.js index 40ac2baa032d63..f8fbc6c4976ff2 100644 --- a/src/legacy/server/http/index.js +++ b/src/legacy/server/http/index.js @@ -25,6 +25,7 @@ import Boom from 'boom'; import { setupVersionCheck } from './version_check'; import { registerHapiPlugins } from './register_hapi_plugins'; import { setupBasePathProvider } from './setup_base_path_provider'; +import { setupDefaultRouteProvider } from './setup_default_route_provider'; import { setupXsrf } from './xsrf'; export default async function (kbnServer, server, config) { @@ -33,6 +34,8 @@ export default async function (kbnServer, server, config) { setupBasePathProvider(kbnServer); + setupDefaultRouteProvider(server); + await registerHapiPlugins(server); // provide a simple way to expose static directories @@ -86,10 +89,8 @@ export default async function (kbnServer, server, config) { server.route({ path: '/', method: 'GET', - handler(req, h) { - const basePath = req.getBasePath(); - const defaultRoute = config.get('server.defaultRoute'); - return h.redirect(`${basePath}${defaultRoute}`); + async handler(req, h) { + return h.redirect(await req.getDefaultRoute()); } }); diff --git a/src/legacy/server/http/integration_tests/default_route_provider.test.ts b/src/legacy/server/http/integration_tests/default_route_provider.test.ts new file mode 100644 index 00000000000000..fe8c4649651329 --- /dev/null +++ b/src/legacy/server/http/integration_tests/default_route_provider.test.ts @@ -0,0 +1,87 @@ +/* + * 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. + */ +jest.mock('../../../ui/ui_settings/ui_settings_mixin', () => { + return jest.fn(); +}); + +import * as kbnTestServer from '../../../../test_utils/kbn_server'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { Root } from '../../../../core/server/root'; + +let mockDefaultRouteSetting: any = ''; + +describe('default route provider', () => { + let root: Root; + beforeAll(async () => { + root = kbnTestServer.createRoot(); + + await root.setup(); + await root.start(); + + const kbnServer = kbnTestServer.getKbnServer(root); + + kbnServer.server.decorate('request', 'getUiSettingsService', function() { + return { + get: (key: string) => { + if (key === 'defaultRoute') { + return Promise.resolve(mockDefaultRouteSetting); + } + throw Error(`unsupported ui setting: ${key}`); + }, + getDefaults: () => { + return Promise.resolve({ + defaultRoute: { + value: '/app/kibana', + }, + }); + }, + }; + }); + }, 30000); + + afterAll(async () => await root.shutdown()); + + it('redirects to the configured default route', async function() { + mockDefaultRouteSetting = '/app/some/default/route'; + + const { status, header } = await kbnTestServer.request.get(root, '/'); + expect(status).toEqual(302); + expect(header).toMatchObject({ + location: '/app/some/default/route', + }); + }); + + const invalidRoutes = [ + 'http://not-your-kibana.com', + '///example.com', + '//example.com', + ' //example.com', + ]; + for (const route of invalidRoutes) { + it(`falls back to /app/kibana when the configured route (${route}) is not a valid relative path`, async function() { + mockDefaultRouteSetting = route; + + const { status, header } = await kbnTestServer.request.get(root, '/'); + expect(status).toEqual(302); + expect(header).toMatchObject({ + location: '/app/kibana', + }); + }); + } +}); diff --git a/src/legacy/server/http/setup_default_route_provider.ts b/src/legacy/server/http/setup_default_route_provider.ts new file mode 100644 index 00000000000000..07ff61015a1875 --- /dev/null +++ b/src/legacy/server/http/setup_default_route_provider.ts @@ -0,0 +1,74 @@ +/* + * 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 { Legacy } from 'kibana'; +import { parse } from 'url'; + +export function setupDefaultRouteProvider(server: Legacy.Server) { + server.decorate('request', 'getDefaultRoute', async function() { + // @ts-ignore + const request: Legacy.Request = this; + + const serverBasePath: string = server.config().get('server.basePath'); + + const uiSettings = request.getUiSettingsService(); + + const defaultRoute = await uiSettings.get('defaultRoute'); + const qualifiedDefaultRoute = `${request.getBasePath()}${defaultRoute}`; + + if (isRelativePath(qualifiedDefaultRoute, serverBasePath)) { + return qualifiedDefaultRoute; + } else { + server.log( + ['http', 'warn'], + `Ignoring configured default route of '${defaultRoute}', as it is malformed.` + ); + + const fallbackRoute = (await uiSettings.getDefaults()).defaultRoute.value; + + const qualifiedFallbackRoute = `${request.getBasePath()}${fallbackRoute}`; + return qualifiedFallbackRoute; + } + }); + + function isRelativePath(candidatePath: string, basePath = '') { + // validate that `candidatePath` is not attempting a redirect to somewhere + // outside of this Kibana install + const { protocol, hostname, port, pathname } = parse( + candidatePath, + false /* parseQueryString */, + true /* slashesDenoteHost */ + ); + + // We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not + // detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but + // browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser + // hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`) + // and the first slash that belongs to path. + if (protocol !== null || hostname !== null || port !== null) { + return false; + } + + if (!String(pathname).startsWith(basePath)) { + return false; + } + + return true; + } +} diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index 406697ab65d8f6..69bf95e57cab9b 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -83,6 +83,7 @@ declare module 'hapi' { interface Request { getSavedObjectsClient(options?: SavedObjectsClientProviderOptions): SavedObjectsClientContract; getBasePath(): string; + getDefaultRoute(): Promise; getUiSettingsService(): any; getCapabilities(): Promise; } diff --git a/x-pack/legacy/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx b/x-pack/legacy/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx index 5a718a34b90057..3c49e5717ba42b 100644 --- a/x-pack/legacy/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx +++ b/x-pack/legacy/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx @@ -14,7 +14,7 @@ import { import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react'; import _ from 'lodash'; import React, { Component } from 'react'; -import { getSpaceColor } from '../../../../../../../../../spaces/common'; +import { getSpaceColor } from '../../../../../../../../../spaces/public/lib/space_attributes'; import { Space } from '../../../../../../../../../spaces/common/model/space'; import { FeaturesPrivileges, diff --git a/x-pack/legacy/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_section/space_selector.tsx b/x-pack/legacy/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_section/space_selector.tsx index 9cc9894a0f0513..75211498c57b89 100644 --- a/x-pack/legacy/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_section/space_selector.tsx +++ b/x-pack/legacy/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_section/space_selector.tsx @@ -14,7 +14,7 @@ import { import { InjectedIntl } from '@kbn/i18n/react'; import React, { Component } from 'react'; import { Space } from '../../../../../../../../../spaces/common/model/space'; -import { getSpaceColor } from '../../../../../../../../../spaces/common/space_attributes'; +import { getSpaceColor } from '../../../../../../../../../spaces/public/lib/space_attributes'; const spaceToOption = (space?: Space, currentSelection?: 'global' | 'spaces') => { if (!space) { diff --git a/x-pack/legacy/plugins/spaces/common/constants.ts b/x-pack/legacy/plugins/spaces/common/constants.ts index 50423517bc9184..11882ca2f1b3a8 100644 --- a/x-pack/legacy/plugins/spaces/common/constants.ts +++ b/x-pack/legacy/plugins/spaces/common/constants.ts @@ -21,3 +21,8 @@ export const MAX_SPACE_INITIALS = 2; * @type {string} */ export const KIBANA_SPACES_STATS_TYPE = 'spaces'; + +/** + * The path to enter a space. + */ +export const ENTER_SPACE_PATH = '/spaces/enter'; diff --git a/x-pack/legacy/plugins/spaces/common/index.ts b/x-pack/legacy/plugins/spaces/common/index.ts index 0e605562ea3ea4..a0842201e0f083 100644 --- a/x-pack/legacy/plugins/spaces/common/index.ts +++ b/x-pack/legacy/plugins/spaces/common/index.ts @@ -7,4 +7,4 @@ export { isReservedSpace } from './is_reserved_space'; export { MAX_SPACE_INITIALS } from './constants'; -export { getSpaceInitials, getSpaceColor } from './space_attributes'; +export { getSpaceIdFromPath, addSpaceIdToPath } from './lib/spaces_url_parser'; diff --git a/x-pack/legacy/plugins/spaces/server/lib/__snapshots__/spaces_url_parser.test.ts.snap b/x-pack/legacy/plugins/spaces/common/lib/__snapshots__/spaces_url_parser.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/__snapshots__/spaces_url_parser.test.ts.snap rename to x-pack/legacy/plugins/spaces/common/lib/__snapshots__/spaces_url_parser.test.ts.snap diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_url_parser.test.ts b/x-pack/legacy/plugins/spaces/common/lib/spaces_url_parser.test.ts similarity index 97% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_url_parser.test.ts rename to x-pack/legacy/plugins/spaces/common/lib/spaces_url_parser.test.ts index 5878272c849246..b25d79c0a69076 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_url_parser.test.ts +++ b/x-pack/legacy/plugins/spaces/common/lib/spaces_url_parser.test.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { DEFAULT_SPACE_ID } from '../../common/constants'; +import { DEFAULT_SPACE_ID } from '../constants'; import { addSpaceIdToPath, getSpaceIdFromPath } from './spaces_url_parser'; describe('getSpaceIdFromPath', () => { diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_url_parser.ts b/x-pack/legacy/plugins/spaces/common/lib/spaces_url_parser.ts similarity index 95% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_url_parser.ts rename to x-pack/legacy/plugins/spaces/common/lib/spaces_url_parser.ts index 14113cbf9d8070..994ec7c59cb6e0 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_url_parser.ts +++ b/x-pack/legacy/plugins/spaces/common/lib/spaces_url_parser.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { DEFAULT_SPACE_ID } from '../../common/constants'; +import { DEFAULT_SPACE_ID } from '../constants'; export function getSpaceIdFromPath( requestBasePath: string = '/', diff --git a/x-pack/legacy/plugins/spaces/index.ts b/x-pack/legacy/plugins/spaces/index.ts index 6f9397233d1d0c..a287aa2fcbb3f7 100644 --- a/x-pack/legacy/plugins/spaces/index.ts +++ b/x-pack/legacy/plugins/spaces/index.ts @@ -14,12 +14,11 @@ import { AuditLogger } from '../../server/lib/audit_logger'; import mappings from './mappings.json'; import { wrapError } from './server/lib/errors'; import { getActiveSpace } from './server/lib/get_active_space'; -import { getSpaceSelectorUrl } from './server/lib/get_space_selector_url'; import { migrateToKibana660 } from './server/lib/migrations'; import { plugin } from './server/new_platform'; import { SecurityPlugin } from '../security'; import { SpacesServiceSetup } from './server/new_platform/spaces_service/spaces_service'; -import { initSpaceSelectorView } from './server/routes/views'; +import { initSpaceSelectorView, initEnterSpaceView } from './server/routes/views'; export interface SpacesPlugin { getSpaceId: SpacesServiceSetup['getSpaceId']; @@ -88,7 +87,7 @@ export const spaces = (kibana: Record) => return { spaces: [], activeSpace: null, - spaceSelectorURL: getSpaceSelectorUrl(server.config()), + serverBasePath: server.config().get('server.basePath'), }; }, async replaceInjectedVars( @@ -181,6 +180,7 @@ export const spaces = (kibana: Record) => }, }); + initEnterSpaceView(server); initSpaceSelectorView(server); server.expose('getSpaceId', (request: any) => spacesService.getSpaceId(request)); diff --git a/x-pack/legacy/plugins/spaces/public/components/space_avatar.tsx b/x-pack/legacy/plugins/spaces/public/components/space_avatar.tsx index ee3755b8df5fa7..0211fe7e82643d 100644 --- a/x-pack/legacy/plugins/spaces/public/components/space_avatar.tsx +++ b/x-pack/legacy/plugins/spaces/public/components/space_avatar.tsx @@ -6,9 +6,9 @@ import { EuiAvatar, isValidHex } from '@elastic/eui'; import React, { SFC } from 'react'; -import { getSpaceColor, getSpaceInitials, MAX_SPACE_INITIALS } from '../../common'; +import { MAX_SPACE_INITIALS } from '../../common'; import { Space } from '../../common/model/space'; -import { getSpaceImageUrl } from '../../common/space_attributes'; +import { getSpaceColor, getSpaceInitials, getSpaceImageUrl } from '../lib/space_attributes'; interface Props { space: Partial; diff --git a/x-pack/legacy/plugins/spaces/public/lib/index.ts b/x-pack/legacy/plugins/spaces/public/lib/index.ts index 538dd77e053f57..56ac7b8ff37f4f 100644 --- a/x-pack/legacy/plugins/spaces/public/lib/index.ts +++ b/x-pack/legacy/plugins/spaces/public/lib/index.ts @@ -5,3 +5,4 @@ */ export { SpacesManager } from './spaces_manager'; +export { getSpaceInitials, getSpaceColor, getSpaceImageUrl } from './space_attributes'; diff --git a/x-pack/legacy/plugins/spaces/common/space_attributes.test.ts b/x-pack/legacy/plugins/spaces/public/lib/space_attributes.test.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/common/space_attributes.test.ts rename to x-pack/legacy/plugins/spaces/public/lib/space_attributes.test.ts diff --git a/x-pack/legacy/plugins/spaces/common/space_attributes.ts b/x-pack/legacy/plugins/spaces/public/lib/space_attributes.ts similarity index 94% rename from x-pack/legacy/plugins/spaces/common/space_attributes.ts rename to x-pack/legacy/plugins/spaces/public/lib/space_attributes.ts index f943dcf4af105b..dbb1e8fed2d0b6 100644 --- a/x-pack/legacy/plugins/spaces/common/space_attributes.ts +++ b/x-pack/legacy/plugins/spaces/public/lib/space_attributes.ts @@ -5,8 +5,8 @@ */ import { VISUALIZATION_COLORS } from '@elastic/eui'; -import { MAX_SPACE_INITIALS } from './constants'; -import { Space } from './model/space'; +import { Space } from '../../common/model/space'; +import { MAX_SPACE_INITIALS } from '../../common'; // code point for lowercase "a" const FALLBACK_CODE_POINT = 97; diff --git a/x-pack/legacy/plugins/spaces/public/lib/spaces_manager.ts b/x-pack/legacy/plugins/spaces/public/lib/spaces_manager.ts index d39b751e30a8aa..e40e247e405fb9 100644 --- a/x-pack/legacy/plugins/spaces/public/lib/spaces_manager.ts +++ b/x-pack/legacy/plugins/spaces/public/lib/spaces_manager.ts @@ -3,21 +3,18 @@ * 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'; -import { toastNotifications } from 'ui/notify'; import { EventEmitter } from 'events'; import { kfetch } from 'ui/kfetch'; import { SavedObjectsManagementRecord } from 'ui/management/saved_objects_management'; import { Space } from '../../common/model/space'; import { GetSpacePurpose } from '../../common/model/types'; import { CopySavedObjectsToSpaceResponse } from './copy_saved_objects_to_space/types'; +import { ENTER_SPACE_PATH } from '../../common/constants'; +import { addSpaceIdToPath } from '../../common'; export class SpacesManager extends EventEmitter { - private spaceSelectorURL: string; - - constructor(spaceSelectorURL: string) { + constructor(private readonly serverBasePath: string) { super(); - this.spaceSelectorURL = spaceSelectorURL; } public async getSpaces(purpose?: GetSpacePurpose): Promise { @@ -89,36 +86,14 @@ export class SpacesManager extends EventEmitter { } public async changeSelectedSpace(space: Space) { - await kfetch({ - pathname: `/api/spaces/v1/space/${encodeURIComponent(space.id)}/select`, - method: 'POST', - }) - .then(response => { - if (response.location) { - window.location = response.location; - } else { - this._displayError(); - } - }) - .catch(() => this._displayError()); + window.location.href = addSpaceIdToPath(this.serverBasePath, space.id, ENTER_SPACE_PATH); } public redirectToSpaceSelector() { - window.location.href = this.spaceSelectorURL; + window.location.href = `${this.serverBasePath}/spaces/space_selector`; } public async requestRefresh() { this.emit('request_refresh'); } - - public _displayError() { - toastNotifications.addDanger({ - title: i18n.translate('xpack.spaces.spacesManager.unableToChangeSpaceWarningTitle', { - defaultMessage: 'Unable to change your Space', - }), - text: i18n.translate('xpack.spaces.spacesManager.unableToChangeSpaceWarningDescription', { - defaultMessage: 'please try again later', - }), - }); - } } diff --git a/x-pack/legacy/plugins/spaces/public/views/management/edit_space/customize_space/customize_space_avatar.tsx b/x-pack/legacy/plugins/spaces/public/views/management/edit_space/customize_space/customize_space_avatar.tsx index 2f179083d7b90a..12fa0193b59a4e 100644 --- a/x-pack/legacy/plugins/spaces/public/views/management/edit_space/customize_space/customize_space_avatar.tsx +++ b/x-pack/legacy/plugins/spaces/public/views/management/edit_space/customize_space/customize_space_avatar.tsx @@ -18,11 +18,11 @@ import { } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; +import { getSpaceColor, getSpaceInitials } from '../../../../lib/space_attributes'; import { encode, imageTypes } from '../../../../../common/lib/dataurl'; import { MAX_SPACE_INITIALS } from '../../../../../common/constants'; import { Space } from '../../../../../common/model/space'; -import { getSpaceColor, getSpaceInitials } from '../../../../../common/space_attributes'; interface Props { space: Partial; diff --git a/x-pack/legacy/plugins/spaces/public/views/management/index.tsx b/x-pack/legacy/plugins/spaces/public/views/management/index.tsx index 46a718bbc6f35d..179665ed11111c 100644 --- a/x-pack/legacy/plugins/spaces/public/views/management/index.tsx +++ b/x-pack/legacy/plugins/spaces/public/views/management/index.tsx @@ -24,7 +24,7 @@ const MANAGE_SPACES_KEY = 'spaces'; routes.defaults(/\/management/, { resolve: { - spacesManagementSection(activeSpace: any, spaceSelectorURL: string) { + spacesManagementSection(activeSpace: any, serverBasePath: string) { function getKibanaSection() { return management.getSection('kibana'); } @@ -49,7 +49,7 @@ routes.defaults(/\/management/, { // Customize Saved Objects Management const action = new CopyToSpaceSavedObjectsManagementAction( - new SpacesManager(spaceSelectorURL), + new SpacesManager(serverBasePath), activeSpace.space ); // This route resolve function executes any time the management screen is loaded, and we want to ensure diff --git a/x-pack/legacy/plugins/spaces/public/views/management/page_routes.tsx b/x-pack/legacy/plugins/spaces/public/views/management/page_routes.tsx index d38c5c1998b3a4..66cdb0d276e94b 100644 --- a/x-pack/legacy/plugins/spaces/public/views/management/page_routes.tsx +++ b/x-pack/legacy/plugins/spaces/public/views/management/page_routes.tsx @@ -22,11 +22,11 @@ routes.when('/management/spaces/list', { template, k7Breadcrumbs: getListBreadcrumbs, requireUICapability: 'management.kibana.spaces', - controller($scope: any, spacesNavState: SpacesNavState, spaceSelectorURL: string) { + controller($scope: any, spacesNavState: SpacesNavState, serverBasePath: string) { $scope.$$postDigest(async () => { const domNode = document.getElementById(reactRootNodeId); - const spacesManager = new SpacesManager(spaceSelectorURL); + const spacesManager = new SpacesManager(serverBasePath); render( @@ -49,11 +49,11 @@ routes.when('/management/spaces/create', { template, k7Breadcrumbs: getCreateBreadcrumbs, requireUICapability: 'management.kibana.spaces', - controller($scope: any, spacesNavState: SpacesNavState, spaceSelectorURL: string) { + controller($scope: any, spacesNavState: SpacesNavState, serverBasePath: string) { $scope.$$postDigest(async () => { const domNode = document.getElementById(reactRootNodeId); - const spacesManager = new SpacesManager(spaceSelectorURL); + const spacesManager = new SpacesManager(serverBasePath); render( @@ -85,14 +85,14 @@ routes.when('/management/spaces/edit/:spaceId', { $route: any, chrome: any, spacesNavState: SpacesNavState, - spaceSelectorURL: string + serverBasePath: string ) { $scope.$$postDigest(async () => { const domNode = document.getElementById(reactRootNodeId); const { spaceId } = $route.current.params; - const spacesManager = new SpacesManager(spaceSelectorURL); + const spacesManager = new SpacesManager(serverBasePath); render( diff --git a/x-pack/legacy/plugins/spaces/public/views/nav_control/nav_control.tsx b/x-pack/legacy/plugins/spaces/public/views/nav_control/nav_control.tsx index ad2ae08374708a..bac95bbf22099e 100644 --- a/x-pack/legacy/plugins/spaces/public/views/nav_control/nav_control.tsx +++ b/x-pack/legacy/plugins/spaces/public/views/nav_control/nav_control.tsx @@ -54,9 +54,9 @@ chromeHeaderNavControlsRegistry.register((chrome: any, activeSpace: any) => ({ return; } - const spaceSelectorURL = chrome.getInjected('spaceSelectorURL'); + const serverBasePath = chrome.getInjected('serverBasePath'); - spacesManager = new SpacesManager(spaceSelectorURL); + spacesManager = new SpacesManager(serverBasePath); ReactDOM.render( diff --git a/x-pack/legacy/plugins/spaces/public/views/space_selector/index.tsx b/x-pack/legacy/plugins/spaces/public/views/space_selector/index.tsx index 935e79e73517e3..8c650fa778bdd4 100644 --- a/x-pack/legacy/plugins/spaces/public/views/space_selector/index.tsx +++ b/x-pack/legacy/plugins/spaces/public/views/space_selector/index.tsx @@ -21,10 +21,10 @@ import { SpaceSelector } from './space_selector'; const module = uiModules.get('spaces_selector', []); module.controller( 'spacesSelectorController', - ($scope: any, spaces: Space[], spaceSelectorURL: string) => { + ($scope: any, spaces: Space[], serverBasePath: string) => { const domNode = document.getElementById('spaceSelectorRoot'); - const spacesManager = new SpacesManager(spaceSelectorURL); + const spacesManager = new SpacesManager(serverBasePath); render( diff --git a/x-pack/legacy/plugins/spaces/server/lib/get_active_space.ts b/x-pack/legacy/plugins/spaces/server/lib/get_active_space.ts index 907b7b164b69b3..a77a945239100b 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/get_active_space.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/get_active_space.ts @@ -7,7 +7,7 @@ import { Space } from '../../common/model/space'; import { wrapError } from './errors'; import { SpacesClient } from './spaces_client'; -import { getSpaceIdFromPath } from './spaces_url_parser'; +import { getSpaceIdFromPath } from '../../common'; export async function getActiveSpace( spacesClient: SpacesClient, diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts b/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts index dfd4d586554bba..511af53c13ab46 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts @@ -428,7 +428,7 @@ describe('onPostAuthInterceptor', () => { ); }, 30000); - it('allows the request to continue when accessing the root of a non-default space', async () => { + it('redirects to the "enter space" endpoint when accessing the root of a non-default space', async () => { const spaces = [ { id: 'default', @@ -449,9 +449,8 @@ describe('onPostAuthInterceptor', () => { const { response, spacesService } = await request('/s/a-space', spaces); - // OSS handles this redirection for us expect(response.status).toEqual(302); - expect(response.header.location).toEqual(`/s/a-space${defaultRoute}`); + expect(response.header.location).toEqual(`/s/a-space/spaces/enter`); expect(spacesService.scopedClient).toHaveBeenCalledWith( expect.objectContaining({ @@ -463,7 +462,7 @@ describe('onPostAuthInterceptor', () => { }, 30000); describe('with a single available space', () => { - it('it redirects to the defaultRoute within the context of the single Space when navigating to Kibana root', async () => { + it('it redirects to the "enter space" endpoint within the context of the single Space when navigating to Kibana root', async () => { const spaces = [ { id: 'a-space', @@ -477,7 +476,7 @@ describe('onPostAuthInterceptor', () => { const { response, spacesService } = await request('/', spaces); expect(response.status).toEqual(302); - expect(response.header.location).toEqual(`/s/a-space${defaultRoute}`); + expect(response.header.location).toEqual(`/s/a-space/spaces/enter`); expect(spacesService.scopedClient).toHaveBeenCalledWith( expect.objectContaining({ @@ -488,7 +487,7 @@ describe('onPostAuthInterceptor', () => { ); }); - it('it redirects to the defaultRoute within the context of the Default Space when navigating to Kibana root', async () => { + it('it redirects to the "enter space" endpoint within the context of the Default Space when navigating to Kibana root', async () => { // This is very similar to the test above, but this handles the condition where the only available space is the Default Space, // which does not have a URL Context. In this scenario, the end result is the same as the other test, but the final URL the user // is redirected to does not contain a space identifier (e.g., /s/foo) @@ -506,7 +505,7 @@ describe('onPostAuthInterceptor', () => { const { response, spacesService } = await request('/', spaces); expect(response.status).toEqual(302); - expect(response.header.location).toEqual(defaultRoute); + expect(response.header.location).toEqual('/spaces/enter'); expect(spacesService.scopedClient).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts b/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts index 7cd3114ced2fab..e02677d94a8da4 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts @@ -6,12 +6,12 @@ import { Logger, CoreSetup } from 'src/core/server'; import { Space } from '../../../common/model/space'; import { wrapError } from '../errors'; -import { addSpaceIdToPath } from '../spaces_url_parser'; import { XPackMainPlugin } from '../../../../xpack_main/xpack_main'; import { SpacesServiceSetup } from '../../new_platform/spaces_service/spaces_service'; import { LegacyAPI } from '../../new_platform/plugin'; import { getSpaceSelectorUrl } from '../get_space_selector_url'; -import { DEFAULT_SPACE_ID } from '../../../common/constants'; +import { DEFAULT_SPACE_ID, ENTER_SPACE_PATH } from '../../../common/constants'; +import { addSpaceIdToPath } from '../../../common'; export interface OnPostAuthInterceptorDeps { getLegacyAPI(): LegacyAPI; @@ -28,7 +28,7 @@ export function initSpacesOnPostAuthRequestInterceptor({ log, http, }: OnPostAuthInterceptorDeps) { - const { serverBasePath, serverDefaultRoute } = getLegacyAPI().legacyConfig; + const { serverBasePath } = getLegacyAPI().legacyConfig; http.registerOnPostAuth(async (request, response, toolkit) => { const path = request.url.pathname!; @@ -38,6 +38,7 @@ export function initSpacesOnPostAuthRequestInterceptor({ // The root of kibana is also the root of the defaut space, // since the default space does not have a URL Identifier (i.e., `/s/foo`). const isRequestingKibanaRoot = path === '/' && spaceId === DEFAULT_SPACE_ID; + const isRequestingSpaceRoot = path === '/' && spaceId !== DEFAULT_SPACE_ID; const isRequestingApplication = path.startsWith('/app'); const spacesClient = await spacesService.scopedClient(request); @@ -54,7 +55,7 @@ export function initSpacesOnPostAuthRequestInterceptor({ // No need for an interstitial screen where there is only one possible outcome. const space = spaces[0]; - const destination = addSpaceIdToPath(serverBasePath, space.id, serverDefaultRoute); + const destination = addSpaceIdToPath(serverBasePath, space.id, ENTER_SPACE_PATH); return response.redirected({ headers: { location: destination } }); } @@ -72,6 +73,9 @@ export function initSpacesOnPostAuthRequestInterceptor({ statusCode: wrappedError.output.statusCode, }); } + } else if (isRequestingSpaceRoot) { + const destination = addSpaceIdToPath(serverBasePath, spaceId, ENTER_SPACE_PATH); + return response.redirected({ headers: { location: destination } }); } // This condition should only happen after selecting a space, or when transitioning from one application to another diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts b/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts index 5da9bdbe6543f1..114cc9bf86d467 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts @@ -11,9 +11,9 @@ import { } from 'src/core/server'; import { format } from 'url'; import { DEFAULT_SPACE_ID } from '../../../common/constants'; -import { getSpaceIdFromPath } from '../spaces_url_parser'; import { modifyUrl } from '../utils/url'; import { LegacyAPI } from '../../new_platform/plugin'; +import { getSpaceIdFromPath } from '../../../common'; export interface OnRequestInterceptorDeps { getLegacyAPI(): LegacyAPI; diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts b/x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts index 2bd9edadc52ef0..ed11e6da317fa1 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts +++ b/x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts @@ -20,7 +20,6 @@ import { checkLicense } from '../lib/check_license'; import { spacesSavedObjectsClientWrapperFactory } from '../lib/saved_objects_client/saved_objects_client_wrapper_factory'; import { SpacesAuditLogger } from '../lib/audit_logger'; import { createSpacesTutorialContextFactory } from '../lib/spaces_tutorial_context_factory'; -import { initInternalApis } from '../routes/api/v1'; import { initExternalSpacesApi } from '../routes/api/external'; import { getSpacesUsageCollector } from '../lib/get_spaces_usage_collector'; import { SpacesService } from './spaces_service'; @@ -178,13 +177,6 @@ export class Plugin { }) ); - initInternalApis({ - legacyRouter: legacyAPI.router, - getLegacyAPI: this.getLegacyAPI, - spacesService, - xpackMain: xpackMainPlugin, - }); - initExternalSpacesApi({ legacyRouter: legacyAPI.router, log: this.log, diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.test.ts b/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.test.ts index 3200c90bca2be0..817474dc0fb3a0 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.test.ts +++ b/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.test.ts @@ -13,9 +13,9 @@ import { SavedObjectsErrorHelpers, } from 'src/core/server'; import { DEFAULT_SPACE_ID } from '../../../common/constants'; -import { getSpaceIdFromPath } from '../../lib/spaces_url_parser'; import { createOptionalPlugin } from '../../../../../server/lib/optional_plugin'; import { LegacyAPI } from '../plugin'; +import { getSpaceIdFromPath } from '../../../common'; const mockLogger = { trace: jest.fn(), diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.ts b/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.ts index 623e6c43b16e8c..08ebc2cb317482 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.ts +++ b/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.ts @@ -12,11 +12,11 @@ import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; import { DEFAULT_SPACE_ID } from '../../../common/constants'; import { SecurityPlugin } from '../../../../security'; import { SpacesClient } from '../../lib/spaces_client'; -import { getSpaceIdFromPath, addSpaceIdToPath } from '../../lib/spaces_url_parser'; import { SpacesConfigType } from '../config'; import { namespaceToSpaceId, spaceIdToNamespace } from '../../lib/utils/namespace'; import { LegacyAPI } from '../plugin'; import { Space } from '../../../common/model/space'; +import { getSpaceIdFromPath, addSpaceIdToPath } from '../../../common'; type RequestFacade = KibanaRequest | Legacy.Request; diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts b/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts index 13667555a9468f..405a3dd34e7fc6 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts +++ b/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts @@ -18,7 +18,6 @@ import { createSpaces } from './create_spaces'; import { ExternalRouteDeps } from '../external'; import { SpacesService } from '../../../new_platform/spaces_service'; import { SpacesAuditLogger } from '../../../lib/audit_logger'; -import { InternalRouteDeps } from '../v1'; import { LegacyAPI } from '../../../new_platform/plugin'; interface KibanaServer extends Legacy.Server { @@ -79,9 +78,7 @@ async function readStreamToCompletion(stream: Readable) { return (createPromiseFromStreams([stream, createConcatStream([])]) as unknown) as any[]; } -export function createTestHandler( - initApiFn: (deps: ExternalRouteDeps & InternalRouteDeps) => void -) { +export function createTestHandler(initApiFn: (deps: ExternalRouteDeps) => void) { const teardowns: TeardownFn[] = []; const spaces = createSpaces(); @@ -254,7 +251,6 @@ export function createTestHandler( }); initApiFn({ - getLegacyAPI: () => legacyAPI, routePreCheckLicenseFn: pre, savedObjects: server.savedObjects, spacesService, diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/v1/index.ts b/x-pack/legacy/plugins/spaces/server/routes/api/v1/index.ts deleted file mode 100644 index ddbca3e8e3d71d..00000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/v1/index.ts +++ /dev/null @@ -1,36 +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 { Legacy } from 'kibana'; -import { XPackMainPlugin } from '../../../../../xpack_main/xpack_main'; -import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; -import { initInternalSpacesApi } from './spaces'; -import { SpacesServiceSetup } from '../../../new_platform/spaces_service/spaces_service'; -import { LegacyAPI } from '../../../new_platform/plugin'; - -type Omit = Pick>; - -interface RouteDeps { - xpackMain: XPackMainPlugin; - spacesService: SpacesServiceSetup; - getLegacyAPI(): LegacyAPI; - legacyRouter: Legacy.Server['route']; -} - -export interface InternalRouteDeps extends Omit { - routePreCheckLicenseFn: any; -} - -export function initInternalApis({ xpackMain, ...rest }: RouteDeps) { - const routePreCheckLicenseFn = routePreCheckLicense({ xpackMain }); - - const deps: InternalRouteDeps = { - ...rest, - routePreCheckLicenseFn, - }; - - initInternalSpacesApi(deps); -} diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.test.ts deleted file mode 100644 index 4d9952f4ab3dcc..00000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.test.ts +++ /dev/null @@ -1,93 +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. - */ - -jest.mock('../../../lib/route_pre_check_license', () => { - return { - routePreCheckLicense: () => (request: any, h: any) => h.continue, - }; -}); - -jest.mock('../../../../../../server/lib/get_client_shield', () => { - return { - getClient: () => { - return { - callWithInternalUser: jest.fn(() => { - return; - }), - }; - }, - }; -}); - -import Boom from 'boom'; -import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; -import { initInternalSpacesApi } from './spaces'; - -describe('Spaces API', () => { - let request: RequestRunner; - let teardowns: TeardownFn[]; - - beforeEach(() => { - const setup = createTestHandler(initInternalSpacesApi); - - request = setup.request; - teardowns = setup.teardowns; - }); - - afterEach(async () => { - await Promise.all(teardowns.splice(0).map(fn => fn())); - }); - - test('POST space/{id}/select should respond with the new space location', async () => { - const { response } = await request('POST', '/api/spaces/v1/space/a-space/select'); - - const { statusCode, payload } = response; - - expect(statusCode).toEqual(200); - - const result = JSON.parse(payload); - expect(result.location).toEqual('/s/a-space'); - }); - - test(`returns result of routePreCheckLicense`, async () => { - const { response } = await request('POST', '/api/spaces/v1/space/a-space/select', { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - }); - - const { statusCode, payload } = response; - - expect(statusCode).toEqual(403); - expect(JSON.parse(payload)).toMatchObject({ - message: 'test forbidden message', - }); - }); - - test('POST space/{id}/select should respond with 404 when the space is not found', async () => { - const { response } = await request('POST', '/api/spaces/v1/space/not-a-space/select'); - - const { statusCode } = response; - - expect(statusCode).toEqual(404); - }); - - test('POST space/{id}/select should respond with the new space location when a server.basePath is in use', async () => { - const testConfig = { - 'server.basePath': '/my/base/path', - }; - - const { response } = await request('POST', '/api/spaces/v1/space/a-space/select', { - testConfig, - }); - - const { statusCode, payload } = response; - - expect(statusCode).toEqual(200); - - const result = JSON.parse(payload); - expect(result.location).toEqual('/my/base/path/s/a-space'); - }); -}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.ts b/x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.ts deleted file mode 100644 index 3d15044d129e93..00000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.ts +++ /dev/null @@ -1,51 +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 Boom from 'boom'; -import { Space } from '../../../../common/model/space'; -import { wrapError } from '../../../lib/errors'; -import { SpacesClient } from '../../../lib/spaces_client'; -import { addSpaceIdToPath } from '../../../lib/spaces_url_parser'; -import { getSpaceById } from '../../lib'; -import { InternalRouteDeps } from '.'; - -export function initInternalSpacesApi(deps: InternalRouteDeps) { - const { legacyRouter, spacesService, getLegacyAPI, routePreCheckLicenseFn } = deps; - - legacyRouter({ - method: 'POST', - path: '/api/spaces/v1/space/{id}/select', - async handler(request: any) { - const { savedObjects, legacyConfig } = getLegacyAPI(); - - const { SavedObjectsClient } = savedObjects; - const spacesClient: SpacesClient = await spacesService.scopedClient(request); - const id = request.params.id; - - const basePath = legacyConfig.serverBasePath; - const defaultRoute = legacyConfig.serverDefaultRoute; - try { - const existingSpace: Space | null = await getSpaceById( - spacesClient, - id, - SavedObjectsClient.errors - ); - if (!existingSpace) { - return Boom.notFound(); - } - - return { - location: addSpaceIdToPath(basePath, existingSpace.id, defaultRoute), - }; - } catch (error) { - return wrapError(error); - } - }, - options: { - pre: [routePreCheckLicenseFn], - }, - }); -} diff --git a/x-pack/legacy/plugins/spaces/server/routes/views/enter_space.ts b/x-pack/legacy/plugins/spaces/server/routes/views/enter_space.ts new file mode 100644 index 00000000000000..e560d4278b4079 --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/routes/views/enter_space.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 { Legacy } from 'kibana'; +import { ENTER_SPACE_PATH } from '../../../common/constants'; +import { wrapError } from '../../lib/errors'; + +export function initEnterSpaceView(server: Legacy.Server) { + server.route({ + method: 'GET', + path: ENTER_SPACE_PATH, + async handler(request, h) { + try { + return h.redirect(await request.getDefaultRoute()); + } catch (e) { + server.log(['spaces', 'error'], `Error navigating to space: ${e}`); + return wrapError(e); + } + }, + }); +} diff --git a/x-pack/legacy/plugins/spaces/server/routes/views/index.ts b/x-pack/legacy/plugins/spaces/server/routes/views/index.ts index a0f72886940a4a..d7637e299652fe 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/views/index.ts +++ b/x-pack/legacy/plugins/spaces/server/routes/views/index.ts @@ -5,3 +5,4 @@ */ export { initSpaceSelectorView } from './space_selector'; +export { initEnterSpaceView } from './enter_space'; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a821a66076bae9..6887dab9425cee 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11074,8 +11074,6 @@ "xpack.spaces.spaceSelector.findSpacePlaceholder": "スペースを検索", "xpack.spaces.spaceSelector.noSpacesMatchSearchCriteriaDescription": "検索条件に一致するスペースがありません", "xpack.spaces.spaceSelector.selectSpacesTitle": "スペースの選択", - "xpack.spaces.spacesManager.unableToChangeSpaceWarningDescription": "後程再試行してください", - "xpack.spaces.spacesManager.unableToChangeSpaceWarningTitle": "スペースを変更できません", "xpack.spaces.spacesTitle": "スペース", "xpack.spaces.management.copyToSpace.actionDescription": "この保存されたオブジェクトを 1 つまたは複数のスペースにコピーします。", "xpack.spaces.management.copyToSpace.actionTitle": "スペースにコピー", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 1f856731d43e19..649ead90b63561 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11076,8 +11076,6 @@ "xpack.spaces.spaceSelector.findSpacePlaceholder": "查找工作区", "xpack.spaces.spaceSelector.noSpacesMatchSearchCriteriaDescription": "没有匹配搜索条件的空间", "xpack.spaces.spaceSelector.selectSpacesTitle": "选择您的空间", - "xpack.spaces.spacesManager.unableToChangeSpaceWarningDescription": "请稍后重试", - "xpack.spaces.spacesManager.unableToChangeSpaceWarningTitle": "无法更改空间", "xpack.spaces.spacesTitle": "工作区", "xpack.spaces.management.copyToSpace.actionDescription": "将此已保存对象复制到一个或多个工作区", "xpack.spaces.management.copyToSpace.actionTitle": "复制到工作区", diff --git a/x-pack/test/functional/apps/spaces/enter_space.ts b/x-pack/test/functional/apps/spaces/enter_space.ts new file mode 100644 index 00000000000000..017d252b166cc4 --- /dev/null +++ b/x-pack/test/functional/apps/spaces/enter_space.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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function enterSpaceFunctonalTests({ + getService, + getPageObjects, +}: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const PageObjects = getPageObjects(['security', 'spaceSelector']); + + describe('Enter Space', function() { + this.tags('smoke'); + before(async () => await esArchiver.load('spaces/enter_space')); + after(async () => await esArchiver.unload('spaces/enter_space')); + + afterEach(async () => { + await PageObjects.security.logout(); + }); + + it('allows user to navigate to different spaces, respecting the configured default route', async () => { + const spaceId = 'another-space'; + + await PageObjects.security.login(null, null, { + expectSpaceSelector: true, + }); + + await PageObjects.spaceSelector.clickSpaceCard(spaceId); + + await PageObjects.spaceSelector.expectRoute(spaceId, '/app/kibana/#/dashboard'); + + await PageObjects.spaceSelector.openSpacesNav(); + + // change spaces + + await PageObjects.spaceSelector.clickSpaceAvatar('default'); + + await PageObjects.spaceSelector.expectRoute('default', '/app/canvas'); + }); + + it('falls back to the default home page when the configured default route is malformed', async () => { + await kibanaServer.uiSettings.replace({ defaultRoute: 'http://example.com/evil' }); + + // This test only works with the default space, as other spaces have an enforced relative url of `${serverBasePath}/s/space-id/${defaultRoute}` + const spaceId = 'default'; + + await PageObjects.security.login(null, null, { + expectSpaceSelector: true, + }); + + await PageObjects.spaceSelector.clickSpaceCard(spaceId); + + await PageObjects.spaceSelector.expectHomePage(spaceId); + }); + }); +} diff --git a/x-pack/test/functional/apps/spaces/index.ts b/x-pack/test/functional/apps/spaces/index.ts index 7cc704a41becc2..7a876952fad83a 100644 --- a/x-pack/test/functional/apps/spaces/index.ts +++ b/x-pack/test/functional/apps/spaces/index.ts @@ -12,5 +12,6 @@ export default function spacesApp({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./copy_saved_objects')); loadTestFile(require.resolve('./feature_controls/spaces_security')); loadTestFile(require.resolve('./spaces_selection')); + loadTestFile(require.resolve('./enter_space')); }); } diff --git a/x-pack/test/functional/es_archives/spaces/enter_space/data.json b/x-pack/test/functional/es_archives/spaces/enter_space/data.json new file mode 100644 index 00000000000000..462a2a1ee38fe1 --- /dev/null +++ b/x-pack/test/functional/es_archives/spaces/enter_space/data.json @@ -0,0 +1,83 @@ +{ + "type": "doc", + "value": { + "id": "config:6.0.0", + "index": ".kibana", + "source": { + "config": { + "buildNum": 8467, + "dateFormat:tz": "UTC", + "defaultRoute": "/app/canvas" + }, + "type": "config" + } + } +} + +{ + "type": "doc", + "value": { + "id": "another-space:config:6.0.0", + "index": ".kibana", + "source": { + "namespace": "another-space", + "config": { + "buildNum": 8467, + "dateFormat:tz": "UTC", + "defaultRoute": "/app/kibana/#dashboard" + }, + "type": "config" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "another-space:index-pattern:logstash-*", + "source": { + "index-pattern": { + "title": "logstash-*", + "timeFieldName": "@timestamp", + "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"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\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]" + }, + "type": "index-pattern", + "namespace": "another-space", + "migrationVersion": { + "index-pattern": "6.5.0" + }, + "updated_at": "2018-12-21T00:43:07.096Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana", + "source": { + "space": { + "description": "This is the default space!", + "name": "Default" + }, + "type": "space" + } + } +} + +{ + "type": "doc", + "value": { + "id": "space:another-space", + "index": ".kibana", + "source": { + "space": { + "description": "This is another space", + "name": "Another Space" + }, + "type": "space" + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/es_archives/spaces/enter_space/mappings.json b/x-pack/test/functional/es_archives/spaces/enter_space/mappings.json new file mode 100644 index 00000000000000..f3793c7ca6780b --- /dev/null +++ b/x-pack/test/functional/es_archives/spaces/enter_space/mappings.json @@ -0,0 +1,287 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "mappings": { + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "defaultRoute": { + "type": "keyword" + } + } + }, + "dashboard": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "dynamic": "strict", + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "search": { + "dynamic": "strict", + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "dynamic": "strict", + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "spaceId": { + "type": "keyword" + }, + "timelion-sheet": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "url": { + "dynamic": "strict", + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "visualization": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/page_objects/space_selector_page.js b/x-pack/test/functional/page_objects/space_selector_page.js index 3be1ae174ce468..ad0f48bdd50bff 100644 --- a/x-pack/test/functional/page_objects/space_selector_page.js +++ b/x-pack/test/functional/page_objects/space_selector_page.js @@ -28,14 +28,18 @@ export function SpaceSelectorPageProvider({ getService, getPageObjects }) { } async expectHomePage(spaceId) { + return await this.expectRoute(spaceId, `/app/kibana#/home`); + } + + async expectRoute(spaceId, route) { return await retry.try(async () => { - log.debug(`expectHomePage(${spaceId})`); + log.debug(`expectRoute(${spaceId}, ${route})`); await find.byCssSelector('[data-test-subj="kibanaChrome"] nav:not(.ng-hide) ', 20000); const url = await browser.getCurrentUrl(); if (spaceId === 'default') { - expect(url).to.contain(`/app/kibana#/home`); + expect(url).to.contain(route); } else { - expect(url).to.contain(`/s/${spaceId}/app/kibana#/home`); + expect(url).to.contain(`/s/${spaceId}${route}`); } }); } diff --git a/x-pack/test/spaces_api_integration/common/suites/select.ts b/x-pack/test/spaces_api_integration/common/suites/select.ts deleted file mode 100644 index 07471fe4e324f0..00000000000000 --- a/x-pack/test/spaces_api_integration/common/suites/select.ts +++ /dev/null @@ -1,125 +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 expect from '@kbn/expect'; -import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../legacy/plugins/spaces/common/constants'; -import { getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface SelectTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; -} - -interface SelectTests { - default: SelectTest; -} - -interface SelectTestDefinition { - user?: TestDefinitionAuthentication; - currentSpaceId: string; - selectSpaceId: string; - tests: SelectTests; -} - -const nonExistantSpaceId = 'not-a-space'; - -export function selectTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectEmptyResult = () => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql(''); - }; - - const createExpectNotFoundResult = () => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - error: 'Not Found', - message: 'Not Found', - statusCode: 404, - }); - }; - - const createExpectRbacForbidden = (spaceId: any) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unauthorized to get ${spaceId} space`, - }); - }; - - const createExpectResults = (spaceId: string) => (resp: { [key: string]: any }) => { - const allSpaces = [ - { - id: 'default', - name: 'Default Space', - description: 'This is the default space', - disabledFeatures: [], - _reserved: true, - }, - { - id: 'space_1', - name: 'Space 1', - description: 'This is the first test space', - disabledFeatures: [], - }, - { - id: 'space_2', - name: 'Space 2', - description: 'This is the second test space', - disabledFeatures: [], - }, - ]; - expect(resp.body).to.eql(allSpaces.find(space => space.id === spaceId)); - }; - - const createExpectSpaceResponse = (spaceId: string) => (resp: { [key: string]: any }) => { - if (spaceId === DEFAULT_SPACE_ID) { - expectDefaultSpaceResponse(resp); - } else { - expect(resp.body).to.eql({ - location: `/s/${spaceId}/app/kibana`, - }); - } - }; - - const expectDefaultSpaceResponse = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - location: `/app/kibana`, - }); - }; - - const makeSelectTest = (describeFn: DescribeFn) => ( - description: string, - { user = {}, currentSpaceId, selectSpaceId, tests }: SelectTestDefinition - ) => { - describeFn(description, () => { - before(() => esArchiver.load('saved_objects/spaces')); - after(() => esArchiver.unload('saved_objects/spaces')); - - it(`should return ${tests.default.statusCode}`, async () => { - return supertest - .post(`${getUrlPrefix(currentSpaceId)}/api/spaces/v1/space/${selectSpaceId}/select`) - .auth(user.username, user.password) - .expect(tests.default.statusCode) - .then(tests.default.response); - }); - }); - }; - - const selectTest = makeSelectTest(describe); - // @ts-ignore - selectTest.only = makeSelectTest(describe.only); - - return { - createExpectEmptyResult, - createExpectNotFoundResult, - createExpectRbacForbidden, - createExpectResults, - createExpectSpaceResponse, - expectDefaultSpaceResponse, - nonExistantSpaceId, - selectTest, - }; -} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts index 4493a5332b62c1..300949f41f0369 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts @@ -25,7 +25,6 @@ export default function({ loadTestFile, getService }: TestInvoker) { loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./get_all')); loadTestFile(require.resolve('./get')); - loadTestFile(require.resolve('./select')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/select.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/select.ts deleted file mode 100644 index a905fe623a7c1d..00000000000000 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/select.ts +++ /dev/null @@ -1,341 +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 { AUTHENTICATION } from '../../common/lib/authentication'; -import { SPACES } from '../../common/lib/spaces'; -import { TestInvoker } from '../../common/lib/types'; -import { selectTestSuiteFactory } from '../../common/suites/select'; - -// eslint-disable-next-line import/no-default-export -export default function selectSpaceTestSuite({ getService }: TestInvoker) { - const supertestWithoutAuth = getService('supertestWithoutAuth'); - const esArchiver = getService('esArchiver'); - - const { - selectTest, - nonExistantSpaceId, - createExpectSpaceResponse, - createExpectRbacForbidden, - createExpectNotFoundResult, - } = selectTestSuiteFactory(esArchiver, supertestWithoutAuth); - - describe('select', () => { - // Tests with users that have privileges globally in Kibana - [ - { - currentSpaceId: SPACES.DEFAULT.spaceId, - selectSpaceId: SPACES.SPACE_1.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - }, - }, - { - currentSpaceId: SPACES.SPACE_1.spaceId, - selectSpaceId: SPACES.DEFAULT.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - }, - }, - ].forEach(scenario => { - selectTest( - `user with no access selects ${scenario.selectSpaceId} space from the ${scenario.currentSpaceId} space`, - { - currentSpaceId: scenario.currentSpaceId, - selectSpaceId: scenario.selectSpaceId, - user: scenario.users.noAccess, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(scenario.selectSpaceId), - }, - }, - } - ); - - selectTest( - `superuser selects ${scenario.selectSpaceId} space from the ${scenario.currentSpaceId} space`, - { - currentSpaceId: scenario.currentSpaceId, - selectSpaceId: scenario.selectSpaceId, - user: scenario.users.superuser, - tests: { - default: { - statusCode: 200, - response: createExpectSpaceResponse(scenario.selectSpaceId), - }, - }, - } - ); - - selectTest( - `rbac user with all globally selects ${scenario.selectSpaceId} space from the ${scenario.currentSpaceId} space`, - { - currentSpaceId: scenario.currentSpaceId, - selectSpaceId: scenario.selectSpaceId, - user: scenario.users.allGlobally, - tests: { - default: { - statusCode: 200, - response: createExpectSpaceResponse(scenario.selectSpaceId), - }, - }, - } - ); - - selectTest( - `dual-privileges user selects ${scenario.selectSpaceId} space from the ${scenario.currentSpaceId}`, - { - currentSpaceId: scenario.currentSpaceId, - selectSpaceId: scenario.selectSpaceId, - user: scenario.users.dualAll, - tests: { - default: { - statusCode: 200, - response: createExpectSpaceResponse(scenario.selectSpaceId), - }, - }, - } - ); - - selectTest( - `legacy user selects ${scenario.selectSpaceId} space from the ${scenario.currentSpaceId}`, - { - currentSpaceId: scenario.currentSpaceId, - selectSpaceId: scenario.selectSpaceId, - user: scenario.users.legacyAll, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(scenario.selectSpaceId), - }, - }, - } - ); - - selectTest( - `user with read globally selects ${scenario.selectSpaceId} space from the - ${scenario.currentSpaceId} space`, - { - currentSpaceId: scenario.currentSpaceId, - selectSpaceId: scenario.selectSpaceId, - user: scenario.users.readGlobally, - tests: { - default: { - statusCode: 200, - response: createExpectSpaceResponse(scenario.selectSpaceId), - }, - }, - } - ); - - selectTest( - `dual-privileges readonly user selects ${scenario.selectSpaceId} space from - the ${scenario.currentSpaceId}`, - { - currentSpaceId: scenario.currentSpaceId, - selectSpaceId: scenario.selectSpaceId, - user: scenario.users.dualRead, - tests: { - default: { - statusCode: 200, - response: createExpectSpaceResponse(scenario.selectSpaceId), - }, - }, - } - ); - }); - - // Select the same space that you're currently in with users which have space specific privileges. - // Our intent is to ensure that you have privileges at the space that you're selecting. - [ - { - spaceId: SPACES.DEFAULT.spaceId, - users: { - allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - users: { - allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - ].forEach(scenario => { - selectTest( - `rbac user with all at space can select ${scenario.spaceId} - from the same space`, - { - currentSpaceId: scenario.spaceId, - selectSpaceId: scenario.spaceId, - user: scenario.users.allAtSpace, - tests: { - default: { - statusCode: 200, - response: createExpectSpaceResponse(scenario.spaceId), - }, - }, - } - ); - - selectTest( - `rbac user with read at space can select ${scenario.spaceId} - from the same space`, - { - currentSpaceId: scenario.spaceId, - selectSpaceId: scenario.spaceId, - user: scenario.users.readAtSpace, - tests: { - default: { - statusCode: 200, - response: createExpectSpaceResponse(scenario.spaceId), - }, - }, - } - ); - - selectTest( - `rbac user with all at other space cannot select ${scenario.spaceId} - from the same space`, - { - currentSpaceId: scenario.spaceId, - selectSpaceId: scenario.spaceId, - user: scenario.users.allAtOtherSpace, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(scenario.spaceId), - }, - }, - } - ); - }); - - // Select a different space with users that only have privileges at certain spaces. Our intent - // is to ensure that a user can select a space based on their privileges at the space that they're selecting - // not at the space that they're currently in. - [ - { - currentSpaceId: SPACES.SPACE_2.spaceId, - selectSpaceId: SPACES.SPACE_1.spaceId, - users: { - userWithAllAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - userWithAllAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_2_ALL_USER, - userWithAllAtBothSpaces: AUTHENTICATION.KIBANA_RBAC_SPACE_1_2_ALL_USER, - }, - }, - ].forEach(scenario => { - selectTest( - `rbac user with all at ${scenario.selectSpaceId} can select ${scenario.selectSpaceId} - from ${scenario.currentSpaceId}`, - { - currentSpaceId: scenario.currentSpaceId, - selectSpaceId: scenario.selectSpaceId, - user: scenario.users.userWithAllAtSpace, - tests: { - default: { - statusCode: 200, - response: createExpectSpaceResponse(scenario.selectSpaceId), - }, - }, - } - ); - - selectTest( - `rbac user with all at both spaces can select ${scenario.selectSpaceId} - from ${scenario.currentSpaceId}`, - { - currentSpaceId: scenario.currentSpaceId, - selectSpaceId: scenario.selectSpaceId, - user: scenario.users.userWithAllAtBothSpaces, - tests: { - default: { - statusCode: 200, - response: createExpectSpaceResponse(scenario.selectSpaceId), - }, - }, - } - ); - - selectTest( - `rbac user with all at ${scenario.currentSpaceId} space cannot select ${scenario.selectSpaceId} - from ${scenario.currentSpaceId}`, - { - currentSpaceId: scenario.currentSpaceId, - selectSpaceId: scenario.selectSpaceId, - user: scenario.users.userWithAllAtOtherSpace, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(scenario.selectSpaceId), - }, - }, - } - ); - }); - - // Select non-existent spaces and ensure we get a 404 or a 403 - describe('non-existent space', () => { - [ - { - currentSpaceId: SPACES.DEFAULT.spaceId, - selectSpaceId: nonExistantSpaceId, - users: { - userWithAllGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - userWithAllAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - { - currentSpaceId: SPACES.SPACE_1.spaceId, - selectSpaceId: nonExistantSpaceId, - users: { - userWithAllGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - userWithAllAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - ].forEach(scenario => { - selectTest(`rbac user with all globally cannot access non-existent space`, { - currentSpaceId: scenario.currentSpaceId, - selectSpaceId: scenario.selectSpaceId, - user: scenario.users.userWithAllGlobally, - tests: { - default: { - statusCode: 404, - response: createExpectNotFoundResult(), - }, - }, - }); - - selectTest(`rbac user with all at space cannot access non-existent space`, { - currentSpaceId: scenario.currentSpaceId, - selectSpaceId: scenario.selectSpaceId, - user: scenario.users.userWithAllAtSpace, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(scenario.selectSpaceId), - }, - }, - }); - }); - }); - }); -} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts index 764d1cfae22b61..1182f6bdabcff4 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts @@ -17,7 +17,6 @@ export default function spacesOnlyTestSuite({ loadTestFile }: TestInvoker) { loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./get_all')); loadTestFile(require.resolve('./get')); - loadTestFile(require.resolve('./select')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/select.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/select.ts deleted file mode 100644 index 82a60f7d455558..00000000000000 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/select.ts +++ /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 { SPACES } from '../../common/lib/spaces'; -import { TestInvoker } from '../../common/lib/types'; -import { selectTestSuiteFactory } from '../../common/suites/select'; - -// eslint-disable-next-line import/no-default-export -export default function selectSpaceTestSuite({ getService }: TestInvoker) { - const supertestWithoutAuth = getService('supertestWithoutAuth'); - const esArchiver = getService('esArchiver'); - - const { - selectTest, - createExpectSpaceResponse, - createExpectNotFoundResult, - nonExistantSpaceId, - } = selectTestSuiteFactory(esArchiver, supertestWithoutAuth); - - describe('select', () => { - [ - { - spaceId: SPACES.DEFAULT.spaceId, - otherSpaceId: SPACES.SPACE_1.spaceId, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - otherSpaceId: SPACES.DEFAULT.spaceId, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - otherSpaceId: SPACES.SPACE_2.spaceId, - }, - ].forEach(scenario => { - selectTest(`can select ${scenario.otherSpaceId} from ${scenario.spaceId}`, { - currentSpaceId: scenario.spaceId, - selectSpaceId: scenario.otherSpaceId, - tests: { - default: { - statusCode: 200, - response: createExpectSpaceResponse(scenario.otherSpaceId), - }, - }, - }); - }); - - describe('non-existant space', () => { - [ - { - spaceId: SPACES.DEFAULT.spaceId, - otherSpaceId: nonExistantSpaceId, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - otherSpaceId: nonExistantSpaceId, - }, - ].forEach(scenario => { - selectTest(`cannot select non-existant space from ${scenario.spaceId}`, { - currentSpaceId: scenario.spaceId, - selectSpaceId: scenario.otherSpaceId, - tests: { - default: { - statusCode: 404, - response: createExpectNotFoundResult(), - }, - }, - }); - }); - }); - }); -} From d243697e81778d42357602064eb47a36fe87abd5 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Wed, 2 Oct 2019 18:30:27 +0200 Subject: [PATCH 32/59] [Console] Fix Safari layout issue (#47100) * Remove 100% height, migrate console root to display flex * Remove unused import * Removed vendor prefixes in CSS --- .../public/application/containers/main/main.tsx | 10 ++++------ .../core_plugins/console/public/quarantined/_app.scss | 3 ++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/legacy/core_plugins/console/np_ready/public/application/containers/main/main.tsx b/src/legacy/core_plugins/console/np_ready/public/application/containers/main/main.tsx index d7b369cc264816..82256cf7398820 100644 --- a/src/legacy/core_plugins/console/np_ready/public/application/containers/main/main.tsx +++ b/src/legacy/core_plugins/console/np_ready/public/application/containers/main/main.tsx @@ -17,7 +17,7 @@ * under the License. */ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { debounce } from 'lodash'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; @@ -52,8 +52,6 @@ export function Main() { const [showSettings, setShowSettings] = useState(false); const [showHelp, setShowHelp] = useState(false); - const containerRef = useRef(null); - const [firstPanelWidth, secondPanelWidth] = storage.get(StorageKeys.WIDTH, [ INITIAL_PANEL_WIDTH, INITIAL_PANEL_WIDTH, @@ -71,9 +69,9 @@ export function Main() { }; return ( -
+ <> setShowSettings(false)} /> : null} {showHelp ? setShowHelp(false)} /> : null} -
+ ); } diff --git a/src/legacy/core_plugins/console/public/quarantined/_app.scss b/src/legacy/core_plugins/console/public/quarantined/_app.scss index 5fd2cd080d06d8..6ec94c8fb4c96f 100644 --- a/src/legacy/core_plugins/console/public/quarantined/_app.scss +++ b/src/legacy/core_plugins/console/public/quarantined/_app.scss @@ -1,6 +1,7 @@ // TODO: Move all of the styles here (should be modularised by, e.g., CSS-in-JS or CSS modules). #consoleRoot { - height: 100%; + display: flex; + flex: 1 1 auto; // Make sure the editor actions don't create scrollbars on this container // SASSTODO: Uncomment when tooltips are EUI-ified (inside portals) overflow: hidden; From d935b3da085485aa380b70c7e02caea626f4bef8 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Wed, 2 Oct 2019 12:31:26 -0400 Subject: [PATCH 33/59] [Monitoring] Metricbeat Migration Wizard (last step!!) (#45799) * Enable setup mode UI toggles * We want to keep the no data page but update the copy * More updated copy * Remove manual checks for logstash, beats, apm and kibana * Hide the setup mode controls on the no data page. There is nothing different in setup mode * Setup mode test * Fix bug with disabling internal collection for ES * First steps towards the redesign of setup mode * Consolidate UI code, design changes, use constants defined in our plugin * Fix tooltips * Design/copy feedback * Use badge and onClick * More feedback * Only detect usage on the live cluster * Fix existing tests, remove test that will be added in other PR * Fix failing test * Fix issue with wrong callout showing * Ensure we check for live nodes if no cluster uuid is provided * We need a custom listing callout for ES * Custom callout for kibana instances * More space from the bottom bar * Disable switching if they enabled internal collection * Copy updates * Fix broken tests * Fix more tests * Fix i18n * Update copy * Fix a couple i18n issues * Fixing a couple of missing scenarios * Fix translations * Update snapshots * PR feedback * PR feedback * We also need totalUniqueInternallyCollectedCount to identify when we have detected products but they are not monitored (thinking ES and Kibana) * Remove why documentation link until we have the resource available * Ensure tabs are properly disabled * Address issue with the ES nodes callout not working at times * Ensure we check if setup mode is enabled * Change internal collection to self monitoring, and remove the word 'collection' usage * Only show Enter setup mode on pages with valid setup mode options * Copy updates * Copy updates * Ensure we update the top nav item when we toggle setup mode on or off --- .../plugins/monitoring/common/constants.js | 7 +- .../components/apm/instances/instances.js | 205 ++++++++++-------- .../components/beats/listing/listing.js | 94 ++++---- .../components/cluster/overview/apm_panel.js | 61 ++---- .../cluster/overview/beats_panel.js | 60 ++--- .../cluster/overview/elasticsearch_panel.js | 66 ++---- .../cluster/overview/kibana_panel.js | 59 ++--- .../cluster/overview/logstash_panel.js | 63 ++---- .../elasticsearch/cluster_status/index.js | 4 +- .../index_detail_status/index.js | 4 +- .../components/elasticsearch/nodes/nodes.js | 167 ++++++++------ .../components/kibana/instances/instances.js | 126 +++++++---- .../__snapshots__/listing.test.js.snap | 8 +- .../components/logstash/listing/listing.js | 101 +++++---- .../metricbeat_migration/flyout/flyout.js | 80 +++---- .../apm/common_apm_instructions.js | 10 - ...isable_internal_collection_instructions.js | 142 +----------- .../apm/enable_metricbeat_instructions.js | 87 +------- .../beats/common_beats_instructions.js | 5 - ...isable_internal_collection_instructions.js | 143 +----------- .../beats/enable_metricbeat_instructions.js | 87 +------- .../instruction_steps/common_instructions.js | 176 +++++++++++++++ .../common_elasticsearch_instructions.js | 14 -- ...isable_internal_collection_instructions.js | 85 +------- .../enable_metricbeat_instructions.js | 103 ++------- .../get_instruction_steps.js | 15 +- .../kibana/common_kibana_instructions.js | 14 -- ...isable_internal_collection_instructions.js | 150 +------------ .../kibana/enable_metricbeat_instructions.js | 87 +------- .../logstash/common_logstash_instructions.js | 10 - ...isable_internal_collection_instructions.js | 142 +----------- .../enable_metricbeat_instructions.js | 87 +------- .../__snapshots__/no_data.test.js.snap | 150 +++++++++---- .../public/components/no_data/no_data.js | 99 ++++++++- .../public/components/renderers/setup_mode.js | 68 +++++- .../public/components/setup_mode/badge.js | 124 +++++++++++ .../components/setup_mode/formatting.js | 56 +++++ .../components/setup_mode/listing_callout.js | 174 +++++++++++++++ .../public/components/setup_mode/tooltip.js | 141 ++++++++++++ .../public/components/table/eui_table.js | 158 +------------- .../public/directives/main/index.js | 12 +- .../monitoring/public/lib/route_init.js | 6 +- .../monitoring/public/lib/setup_mode.js | 108 +++++---- .../public/services/breadcrumbs_provider.js | 11 +- .../public/views/apm/instances/index.js | 7 +- .../public/views/beats/listing/index.js | 6 +- .../public/views/cluster/overview/index.js | 3 +- .../public/views/elasticsearch/nodes/index.js | 7 +- .../public/views/kibana/instances/index.js | 6 +- .../monitoring/public/views/loading/index.js | 20 +- .../public/views/logstash/nodes/index.js | 6 +- .../public/views/no_data/controller.js | 5 +- .../__test__/get_collection_status.js | 25 ++- .../setup/collection/get_collection_status.js | 108 +++++---- .../translations/translations/ja-JP.json | 112 ---------- .../translations/translations/zh-CN.json | 112 ---------- .../setup/collection/fixtures/detect_apm.json | 5 + .../collection/fixtures/detect_beats.json | 5 + .../fixtures/detect_beats_management.json | 5 + .../collection/fixtures/detect_logstash.json | 5 + .../fixtures/detect_logstash_management.json | 5 + .../fixtures/es_and_kibana_exclusive_mb.json | 5 + .../collection/fixtures/es_and_kibana_mb.json | 5 + .../fixtures/kibana_exclusive_mb.json | 5 + .../setup/collection/fixtures/kibana_mb.json | 5 + .../monitoring/elasticsearch/index_detail.js | 12 +- .../apps/monitoring/elasticsearch/indices.js | 4 +- .../apps/monitoring/elasticsearch/nodes.js | 8 +- .../apps/monitoring/elasticsearch/overview.js | 4 +- .../functional/services/monitoring/no_data.js | 1 + 70 files changed, 1790 insertions(+), 2270 deletions(-) delete mode 100644 x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/common_apm_instructions.js create mode 100644 x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/common_instructions.js delete mode 100644 x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/common_elasticsearch_instructions.js delete mode 100644 x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/common_kibana_instructions.js delete mode 100644 x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/common_logstash_instructions.js create mode 100644 x-pack/legacy/plugins/monitoring/public/components/setup_mode/badge.js create mode 100644 x-pack/legacy/plugins/monitoring/public/components/setup_mode/formatting.js create mode 100644 x-pack/legacy/plugins/monitoring/public/components/setup_mode/listing_callout.js create mode 100644 x-pack/legacy/plugins/monitoring/public/components/setup_mode/tooltip.js diff --git a/x-pack/legacy/plugins/monitoring/common/constants.js b/x-pack/legacy/plugins/monitoring/common/constants.js index 98246141130942..f953741cd2e02d 100644 --- a/x-pack/legacy/plugins/monitoring/common/constants.js +++ b/x-pack/legacy/plugins/monitoring/common/constants.js @@ -154,8 +154,11 @@ export const INDEX_PATTERN_FILEBEAT = 'filebeat-*'; export const METRICBEAT_INDEX_NAME_UNIQUE_TOKEN = '-mb-'; // We use this for metricbeat migration to identify specific products that we do not have constants for -export const ELASTICSEARCH_CUSTOM_ID = 'elasticsearch'; -export const APM_CUSTOM_ID = 'apm'; +export const ELASTICSEARCH_SYSTEM_ID = 'elasticsearch'; +export const KIBANA_SYSTEM_ID = 'kibana'; +export const BEATS_SYSTEM_ID = 'beats'; +export const APM_SYSTEM_ID = 'apm'; +export const LOGSTASH_SYSTEM_ID = 'logstash'; /** * The id of the infra source owned by the monitoring plugin. */ diff --git a/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js b/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js index 04b6652c6ce0ab..1a660934053634 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js +++ b/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js @@ -8,80 +8,111 @@ import React, { Fragment } from 'react'; import moment from 'moment'; import { uniq, get } from 'lodash'; import { EuiMonitoringTable } from '../../table'; -import { EuiLink, EuiPage, EuiPageBody, EuiPageContent, EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { EuiLink, EuiPage, EuiPageBody, EuiPageContent, EuiSpacer } from '@elastic/eui'; import { Status } from './status'; import { formatMetric } from '../../../lib/format_number'; import { formatTimestampToDuration } from '../../../../common'; import { i18n } from '@kbn/i18n'; +import { APM_SYSTEM_ID } from '../../../../common/constants'; +import { ListingCallOut } from '../../setup_mode/listing_callout'; +import { SetupModeBadge } from '../../setup_mode/badge'; -const columns = [ - { - name: i18n.translate('xpack.monitoring.apm.instances.nameTitle', { - defaultMessage: 'Name' - }), - field: 'name', - render: (name, instance) => ( - - {name} - - ) - }, - { - name: i18n.translate('xpack.monitoring.apm.instances.outputEnabledTitle', { - defaultMessage: 'Output Enabled' - }), - field: 'output' - }, - { - name: i18n.translate('xpack.monitoring.apm.instances.totalEventsRateTitle', { - defaultMessage: 'Total Events Rate' - }), - field: 'total_events_rate', - render: value => formatMetric(value, '', '/s') - }, - { - name: i18n.translate('xpack.monitoring.apm.instances.bytesSentRateTitle', { - defaultMessage: 'Bytes Sent Rate' - }), - field: 'bytes_sent_rate', - render: value => formatMetric(value, 'byte', '/s') - }, - { - name: i18n.translate('xpack.monitoring.apm.instances.outputErrorsTitle', { - defaultMessage: 'Output Errors' - }), - field: 'errors', - render: value => formatMetric(value, '0') - }, - { - name: i18n.translate('xpack.monitoring.apm.instances.lastEventTitle', { - defaultMessage: 'Last Event' - }), - field: 'time_of_last_event', - render: value => i18n.translate('xpack.monitoring.apm.instances.lastEventValue', { - defaultMessage: '{timeOfLastEvent} ago', - values: { - timeOfLastEvent: formatTimestampToDuration(+moment(value), 'since') +function getColumns(setupMode) { + return [ + { + name: i18n.translate('xpack.monitoring.apm.instances.nameTitle', { + defaultMessage: 'Name' + }), + field: 'name', + render: (name, apm) => { + let setupModeStatus = null; + if (setupMode && setupMode.enabled) { + const list = get(setupMode, 'data.byUuid', {}); + const status = list[apm.uuid] || {}; + const instance = { + uuid: apm.uuid, + name: apm.name + }; + + setupModeStatus = ( +
+ +
+ ); + } + + return ( + + + {name} + + {setupModeStatus} + + ); } - }) - }, - { - name: i18n.translate('xpack.monitoring.apm.instances.allocatedMemoryTitle', { - defaultMessage: 'Allocated Memory' - }), - field: 'memory', - render: value => formatMetric(value, 'byte') - }, - { - name: i18n.translate('xpack.monitoring.apm.instances.versionTitle', { - defaultMessage: 'Version' - }), - field: 'version' - }, -]; + }, + { + name: i18n.translate('xpack.monitoring.apm.instances.outputEnabledTitle', { + defaultMessage: 'Output Enabled' + }), + field: 'output' + }, + { + name: i18n.translate('xpack.monitoring.apm.instances.totalEventsRateTitle', { + defaultMessage: 'Total Events Rate' + }), + field: 'total_events_rate', + render: value => formatMetric(value, '', '/s') + }, + { + name: i18n.translate('xpack.monitoring.apm.instances.bytesSentRateTitle', { + defaultMessage: 'Bytes Sent Rate' + }), + field: 'bytes_sent_rate', + render: value => formatMetric(value, 'byte', '/s') + }, + { + name: i18n.translate('xpack.monitoring.apm.instances.outputErrorsTitle', { + defaultMessage: 'Output Errors' + }), + field: 'errors', + render: value => formatMetric(value, '0') + }, + { + name: i18n.translate('xpack.monitoring.apm.instances.lastEventTitle', { + defaultMessage: 'Last Event' + }), + field: 'time_of_last_event', + render: value => i18n.translate('xpack.monitoring.apm.instances.lastEventValue', { + defaultMessage: '{timeOfLastEvent} ago', + values: { + timeOfLastEvent: formatTimestampToDuration(+moment(value), 'since') + } + }) + }, + { + name: i18n.translate('xpack.monitoring.apm.instances.allocatedMemoryTitle', { + defaultMessage: 'Allocated Memory' + }), + field: 'memory', + render: value => formatMetric(value, 'byte') + }, + { + name: i18n.translate('xpack.monitoring.apm.instances.versionTitle', { + defaultMessage: 'Version' + }), + field: 'version' + }, + ]; +} export function ApmServerInstances({ apms, setupMode }) { const { @@ -91,26 +122,14 @@ export function ApmServerInstances({ apms, setupMode }) { data, } = apms; - let detectedInstanceMessage = null; - if (setupMode.enabled && setupMode.data && get(setupMode.data, 'detected.mightExist')) { - detectedInstanceMessage = ( - - -

- {i18n.translate('xpack.monitoring.apm.instances.metricbeatMigration.detectedInstanceDescription', { - defaultMessage: `Based on your indices, we think you might have an APM server. Click the 'Setup monitoring' - button below to start monitoring this APM server.` - })} -

-
- -
+ let setupModeCallout = null; + if (setupMode.enabled && setupMode.data) { + setupModeCallout = ( + ); } @@ -124,19 +143,15 @@ export function ApmServerInstances({ apms, setupMode }) { - {detectedInstanceMessage} + {setupModeCallout} ( - { - scope.$evalAsync(() => { - kbnUrl.changePath(`/beats/beat/${beat.uuid}`); - }); - }} - data-test-subj={`beatLink-${name}`} - > - {name} - - ) + render: (name, beat) => { + let setupModeStatus = null; + if (setupMode && setupMode.enabled) { + const list = get(setupMode, 'data.byUuid', {}); + const status = list[beat.uuid] || {}; + const instance = { + uuid: beat.uuid, + name: beat.name + }; + + setupModeStatus = ( +
+ +
+ ); + } + + return ( +
+ { + scope.$evalAsync(() => { + kbnUrl.changePath(`/beats/beat/${beat.uuid}`); + }); + }} + data-test-subj={`beatLink-${name}`} + > + {name} + + {setupModeStatus} +
+ ); + } }, { name: i18n.translate('xpack.monitoring.beats.instances.typeTitle', { defaultMessage: 'Type' }), @@ -78,26 +108,14 @@ export class Listing extends PureComponent { setupMode } = this.props; - let detectedInstanceMessage = null; - if (setupMode.enabled && setupMode.data && get(setupMode.data, 'detected.mightExist')) { - detectedInstanceMessage = ( - - -

- {i18n.translate('xpack.monitoring.beats.instances.metricbeatMigration.detectedInstanceDescription', { - defaultMessage: `Based on your indices, we think you might have a beats instance. Click the 'Setup monitoring' - button below to start monitoring this instance.` - })} -

-
- -
+ let setupModeCallOut = null; + if (setupMode.enabled && setupMode.data) { + setupModeCallOut = ( + ); } @@ -115,16 +133,12 @@ export class Listing extends PureComponent { - {detectedInstanceMessage} + {setupModeCallOut} props.changeUrl('apm'); const goToInstances = () => props.changeUrl('apm/instances'); - const setupModeAPMData = get(setupMode.data, 'apm'); - let setupModeInstancesData = null; - if (setupMode.enabled && setupMode.data) { - const { - totalUniqueInstanceCount, - totalUniqueFullyMigratedCount, - totalUniquePartiallyMigratedCount - } = setupModeAPMData; - const hasInstances = totalUniqueInstanceCount > 0 || get(setupModeAPMData, 'detected.mightExist', false); - const allMonitoredByMetricbeat = totalUniqueInstanceCount > 0 && - (totalUniqueFullyMigratedCount === totalUniqueInstanceCount || totalUniquePartiallyMigratedCount === totalUniqueInstanceCount); - const internalCollectionOn = totalUniquePartiallyMigratedCount > 0; - if (hasInstances && (!allMonitoredByMetricbeat || internalCollectionOn)) { - let tooltipText = null; - - if (!allMonitoredByMetricbeat) { - tooltipText = i18n.translate('xpack.monitoring.cluster.overview.apmPanel.setupModeNodesTooltip.oneInternal', { - defaultMessage: `There's at least one server that isn't being monitored using Metricbeat. Click the flag - icon to visit the servers listing page and find out more information about the status of each server.` - }); - } - else if (internalCollectionOn) { - tooltipText = i18n.translate('xpack.monitoring.cluster.overview.apmPanel.setupModeNodesTooltip.disableInternal', { - defaultMessage: `All servers are being monitored using Metricbeat but internal collection still needs to be turned - off. Click the flag icon to visit the servers listing page and disable internal collection.` - }); - } - - setupModeInstancesData = ( - - - - - - - - ); - } - } + const setupModeData = get(setupMode.data, 'apm'); + const setupModeTooltip = setupMode && setupMode.enabled + ? ( + + ) + : null; return (
- {setupModeInstancesData} + {setupModeTooltip} diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/beats_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/beats_panel.js index 8ea987d0a67623..935ee1f9cc1001 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/beats_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/beats_panel.js @@ -18,12 +18,12 @@ import { EuiDescriptionListDescription, EuiHorizontalRule, EuiFlexGroup, - EuiToolTip, - EuiIcon } from '@elastic/eui'; import { ClusterItemContainer, DisabledIfNoDataAndInSetupModeLink } from './helpers'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { SetupModeTooltip } from '../../setup_mode/tooltip'; +import { BEATS_SYSTEM_ID } from '../../../../common/constants'; export function BeatsPanel(props) { const { setupMode } = props; @@ -36,48 +36,16 @@ export function BeatsPanel(props) { const goToBeats = () => props.changeUrl('beats'); const goToInstances = () => props.changeUrl('beats/beats'); - const setupModeBeatsData = get(setupMode.data, 'beats'); - let setupModeInstancesData = null; - if (setupMode.enabled && setupMode.data) { - const { - totalUniqueInstanceCount, - totalUniqueFullyMigratedCount, - totalUniquePartiallyMigratedCount - } = setupModeBeatsData; - const hasInstances = totalUniqueInstanceCount > 0 || get(setupModeBeatsData, 'detected.mightExist', false); - const allMonitoredByMetricbeat = totalUniqueInstanceCount > 0 && - (totalUniqueFullyMigratedCount === totalUniqueInstanceCount || totalUniquePartiallyMigratedCount === totalUniqueInstanceCount); - const internalCollectionOn = totalUniquePartiallyMigratedCount > 0; - if (hasInstances && (!allMonitoredByMetricbeat || internalCollectionOn)) { - let tooltipText = null; - - if (!allMonitoredByMetricbeat) { - tooltipText = i18n.translate('xpack.monitoring.cluster.overview.beatsPanel.setupModeNodesTooltip.oneInternal', { - defaultMessage: `There's at least one instance that isn't being monitored using Metricbeat. Click the flag - icon to visit the instances listing page and find out more information about the status of each instance.` - }); - } - else if (internalCollectionOn) { - tooltipText = i18n.translate('xpack.monitoring.cluster.overview.beatsPanel.setupModeNodesTooltip.disableInternal', { - defaultMessage: `All instances are being monitored using Metricbeat but internal collection still needs to be turned - off. Click the flag icon to visit the instances listing page and disable internal collection.` - }); - } - - setupModeInstancesData = ( - - - - - - - - ); - } - } + const setupModeData = get(setupMode.data, 'beats'); + const setupModeTooltip = setupMode && setupMode.enabled + ? ( + + ) + : null; const beatTypes = props.beats.types.map((beat, index) => { return [ @@ -111,7 +79,7 @@ export function BeatsPanel(props) {

- {setupModeInstancesData} + {setupModeTooltip} diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js index 3f45d6e07297ca..9bf18adf50069f 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js @@ -27,12 +27,13 @@ import { EuiBadge, EuiToolTip, EuiFlexGroup, - EuiIcon } from '@elastic/eui'; import { LicenseText } from './license_text'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { Reason } from '../../logs/reason'; +import { SetupModeTooltip } from '../../setup_mode/tooltip'; +import { ELASTICSEARCH_SYSTEM_ID } from '../../../../common/constants'; const calculateShards = shards => { const total = get(shards, 'total', 0); @@ -160,47 +161,16 @@ export function ElasticsearchPanel(props) { const licenseText = ; - const setupModeElasticsearchData = get(setupMode.data, 'elasticsearch'); - let setupModeNodesData = null; - if (setupMode.enabled && setupModeElasticsearchData) { - const { - totalUniqueInstanceCount, - totalUniqueFullyMigratedCount, - totalUniquePartiallyMigratedCount - } = setupModeElasticsearchData; - const allMonitoredByMetricbeat = totalUniqueInstanceCount > 0 && - (totalUniqueFullyMigratedCount === totalUniqueInstanceCount || totalUniquePartiallyMigratedCount === totalUniqueInstanceCount); - const internalCollectionOn = totalUniquePartiallyMigratedCount > 0; - if (!allMonitoredByMetricbeat || internalCollectionOn) { - let tooltipText = null; - - if (!allMonitoredByMetricbeat) { - tooltipText = i18n.translate('xpack.monitoring.cluster.overview.elasticsearchPanel.setupModeNodesTooltip.oneInternal', { - defaultMessage: `There's at least one node that isn't being monitored using Metricbeat. Click the flag icon to visit the nodes - listing page and find out more information about the status of each node.` - }); - } - else if (internalCollectionOn) { - tooltipText = i18n.translate('xpack.monitoring.cluster.overview.elasticsearchPanel.setupModeNodesTooltip.disableInternal', { - defaultMessage: `All nodes are being monitored using Metricbeat but internal collection still needs to be turned off. Click the - flag icon to visit the nodes listing page and disable internal collection.` - }); - } - - setupModeNodesData = ( - - - - - - - - ); - } - } + const setupModeData = get(setupMode.data, 'elasticsearch'); + const setupModeTooltip = setupMode && setupMode.enabled + ? ( + + ) + : null; const showMlJobs = () => { // if license doesn't support ML, then `ml === null` @@ -211,7 +181,7 @@ export function ElasticsearchPanel(props) { {props.ml.jobs} @@ -251,7 +221,7 @@ export function ElasticsearchPanel(props) {

- {setupModeNodesData} + {setupModeTooltip} @@ -353,7 +323,7 @@ export function ElasticsearchPanel(props) {

props.changeUrl('kibana'); const goToInstances = () => props.changeUrl('kibana/instances'); - const setupModeKibanaData = get(setupMode.data, 'kibana'); - let setupModeInstancesData = null; - if (setupMode.enabled && setupMode.data) { - const { - totalUniqueInstanceCount, - totalUniqueFullyMigratedCount, - totalUniquePartiallyMigratedCount - } = setupModeKibanaData; - const allMonitoredByMetricbeat = totalUniqueInstanceCount > 0 && - (totalUniqueFullyMigratedCount === totalUniqueInstanceCount || totalUniquePartiallyMigratedCount === totalUniqueInstanceCount); - const internalCollectionOn = totalUniquePartiallyMigratedCount > 0; - if (!allMonitoredByMetricbeat || internalCollectionOn) { - let tooltipText = null; - - if (!allMonitoredByMetricbeat) { - tooltipText = i18n.translate('xpack.monitoring.cluster.overview.kibanaPanel.setupModeNodesTooltip.oneInternal', { - defaultMessage: `There's at least one instance that isn't being monitored using Metricbeat. Click the flag - icon to visit the instances listing page and find out more information about the status of each instance.` - }); - } - else if (internalCollectionOn) { - tooltipText = i18n.translate('xpack.monitoring.cluster.overview.kibanaPanel.setupModeNodesTooltip.disableInternal', { - defaultMessage: `All instances are being monitored using Metricbeat but internal collection still needs to be turned - off. Click the flag icon to visit the instances listing page and disable internal collection.` - }); - } - - setupModeInstancesData = ( - - - - - - - - ); - } - } + const setupModeData = get(setupMode.data, 'kibana'); + const setupModeTooltip = setupMode && setupMode.enabled + ? ( + + ) + : null; return ( - {setupModeInstancesData} + {setupModeTooltip} diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/logstash_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/logstash_panel.js index ff647c1c219aa4..1033f178b7010c 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/logstash_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/logstash_panel.js @@ -7,7 +7,7 @@ import React from 'react'; import { formatNumber } from 'plugins/monitoring/lib/format_number'; import { ClusterItemContainer, BytesPercentageUsage, DisabledIfNoDataAndInSetupModeLink } from './helpers'; -import { LOGSTASH } from '../../../../common/constants'; +import { LOGSTASH, LOGSTASH_SYSTEM_ID } from '../../../../common/constants'; import { EuiFlexGrid, @@ -21,12 +21,11 @@ import { EuiDescriptionListDescription, EuiHorizontalRule, EuiIconTip, - EuiToolTip, - EuiIcon } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; +import { SetupModeTooltip } from '../../setup_mode/tooltip'; export function LogstashPanel(props) { const { setupMode } = props; @@ -42,48 +41,16 @@ export function LogstashPanel(props) { const goToNodes = () => props.changeUrl('logstash/nodes'); const goToPipelines = () => props.changeUrl('logstash/pipelines'); - const setupModeLogstashData = get(setupMode.data, 'logstash'); - let setupModeInstancesData = null; - if (setupMode.enabled && setupMode.data) { - const { - totalUniqueInstanceCount, - totalUniqueFullyMigratedCount, - totalUniquePartiallyMigratedCount - } = setupModeLogstashData; - const hasInstances = totalUniqueInstanceCount > 0 || get(setupModeLogstashData, 'detected.mightExist', false); - const allMonitoredByMetricbeat = totalUniqueInstanceCount > 0 && - (totalUniqueFullyMigratedCount === totalUniqueInstanceCount || totalUniquePartiallyMigratedCount === totalUniqueInstanceCount); - const internalCollectionOn = totalUniquePartiallyMigratedCount > 0; - if (hasInstances && (!allMonitoredByMetricbeat || internalCollectionOn)) { - let tooltipText = null; - - if (!allMonitoredByMetricbeat) { - tooltipText = i18n.translate('xpack.monitoring.cluster.overview.logstashPanel.setupModeNodesTooltip.oneInternal', { - defaultMessage: `There's at least one node that isn't being monitored using Metricbeat. Click the flag - icon to visit the nodes listing page and find out more information about the status of each node.` - }); - } - else if (internalCollectionOn) { - tooltipText = i18n.translate('xpack.monitoring.cluster.overview.logstashPanel.setupModeNodesTooltip.disableInternal', { - defaultMessage: `All nodes are being monitored using Metricbeat but internal collection still needs to be turned - off. Click the flag icon to visit the nodes listing page and disable internal collection.` - }); - } - - setupModeInstancesData = ( - - - - - - - - ); - } - } + const setupModeData = get(setupMode.data, 'logstash'); + const setupModeTooltip = setupMode && setupMode.enabled + ? ( + + ) + : null; return ( - {setupModeInstancesData} + {setupModeTooltip} @@ -198,7 +165,7 @@ export function LogstashPanel(props) {

(item) => _.get(item, [type, 'summary', 'lastVal']); -const getColumns = (showCgroupMetricsElasticsearch, setupMode) => { +const getColumns = (showCgroupMetricsElasticsearch, setupMode, clusterUuid) => { const cols = []; const cpuUsageColumnTitle = i18n.translate('xpack.monitoring.elasticsearch.nodes.cpuUsageColumnTitle', { @@ -50,9 +53,26 @@ const getColumns = (showCgroupMetricsElasticsearch, setupMode) => { ); + let setupModeStatus = null; if (setupMode && setupMode.enabled) { const list = _.get(setupMode, 'data.byUuid', {}); const status = list[node.resolver] || {}; + const instance = { + uuid: node.resolver, + name: node.name + }; + + setupModeStatus = ( +
+ +
+ ); if (status.isNetNewUser) { nameLink = value; } @@ -77,6 +97,7 @@ const getColumns = (showCgroupMetricsElasticsearch, setupMode) => {
{extractIp(node.transport_address)}
+ {setupModeStatus}

); } @@ -223,7 +244,7 @@ const getColumns = (showCgroupMetricsElasticsearch, setupMode) => { export function ElasticsearchNodes({ clusterStatus, showCgroupMetricsElasticsearch, ...props }) { const { sorting, pagination, onTableChange, clusterUuid, setupMode } = props; - const columns = getColumns(showCgroupMetricsElasticsearch, setupMode); + const columns = getColumns(showCgroupMetricsElasticsearch, setupMode, clusterUuid); // Merge the nodes data with the setup data if enabled const nodes = props.nodes || []; @@ -238,70 +259,89 @@ export function ElasticsearchNodes({ clusterStatus, showCgroupMetricsElasticsear nodes.push(...Object.entries(setupMode.data.byUuid) .reduce((nodes, [nodeUuid, instance]) => { - if (!nodesByUuid[nodeUuid]) { + if (!nodesByUuid[nodeUuid] && instance.node) { nodes.push(instance.node); } return nodes; }, [])); } - let netNewUserMessage = null; - let disableInternalCollectionForMigrationMessage = null; - if (setupMode.data) { - // Think net new user scenario - const hasInstances = setupMode.data.totalUniqueInstanceCount > 0; - if (hasInstances && setupMode.data.totalUniquePartiallyMigratedCount === setupMode.data.totalUniqueInstanceCount) { - const finishMigrationAction = _.get(setupMode.meta, 'liveClusterUuid') === clusterUuid - ? setupMode.shortcutToFinishMigration - : setupMode.openFlyout; + let setupModeCallout = null; + if (setupMode.enabled && setupMode.data) { + setupModeCallout = ( + { + const customRenderResponse = { + shouldRender: false, + componentToRender: null + }; - disableInternalCollectionForMigrationMessage = ( - - -

- {i18n.translate('xpack.monitoring.elasticsearch.nodes.metribeatMigration.disableInternalCollectionDescription', { - defaultMessage: `All of your Elasticsearch servers are monitored using Metricbeat, - but you need to disable internal collection to finish the migration.` - })} -

- - {i18n.translate('xpack.monitoring.elasticsearch.nodes.metribeatMigration.disableInternalCollectionMigrationButtonLabel', { - defaultMessage: 'Disable and finish migration' - })} - -
- -
- ); - } - else if (!hasInstances) { - netNewUserMessage = ( - - -

- {i18n.translate('xpack.monitoring.elasticsearch.nodes.metribeatMigration.netNewUserDescription', { - defaultMessage: `We did not detect any monitoring data, but we did detect the following Elasticsearch nodes. - Each detected node is listed below along with a Setup button. Clicking this button will guide you through - the process of enabling monitoring for each node.` - })} -

-
- -
- ); - } + const isNetNewUser = setupMode.data.totalUniqueInstanceCount === 0; + const hasNoInstances = setupMode.data.totalUniqueInternallyCollectedCount === 0 + && setupMode.data.totalUniqueFullyMigratedCount === 0 + && setupMode.data.totalUniquePartiallyMigratedCount === 0; + + if (isNetNewUser || hasNoInstances) { + customRenderResponse.shouldRender = true; + customRenderResponse.componentToRender = ( + + 0 ? 'danger' : 'warning'} + iconType="flag" + > +

+ {i18n.translate('xpack.monitoring.elasticsearch.nodes.metricbeatMigration.detectedNodeDescription', { + defaultMessage: `The following nodes are not monitored. Click 'Monitor with Metricbeat' below to start monitoring.`, + })} +

+
+ +
+ ); + } + else if (setupMode.data.totalUniquePartiallyMigratedCount === setupMode.data.totalUniqueInstanceCount) { + const finishMigrationAction = _.get(setupMode.meta, 'liveClusterUuid') === clusterUuid + ? setupMode.shortcutToFinishMigration + : setupMode.openFlyout; + + customRenderResponse.shouldRender = true; + customRenderResponse.componentToRender = ( + + +

+ {i18n.translate('xpack.monitoring.elasticsearch.nodes.metricbeatMigration.disableInternalCollectionDescription', { + defaultMessage: `Disable self monitoring to finish the migration.` + })} +

+ + {i18n.translate( + 'xpack.monitoring.elasticsearch.nodes.metricbeatMigration.disableInternalCollectionMigrationButtonLabel', { + defaultMessage: 'Disable self monitoring' + } + )} + +
+ +
+ ); + } + + return customRenderResponse; + }} + /> + ); } function renderClusterStatus() { @@ -322,8 +362,7 @@ export function ElasticsearchNodes({ clusterStatus, showCgroupMetricsElasticsear {renderClusterStatus()} - {disableInternalCollectionForMigrationMessage} - {netNewUserMessage} + {setupModeCallout} { const columns = [ @@ -31,25 +34,50 @@ const getColumns = (kbnUrl, scope, setupMode) => { }), field: 'name', render: (name, kibana) => { + let setupModeStatus = null; if (setupMode && setupMode.enabled) { const list = get(setupMode, 'data.byUuid', {}); - const status = list[get(kibana, 'kibana.uuid')] || {}; + const uuid = get(kibana, 'kibana.uuid'); + const status = list[uuid] || {}; + const instance = { + uuid, + name: kibana.name + }; + + setupModeStatus = ( +
+ +
+ ); if (status.isNetNewUser) { - return name; + return ( +
+ {name} + {setupModeStatus} +
+ ); } } return ( - { - scope.$evalAsync(() => { - kbnUrl.changePath(`/kibana/instances/${kibana.kibana.uuid}`); - }); - }} - data-test-subj={`kibanaLink-${name}`} - > - { name } - +
+ { + scope.$evalAsync(() => { + kbnUrl.changePath(`/kibana/instances/${kibana.kibana.uuid}`); + }); + }} + data-test-subj={`kibanaLink-${name}`} + > + { name } + + {setupModeStatus} +
); } }, @@ -152,7 +180,7 @@ export class KibanaInstances extends PureComponent { onTableChange } = this.props; - let netNewUserMessage = null; + let setupModeCallOut = null; // Merge the instances data with the setup data if enabled const instances = this.props.instances || []; if (setupMode.enabled && setupMode.data) { @@ -177,29 +205,45 @@ export class KibanaInstances extends PureComponent { return instances; }, [])); - const hasInstances = setupMode.data.totalUniqueInstanceCount > 0; - if (!hasInstances) { - netNewUserMessage = ( - - -

- {i18n.translate('xpack.monitoring.kibana.nodes.metribeatMigration.netNewUserDescription', { - defaultMessage: `We did not detect any monitoring data, but we did detect the following Kibana instance. - This detected instance is listed below along with a Setup button. Clicking this button will guide you through - the process of enabling monitoring for this instance.` - })} -

-
- -
- ); - } + setupModeCallOut = ( + { + const customRenderResponse = { + shouldRender: false, + componentToRender: null + }; + + const hasInstances = setupMode.data.totalUniqueInstanceCount > 0; + if (!hasInstances) { + customRenderResponse.shouldRender = true; + customRenderResponse.componentToRender = ( + + +

+ {i18n.translate('xpack.monitoring.kibana.instances.metricbeatMigration.detectedNodeDescription', { + defaultMessage: `The following instances are not monitored. + Click 'Monitor with Metricbeat' below to start monitoring.`, + })} +

+
+ +
+ ); + } + + return customRenderResponse; + }} + /> + ); } const dataFlattened = instances.map(item => ({ @@ -216,7 +260,7 @@ export class KibanaInstances extends PureComponent { - {netNewUserMessage} + {setupModeCallOut} `; @@ -152,7 +150,7 @@ exports[`Listing should render with expected props 1`] = ` ], } } - nameField="name" + productName="logstash" rows={ Array [ Object { @@ -211,7 +209,6 @@ exports[`Listing should render with expected props 1`] = ` } } setupMode={Object {}} - setupNewButtonLabel="Setup monitoring for new Logstash node" sorting={ Object { "sort": Object { @@ -222,6 +219,5 @@ exports[`Listing should render with expected props 1`] = ` }, } } - uuidField="logstash.uuid" /> `; diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js b/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js index bc51d6278c1421..94f83e4046c618 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js @@ -4,18 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent } from 'react'; import { get } from 'lodash'; -import { EuiPage, EuiLink, EuiPageBody, EuiPageContent, EuiPanel, EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { EuiPage, EuiLink, EuiPageBody, EuiPageContent, EuiPanel, EuiSpacer } from '@elastic/eui'; import { formatPercentageUsage, formatNumber } from '../../../lib/format_number'; import { ClusterStatus } from '..//cluster_status'; import { EuiMonitoringTable } from '../../table'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { LOGSTASH_SYSTEM_ID } from '../../../../common/constants'; +import { SetupModeBadge } from '../../setup_mode/badge'; +import { ListingCallOut } from '../../setup_mode/listing_callout'; export class Listing extends PureComponent { getColumns() { const { kbnUrl, scope } = this.props.angular; + const setupMode = this.props.setupMode; return [ { @@ -24,24 +28,49 @@ export class Listing extends PureComponent { }), field: 'name', sortable: true, - render: (name, node) => ( -
-
- { - scope.$evalAsync(() => { - kbnUrl.changePath(`/logstash/node/${node.logstash.uuid}`); - }); - }} - > - {name} - -
+ render: (name, node) => { + let setupModeStatus = null; + if (setupMode && setupMode.enabled) { + const list = get(setupMode, 'data.byUuid', {}); + const uuid = get(node, 'logstash.uuid'); + const status = list[uuid] || {}; + const instance = { + uuid, + name: node.name + }; + + setupModeStatus = ( +
+ +
+ ); + } + + return (
- {node.logstash.http_address} +
+ { + scope.$evalAsync(() => { + kbnUrl.changePath(`/logstash/node/${node.logstash.uuid}`); + }); + }} + > + {name} + +
+
+ {node.logstash.http_address} +
+ {setupModeStatus}
-
- ) + ); + } }, { name: i18n.translate('xpack.monitoring.logstash.nodes.cpuUsageTitle', { @@ -124,26 +153,14 @@ export class Listing extends PureComponent { version: get(item, 'logstash.version', 'N/A'), })); - let netNewUserMessage = null; - if (setupMode.enabled && setupMode.data && get(setupMode.data, 'detected.mightExist')) { - netNewUserMessage = ( - - -

- {i18n.translate('xpack.monitoring.logstash.nodes.metribeatMigration.netNewUserDescription', { - defaultMessage: `Based on your indices, we think you might have a Logstash node. Click the 'Setup monitoring' - button below to start monitoring this node.` - })} -

-
- -
+ let setupModeCallOut = null; + if (setupMode.enabled && setupMode.data) { + setupModeCallOut = ( + ); } @@ -154,17 +171,13 @@ export class Listing extends PureComponent { - {netNewUserMessage} + {setupModeCallOut} `"${url}"`); const instructionSteps = getInstructionSteps(productName, product, activeStep, meta, { doneWithMigration: onClose, - esMonitoringUrl, + esMonitoringUrl: esMonitoringUrls, hasCheckedStatus: checkedStatusByStep[activeStep], }); @@ -142,7 +143,7 @@ export class Flyout extends Component { let willShowNextButton = activeStep !== INSTRUCTION_STEP_DISABLE_INTERNAL; if (activeStep === INSTRUCTION_STEP_ENABLE_METRICBEAT) { - if (productName === ELASTICSEARCH_CUSTOM_ID) { + if (productName === ELASTICSEARCH_SYSTEM_ID) { willShowNextButton = false; // ES can be fully migrated for net new users willDisableDoneButton = !product.isPartiallyMigrated && !product.isFullyMigrated; @@ -222,7 +223,7 @@ export class Flyout extends Component { if (productName === KIBANA_SYSTEM_ID) { documentationUrl = `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/monitoring-metricbeat.html`; } - else if (productName === ELASTICSEARCH_CUSTOM_ID) { + else if (productName === ELASTICSEARCH_SYSTEM_ID) { documentationUrl = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/configuring-metricbeat.html`; } @@ -233,7 +234,9 @@ export class Flyout extends Component { return ( - Read more about this migration. + {i18n.translate('xpack.monitoring.metricbeatMigration.flyout.learnMore', { + defaultMessage: 'Learn about why.' + })} ); @@ -242,59 +245,29 @@ export class Flyout extends Component { render() { const { onClose, instance, productName, product } = this.props; - let instanceType = null; - let instanceName = instance ? instance.name : null; - - if (productName === KIBANA_SYSTEM_ID) { - instanceType = i18n.translate('xpack.monitoring.metricbeatMigration.flyout.kibanaInstance', { - defaultMessage: 'instance', - }); - } - else if (productName === ELASTICSEARCH_CUSTOM_ID) { - if (instance) { - instanceType = i18n.translate('xpack.monitoring.metricbeatMigration.flyout.elasticsearchNode', { - defaultMessage: 'node', - }); - } - else { - instanceName = i18n.translate('xpack.monitoring.metricbeatMigration.flyout.elasticsearchNodesTitle', { - defaultMessage: 'Elasticsearch nodes', - }); - } - } + const instanceIdentifier = getIdentifier(productName); + const instanceName = (instance && instance.name) || formatProductName(productName); let title = i18n.translate('xpack.monitoring.metricbeatMigration.flyout.flyoutTitle', { - defaultMessage: 'Migrate {instanceName} {instanceType} to Metricbeat', + defaultMessage: 'Monitor `{instanceName}` {instanceIdentifier} with Metricbeat', values: { instanceName, - instanceType + instanceIdentifier } }); if (product.isNetNewUser) { title = i18n.translate('xpack.monitoring.metricbeatMigration.flyout.flyoutTitleNewUser', { - defaultMessage: 'Monitor {instanceName} {instanceType} with Metricbeat', + defaultMessage: 'Monitor {instanceName} {instanceIdentifier} with Metricbeat', values: { - instanceName, - instanceType + instanceIdentifier, + instanceName } }); } let noClusterUuidPrompt = null; if (product.isFullyMigrated && product.clusterUuid === null) { - const nodeText = i18n.translate('xpack.monitoring.metricbeatMigration.flyout.node', { - defaultMessage: 'node' - }); - const instanceText = i18n.translate('xpack.monitoring.metricbeatMigration.flyout.instance', { - defaultMessage: 'instance' - }); - - let typeText = nodeText; - if (productName === BEATS_SYSTEM_ID) { - typeText = instanceText; - } - noClusterUuidPrompt = ( Click here to view the Standalone cluster. @@ -330,10 +303,10 @@ export class Flyout extends Component { 'xpack.monitoring.metricbeatMigration.flyout.noClusterUuidCheckboxLabel', { defaultMessage: `Yes, I understand that I will need to look in the Standalone cluster for - this {productName} {typeText}.`, + this {productName} {instanceIdentifier}.`, values: { productName, - typeText + instanceIdentifier } } )} @@ -357,13 +330,14 @@ export class Flyout extends Component { {title} - {this.getDocumentationTitle()} + {/* Remove until we have a why article: https://github.com/elastic/kibana/pull/45799#issuecomment-536778656 */} + {/* {this.getDocumentationTitle()} */} {this.renderActiveStep()} {noClusterUuidPrompt} - + @@ -65,130 +54,7 @@ export function getApmInstructionsForDisablingInternalCollection(product, meta, ) }; - let migrationStatusStep = null; - if (!product || !product.isFullyMigrated) { - let status = null; - if (hasCheckedStatus) { - let lastInternallyCollectedMessage = ''; - // It is possible that, during the migration steps, products are not reporting - // monitoring data for a period of time outside the window of our server-side check - // and this is most likely temporary so we want to be defensive and not error out - // and hopefully wait for the next check and this state will be self-corrected. - if (product) { - const lastInternallyCollectedTimestamp = product.lastInternallyCollectedTimestamp || product.lastTimestamp; - const secondsSinceLastInternalCollectionLabel = - formatTimestampToDuration(lastInternallyCollectedTimestamp, CALCULATE_DURATION_SINCE); - lastInternallyCollectedMessage = (); - } - - status = ( - - - -

- -

-

- {lastInternallyCollectedMessage} -

-
-
- ); - } - - let buttonLabel; - if (checkingMigrationStatus) { - buttonLabel = i18n.translate( - 'xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.checkingStatusButtonLabel', - { - defaultMessage: 'Checking...' - } - ); - } else { - buttonLabel = i18n.translate( - 'xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.checkStatusButtonLabel', - { - defaultMessage: 'Check' - } - ); - } - - migrationStatusStep = { - title: statusTitle, - status: 'incomplete', - children: ( - - - - -

- {i18n.translate( - 'xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.statusDescription', - { - defaultMessage: 'Check that no documents are coming from internal collection.' - } - )} -

-
-
- - - {buttonLabel} - - -
- {status} -
- ) - }; - } - else { - migrationStatusStep = { - title: statusTitle, - status: 'complete', - children: ( - -

- -

-
- ) - }; - } + const migrationStatusStep = getDisableStatusStep(product, meta); return [ disableInternalCollectionStep, diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js index 3f27cdd35ace0d..eaf7066c92e65a 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js @@ -9,50 +9,20 @@ import { EuiSpacer, EuiCodeBlock, EuiLink, - EuiCallOut, EuiText } from '@elastic/eui'; import { Monospace } from '../components/monospace'; import { FormattedMessage } from '@kbn/i18n/react'; -import { statusTitle } from './common_apm_instructions'; import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; +import { getMigrationStatusStep, getSecurityStep } from '../common_instructions'; export function getApmInstructionsForEnablingMetricbeat(product, _meta, { esMonitoringUrl, }) { - const securitySetup = ( - - - - - {` `} - - - - - ) - }} - /> - - )} - /> -
+ const securitySetup = getSecurityStep( + `${ELASTIC_WEBSITE_URL}guide/en/apm/reference/${DOC_LINK_VERSION}/configuring-metricbeat.html` ); + const installMetricbeatStep = { title: i18n.translate('xpack.monitoring.metricbeatMigration.apmInstructions.installMetricbeatTitle', { defaultMessage: 'Install Metricbeat on the same server as the APM server' @@ -66,7 +36,7 @@ export function getApmInstructionsForEnablingMetricbeat(product, _meta, { >

@@ -130,7 +100,7 @@ export function getApmInstructionsForEnablingMetricbeat(product, _meta, { isCopyable > {`output.elasticsearch: - hosts: ["${esMonitoringUrl}"] ## Monitoring cluster + hosts: [${esMonitoringUrl}] ## Monitoring cluster # Optional protocol and basic auth credentials. #protocol: "https" @@ -157,7 +127,7 @@ export function getApmInstructionsForEnablingMetricbeat(product, _meta, { >

@@ -165,48 +135,7 @@ export function getApmInstructionsForEnablingMetricbeat(product, _meta, { ) }; - let migrationStatusStep = null; - if (product.isInternalCollector || product.isNetNewUser) { - migrationStatusStep = { - title: statusTitle, - status: 'incomplete', - children: ( - - ) - }; - } - else if (product.isPartiallyMigrated || product.isFullyMigrated) { - migrationStatusStep = { - title: statusTitle, - status: 'complete', - children: ( - -

- -

-
- ) - }; - } + const migrationStatusStep = getMigrationStatusStep(product); return [ installMetricbeatStep, diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/common_beats_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/common_beats_instructions.js index 0ada632f9779e0..8953b8a858d43e 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/common_beats_instructions.js +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/common_beats_instructions.js @@ -3,11 +3,6 @@ * 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'; - -export const statusTitle = i18n.translate('xpack.monitoring.metricbeatMigration.beatsInstructions.statusTitle', { - defaultMessage: `Migration status` -}); export const UNDETECTED_BEAT_TYPE = 'beat'; export const DEFAULT_BEAT_FOR_URLS = 'metricbeat'; diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/disable_internal_collection_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/disable_internal_collection_instructions.js index 4a843ff286598a..b7400f2a798cad 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/disable_internal_collection_instructions.js +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/disable_internal_collection_instructions.js @@ -8,28 +8,18 @@ import React, { Fragment } from 'react'; import { EuiSpacer, EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, - EuiButton, - EuiCallOut, EuiText } from '@elastic/eui'; -import { formatTimestampToDuration } from '../../../../../common'; -import { CALCULATE_DURATION_SINCE } from '../../../../../common/constants'; import { Monospace } from '../components/monospace'; import { FormattedMessage } from '@kbn/i18n/react'; -import { statusTitle, UNDETECTED_BEAT_TYPE } from './common_beats_instructions'; +import { UNDETECTED_BEAT_TYPE } from './common_beats_instructions'; +import { getDisableStatusStep } from '../common_instructions'; -export function getBeatsInstructionsForDisablingInternalCollection(product, meta, { - checkForMigrationStatus, - checkingMigrationStatus, - hasCheckedStatus, - autoCheckIntervalInMs, -}) { +export function getBeatsInstructionsForDisablingInternalCollection(product, meta) { const beatType = product.beatType; const disableInternalCollectionStep = { title: i18n.translate('xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.title', { - defaultMessage: 'Disable internal collection of {beatType}\'s monitoring metrics', + defaultMessage: 'Disable self monitoring of {beatType}\'s monitoring metrics', values: { beatType: beatType || UNDETECTED_BEAT_TYPE } @@ -73,130 +63,7 @@ export function getBeatsInstructionsForDisablingInternalCollection(product, meta ) }; - let migrationStatusStep = null; - if (!product || !product.isFullyMigrated) { - let status = null; - if (hasCheckedStatus) { - let lastInternallyCollectedMessage = ''; - // It is possible that, during the migration steps, products are not reporting - // monitoring data for a period of time outside the window of our server-side check - // and this is most likely temporary so we want to be defensive and not error out - // and hopefully wait for the next check and this state will be self-corrected. - if (product) { - const lastInternallyCollectedTimestamp = product.lastInternallyCollectedTimestamp || product.lastTimestamp; - const secondsSinceLastInternalCollectionLabel = - formatTimestampToDuration(lastInternallyCollectedTimestamp, CALCULATE_DURATION_SINCE); - lastInternallyCollectedMessage = (); - } - - status = ( - - - -

- -

-

- {lastInternallyCollectedMessage} -

-
-
- ); - } - - let buttonLabel; - if (checkingMigrationStatus) { - buttonLabel = i18n.translate( - 'xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.checkingStatusButtonLabel', - { - defaultMessage: 'Checking...' - } - ); - } else { - buttonLabel = i18n.translate( - 'xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.checkStatusButtonLabel', - { - defaultMessage: 'Check' - } - ); - } - - migrationStatusStep = { - title: statusTitle, - status: 'incomplete', - children: ( - - - - -

- {i18n.translate( - 'xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.statusDescription', - { - defaultMessage: 'Check that no documents are coming from internal collection.' - } - )} -

-
-
- - - {buttonLabel} - - -
- {status} -
- ) - }; - } - else { - migrationStatusStep = { - title: statusTitle, - status: 'complete', - children: ( - -

- -

-
- ) - }; - } + const migrationStatusStep = getDisableStatusStep(product, meta); return [ disableInternalCollectionStep, diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/enable_metricbeat_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/enable_metricbeat_instructions.js index 8d167379615d5c..f36fb49521a1ea 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/enable_metricbeat_instructions.js +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/enable_metricbeat_instructions.js @@ -14,46 +14,18 @@ import { } from '@elastic/eui'; import { Monospace } from '../components/monospace'; import { FormattedMessage } from '@kbn/i18n/react'; -import { statusTitle, UNDETECTED_BEAT_TYPE, DEFAULT_BEAT_FOR_URLS } from './common_beats_instructions'; +import { UNDETECTED_BEAT_TYPE, DEFAULT_BEAT_FOR_URLS } from './common_beats_instructions'; import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; +import { getMigrationStatusStep, getSecurityStep } from '../common_instructions'; export function getBeatsInstructionsForEnablingMetricbeat(product, _meta, { esMonitoringUrl, }) { const beatType = product.beatType; - const securitySetup = ( - - - - - {` `} - - - - - ) - }} - /> - - )} - /> - + const securitySetup = getSecurityStep( + `${ELASTIC_WEBSITE_URL}guide/en/beats/reference/${DOC_LINK_VERSION}/configuring-metricbeat.html` ); + const installMetricbeatStep = { title: i18n.translate('xpack.monitoring.metricbeatMigration.beatsInstructions.installMetricbeatTitle', { defaultMessage: 'Install Metricbeat on the same server as this {beatType}', @@ -70,7 +42,7 @@ export function getBeatsInstructionsForEnablingMetricbeat(product, _meta, { >

@@ -170,7 +142,7 @@ export function getBeatsInstructionsForEnablingMetricbeat(product, _meta, { isCopyable > {`output.elasticsearch: - hosts: ["${esMonitoringUrl}"] ## Monitoring cluster + hosts: [${esMonitoringUrl}] ## Monitoring cluster # Optional protocol and basic auth credentials. #protocol: "https" @@ -197,7 +169,7 @@ export function getBeatsInstructionsForEnablingMetricbeat(product, _meta, { >

@@ -205,48 +177,7 @@ export function getBeatsInstructionsForEnablingMetricbeat(product, _meta, { ) }; - let migrationStatusStep = null; - if (product.isInternalCollector || product.isNetNewUser) { - migrationStatusStep = { - title: statusTitle, - status: 'incomplete', - children: ( - - ) - }; - } - else if (product.isPartiallyMigrated || product.isFullyMigrated) { - migrationStatusStep = { - title: statusTitle, - status: 'complete', - children: ( - -

- -

-
- ) - }; - } + const migrationStatusStep = getMigrationStatusStep(product); return [ installMetricbeatStep, diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/common_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/common_instructions.js new file mode 100644 index 00000000000000..f263837b80cc3c --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/common_instructions.js @@ -0,0 +1,176 @@ +/* + * 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, { Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiCallOut, + EuiSpacer, + EuiText, + EuiLink +} from '@elastic/eui'; +import { CALCULATE_DURATION_SINCE } from '../../../../common/constants'; +import { formatTimestampToDuration } from '../../../../common'; + +export const MIGRATION_STATUS_LABEL = i18n.translate('xpack.monitoring.metricbeatMigration.migrationStatus', { + defaultMessage: `Migration status` +}); + +export const MONITORING_STATUS_LABEL = i18n.translate('xpack.monitoring.metricbeatMigration.monitoringStatus', { + defaultMessage: `Monitoring status` +}); + +export function getSecurityStep(url) { + return ( + + + + + {` `} + + + + + ) + }} + /> + + )} + /> + + ); +} + +export function getMigrationStatusStep(product) { + if (product.isInternalCollector || product.isNetNewUser) { + return { + title: product.isNetNewUser ? MONITORING_STATUS_LABEL : MIGRATION_STATUS_LABEL, + status: 'incomplete', + children: ( + + ) + }; + } + else if (product.isPartiallyMigrated || product.isFullyMigrated) { + return { + title: MIGRATION_STATUS_LABEL, + status: 'complete', + children: ( + +

+ {i18n.translate('xpack.monitoring.metricbeatMigration.fullyMigratedStatusDescription', { + defaultMessage: 'Metricbeat is shipping monitoring data.' + })} +

+
+ ) + }; + } + + return null; +} + +export function getDisableStatusStep(product, meta) { + if (!product || !product.isFullyMigrated) { + let lastInternallyCollectedMessage = ''; + // It is possible that, during the migration steps, products are not reporting + // monitoring data for a period of time outside the window of our server-side check + // and this is most likely temporary so we want to be defensive and not error out + // and hopefully wait for the next check and this state will be self-corrected. + if (product) { + const lastInternallyCollectedTimestamp = product.lastInternallyCollectedTimestamp || product.lastTimestamp; + const secondsSinceLastInternalCollectionLabel = + formatTimestampToDuration(lastInternallyCollectedTimestamp, CALCULATE_DURATION_SINCE); + lastInternallyCollectedMessage = i18n.translate( + 'xpack.monitoring.metricbeatMigration.disableInternalCollection.partiallyMigratedStatusDescription', + { + defaultMessage: 'Last self monitoring was {secondsSinceLastInternalCollectionLabel} ago.', + values: { + secondsSinceLastInternalCollectionLabel + } + } + ); + } + + return { + title: MIGRATION_STATUS_LABEL, + status: 'incomplete', + children: ( + +

+ {i18n.translate('xpack.monitoring.metricbeatMigration.partiallyMigratedStatusDescription', { + defaultMessage: `It can take up to {secondsAgo} seconds to detect data.`, + values: { + secondsAgo: meta.secondsAgo + } + })} +

+

+ {lastInternallyCollectedMessage} +

+
+ ) + }; + } + + return { + title: MIGRATION_STATUS_LABEL, + status: 'complete', + children: ( + +

+ {i18n.translate('xpack.monitoring.metricbeatMigration.disableInternalCollection.fullyMigratedStatusDescription', { + defaultMessage: 'We are not seeing any documents from self monitoring. Migration complete!' + })} +

+
+ ) + }; +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/common_elasticsearch_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/common_elasticsearch_instructions.js deleted file mode 100644 index 3c55fef3ab7f3f..00000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/common_elasticsearch_instructions.js +++ /dev/null @@ -1,14 +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 { i18n } from '@kbn/i18n'; - -export const statusTitle = i18n.translate('xpack.monitoring.metricbeatMigration.elasticsearchInstructions.statusTitle', { - defaultMessage: `Migration status` -}); - -export const statusTitleNewUser = i18n.translate('xpack.monitoring.metricbeatMigration.elasticsearchInstructions.statusTitleNewUser', { - defaultMessage: `Monitoring status` -}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/disable_internal_collection_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/disable_internal_collection_instructions.js index d09f134b1d2991..361b1262f4481f 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/disable_internal_collection_instructions.js +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/disable_internal_collection_instructions.js @@ -8,19 +8,16 @@ import React, { Fragment } from 'react'; import { EuiSpacer, EuiCodeBlock, - EuiCallOut, EuiText } from '@elastic/eui'; -import { formatTimestampToDuration } from '../../../../../common'; -import { CALCULATE_DURATION_SINCE } from '../../../../../common/constants'; import { Monospace } from '../components/monospace'; import { FormattedMessage } from '@kbn/i18n/react'; -import { statusTitle } from './common_elasticsearch_instructions'; +import { getDisableStatusStep } from '../common_instructions'; export function getElasticsearchInstructionsForDisablingInternalCollection(product, meta) { const disableInternalCollectionStep = { title: i18n.translate('xpack.monitoring.metricbeatMigration.elasticsearchInstructions.disableInternalCollectionTitle', { - defaultMessage: 'Disable internal collection of Elasticsearch monitoring metrics' + defaultMessage: 'Disable self monitoring of Elasticsearch monitoring metrics' }), children: ( @@ -28,7 +25,7 @@ export function getElasticsearchInstructionsForDisablingInternalCollection(produ

); - } - - migrationStatusStep = { - title: statusTitle, - status: 'incomplete', - children: ( - -

- -

-

- {lastInternallyCollectedMessage} -

-
- ) - }; - } - else { - migrationStatusStep = { - title: statusTitle, - status: 'complete', - children: ( - -

- -

-
- ) - }; - } + const migrationStatusStep = getDisableStatusStep(product, meta); return [ disableInternalCollectionStep, diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/enable_metricbeat_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/enable_metricbeat_instructions.js index 2de84fa883e6ef..eea188461b088d 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/enable_metricbeat_instructions.js +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/enable_metricbeat_instructions.js @@ -9,49 +9,18 @@ import { EuiSpacer, EuiCodeBlock, EuiLink, - EuiCallOut, EuiText } from '@elastic/eui'; import { Monospace } from '../components/monospace'; import { FormattedMessage } from '@kbn/i18n/react'; -import { statusTitle, statusTitleNewUser } from './common_elasticsearch_instructions'; import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; +import { getSecurityStep, getMigrationStatusStep } from '../common_instructions'; export function getElasticsearchInstructionsForEnablingMetricbeat(product, _meta, { esMonitoringUrl, }) { - const securitySetup = ( - - - - - {` `} - - - - - ) - }} - /> - - )} - /> - + const securitySetup = getSecurityStep( + `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/configuring-metricbeat.html` ); const installMetricbeatStep = { @@ -67,7 +36,7 @@ export function getElasticsearchInstructionsForEnablingMetricbeat(product, _meta >

@@ -82,6 +51,14 @@ export function getElasticsearchInstructionsForEnablingMetricbeat(product, _meta }), children: ( + +

+ {i18n.translate('xpack.monitoring.metricbeatMigration.elasticsearchInstructions.enableMetricbeatModuleInstallDirectory', { + defaultMessage: 'From the installation directory, run:' + })} +

+
+ modules.d/elasticsearch-xpack.yml @@ -114,14 +90,14 @@ export function getElasticsearchInstructionsForEnablingMetricbeat(product, _meta const configureMetricbeatStep = { title: i18n.translate('xpack.monitoring.metricbeatMigration.elasticsearchInstructions.configureMetricbeatTitle', { - defaultMessage: 'Configure Metricbeat to send to the monitoring cluster' + defaultMessage: 'Configure Metricbeat to send data to the monitoring cluster' }), children: ( metricbeat.yml @@ -134,7 +110,7 @@ export function getElasticsearchInstructionsForEnablingMetricbeat(product, _meta isCopyable > {`output.elasticsearch: - hosts: ["${esMonitoringUrl}"] ## Monitoring cluster + hosts: [${esMonitoringUrl}] ## Monitoring cluster # Optional protocol and basic auth credentials. #protocol: "https" @@ -161,7 +137,7 @@ export function getElasticsearchInstructionsForEnablingMetricbeat(product, _meta >

@@ -169,48 +145,7 @@ export function getElasticsearchInstructionsForEnablingMetricbeat(product, _meta ) }; - let migrationStatusStep = null; - if (product.isInternalCollector || product.isNetNewUser) { - migrationStatusStep = { - title: product.isNetNewUser ? statusTitleNewUser : statusTitle, - status: 'incomplete', - children: ( - - ) - }; - } - else if (product.isPartiallyMigrated || product.isFullyMigrated) { - migrationStatusStep = { - title: statusTitle, - status: 'complete', - children: ( - -

- -

-
- ) - }; - } + const migrationStatusStep = getMigrationStatusStep(product); return [ installMetricbeatStep, diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/get_instruction_steps.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/get_instruction_steps.js index 7075be3def9bbf..933e95a6c2e7a4 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/get_instruction_steps.js +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/get_instruction_steps.js @@ -21,15 +21,20 @@ import { getBeatsInstructionsForDisablingInternalCollection, } from './beats'; import { - getApmInstructionsForEnablingMetricbeat, getApmInstructionsForDisablingInternalCollection, + getApmInstructionsForEnablingMetricbeat, } from './apm'; import { INSTRUCTION_STEP_ENABLE_METRICBEAT, INSTRUCTION_STEP_DISABLE_INTERNAL } from '../constants'; -import { ELASTICSEARCH_CUSTOM_ID, APM_CUSTOM_ID } from '../../../../common/constants'; -import { KIBANA_SYSTEM_ID, LOGSTASH_SYSTEM_ID, BEATS_SYSTEM_ID } from '../../../../../telemetry/common/constants'; +import { + ELASTICSEARCH_SYSTEM_ID, + APM_SYSTEM_ID, + KIBANA_SYSTEM_ID, + LOGSTASH_SYSTEM_ID, + BEATS_SYSTEM_ID +} from '../../../../common/constants'; export function getInstructionSteps(productName, product, step, meta, opts) { switch (productName) { @@ -40,7 +45,7 @@ export function getInstructionSteps(productName, product, step, meta, opts) { if (step === INSTRUCTION_STEP_DISABLE_INTERNAL) { return getKibanaInstructionsForDisablingInternalCollection(product, meta, opts); } - case ELASTICSEARCH_CUSTOM_ID: + case ELASTICSEARCH_SYSTEM_ID: if (step === INSTRUCTION_STEP_ENABLE_METRICBEAT) { return getElasticsearchInstructionsForEnablingMetricbeat(product, meta, opts); } @@ -61,7 +66,7 @@ export function getInstructionSteps(productName, product, step, meta, opts) { if (step === INSTRUCTION_STEP_DISABLE_INTERNAL) { return getBeatsInstructionsForDisablingInternalCollection(product, meta, opts); } - case APM_CUSTOM_ID: + case APM_SYSTEM_ID: if (step === INSTRUCTION_STEP_ENABLE_METRICBEAT) { return getApmInstructionsForEnablingMetricbeat(product, meta, opts); } diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/common_kibana_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/common_kibana_instructions.js deleted file mode 100644 index 25b869e32b9b76..00000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/common_kibana_instructions.js +++ /dev/null @@ -1,14 +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 { i18n } from '@kbn/i18n'; - -export const statusTitle = i18n.translate('xpack.monitoring.metricbeatMigration.kibanaInstructions.statusTitle', { - defaultMessage: `Migration status` -}); - -export const statusTitleNewUser = i18n.translate('xpack.monitoring.metricbeatMigration.kibanaInstructions.statusTitleNewUser', { - defaultMessage: `Monitoring status` -}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/disable_internal_collection_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/disable_internal_collection_instructions.js index e4219fe47c3c26..02bade281940bc 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/disable_internal_collection_instructions.js +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/disable_internal_collection_instructions.js @@ -8,24 +8,14 @@ import React, { Fragment } from 'react'; import { EuiSpacer, EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, - EuiButton, EuiCallOut, EuiText } from '@elastic/eui'; -import { formatTimestampToDuration } from '../../../../../common'; -import { CALCULATE_DURATION_SINCE } from '../../../../../common/constants'; import { Monospace } from '../components/monospace'; import { FormattedMessage } from '@kbn/i18n/react'; -import { statusTitle } from './common_kibana_instructions'; +import { getDisableStatusStep } from '../common_instructions'; -export function getKibanaInstructionsForDisablingInternalCollection(product, meta, { - checkForMigrationStatus, - checkingMigrationStatus, - hasCheckedStatus, - autoCheckIntervalInMs, -}) { +export function getKibanaInstructionsForDisablingInternalCollection(product, meta) { let restartWarning = null; if (product.isPrimary) { restartWarning = ( @@ -35,7 +25,7 @@ export function getKibanaInstructionsForDisablingInternalCollection(product, met title={i18n.translate( 'xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.restartWarningTitle', { - defaultMessage: 'Warning' + defaultMessage: 'This step requires you to restart the Kibana server' } )} color="warning" @@ -45,8 +35,7 @@ export function getKibanaInstructionsForDisablingInternalCollection(product, met

@@ -57,7 +46,7 @@ export function getKibanaInstructionsForDisablingInternalCollection(product, met const disableInternalCollectionStep = { title: i18n.translate('xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.title', { - defaultMessage: 'Disable internal collection of Kibana monitoring metrics' + defaultMessage: 'Disable self monitoring of Kibana monitoring metrics' }), children: ( @@ -65,7 +54,7 @@ export function getKibanaInstructionsForDisablingInternalCollection(product, met

kibana.yml @@ -86,7 +75,7 @@ export function getKibanaInstructionsForDisablingInternalCollection(product, met

xpack.monitoring.enabled @@ -103,130 +92,7 @@ export function getKibanaInstructionsForDisablingInternalCollection(product, met ) }; - let migrationStatusStep = null; - if (!product || !product.isFullyMigrated) { - let status = null; - if (hasCheckedStatus) { - let lastInternallyCollectedMessage = ''; - // It is possible that, during the migration steps, products are not reporting - // monitoring data for a period of time outside the window of our server-side check - // and this is most likely temporary so we want to be defensive and not error out - // and hopefully wait for the next check and this state will be self-corrected. - if (product) { - const lastInternallyCollectedTimestamp = product.lastInternallyCollectedTimestamp || product.lastTimestamp; - const secondsSinceLastInternalCollectionLabel = - formatTimestampToDuration(lastInternallyCollectedTimestamp, CALCULATE_DURATION_SINCE); - lastInternallyCollectedMessage = (); - } - - status = ( - - - -

- -

-

- {lastInternallyCollectedMessage} -

-
- - ); - } - - let buttonLabel; - if (checkingMigrationStatus) { - buttonLabel = i18n.translate( - 'xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.checkingStatusButtonLabel', - { - defaultMessage: 'Checking...' - } - ); - } else { - buttonLabel = i18n.translate( - 'xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.checkStatusButtonLabel', - { - defaultMessage: 'Check' - } - ); - } - - migrationStatusStep = { - title: statusTitle, - status: 'incomplete', - children: ( - - - - -

- {i18n.translate( - 'xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.statusDescription', - { - defaultMessage: 'Check that no documents are coming from internal collection.' - } - )} -

-
-
- - - {buttonLabel} - - -
- {status} -
- ) - }; - } - else { - migrationStatusStep = { - title: statusTitle, - status: 'complete', - children: ( - -

- -

-
- ) - }; - } + const migrationStatusStep = getDisableStatusStep(product, meta); return [ disableInternalCollectionStep, diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/enable_metricbeat_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/enable_metricbeat_instructions.js index 1cd8fdea25151e..be4bdf9f2ac1d7 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/enable_metricbeat_instructions.js +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/enable_metricbeat_instructions.js @@ -9,50 +9,20 @@ import { EuiSpacer, EuiCodeBlock, EuiLink, - EuiCallOut, EuiText } from '@elastic/eui'; import { Monospace } from '../components/monospace'; import { FormattedMessage } from '@kbn/i18n/react'; -import { statusTitle, statusTitleNewUser } from './common_kibana_instructions'; import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; +import { getMigrationStatusStep, getSecurityStep } from '../common_instructions'; export function getKibanaInstructionsForEnablingMetricbeat(product, _meta, { esMonitoringUrl, }) { - const securitySetup = ( - - - - - {` `} - - - - - ) - }} - /> - - )} - /> - + const securitySetup = getSecurityStep( + `${ELASTIC_WEBSITE_URL}guide/en/kibana/reference/${DOC_LINK_VERSION}/configuring-metricbeat.html` ); + const installMetricbeatStep = { title: i18n.translate('xpack.monitoring.metricbeatMigration.kibanaInstructions.installMetricbeatTitle', { defaultMessage: 'Install Metricbeat on the same server as Kibana' @@ -66,7 +36,7 @@ export function getKibanaInstructionsForEnablingMetricbeat(product, _meta, { >

@@ -130,7 +100,7 @@ export function getKibanaInstructionsForEnablingMetricbeat(product, _meta, { isCopyable > {`output.elasticsearch: - hosts: ["${esMonitoringUrl}"] ## Monitoring cluster + hosts: [${esMonitoringUrl}] ## Monitoring cluster # Optional protocol and basic auth credentials. #protocol: "https" @@ -157,7 +127,7 @@ export function getKibanaInstructionsForEnablingMetricbeat(product, _meta, { >

@@ -165,48 +135,7 @@ export function getKibanaInstructionsForEnablingMetricbeat(product, _meta, { ) }; - let migrationStatusStep = null; - if (product.isInternalCollector || product.isNetNewUser) { - migrationStatusStep = { - title: product.isNetNewUser ? statusTitleNewUser : statusTitle, - status: 'incomplete', - children: ( - - ) - }; - } - else if (product.isPartiallyMigrated || product.isFullyMigrated) { - migrationStatusStep = { - title: statusTitle, - status: 'complete', - children: ( - -

- -

-
- ) - }; - } + const migrationStatusStep = getMigrationStatusStep(product); return [ installMetricbeatStep, diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/common_logstash_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/common_logstash_instructions.js deleted file mode 100644 index 642add4d43fc4c..00000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/common_logstash_instructions.js +++ /dev/null @@ -1,10 +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 { i18n } from '@kbn/i18n'; - -export const statusTitle = i18n.translate('xpack.monitoring.metricbeatMigration.logstashInstructions.statusTitle', { - defaultMessage: `Migration status` -}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/disable_internal_collection_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/disable_internal_collection_instructions.js index 9efc5a26ef8223..350a50b973b29a 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/disable_internal_collection_instructions.js +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/disable_internal_collection_instructions.js @@ -8,27 +8,16 @@ import React, { Fragment } from 'react'; import { EuiSpacer, EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, - EuiButton, - EuiCallOut, EuiText } from '@elastic/eui'; -import { formatTimestampToDuration } from '../../../../../common'; -import { CALCULATE_DURATION_SINCE } from '../../../../../common/constants'; import { Monospace } from '../components/monospace'; import { FormattedMessage } from '@kbn/i18n/react'; -import { statusTitle } from './common_logstash_instructions'; +import { getDisableStatusStep } from '../common_instructions'; -export function getLogstashInstructionsForDisablingInternalCollection(product, meta, { - checkForMigrationStatus, - checkingMigrationStatus, - hasCheckedStatus, - autoCheckIntervalInMs, -}) { +export function getLogstashInstructionsForDisablingInternalCollection(product, meta) { const disableInternalCollectionStep = { title: i18n.translate('xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.title', { - defaultMessage: 'Disable internal collection of Logstash monitoring metrics' + defaultMessage: 'Disable self monitoring of Logstash monitoring metrics' }), children: ( @@ -65,130 +54,7 @@ export function getLogstashInstructionsForDisablingInternalCollection(product, m ) }; - let migrationStatusStep = null; - if (!product || !product.isFullyMigrated) { - let status = null; - if (hasCheckedStatus) { - let lastInternallyCollectedMessage = ''; - // It is possible that, during the migration steps, products are not reporting - // monitoring data for a period of time outside the window of our server-side check - // and this is most likely temporary so we want to be defensive and not error out - // and hopefully wait for the next check and this state will be self-corrected. - if (product) { - const lastInternallyCollectedTimestamp = product.lastInternallyCollectedTimestamp || product.lastTimestamp; - const secondsSinceLastInternalCollectionLabel = - formatTimestampToDuration(lastInternallyCollectedTimestamp, CALCULATE_DURATION_SINCE); - lastInternallyCollectedMessage = (); - } - - status = ( - - - -

- -

-

- {lastInternallyCollectedMessage} -

-
-
- ); - } - - let buttonLabel; - if (checkingMigrationStatus) { - buttonLabel = i18n.translate( - 'xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.checkingStatusButtonLabel', - { - defaultMessage: 'Checking...' - } - ); - } else { - buttonLabel = i18n.translate( - 'xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.checkStatusButtonLabel', - { - defaultMessage: 'Check' - } - ); - } - - migrationStatusStep = { - title: statusTitle, - status: 'incomplete', - children: ( - - - - -

- {i18n.translate( - 'xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.statusDescription', - { - defaultMessage: 'Check that no documents are coming from internal collection.' - } - )} -

-
-
- - - {buttonLabel} - - -
- {status} -
- ) - }; - } - else { - migrationStatusStep = { - title: statusTitle, - status: 'complete', - children: ( - -

- -

-
- ) - }; - } + const migrationStatusStep = getDisableStatusStep(product, meta); return [ disableInternalCollectionStep, diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/enable_metricbeat_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/enable_metricbeat_instructions.js index 71300163ce6d2a..875ab89c99454b 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/enable_metricbeat_instructions.js +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/enable_metricbeat_instructions.js @@ -9,50 +9,20 @@ import { EuiSpacer, EuiCodeBlock, EuiLink, - EuiCallOut, EuiText } from '@elastic/eui'; import { Monospace } from '../components/monospace'; import { FormattedMessage } from '@kbn/i18n/react'; -import { statusTitle } from './common_logstash_instructions'; import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; +import { getMigrationStatusStep, getSecurityStep } from '../common_instructions'; export function getLogstashInstructionsForEnablingMetricbeat(product, _meta, { esMonitoringUrl, }) { - const securitySetup = ( - - - - - {` `} - - - - - ) - }} - /> - - )} - /> -
+ const securitySetup = getSecurityStep( + `${ELASTIC_WEBSITE_URL}guide/en/logstash/reference/${DOC_LINK_VERSION}/configuring-metricbeat.html` ); + const installMetricbeatStep = { title: i18n.translate('xpack.monitoring.metricbeatMigration.logstashInstructions.installMetricbeatTitle', { defaultMessage: 'Install Metricbeat on the same server as Logstash' @@ -66,7 +36,7 @@ export function getLogstashInstructionsForEnablingMetricbeat(product, _meta, { >

@@ -130,7 +100,7 @@ export function getLogstashInstructionsForEnablingMetricbeat(product, _meta, { isCopyable > {`output.elasticsearch: - hosts: ["${esMonitoringUrl}"] ## Monitoring cluster + hosts: [${esMonitoringUrl}] ## Monitoring cluster # Optional protocol and basic auth credentials. #protocol: "https" @@ -157,7 +127,7 @@ export function getLogstashInstructionsForEnablingMetricbeat(product, _meta, { >

@@ -165,48 +135,7 @@ export function getLogstashInstructionsForEnablingMetricbeat(product, _meta, { ) }; - let migrationStatusStep = null; - if (product.isInternalCollector || product.isNetNewUser) { - migrationStatusStep = { - title: statusTitle, - status: 'incomplete', - children: ( - - ) - }; - } - else if (product.isPartiallyMigrated || product.isFullyMigrated) { - migrationStatusStep = { - title: statusTitle, - status: 'complete', - children: ( - -

- -

-
- ) - }; - } + const migrationStatusStep = getMigrationStatusStep(product); return [ installMetricbeatStep, diff --git a/x-pack/legacy/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap index 7c663a26bc9df1..1d0f58b6235952 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap @@ -22,43 +22,69 @@ exports[`NoData should show a default message if reason is unknown 1`] = `
+

+ No monitoring data found +

+

- There is a - - - food - - - setting that has - +

+
+
+
+ +
+
+
+
+ +
@@ -89,38 +115,66 @@ exports[`NoData should show text next to the spinner while checking a setting 1`

- We're looking for your monitoring data + No monitoring data found

- -
-

- Monitoring provides insight to your hardware performance and load. -

-
-

+

+ Have you set up monitoring yet? If so, make sure that the selected time period in the upper right includes monitoring data. +

+
+
+
- -
-
- checking something to test... +
+
+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/no_data/no_data.js b/x-pack/legacy/plugins/monitoring/public/components/no_data/no_data.js index 286727449eb755..e50e49eec9f6eb 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/no_data/no_data.js +++ b/x-pack/legacy/plugins/monitoring/public/components/no_data/no_data.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { EuiSpacer, @@ -12,7 +12,17 @@ import { EuiPage, EuiPageBody, EuiPageContent, + EuiHorizontalRule, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiText, + EuiTitle, + EuiTextColor, + EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { toggleSetupMode } from '../../lib/setup_mode'; import { CheckingSettings } from './checking_settings'; import { ReasonFound, WeTried } from './reasons'; import { CheckerErrors } from './checker_errors'; @@ -32,6 +42,42 @@ function NoDataMessage(props) { } export function NoData(props) { + const [isLoading, setIsLoading] = useState(false); + const [useInternalCollection, setUseInternalCollection] = useState(false); + + async function startSetup() { + setIsLoading(true); + await toggleSetupMode(true); + props.changePath('/elasticsearch/nodes'); + } + + if (useInternalCollection) { + return ( + + + + + + + + + setUseInternalCollection(false)}> + + + + + + + + ); + } return ( @@ -43,8 +89,54 @@ export function NoData(props) { > - - + +

+ +

+
+ + +

+ +

+
+ + + + + + + + + + setUseInternalCollection(true)} data-test-subj="useInternalCollection"> + + + +
@@ -52,6 +144,7 @@ export function NoData(props) { } NoData.propTypes = { + changePath: PropTypes.func, isLoading: PropTypes.bool.isRequired, reason: PropTypes.object, checkMessage: PropTypes.string diff --git a/x-pack/legacy/plugins/monitoring/public/components/renderers/setup_mode.js b/x-pack/legacy/plugins/monitoring/public/components/renderers/setup_mode.js index f1bfebe7851ca9..079a3e7eeae093 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/renderers/setup_mode.js +++ b/x-pack/legacy/plugins/monitoring/public/components/renderers/setup_mode.js @@ -3,10 +3,28 @@ * 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 { getSetupModeState, initSetupModeState, updateSetupModeData, disableElasticsearchInternalCollection } from '../../lib/setup_mode'; +import React, { Fragment } from 'react'; +import { + getSetupModeState, + initSetupModeState, + updateSetupModeData, + disableElasticsearchInternalCollection, + toggleSetupMode, + setSetupModeMenuItem +} from '../../lib/setup_mode'; import { Flyout } from '../metricbeat_migration/flyout'; +import { + EuiBottomBar, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiTextColor, + EuiIcon, + EuiSpacer +} from '@elastic/eui'; import { findNewUuid } from './lib/find_new_uuid'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; export class SetupModeRenderer extends React.Component { state = { @@ -44,6 +62,7 @@ export class SetupModeRenderer extends React.Component { this.setState(newState); }); + setSetupModeMenuItem(); } reset() { @@ -97,6 +116,50 @@ export class SetupModeRenderer extends React.Component { ); } + getBottomBar(setupModeState) { + if (!setupModeState.enabled) { + return null; + } + + return ( + + + + + + + + + + ) + }} + /> + + + + + + + + toggleSetupMode(false)}> + {i18n.translate('xpack.monitoring.setupMode.exit', { + defaultMessage: `Exit setup mode` + })} + + + + + + + + ); + } + async shortcutToFinishMigration() { await disableElasticsearchInternalCollection(); await updateSetupModeData(); @@ -130,6 +193,7 @@ export class SetupModeRenderer extends React.Component { closeFlyout: () => this.setState({ isFlyoutOpen: false }), }, flyoutComponent: this.getFlyout(data, meta), + bottomBarComponent: this.getBottomBar(setupModeState) }); } } diff --git a/x-pack/legacy/plugins/monitoring/public/components/setup_mode/badge.js b/x-pack/legacy/plugins/monitoring/public/components/setup_mode/badge.js new file mode 100644 index 00000000000000..d4dcd2df1fec2c --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/setup_mode/badge.js @@ -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, { Fragment } from 'react'; +import { + EuiTextColor, + EuiIcon, + EuiBadge +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ELASTICSEARCH_SYSTEM_ID } from '../../../common/constants'; + +const clickToMonitorWithMetricbeat = i18n.translate('xpack.monitoring.setupMode.clickToMonitorWithMetricbeat', { + defaultMessage: 'Monitor with Metricbeat' +}); + +const clickToDisableInternalCollection = i18n.translate('xpack.monitoring.setupMode.clickToDisableInternalCollection', { + defaultMessage: 'Disable self monitoring' +}); + +const monitoredWithMetricbeat = i18n.translate('xpack.monitoring.setupMode.usingMetricbeatCollection', { + defaultMessage: 'Monitored with Metricbeat' +}); + +const unknown = i18n.translate('xpack.monitoring.setupMode.unknown', { + defaultMessage: 'N/A' +}); + +export function SetupModeBadge({ setupMode, productName, status, instance, clusterUuid }) { + let customAction = null; + let customText = null; + + const setupModeData = setupMode.data || {}; + const setupModeMeta = setupMode.meta || {}; + + // Migrating from partially to fully for Elasticsearch involves changing a cluster + // setting which impacts all nodes in the cluster so the action text needs to reflect that + const allPartiallyMigrated = setupModeData.totalUniquePartiallyMigratedCount === setupModeData.totalUniqueInstanceCount; + + if (status.isPartiallyMigrated && productName === ELASTICSEARCH_SYSTEM_ID) { + if (allPartiallyMigrated) { + customText = clickToDisableInternalCollection; + if (setupModeMeta.liveClusterUuid === clusterUuid) { + customAction = setupMode.shortcutToFinishMigration; + } + } + else { + return ( + + +   + + {i18n.translate('xpack.monitoring.setupMode.monitorAllNodes', { + defaultMessage: 'Some nodes use only self monitoring' + })} + + + ); + } + } + + const badgeProps = {}; + if (status.isInternalCollector || status.isPartiallyMigrated || status.isNetNewUser) { + badgeProps.onClick = customAction ? customAction : () => setupMode.openFlyout(instance); + } + + + let statusText = null; + if (status.isInternalCollector) { + if (badgeProps.onClick) { + badgeProps.onClickAriaLabel = customText || clickToMonitorWithMetricbeat; + } + statusText = ( + + {customText || clickToMonitorWithMetricbeat} + + ); + } + else if (status.isPartiallyMigrated) { + if (badgeProps.onClick) { + badgeProps.onClickAriaLabel = customText || clickToDisableInternalCollection; + } + statusText = ( + + {customText || clickToDisableInternalCollection} + + ); + } + else if (status.isFullyMigrated) { + if (badgeProps.onClick) { + badgeProps.onClickAriaLabel = customText || monitoredWithMetricbeat; + } + statusText = ( + + {customText || monitoredWithMetricbeat} + + ); + } + else if (status.isNetNewUser) { + if (badgeProps.onClick) { + badgeProps.onClickAriaLabel = customText || clickToMonitorWithMetricbeat; + } + statusText = ( + + {customText || clickToMonitorWithMetricbeat} + + ); + } + else { + if (badgeProps.onClick) { + badgeProps.onClickAriaLabel = customText || unknown; + } + statusText = ( + + {customText || unknown} + + ); + } + + return statusText; +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/setup_mode/formatting.js b/x-pack/legacy/plugins/monitoring/public/components/setup_mode/formatting.js new file mode 100644 index 00000000000000..d88608bbb1afe3 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/setup_mode/formatting.js @@ -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 { capitalize } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + APM_SYSTEM_ID, + LOGSTASH_SYSTEM_ID, + ELASTICSEARCH_SYSTEM_ID, + KIBANA_SYSTEM_ID, + BEATS_SYSTEM_ID +} from '../../../common/constants'; + +const NODE_IDENTIFIER_SINGULAR = i18n.translate('xpack.monitoring.setupMode.node', { + defaultMessage: `node`, +}); +const NODE_IDENTIFIER_PLURAL = i18n.translate('xpack.monitoring.setupMode.nodes', { + defaultMessage: `nodes`, +}); +const INSTANCE_IDENTIFIER_SINGULAR = i18n.translate('xpack.monitoring.setupMode.instance', { + defaultMessage: `instance`, +}); +const INSTANCE_IDENTIFIER_PLURAL = i18n.translate('xpack.monitoring.setupMode.instances', { + defaultMessage: `instances`, +}); +const SERVER_IDENTIFIER_SINGULAR = i18n.translate('xpack.monitoring.setupMode.server', { + defaultMessage: `server`, +}); +const SERVER_IDENTIFIER_PLURAL = i18n.translate('xpack.monitoring.setupMode.servers', { + defaultMessage: `servers`, +}); + + +export function formatProductName(productName) { + if (productName === APM_SYSTEM_ID) { + return productName.toUpperCase(); + } + return capitalize(productName); +} + +const PRODUCTS_THAT_USE_NODES = [LOGSTASH_SYSTEM_ID, ELASTICSEARCH_SYSTEM_ID]; +const PRODUCTS_THAT_USE_INSTANCES = [KIBANA_SYSTEM_ID, BEATS_SYSTEM_ID]; +export function getIdentifier(productName, usePlural = false) { + if (PRODUCTS_THAT_USE_INSTANCES.includes(productName)) { + return usePlural ? INSTANCE_IDENTIFIER_PLURAL : INSTANCE_IDENTIFIER_SINGULAR; + } + if (PRODUCTS_THAT_USE_NODES.includes(productName)) { + return usePlural ? NODE_IDENTIFIER_PLURAL : NODE_IDENTIFIER_SINGULAR; + } + if (productName === APM_SYSTEM_ID) { + return usePlural ? SERVER_IDENTIFIER_PLURAL : SERVER_IDENTIFIER_SINGULAR; + } + return productName; +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/setup_mode/listing_callout.js b/x-pack/legacy/plugins/monitoring/public/components/setup_mode/listing_callout.js new file mode 100644 index 00000000000000..adede59d384d6a --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/setup_mode/listing_callout.js @@ -0,0 +1,174 @@ +/* + * 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, { Fragment } from 'react'; +import { get } from 'lodash'; +import { + EuiCallOut, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { formatProductName, getIdentifier } from './formatting'; + +const MIGRATE_TO_MB_LABEL = i18n.translate('xpack.monitoring.setupMode.migrateToMetricbeat', { + defaultMessage: 'Monitor with Metricbeat', +}); + +export function ListingCallOut({ setupModeData, productName, customRenderer = null }) { + if (customRenderer) { + const { shouldRender, componentToRender } = customRenderer(); + if (shouldRender) { + return componentToRender; + } + } + + const mightExist = get(setupModeData, 'detected.mightExist'); + + const hasInstances = setupModeData.totalUniqueInstanceCount > 0; + if (!hasInstances) { + if (mightExist) { + return ( + + +

+ {i18n.translate('xpack.monitoring.setupMode.detectedNodeDescription', { + defaultMessage: `Click 'Set up monitoring' below to start monitoring this {identifier}.`, + values: { + identifier: getIdentifier(productName) + } + })} +

+
+ +
+ ); + } + return ( + + +

+ {i18n.translate('xpack.monitoring.setupMode.netNewUserDescription', { + defaultMessage: `Click 'Set up monitoring' to start monitoring with Metricbeat.`, + })} +

+
+ +
+ ); + } + + if (setupModeData.totalUniqueFullyMigratedCount === setupModeData.totalUniqueInstanceCount) { + return ( + + + + + ); + } + + if (setupModeData.totalUniquePartiallyMigratedCount === setupModeData.totalUniqueInstanceCount) { + return ( + + +

+ {i18n.translate('xpack.monitoring.setupMode.disableInternalCollectionDescription', { + defaultMessage: `Metricbeat is now monitoring your {product} {identifier}. + Disable self monitoring to finish the migration.`, + values: { + product: formatProductName(productName), + identifier: getIdentifier(productName, true) + } + })} +

+
+ +
+ ); + } + + if (setupModeData.totalUniqueInstanceCount > 0) { + if (setupModeData.totalUniqueFullyMigratedCount === 0 && setupModeData.totalUniquePartiallyMigratedCount === 0) { + return ( + + +

+ {i18n.translate('xpack.monitoring.setupMode.migrateToMetricbeatDescription', { + defaultMessage: `These {product} {identifier} are self monitored. + Click 'Monitor with Metricbeat' to migrate.`, + values: { + product: formatProductName(productName), + identifier: getIdentifier(productName, true) + } + })} +

+
+ +
+ ); + } + + return ( + + +

+ {i18n.translate('xpack.monitoring.setupMode.migrateSomeToMetricbeatDescription', { + defaultMessage: `Some {product} {identifier} are monitored through self monitoring. Migrate to monitor with Metricbeat.`, + values: { + product: formatProductName(productName), + identifier: getIdentifier(productName, true) + } + })} +

+
+ +
+ ); + } + + return null; +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/setup_mode/tooltip.js b/x-pack/legacy/plugins/monitoring/public/components/setup_mode/tooltip.js new file mode 100644 index 00000000000000..cc73a4d29536c9 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/setup_mode/tooltip.js @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { get } from 'lodash'; +import { + EuiBadge, + EuiFlexItem, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { getIdentifier } from './formatting'; + +export function SetupModeTooltip({ setupModeData, badgeClickAction, productName }) { + if (!setupModeData) { + return null; + } + + const { + totalUniqueInstanceCount, + totalUniqueFullyMigratedCount, + totalUniquePartiallyMigratedCount + } = setupModeData; + const allMonitoredByMetricbeat = totalUniqueInstanceCount > 0 && + (totalUniqueFullyMigratedCount === totalUniqueInstanceCount || totalUniquePartiallyMigratedCount === totalUniqueInstanceCount); + const internalCollectionOn = totalUniquePartiallyMigratedCount > 0; + const mightExist = get(setupModeData, 'detected.mightExist') || get(setupModeData, 'detected.doesExist'); + + let tooltip = null; + + if (totalUniqueInstanceCount === 0) { + if (mightExist) { + const detectedText = i18n.translate('xpack.monitoring.setupMode.tooltip.detected', { + defaultMessage: 'No monitoring' + }); + tooltip = ( + + + {detectedText} + + + ); + } + else { + const noMonitoringText = i18n.translate('xpack.monitoring.setupMode.tooltip.noUsage', { + defaultMessage: 'No usage' + }); + + tooltip = ( + + + {noMonitoringText} + + + ); + } + } + + else if (!allMonitoredByMetricbeat) { + const internalCollection = i18n.translate('xpack.monitoring.euiTable.isInternalCollectorLabel', { + defaultMessage: 'Self monitoring' + }); + tooltip = ( + + + {internalCollection} + + + ); + } + else if (internalCollectionOn) { + const internalAndMB = i18n.translate('xpack.monitoring.euiTable.isPartiallyMigratedLabel', { + defaultMessage: 'Self monitoring is on' + }); + tooltip = ( + + + {internalAndMB} + + + ); + } + else { + const metricbeatCollection = i18n.translate('xpack.monitoring.euiTable.isFullyMigratedLabel', { + defaultMessage: 'Metricbeat monitoring' + }); + tooltip = ( + + + {metricbeatCollection} + + + ); + } + + return ( + + {tooltip} + + ); +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/table/eui_table.js b/x-pack/legacy/plugins/monitoring/public/components/table/eui_table.js index 9bd04e89c85121..53b16c29143bc7 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/table/eui_table.js +++ b/x-pack/legacy/plugins/monitoring/public/components/table/eui_table.js @@ -5,17 +5,13 @@ */ import React, { Fragment } from 'react'; -import { get } from 'lodash'; import { EuiInMemoryTable, - EuiBadge, - EuiButtonEmpty, - EuiHealth, EuiButton, EuiSpacer } from '@elastic/eui'; -import { ELASTICSEARCH_CUSTOM_ID } from '../../../common/constants'; import { i18n } from '@kbn/i18n'; +import { getIdentifier } from '../setup_mode/formatting'; export class EuiMonitoringTable extends React.PureComponent { render() { @@ -24,9 +20,7 @@ export class EuiMonitoringTable extends React.PureComponent { search = {}, columns: _columns, setupMode, - uuidField, - nameField, - setupNewButtonLabel, + productName, ...props } = this.props; @@ -51,150 +45,16 @@ export class EuiMonitoringTable extends React.PureComponent { let footerContent = null; if (setupMode && setupMode.enabled) { - columns.push({ - name: i18n.translate('xpack.monitoring.euiTable.setupStatusTitle', { - defaultMessage: 'Setup Status' - }), - sortable: product => { - const list = get(setupMode, 'data.byUuid', {}); - const status = list[get(product, uuidField)] || {}; - - if (status.isInternalCollector) { - return 4; - } - - if (status.isPartiallyMigrated) { - return 3; - } - - if (status.isFullyMigrated) { - return 2; - } - - if (status.isNetNewUser) { - return 1; - } - - return 0; - }, - render: (product) => { - const list = get(setupMode, 'data.byUuid', {}); - const status = list[get(product, uuidField)] || {}; - - let statusBadge = null; - if (status.isInternalCollector) { - statusBadge = ( - - {i18n.translate('xpack.monitoring.euiTable.isInternalCollectorLabel', { - defaultMessage: 'Internal collection' - })} - - ); - } - else if (status.isPartiallyMigrated) { - statusBadge = ( - - {i18n.translate('xpack.monitoring.euiTable.isPartiallyMigratedLabel', { - defaultMessage: 'Internal collection and Metricbeat collection' - })} - - ); - } - else if (status.isFullyMigrated) { - statusBadge = ( - - {i18n.translate('xpack.monitoring.euiTable.isFullyMigratedLabel', { - defaultMessage: 'Metricbeat collection' - })} - - ); - } - else if (status.isNetNewUser) { - statusBadge = ( - - {i18n.translate('xpack.monitoring.euiTable.isNetNewUserLabel', { - defaultMessage: 'No monitoring detected' - })} - - ); - } - else { - statusBadge = i18n.translate('xpack.monitoring.euiTable.migrationStatusUnknown', { - defaultMessage: 'N/A' - }); - } - - return statusBadge; - } - }); - - columns.push({ - name: i18n.translate('xpack.monitoring.euiTable.setupActionTitle', { - defaultMessage: 'Setup Action' - }), - sortable: product => { - const list = get(setupMode, 'data.byUuid', {}); - const status = list[get(product, uuidField)] || {}; - - if (status.isInternalCollector || status.isNetNewUser) { - return 1; - } - - if (status.isPartiallyMigrated) { - if (setupMode.productName === ELASTICSEARCH_CUSTOM_ID) { - // See comment for same conditional in render function - return 0; - } - return 1; - } - - return 0; - }, - render: (product) => { - const uuid = get(product, uuidField); - const list = get(setupMode, 'data.byUuid', {}); - const status = list[uuid] || {}; - const instance = { - uuid: get(product, uuidField), - name: get(product, nameField), - }; - - // Migrating from partially to fully for Elasticsearch involves changing a cluster - // setting which impacts all nodes in the cluster, which we have a separate callout - // for. Since it does not make sense to do this on a per node basis, show nothing here - if (status.isPartiallyMigrated && setupMode.productName === ELASTICSEARCH_CUSTOM_ID) { - return null; - } - - if (status.isInternalCollector || status.isPartiallyMigrated) { - return ( - setupMode.openFlyout(instance)}> - {i18n.translate('xpack.monitoring.euiTable.migrateButtonLabel', { - defaultMessage: 'Migrate' - })} - - ); - } - - if (status.isNetNewUser) { - return ( - setupMode.openFlyout(instance)}> - {i18n.translate('xpack.monitoring.euiTable.setupButtonLabel', { - defaultMessage: 'Setup' - })} - - ); - } - - return null; - } - }); - footerContent = ( - setupMode.openFlyout({}, true)}> - {setupNewButtonLabel} + setupMode.openFlyout({}, true)}> + {i18n.translate('xpack.monitoring.euiTable.setupNewButtonLabel', { + defaultMessage: 'Set up monitoring for new {identifier}', + values: { + identifier: getIdentifier(productName) + } + })} ); diff --git a/x-pack/legacy/plugins/monitoring/public/directives/main/index.js b/x-pack/legacy/plugins/monitoring/public/directives/main/index.js index 9f7debb73de9ba..a7aee9ae780588 100644 --- a/x-pack/legacy/plugins/monitoring/public/directives/main/index.js +++ b/x-pack/legacy/plugins/monitoring/public/directives/main/index.js @@ -18,7 +18,7 @@ import template from './index.html'; import { timefilter } from 'ui/timefilter'; import { shortenPipelineHash } from '../../../common/formatting'; import 'ui/directives/kbn_href'; -import { getSetupModeState } from '../../lib/setup_mode'; +import { getSetupModeState, initSetupModeState } from '../../lib/setup_mode'; const setOptions = (controller) => { if (!controller.pipelineVersions || !controller.pipelineVersions.length || !controller.pipelineDropdownElement) { @@ -56,6 +56,7 @@ const setOptions = (controller) => { , controller.pipelineDropdownElement); }; + /* * Manage data and provide helper methods for the "main" directive's template */ @@ -97,7 +98,7 @@ export class MonitoringMainController { } else { this.inOverview = this.name === 'overview'; this.inAlerts = this.name === 'alerts'; - this.inListing = this.name === 'listing' || this.name === 'no-data'; + this.inListing = this.name === 'listing';// || this.name === 'no-data'; } if (!this.inListing) { @@ -155,6 +156,10 @@ export class MonitoringMainController { if (data.totalUniqueInstanceCount === 0) { return true; } + if (data.totalUniqueInternallyCollectedCount === 0 + && data.totalUniqueFullyMigratedCount === 0 && data.totalUniquePartiallyMigratedCount === 0) { + return true; + } return false; } } @@ -169,6 +174,9 @@ uiModule.directive('monitoringMain', (breadcrumbs, license, kbnUrl, $injector) = controllerAs: 'monitoringMain', bindToController: true, link(scope, _element, attributes, controller) { + initSetupModeState(scope, $injector, () => { + controller.setup(getSetupObj()); + }); if (!scope.cluster) { const $route = $injector.get('$route'); const globalState = $injector.get('globalState'); diff --git a/x-pack/legacy/plugins/monitoring/public/lib/route_init.js b/x-pack/legacy/plugins/monitoring/public/lib/route_init.js index 2f1eef85be7257..1cd8e688854ebc 100644 --- a/x-pack/legacy/plugins/monitoring/public/lib/route_init.js +++ b/x-pack/legacy/plugins/monitoring/public/lib/route_init.js @@ -6,7 +6,7 @@ import _ from 'lodash'; import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { getSetupModeState } from './setup_mode'; +import { isInSetupMode } from './setup_mode'; import { getClusterFromClusters } from './get_cluster_from_clusters'; export function routeInitProvider(Private, monitoringClusters, globalState, license, kbnUrl) { @@ -26,8 +26,8 @@ export function routeInitProvider(Private, monitoringClusters, globalState, lice const clusterUuid = fetchAllClusters ? null : globalState.cluster_uuid; return monitoringClusters(clusterUuid, undefined, codePaths) // Set the clusters collection and current cluster in globalState - .then((clusters) => { - const inSetupMode = getSetupModeState().enabled; + .then(async (clusters) => { + const inSetupMode = await isInSetupMode(); const cluster = getClusterFromClusters(clusters, globalState); if (!cluster && !inSetupMode) { return kbnUrl.redirect('/no-data'); diff --git a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js index dbfbdb324f7aaf..3e7d182f1514c5 100644 --- a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js +++ b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js @@ -5,7 +5,12 @@ */ import { ajaxErrorHandlersProvider } from './ajax_error_handler'; -import { get } from 'lodash'; +import { get, contains } from 'lodash'; +import chrome from 'ui/chrome'; + +function isOnPage(hash) { + return contains(window.location.hash, hash); +} const angularState = { injector: null, @@ -111,70 +116,63 @@ export const disableElasticsearchInternalCollection = async () => { }; export const toggleSetupMode = inSetupMode => { - return new Promise(async (resolve, reject) => { - try { - checkAngularState(); - } catch (err) { - return reject(err); - } + checkAngularState(); - const globalState = angularState.injector.get('globalState'); - setupModeState.enabled = inSetupMode; - globalState.inSetupMode = inSetupMode; - globalState.save(); - setSetupModeMenuItem(); // eslint-disable-line no-use-before-define - notifySetupModeDataChange(); + const globalState = angularState.injector.get('globalState'); + setupModeState.enabled = inSetupMode; + globalState.inSetupMode = inSetupMode; + globalState.save(); + setSetupModeMenuItem(); // eslint-disable-line no-use-before-define + notifySetupModeDataChange(); + + if (inSetupMode) { + // Intentionally do not await this so we don't block UI operations + updateSetupModeData(); + } +}; - if (inSetupMode) { - await updateSetupModeData(); - } +export const setSetupModeMenuItem = () => { + checkAngularState(); - resolve(); - }); -}; + if (isOnPage('no-data')) { + return; + } -const setSetupModeMenuItem = () => { - // Disabling this for this initial release. This will be added back in - // in a subsequent PR - // checkAngularState(); - - // const globalState = angularState.injector.get('globalState'); - // const navItems = globalState.inSetupMode - // ? [ - // { - // key: 'exit', - // label: 'Exit Setup Mode', - // description: 'Exit setup mode', - // run: () => toggleSetupMode(false), - // testId: 'exitSetupMode' - // }, - // { - // key: 'refresh', - // label: 'Refresh Setup Data', - // description: 'Refresh data used for setup mode', - // run: () => updateSetupModeData(), - // testId: 'refreshSetupModeData' - // } - // ] - // : [{ - // key: 'enter', - // label: 'Enter Setup Mode', - // description: 'Enter setup mode', - // run: () => toggleSetupMode(true), - // testId: 'enterSetupMode' - // }]; - - // angularState.scope.topNavMenu = [...navItems]; + const globalState = angularState.injector.get('globalState'); + const navItems = globalState.inSetupMode + ? [] + : [{ + id: 'enter', + label: 'Enter Setup Mode', + description: 'Enter setup', + run: () => toggleSetupMode(true), + testId: 'enterSetupMode' + }]; + + angularState.scope.topNavMenu = [...navItems]; + // LOL angular + if (!angularState.scope.$$phase) { + angularState.scope.$apply(); + } }; -export const initSetupModeState = ($scope, $injector, callback) => { +export const initSetupModeState = async ($scope, $injector, callback) => { angularState.scope = $scope; angularState.injector = $injector; - setSetupModeMenuItem(); callback && setupModeState.callbacks.push(callback); - const globalState = angularState.injector.get('globalState'); + const globalState = $injector.get('globalState'); if (globalState.inSetupMode) { - toggleSetupMode(true); + await toggleSetupMode(true); } }; + +export const isInSetupMode = async () => { + if (setupModeState.enabled) { + return true; + } + + const $injector = angularState.injector || await chrome.dangerouslyGetActiveInjector(); + const globalState = $injector.get('globalState'); + return globalState.inSetupMode; +}; diff --git a/x-pack/legacy/plugins/monitoring/public/services/breadcrumbs_provider.js b/x-pack/legacy/plugins/monitoring/public/services/breadcrumbs_provider.js index 4bc7d31b0a92ce..aa91d0ff6e6edd 100644 --- a/x-pack/legacy/plugins/monitoring/public/services/breadcrumbs_provider.js +++ b/x-pack/legacy/plugins/monitoring/public/services/breadcrumbs_provider.js @@ -120,12 +120,11 @@ function getApmBreadcrumbs(mainInstance) { export function breadcrumbsProvider() { return function createBreadcrumbs(clusterName, mainInstance) { - let breadcrumbs = [ createCrumb('#/home', - i18n.translate( - 'xpack.monitoring.breadcrumbs.clustersLabel', { defaultMessage: 'Clusters' } - ), - 'breadcrumbClusters') - ]; + const homeCrumb = i18n.translate( + 'xpack.monitoring.breadcrumbs.clustersLabel', { defaultMessage: 'Clusters' } + ); + + let breadcrumbs = [ createCrumb('#/home', homeCrumb, 'breadcrumbClusters')]; if (!mainInstance.inOverview && clusterName) { breadcrumbs.push(createCrumb('#/overview', clusterName)); diff --git a/x-pack/legacy/plugins/monitoring/public/views/apm/instances/index.js b/x-pack/legacy/plugins/monitoring/public/views/apm/instances/index.js index 367c9f78a44d8f..35116924f5d5c1 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/apm/instances/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/apm/instances/index.js @@ -14,7 +14,7 @@ import { ApmServerInstances } from '../../../components/apm/instances'; import { MonitoringViewBaseEuiTableController } from '../..'; import { I18nContext } from 'ui/i18n'; import { SetupModeRenderer } from '../../../components/renderers'; -import { APM_CUSTOM_ID, CODE_PATH_APM } from '../../../../common/constants'; +import { APM_SYSTEM_ID, CODE_PATH_APM } from '../../../../common/constants'; uiRoutes.when('/apm/instances', { template, @@ -67,8 +67,8 @@ uiRoutes.when('/apm/instances', { ( + productName={APM_SYSTEM_ID} + render={({ setupMode, flyoutComponent, bottomBarComponent }) => ( {flyoutComponent} + {bottomBarComponent} )} /> diff --git a/x-pack/legacy/plugins/monitoring/public/views/beats/listing/index.js b/x-pack/legacy/plugins/monitoring/public/views/beats/listing/index.js index 2878874e1a4a93..deb195df1d8108 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/beats/listing/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/beats/listing/index.js @@ -15,8 +15,7 @@ import React, { Fragment } from 'react'; import { I18nContext } from 'ui/i18n'; import { Listing } from '../../../components/beats/listing/listing'; import { SetupModeRenderer } from '../../../components/renderers'; -import { BEATS_SYSTEM_ID } from '../../../../../telemetry/common/constants'; -import { CODE_PATH_BEATS } from '../../../../common/constants'; +import { CODE_PATH_BEATS, BEATS_SYSTEM_ID } from '../../../../common/constants'; uiRoutes.when('/beats/beats', { template, @@ -63,7 +62,7 @@ uiRoutes.when('/beats/beats', { scope={this.scope} injector={this.injector} productName={BEATS_SYSTEM_ID} - render={({ setupMode, flyoutComponent }) => ( + render={({ setupMode, flyoutComponent, bottomBarComponent }) => ( {flyoutComponent}
+ {bottomBarComponent} )} /> diff --git a/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js b/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js index 5cf212abf901f9..60cb8349070e49 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js @@ -58,7 +58,7 @@ uiRoutes.when('/overview', { ( + render={({ setupMode, flyoutComponent, bottomBarComponent }) => ( {flyoutComponent} + {bottomBarComponent} )} /> diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/nodes/index.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/nodes/index.js index 67658c665d3cfa..ce7e81a80e5217 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/nodes/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/nodes/index.js @@ -16,7 +16,7 @@ import { ElasticsearchNodes } from '../../../components'; import { I18nContext } from 'ui/i18n'; import { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; import { SetupModeRenderer } from '../../../components/renderers'; -import { ELASTICSEARCH_CUSTOM_ID, CODE_PATH_ELASTICSEARCH } from '../../../../common/constants'; +import { ELASTICSEARCH_SYSTEM_ID, CODE_PATH_ELASTICSEARCH } from '../../../../common/constants'; uiRoutes.when('/elasticsearch/nodes', { template, @@ -83,8 +83,8 @@ uiRoutes.when('/elasticsearch/nodes', { ( + productName={ELASTICSEARCH_SYSTEM_ID} + render={({ setupMode, flyoutComponent, bottomBarComponent }) => ( {flyoutComponent} + {bottomBarComponent} )} /> diff --git a/x-pack/legacy/plugins/monitoring/public/views/kibana/instances/index.js b/x-pack/legacy/plugins/monitoring/public/views/kibana/instances/index.js index 1b8e2b193c97b7..f5f2f5a5a76df1 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/kibana/instances/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/kibana/instances/index.js @@ -13,8 +13,7 @@ import template from './index.html'; import { KibanaInstances } from 'plugins/monitoring/components/kibana/instances'; import { SetupModeRenderer } from '../../../components/renderers'; import { I18nContext } from 'ui/i18n'; -import { KIBANA_SYSTEM_ID } from '../../../../../telemetry/common/constants'; -import { CODE_PATH_KIBANA } from '../../../../common/constants'; +import { KIBANA_SYSTEM_ID, CODE_PATH_KIBANA } from '../../../../common/constants'; uiRoutes.when('/kibana/instances', { template, @@ -47,7 +46,7 @@ uiRoutes.when('/kibana/instances', { scope={$scope} injector={$injector} productName={KIBANA_SYSTEM_ID} - render={({ setupMode, flyoutComponent }) => ( + render={({ setupMode, flyoutComponent, bottomBarComponent }) => ( {flyoutComponent} + {bottomBarComponent} )} /> diff --git a/x-pack/legacy/plugins/monitoring/public/views/loading/index.js b/x-pack/legacy/plugins/monitoring/public/views/loading/index.js index 683a1e1ac5264a..2b79e047177a61 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/loading/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/loading/index.js @@ -10,7 +10,6 @@ import { PageLoading } from 'plugins/monitoring/components'; import uiRoutes from 'ui/routes'; import { I18nContext } from 'ui/i18n'; import template from './index.html'; -import { toggleSetupMode, getSetupModeState, initSetupModeState } from '../../lib/setup_mode'; import { CODE_PATH_LICENSE } from '../../../common/constants'; const REACT_DOM_ID = 'monitoringLoadingReactApp'; @@ -23,17 +22,6 @@ uiRoutes const monitoringClusters = $injector.get('monitoringClusters'); const kbnUrl = $injector.get('kbnUrl'); - initSetupModeState($scope, $injector); - - const setupMode = getSetupModeState(); - // For phase 3, this is not an valid route unless - // setup mode is currently enabled. For phase 4, - // we will remove this check. - if (!setupMode.enabled) { - kbnUrl.changePath('/no-data'); - return; - } - $scope.$on('$destroy', () => { unmountComponentAtNode(document.getElementById(REACT_DOM_ID)); }); @@ -48,12 +36,8 @@ uiRoutes kbnUrl.changePath('/home'); return; } - initSetupModeState($scope, $injector); - return toggleSetupMode(true) - .then(() => { - kbnUrl.changePath('/elasticsearch/nodes'); - $scope.$apply(); - }); + kbnUrl.changePath('/no-data'); + return; }); } diff --git a/x-pack/legacy/plugins/monitoring/public/views/logstash/nodes/index.js b/x-pack/legacy/plugins/monitoring/public/views/logstash/nodes/index.js index a392911dcda1dd..1f4b37b77ad64a 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/logstash/nodes/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/logstash/nodes/index.js @@ -12,8 +12,7 @@ import template from './index.html'; import { I18nContext } from 'ui/i18n'; import { Listing } from '../../../components/logstash/listing'; import { SetupModeRenderer } from '../../../components/renderers'; -import { LOGSTASH_SYSTEM_ID } from '../../../../../telemetry/common/constants'; -import { CODE_PATH_LOGSTASH } from '../../../../common/constants'; +import { CODE_PATH_LOGSTASH, LOGSTASH_SYSTEM_ID } from '../../../../common/constants'; uiRoutes.when('/logstash/nodes', { template, @@ -46,7 +45,7 @@ uiRoutes.when('/logstash/nodes', { scope={$scope} injector={$injector} productName={LOGSTASH_SYSTEM_ID} - render={({ setupMode, flyoutComponent }) => ( + render={({ setupMode, flyoutComponent, bottomBarComponent }) => ( {flyoutComponent} + {bottomBarComponent} )} /> diff --git a/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js b/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js index 0fef0fbe125e2e..0ecd6c83265fff 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js +++ b/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js @@ -78,6 +78,8 @@ export class NoDataController extends MonitoringViewBaseController { } this.render(enabler); }, true); + + this.changePath = path => kbnUrl.changePath(path); } getDefaultModel() { @@ -94,9 +96,10 @@ export class NoDataController extends MonitoringViewBaseController { render(enabler) { const props = this; + this.renderReact( - + ); } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/__test__/get_collection_status.js b/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/__test__/get_collection_status.js index 228e2b602dfbb0..bb42dad26786a5 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/__test__/get_collection_status.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/__test__/get_collection_status.js @@ -9,7 +9,8 @@ import sinon from 'sinon'; import { getCollectionStatus } from '../'; import { getIndexPatterns } from '../../../cluster/get_index_patterns'; -const mockReq = (searchResult = {}, msearchResult = { responses: [] }) => { +const liveClusterUuid = 'a12'; +const mockReq = (searchResult = {}) => { return { server: { config() { @@ -18,12 +19,28 @@ const mockReq = (searchResult = {}, msearchResult = { responses: [] }) => { .withArgs('server.uuid').returns('kibana-1234') }; }, + usage: { + collectorSet: { + getCollectorByType: () => ({ + isReady: () => false + }) + } + }, plugins: { elasticsearch: { getCluster() { return { - callWithRequest(_req, type) { - return Promise.resolve(type === 'search' ? searchResult : msearchResult); + callWithRequest(_req, type, params) { + if (type === 'transport.request' && (params && params.path === '/_cluster/state/cluster_uuid')) { + return Promise.resolve({ cluster_uuid: liveClusterUuid }); + } + if (type === 'transport.request' && (params && params.path === '/_nodes')) { + return Promise.resolve({ nodes: {} }); + } + if (type === 'cat.indices') { + return Promise.resolve([1]); + } + return Promise.resolve(searchResult); } }; } @@ -192,7 +209,7 @@ describe('getCollectionStatus', () => { ] }); - const result = await getCollectionStatus(req, getIndexPatterns(req.server)); + const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); expect(result.kibana.detected.doesExist).to.be(true); expect(result.elasticsearch.detected.doesExist).to.be(true); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js b/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js index 85e0745436463d..a49da8ba60200a 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js @@ -5,8 +5,14 @@ */ import { get, uniq } from 'lodash'; -import { METRICBEAT_INDEX_NAME_UNIQUE_TOKEN, ELASTICSEARCH_CUSTOM_ID, APM_CUSTOM_ID } from '../../../../common/constants'; -import { KIBANA_SYSTEM_ID, BEATS_SYSTEM_ID, LOGSTASH_SYSTEM_ID } from '../../../../../telemetry/common/constants'; +import { + METRICBEAT_INDEX_NAME_UNIQUE_TOKEN, + ELASTICSEARCH_SYSTEM_ID, + APM_SYSTEM_ID, + KIBANA_SYSTEM_ID, + BEATS_SYSTEM_ID, + LOGSTASH_SYSTEM_ID +} from '../../../../common/constants'; import { getLivesNodes } from '../../elasticsearch/nodes/get_nodes/get_live_nodes'; import { KIBANA_STATS_TYPE } from '../../../../../../../../src/legacy/server/status/constants'; @@ -139,18 +145,18 @@ const getRecentMonitoringDocuments = async (req, indexPatterns, clusterUuid, nod return await callWithRequest(req, 'search', params); }; -async function detectProducts(req) { +async function detectProducts(req, isLiveCluster) { const result = { [KIBANA_SYSTEM_ID]: { doesExist: true, }, - [ELASTICSEARCH_CUSTOM_ID]: { + [ELASTICSEARCH_SYSTEM_ID]: { doesExist: true, }, [BEATS_SYSTEM_ID]: { mightExist: false, }, - [APM_CUSTOM_ID]: { + [APM_SYSTEM_ID]: { mightExist: false, }, [LOGSTASH_SYSTEM_ID]: { @@ -174,18 +180,20 @@ async function detectProducts(req) { ] }, { - id: APM_CUSTOM_ID, + id: APM_SYSTEM_ID, indices: [ 'apm-*' ] } ]; - const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('data'); - for (const { id, indices } of detectionSearch) { - const response = await callWithRequest(req, 'cat.indices', { index: indices, format: 'json' }); - if (response.length) { - result[id].mightExist = true; + if (isLiveCluster) { + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('data'); + for (const { id, indices } of detectionSearch) { + const response = await callWithRequest(req, 'cat.indices', { index: indices, format: 'json' }); + if (response.length) { + result[id].mightExist = true; + } } } @@ -194,12 +202,12 @@ async function detectProducts(req) { function getUuidBucketName(productName) { switch (productName) { - case ELASTICSEARCH_CUSTOM_ID: + case ELASTICSEARCH_SYSTEM_ID: return 'es_uuids'; case KIBANA_SYSTEM_ID: return 'kibana_uuids'; case BEATS_SYSTEM_ID: - case APM_CUSTOM_ID: + case APM_SYSTEM_ID: return 'beats_uuids'; case LOGSTASH_SYSTEM_ID: return 'logstash_uuids'; @@ -232,7 +240,7 @@ function shouldSkipBucket(product, bucket) { if (product.name === BEATS_SYSTEM_ID && isBeatFromAPM(bucket)) { return true; } - if (product.name === APM_CUSTOM_ID && !isBeatFromAPM(bucket)) { + if (product.name === APM_SYSTEM_ID && !isBeatFromAPM(bucket)) { return true; } return false; @@ -271,7 +279,7 @@ async function getLiveElasticsearchCollectionEnabled(req) { }); const sources = ['persistent', 'transient', 'defaults']; for (const source of sources) { - const collectionSettings = get(response[source], 'xpack.monitoring.collection'); + const collectionSettings = get(response[source], 'xpack.monitoring.elasticsearch.collection'); if (collectionSettings && collectionSettings.enabled === 'true') { return true; } @@ -308,13 +316,15 @@ async function getLiveElasticsearchCollectionEnabled(req) { export const getCollectionStatus = async (req, indexPatterns, clusterUuid, nodeUuid, skipLiveData) => { const config = req.server.config(); const kibanaUuid = config.get('server.uuid'); + const liveClusterUuid = skipLiveData ? null : await getLiveElasticsearchClusterUuid(req); + const isLiveCluster = !clusterUuid || liveClusterUuid === clusterUuid; const PRODUCTS = [ { name: KIBANA_SYSTEM_ID }, { name: BEATS_SYSTEM_ID }, { name: LOGSTASH_SYSTEM_ID }, - { name: APM_CUSTOM_ID, token: '-beats-' }, - { name: ELASTICSEARCH_CUSTOM_ID, token: '-es-' }, + { name: APM_SYSTEM_ID, token: '-beats-' }, + { name: ELASTICSEARCH_SYSTEM_ID, token: '-es-' }, ]; const [ @@ -322,12 +332,12 @@ export const getCollectionStatus = async (req, indexPatterns, clusterUuid, nodeU detectedProducts ] = await Promise.all([ await getRecentMonitoringDocuments(req, indexPatterns, clusterUuid, nodeUuid), - await detectProducts(req) + await detectProducts(req, isLiveCluster) ]); - const liveClusterUuid = skipLiveData ? null : await getLiveElasticsearchClusterUuid(req); - const liveEsNodes = skipLiveData || (clusterUuid && liveClusterUuid !== clusterUuid) ? [] : await getLivesNodes(req); - const liveKibanaInstance = skipLiveData || (clusterUuid && liveClusterUuid !== clusterUuid) ? {} : await getLiveKibanaInstance(req); + + const liveEsNodes = skipLiveData || !isLiveCluster ? [] : await getLivesNodes(req); + const liveKibanaInstance = skipLiveData || !isLiveCluster ? {} : await getLiveKibanaInstance(req); const indicesBuckets = get(recentDocuments, 'aggregations.indices.buckets', []); const liveClusterInternalCollectionEnabled = await getLiveElasticsearchCollectionEnabled(req); @@ -338,6 +348,7 @@ export const getCollectionStatus = async (req, indexPatterns, clusterUuid, nodeU const productStatus = { totalUniqueInstanceCount: 0, + totalUniqueInternallyCollectedCount: 0, totalUniqueFullyMigratedCount: 0, totalUniquePartiallyMigratedCount: 0, detected: null, @@ -348,28 +359,6 @@ export const getCollectionStatus = async (req, indexPatterns, clusterUuid, nodeU const internalCollectorsUuidsMap = {}; const partiallyMigratedUuidsMap = {}; - if (product.name === ELASTICSEARCH_CUSTOM_ID && liveEsNodes.length) { - productStatus.byUuid = liveEsNodes.reduce((accum, esNode) => ({ - ...accum, - [esNode.id]: { - node: esNode, - isNetNewUser: true - }, - }), {}); - } - - if (product.name === KIBANA_SYSTEM_ID && liveKibanaInstance) { - const kibanaLiveUuid = get(liveKibanaInstance, 'kibana.uuid'); - if (kibanaLiveUuid) { - productStatus.byUuid = { - [kibanaLiveUuid]: { - instance: liveKibanaInstance, - isNetNewUser: true - } - }; - } - } - // If there is no data, then they are a net new user if (!indexBuckets || indexBuckets.length === 0) { productStatus.totalUniqueInstanceCount = 0; @@ -400,6 +389,7 @@ export const getCollectionStatus = async (req, indexPatterns, clusterUuid, nodeU } } productStatus.totalUniqueInstanceCount = Object.keys(map).length; + productStatus.totalUniqueInternallyCollectedCount = Object.keys(internalCollectorsUuidsMap).length; productStatus.totalUniquePartiallyMigratedCount = Object.keys(partiallyMigratedUuidsMap).length; productStatus.totalUniqueFullyMigratedCount = Object.keys(fullyMigratedUuidsMap).length; productStatus.byUuid = { @@ -435,7 +425,7 @@ export const getCollectionStatus = async (req, indexPatterns, clusterUuid, nodeU } // If there are multiple buckets, they are partially upgraded assuming a single mb index exists else { - const considerAllInstancesMigrated = product.name === ELASTICSEARCH_CUSTOM_ID && + const considerAllInstancesMigrated = product.name === ELASTICSEARCH_SYSTEM_ID && clusterUuid === liveClusterUuid && !liveClusterInternalCollectionEnabled; const internalTimestamps = []; for (const indexBucket of indexBuckets) { @@ -479,6 +469,7 @@ export const getCollectionStatus = async (req, indexPatterns, clusterUuid, nodeU ...Object.keys(fullyMigratedUuidsMap), ...Object.keys(partiallyMigratedUuidsMap) ]).length; + productStatus.totalUniqueInternallyCollectedCount = Object.keys(internalCollectorsUuidsMap).length; productStatus.totalUniquePartiallyMigratedCount = Object.keys(partiallyMigratedUuidsMap).length; productStatus.totalUniqueFullyMigratedCount = Object.keys(fullyMigratedUuidsMap).length; productStatus.byUuid = { @@ -518,6 +509,35 @@ export const getCollectionStatus = async (req, indexPatterns, clusterUuid, nodeU productStatus.detected = detectedProducts[product.name]; } + if (product.name === ELASTICSEARCH_SYSTEM_ID && liveEsNodes.length) { + productStatus.byUuid = liveEsNodes.reduce((byUuid, esNode) => { + if (!byUuid[esNode.id]) { + productStatus.totalUniqueInstanceCount++; + return { + ...byUuid, + [esNode.id]: { + node: esNode, + isNetNewUser: true + }, + }; + } + return byUuid; + }, productStatus.byUuid); + } + + if (product.name === KIBANA_SYSTEM_ID && liveKibanaInstance) { + const kibanaLiveUuid = get(liveKibanaInstance, 'kibana.uuid'); + if (kibanaLiveUuid && !productStatus.byUuid[kibanaLiveUuid]) { + productStatus.totalUniqueInstanceCount++; + productStatus.byUuid = { + [kibanaLiveUuid]: { + instance: liveKibanaInstance, + isNetNewUser: true + } + }; + } + } + return { ...products, [product.name]: productStatus, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 6887dab9425cee..adc64130aab423 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8171,9 +8171,6 @@ "xpack.monitoring.elasticsearch.nodes.diskFreeSpaceColumnTitle": "ディスクの空き容量", "xpack.monitoring.elasticsearch.nodes.jvmMemoryColumnTitle": "{javaVirtualMachine} メモリー", "xpack.monitoring.elasticsearch.nodes.loadAverageColumnTitle": "平均負荷", - "xpack.monitoring.elasticsearch.nodes.metribeatMigration.disableInternalCollectionDescription": "ご使用の Elasticsearch サーバーはすべて Metricbeat により監視されています。\n 但し、移行を完了させるには内部収集を無効にする必要があります。", - "xpack.monitoring.elasticsearch.nodes.metribeatMigration.disableInternalCollectionMigrationButtonLabel": "無効にして移行を完了させる", - "xpack.monitoring.elasticsearch.nodes.metribeatMigration.disableInternalCollectionTitle": "内部収集を無効にして移行を完了させる", "xpack.monitoring.elasticsearch.nodes.monitoringTablePlaceholder": "ノードをフィルタリング…", "xpack.monitoring.elasticsearch.nodes.nameColumnTitle": "名前", "xpack.monitoring.elasticsearch.nodes.routeTitle": "Elasticsearch - ノード", @@ -8241,10 +8238,6 @@ "xpack.monitoring.euiTable.isFullyMigratedLabel": "Metricbeat 収集", "xpack.monitoring.euiTable.isInternalCollectorLabel": "内部収集", "xpack.monitoring.euiTable.isPartiallyMigratedLabel": "内部収集と Metricbeat 収集", - "xpack.monitoring.euiTable.migrateButtonLabel": "移行", - "xpack.monitoring.euiTable.migrationStatusUnknown": "N/A", - "xpack.monitoring.euiTable.setupActionTitle": "セットアップアクション", - "xpack.monitoring.euiTable.setupStatusTitle": "セットアップステータス", "xpack.monitoring.feature.reserved.description": "ユーザーアクセスを許可するには、monitoring_user ロールも割り当てる必要があります。", "xpack.monitoring.featureRegistry.monitoringFeatureName": "スタック監視", "xpack.monitoring.formatNumbers.notAvailableLabel": "N/A", @@ -8366,58 +8359,32 @@ "xpack.monitoring.logstashNavigation.pipelineVersionDescription": "バージョンは {relativeLastSeen} 時点でアクティブ、初回検知 {relativeFirstSeen}", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.configureMetricbeatDescription": "{file} にこれらの変更を加えます。", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.configureMetricbeatTitle": "Metricbeat を構成して監視クラスターに送ります", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.disableInternalCollection.fullyMigratedStatusDescription": "内部収集からのドキュメントがありません。移行完了!", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.disableInternalCollection.fullyMigratedStatusTitle": "お疲れさまでした!", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.disableInternalCollection.partiallyMigratedStatusDescription": "最後の内部収集は {secondsSinceLastInternalCollectionLabel} 前に行われました。", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.disableInternalCollectionDescription": "Elasticsearch 監視メトリックの内部収集を無効にします。本番クラスターの各サーバーの {monospace} を false に設定します。", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.disableInternalCollectionTitle": "Elasticsearch 監視メトリックの内部収集を無効にする", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.enableMetricbeatModuleDescription": "モジュールはデフォルトで {url} から Elasticsearch 監視メトリックを収集します。ローカル Elasticsearch サーバーのアドレスが異なる場合は、{module} ファイルのホスト設定で指定する必要があります。", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.enableMetricbeatModuleTitle": "Metricbeat の Elasticsearch X-Pack モジュールの有効化と構成", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.fullyMigratedStatusDescription": "Metricbeat から監視データが送信され始めました!", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.fullyMigratedStatusTitle": "お疲れさまでした!", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.installMetricbeatLinkText": "こちらの手順に従ってください", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.installMetricbeatTitle": "Metricbeat を Elasticsearch と同じサーバーにインストール", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.metricbeatSecuritySetup": "セキュリティ機能が有効な場合、追加セットアップが必要な可能性があります。{link}", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.metricbeatSecuritySetupLinkText": "詳細をご覧ください。", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.partiallyMigratedStatusTitle": "現在も Elasticsearch の内部収集からデータが送信されています。", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.startMetricbeatLinkText": "こちらの手順に従ってください", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.startMetricbeatTitle": "Metricbeat を起動します", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.statusTitle": "移行ステータス", "xpack.monitoring.metricbeatMigration.flyout.closeButtonLabel": "閉じる", "xpack.monitoring.metricbeatMigration.flyout.doneButtonLabel": "完了", - "xpack.monitoring.metricbeatMigration.flyout.elasticsearchNode": "ノード", - "xpack.monitoring.metricbeatMigration.flyout.elasticsearchNodesTitle": "Elasticsearch ノード", - "xpack.monitoring.metricbeatMigration.flyout.flyoutTitle": "{instanceName} {instanceType} の Metricbeat への移行", - "xpack.monitoring.metricbeatMigration.flyout.kibanaInstance": "インスタンス", "xpack.monitoring.metricbeatMigration.flyout.nextButtonLabel": "次へ", "xpack.monitoring.metricbeatMigration.flyout.step1.monitoringUrlHelpText": "これは通常単一のインスタンスですが、複数ある場合は、すべてのインスタンス URL をコンマ区切りで入力します。\n Metricbeat インスタンスの実行には、Elasticsearch サーバーとの通信が必要です。", "xpack.monitoring.metricbeatMigration.flyout.step1.monitoringUrlLabel": "監視クラスター URL", "xpack.monitoring.metricbeatMigration.kibanaInstructions.configureMetricbeatDescription": "{file} にこれらの変更を加えます。", "xpack.monitoring.metricbeatMigration.kibanaInstructions.configureMetricbeatTitle": "Metricbeat を構成して監視クラスターに送ります", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.checkingStatusButtonLabel": "確認中...", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.checkStatusButtonLabel": "確認", "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.description": "Kibana 構成ファイル ({file}) に次の設定を追加します:", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.fullyMigratedStatusDescription": "内部収集からのドキュメントがありません。移行完了!", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.fullyMigratedStatusTitle": "お疲れさまでした!", "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.note": "{config} をデフォルト値のままにします ({defaultValue})。", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.partiallyMigratedStatusDescription": "最後の内部収集は {secondsSinceLastInternalCollectionLabel} 前に行われました。", "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.restartNote": "このステップには Kibana サーバーの再起動が必要です。サーバーの再起動が完了するまでエラーが表示されます。", "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.restartWarningTitle": "警告", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.statusDescription": "内部収集からのドキュメントがないことを確認してください。", "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.title": "Kibana 監視メトリックの内部収集を無効にする", "xpack.monitoring.metricbeatMigration.kibanaInstructions.enableMetricbeatModuleDescription": "モジュールはデフォルトで http://localhost:5601 から Kibana 監視メトリックを収集します。ローカル Kibana インスタンスのアドレスが異なる場合は、{file} ファイルの {hosts} 設定で指定する必要があります。", "xpack.monitoring.metricbeatMigration.kibanaInstructions.enableMetricbeatModuleTitle": "Metricbeat の Kibana X-Pack もウールの有効化と構成", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.fullyMigratedStatusDescription": "Metricbeat から監視データが送信され始めました!", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.fullyMigratedStatusTitle": "お疲れさまでした!", "xpack.monitoring.metricbeatMigration.kibanaInstructions.installMetricbeatLinkText": "こちらの手順に従ってください", "xpack.monitoring.metricbeatMigration.kibanaInstructions.installMetricbeatTitle": "Metricbeat を Kibana と同じサーバーにインストール", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.metricbeatSecuritySetup": "セキュリティ機能が有効な場合、追加セットアップが必要な可能性があります。{link}", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.metricbeatSecuritySetupLinkText": "詳細をご覧ください。", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.partiallyMigratedStatusDescription": "検出には最長 {secondsAgo} 秒かかる場合がありますが、引き続きバックグラウンドで {timePeriod} 秒ごとに確認します。", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.partiallyMigratedStatusTitle": "現在も Kibana の内部収集からデータが送信されています。", "xpack.monitoring.metricbeatMigration.kibanaInstructions.startMetricbeatLinkText": "こちらの手順に従ってください", "xpack.monitoring.metricbeatMigration.kibanaInstructions.startMetricbeatTitle": "Metricbeat を起動します", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.statusTitle": "移行ステータス", "xpack.monitoring.metrics.apm.outputAckedEventsRate.ackedDescription": "アウトプットにより処理されたイベントです (再試行を含む)", "xpack.monitoring.metrics.apm.outputAckedEventsRate.ackedLabel": "承認済み", "xpack.monitoring.metrics.apm.outputAckedEventsRateTitle": "アウトプット承認イベントレート", @@ -8957,131 +8924,52 @@ "xpack.monitoring.summaryStatus.statusIconLabel": "ステータス: {status}", "xpack.monitoring.summaryStatus.statusIconTitle": "ステータス: {statusIcon}", "xpack.monitoring.uiExportsDescription": "Elastic Stack の監視です", - "xpack.monitoring.apm.instances.metricbeatMigration.detectedInstanceDescription": "インデックスによると、APM サーバーがあると思われます。下の「監視をセットアップ」をクリックして\n この APM サーバーの監視を開始してください。", - "xpack.monitoring.apm.instances.metricbeatMigration.detectedInstanceTitle": "APM サーバーが検出されました", - "xpack.monitoring.apm.metricbeatMigration.setupNewButtonLabel": "新規 APM サーバーの監視をセットアップ", - "xpack.monitoring.beats.instances.metricbeatMigration.detectedInstanceDescription": "インデックスによると、Beats インスタンスがあると思われます。下の「監視をセットアップ」をクリックして\n このインスタンスの監視を開始してください。", - "xpack.monitoring.beats.instances.metricbeatMigration.detectedInstanceTitle": "Beats インスタンスが検出されました", - "xpack.monitoring.beats.metricbeatMigration.setupNewButtonLabel": "新規 Beats インスタンスの監視をセットアップ", "xpack.monitoring.chart.timeSeries.zoomOut": "ズームアウト", - "xpack.monitoring.cluster.overview.apmPanel.setupModeNodesTooltip.disableInternal": "すべてのサーバーが Metricbeat によって監視されていますが、内部収集を\n オフにする必要があります。フラッグアイコンをクリックして、サーバーリストページにアクセスし、内部収集を無効にしてください。", - "xpack.monitoring.cluster.overview.apmPanel.setupModeNodesTooltip.oneInternal": "Metricbeat により監視されていないサーバーが少なくとも 1 つあります。フラッグ\n アイコンをクリックして、サーバーリストページにアクセスし、各サーバーの詳細なステータスを確認してください。", - "xpack.monitoring.cluster.overview.beatsPanel.setupModeNodesTooltip.disableInternal": "すべてのインスタンスが Metricbeat によって監視されていますが、内部収集を\n オフにする必要があります。フラッグアイコンをクリックして、インスタンスリストページにアクセスし、内部収集を無効にしてください。", - "xpack.monitoring.cluster.overview.beatsPanel.setupModeNodesTooltip.oneInternal": "Metricbeat により監視されていないインスタンスが少なくとも 1 つあります。フラッグ\n アイコンをクリックして、インスタンスリストページにアクセスし、各インスタンスの詳細なステータスを確認してください。", - "xpack.monitoring.cluster.overview.elasticsearchPanel.setupModeNodesTooltip.disableInternal": "すべてのノードが Metricbeat によって監視されていますが、内部収集を上のメニューバーの\n フラッグアイコンをクリックして、ノードリストページにアクセスし、内部収集を無効にしてください。", - "xpack.monitoring.cluster.overview.elasticsearchPanel.setupModeNodesTooltip.oneInternal": "Metricbeat により監視されていないノードが少なくとも 1 つあります。フラッグアイコンをクリックして、ノード\n リストページにアクセスし、各ノードの詳細なステータスを確認してください。", - "xpack.monitoring.cluster.overview.kibanaPanel.setupModeNodesTooltip.disableInternal": "すべてのインスタンスが Metricbeat によって監視されていますが、内部収集を\n オフにする必要があります。フラッグアイコンをクリックして、インスタンスリストページにアクセスし、内部収集を無効にしてください。", - "xpack.monitoring.cluster.overview.kibanaPanel.setupModeNodesTooltip.oneInternal": "Metricbeat により監視されていないインスタンスが少なくとも 1 つあります。フラッグ\n アイコンをクリックして、インスタンスリストページにアクセスし、各インスタンスの詳細なステータスを確認してください。", - "xpack.monitoring.cluster.overview.logstashPanel.setupModeNodesTooltip.disableInternal": "すべてのノードが Metricbeat によって監視されていますが、内部収集を\n オフにする必要があります。フラッグアイコンをクリックして、ノードリストページにアクセスし、内部収集を無効にしてください。", - "xpack.monitoring.cluster.overview.logstashPanel.setupModeNodesTooltip.oneInternal": "Metricbeat により監視されていないノードが少なくとも 1 つあります。フラッグ\n アイコンをクリックして、ノードリストページにアクセスし、各ノードの詳細なステータスを確認してください。", - "xpack.monitoring.elasticsearch.metricbeatMigration.setupNewButtonLabel": "新規 Elasticsearch ノードの監視をセットアップ", - "xpack.monitoring.elasticsearch.nodes.metribeatMigration.netNewUserDescription": "監視データは検出されませんでしたが、次の Elasticsearch ノードが検出されました。\n 検出された各ノードとセットアップボタンが下に表示されます。このボタンをクリックすると、\n 各ノードの監視を有効にするプロセスをご案内します。", - "xpack.monitoring.elasticsearch.nodes.metribeatMigration.netNewUserTitle": "監視データが検出されませんでした", "xpack.monitoring.errors.monitoringLicenseErrorDescription": "クラスター = 「{clusterId}」のライセンス情報が見つかりませんでした。クラスターのマスターノードサーバーログにエラーや警告がないか確認してください。", "xpack.monitoring.errors.monitoringLicenseErrorTitle": "監視ライセンスエラー", - "xpack.monitoring.euiTable.isNetNewUserLabel": "監視が検出されませんでした", - "xpack.monitoring.euiTable.setupButtonLabel": "セットアップ", - "xpack.monitoring.kibana.metricbeatMigration.setupNewButtonLabel": "新規 Kibana インスタンスの監視をセットアップ", - "xpack.monitoring.kibana.nodes.metribeatMigration.netNewUserDescription": "監視データは検出されませんでしたが、次の Kibana インスタンスが検出されました。\n 検出されたインスタンスとセットアップボタンが下に表示されます。このボタンをクリックすると、\n このインスタンスの監視を有効にするプロセスをご案内します。", - "xpack.monitoring.kibana.nodes.metribeatMigration.netNewUserTitle": "監視データが検出されませんでした", "xpack.monitoring.logs.reason.defaultMessage": "ログデータが見つからず、理由を診断することができません。{link}", "xpack.monitoring.logs.reason.defaultMessageLink": "正しくセットアップされていることを確認してください。", "xpack.monitoring.logs.reason.defaultTitle": "ログデータが見つかりませんでした", - "xpack.monitoring.logstash.metricbeatMigration.setupNewButtonLabel": "新規 Logstash サーバーの監視をセットアップ", - "xpack.monitoring.logstash.nodes.metribeatMigration.netNewUserDescription": "インデックスによると、Logstash ノードがあると思われます。下の「監視をセットアップ」をクリックして\n このノードの監視を開始してください。", - "xpack.monitoring.logstash.nodes.metribeatMigration.netNewUserTitle": "監視データが検出されませんでした", "xpack.monitoring.metricbeatMigration.apmInstructions.configureMetricbeatDescription": "{file} にこれらの変更を加えます。", "xpack.monitoring.metricbeatMigration.apmInstructions.configureMetricbeatTitle": "Metricbeat を構成して監視クラスターに送ります", - "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.checkingStatusButtonLabel": "確認中...", - "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.checkStatusButtonLabel": "確認", "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.description": "APM サーバーの構成ファイル ({file}) に次の設定を追加します:", - "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.fullyMigratedStatusDescription": "内部収集からのドキュメントがありません。移行完了!", - "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.fullyMigratedStatusTitle": "お疲れさまでした!", "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.note": "この変更後、APM サーバーの再起動が必要です。", - "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.partiallyMigratedStatusDescription": "最後の内部収集は {secondsSinceLastInternalCollectionLabel} 前に行われました。", - "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.statusDescription": "内部収集からのドキュメントがないことを確認してください。", "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.title": "APM サーバーの監視メトリックの内部収集を無効にする", "xpack.monitoring.metricbeatMigration.apmInstructions.enableMetricbeatModuleDescription": "モジュールはデフォルトで http://localhost:5066 から APM サーバーの監視メトリックを収集します。ローカル APM サーバーのアドレスが異なる場合は、{file} ファイルの {hosts} 設定で指定する必要があります。", "xpack.monitoring.metricbeatMigration.apmInstructions.enableMetricbeatModuleTitle": "Metricbeat の Beat X-Pack モジュールの有効化と構成", - "xpack.monitoring.metricbeatMigration.apmInstructions.fullyMigratedStatusDescription": "Metricbeat から監視データが送信され始めました!", - "xpack.monitoring.metricbeatMigration.apmInstructions.fullyMigratedStatusTitle": "お疲れさまでした!", "xpack.monitoring.metricbeatMigration.apmInstructions.installMetricbeatLinkText": "こちらの手順に従ってください", "xpack.monitoring.metricbeatMigration.apmInstructions.installMetricbeatTitle": "Metricbeat を APM サーバーと同じサーバーにインストール", - "xpack.monitoring.metricbeatMigration.apmInstructions.isInternalCollectorStatusTitle": "この APM サーバーの Metricbeat からの監視データが検出されていません。\n 引き続きバックグラウンドで確認を続けます。", - "xpack.monitoring.metricbeatMigration.apmInstructions.metricbeatSecuritySetup": "セキュリティ機能が有効な場合、追加セットアップが必要な可能性があります。{link}", - "xpack.monitoring.metricbeatMigration.apmInstructions.metricbeatSecuritySetupLinkText": "詳細をご覧ください。", - "xpack.monitoring.metricbeatMigration.apmInstructions.partiallyMigratedStatusDescription": "検出には最長 {secondsAgo} 秒かかる場合がありますが、引き続きバックグラウンドで {timePeriod} 秒ごとに確認します。", - "xpack.monitoring.metricbeatMigration.apmInstructions.partiallyMigratedStatusTitle": "現在も APM サーバーの内部収集からデータが送信されています。", "xpack.monitoring.metricbeatMigration.apmInstructions.startMetricbeatLinkText": "こちらの手順に従ってください", "xpack.monitoring.metricbeatMigration.apmInstructions.startMetricbeatTitle": "Metricbeat を起動します", - "xpack.monitoring.metricbeatMigration.apmInstructions.statusTitle": "移行ステータス", "xpack.monitoring.metricbeatMigration.beatsInstructions.configureMetricbeatDescription": "{file} にこれらの変更を加えます。", "xpack.monitoring.metricbeatMigration.beatsInstructions.configureMetricbeatTitle": "Metricbeat を構成して監視クラスターに送ります", - "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.checkingStatusButtonLabel": "確認中...", - "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.checkStatusButtonLabel": "確認", "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.description": "{beatType} の構成ファイル ({file}) に次の設定を追加します:", - "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.fullyMigratedStatusDescription": "内部収集からのドキュメントがありません。移行完了!", - "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.fullyMigratedStatusTitle": "お疲れさまでした!", "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.note": "この変更後、{beatType} の再起動が必要です。", - "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.partiallyMigratedStatusDescription": "最後の内部収集は {secondsSinceLastInternalCollectionLabel} 前に行われました。", - "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.statusDescription": "内部収集からのドキュメントがないことを確認してください。", "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.title": "{beatType} の監視メトリックの内部収集を無効にする", "xpack.monitoring.metricbeatMigration.beatsInstructions.enableMetricbeatModuleDescription": "モジュールはデフォルトで http://localhost:5066 から {beatType} 監視メトリックを収集します。監視されている {beatType} インスタンスのアドレスが異なる場合は、{file} ファイルの {hosts} 設定で指定する必要があります。", "xpack.monitoring.metricbeatMigration.beatsInstructions.enableMetricbeatModuleHttpEnabledDirections": "Metricbeat が実行中の {beatType} からメトリックを収集するには、{link} 必要があります。", "xpack.monitoring.metricbeatMigration.beatsInstructions.enableMetricbeatModuleHttpEnabledDirectionsLinkText": "監視されている {beatType} の HTTP エンドポイントを有効にする", "xpack.monitoring.metricbeatMigration.beatsInstructions.enableMetricbeatModuleTitle": "Metricbeat の Beat X-Pack モジュールの有効化と構成", - "xpack.monitoring.metricbeatMigration.beatsInstructions.fullyMigratedStatusDescription": "Metricbeat から監視データが送信され始めました!", - "xpack.monitoring.metricbeatMigration.beatsInstructions.fullyMigratedStatusTitle": "お疲れさまでした!", "xpack.monitoring.metricbeatMigration.beatsInstructions.installMetricbeatLinkText": "こちらの手順に従ってください", "xpack.monitoring.metricbeatMigration.beatsInstructions.installMetricbeatTitle": "Metricbeat を {beatType} と同じサーバーにインストール", - "xpack.monitoring.metricbeatMigration.beatsInstructions.isInternalCollectorStatusTitle": "この Beat の Metricbeat からの監視データが検出されていません。\n 引き続きバックグラウンドで確認を続けます。", - "xpack.monitoring.metricbeatMigration.beatsInstructions.metricbeatSecuritySetup": "セキュリティ機能が有効な場合、追加セットアップが必要な可能性があります。{link}", - "xpack.monitoring.metricbeatMigration.beatsInstructions.metricbeatSecuritySetupLinkText": "詳細をご覧ください。", - "xpack.monitoring.metricbeatMigration.beatsInstructions.partiallyMigratedStatusDescription": "検出には最長 {secondsAgo} 秒かかる場合がありますが、引き続きバックグラウンドで {timePeriod} 秒ごとに確認します。", - "xpack.monitoring.metricbeatMigration.beatsInstructions.partiallyMigratedStatusTitle": "現在も Beat の内部収集からデータが送信されています。", "xpack.monitoring.metricbeatMigration.beatsInstructions.startMetricbeatLinkText": "こちらの手順に従ってください", "xpack.monitoring.metricbeatMigration.beatsInstructions.startMetricbeatTitle": "Metricbeat を起動します", - "xpack.monitoring.metricbeatMigration.beatsInstructions.statusTitle": "移行ステータス", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.statusTitleNewUser": "監視ステータス", - "xpack.monitoring.metricbeatMigration.flyout.flyoutTitleNewUser": "Metricbeat で {instanceName} {instanceType} を監視", - "xpack.monitoring.metricbeatMigration.flyout.instance": "インスタンス", - "xpack.monitoring.metricbeatMigration.flyout.noClusterUuidCheckboxLabel": "はい、\n この {productName} {typeText} スタンドアロンクラスターを調べる必要があることを理解しています。", - "xpack.monitoring.metricbeatMigration.flyout.noClusterUuidDescription": "この {productName} {typeText} は Elasticsearch クラスターに接続されていないため、完全に移行された時点で、この {productName} {typeText} はこのクラスターではなくスタンドアロンクラスターに表示されます。 {link}", "xpack.monitoring.metricbeatMigration.flyout.noClusterUuidTitle": "クラスターが検出されてませんでした", - "xpack.monitoring.metricbeatMigration.flyout.node": "ノード", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.statusTitleNewUser": "監視ステータス", "xpack.monitoring.metricbeatMigration.logstashInstructions.configureMetricbeatDescription": "{file} にこれらの変更を加えます。", "xpack.monitoring.metricbeatMigration.logstashInstructions.configureMetricbeatTitle": "Metricbeat を構成して監視クラスターに送ります", - "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.checkingStatusButtonLabel": "確認中...", - "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.checkStatusButtonLabel": "確認", "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.description": "Logstash 構成ファイル ({file}) に次の設定を追加します:", - "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.fullyMigratedStatusDescription": "内部収集からのドキュメントがありません。移行完了!", - "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.fullyMigratedStatusTitle": "お疲れさまでした!", "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.note": "この変更後、Logstash の再起動が必要です。", - "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.partiallyMigratedStatusDescription": "最後の内部収集は {secondsSinceLastInternalCollectionLabel} 前に行われました。", - "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.statusDescription": "内部収集からのドキュメントがないことを確認してください。", "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.title": "Logstash 監視メトリックの内部収集を無効にする", "xpack.monitoring.metricbeatMigration.logstashInstructions.enableMetricbeatModuleDescription": "モジュールはデフォルトで http://localhost:9600 から Logstash 監視メトリックを収集します。ローカル Logstash インスタンスのアドレスが異なる場合は、{file} ファイルの {hosts} 設定で指定する必要があります。", "xpack.monitoring.metricbeatMigration.logstashInstructions.enableMetricbeatModuleTitle": "Metricbeat の Logstash X-Pack もウールの有効化と構成", - "xpack.monitoring.metricbeatMigration.logstashInstructions.fullyMigratedStatusDescription": "Metricbeat から監視データが送信され始めました!", - "xpack.monitoring.metricbeatMigration.logstashInstructions.fullyMigratedStatusTitle": "お疲れさまでした!", "xpack.monitoring.metricbeatMigration.logstashInstructions.installMetricbeatLinkText": "こちらの手順に従ってください", "xpack.monitoring.metricbeatMigration.logstashInstructions.installMetricbeatTitle": "Metricbeat を Logstash と同じサーバーにインストール", - "xpack.monitoring.metricbeatMigration.logstashInstructions.isInternalCollectorStatusTitle": "この Metricbeat ノードの Metricbeat からの監視データが検出されていません。\n 引き続きバックグラウンドで確認を続けます。", - "xpack.monitoring.metricbeatMigration.logstashInstructions.metricbeatSecuritySetup": "セキュリティ機能が有効な場合、追加セットアップが必要な可能性があります。{link}", - "xpack.monitoring.metricbeatMigration.logstashInstructions.metricbeatSecuritySetupLinkText": "詳細をご覧ください。", - "xpack.monitoring.metricbeatMigration.logstashInstructions.partiallyMigratedStatusDescription": "検出には最長 {secondsAgo} 秒かかる場合がありますが、引き続きバックグラウンドで {timePeriod} 秒ごとに確認します。", - "xpack.monitoring.metricbeatMigration.logstashInstructions.partiallyMigratedStatusTitle": "現在も Logstash の内部収集からデータが送信されています。", "xpack.monitoring.metricbeatMigration.logstashInstructions.startMetricbeatLinkText": "こちらの手順に従ってください", "xpack.monitoring.metricbeatMigration.logstashInstructions.startMetricbeatTitle": "Metricbeat を起動します", - "xpack.monitoring.metricbeatMigration.logstashInstructions.statusTitle": "移行ステータス", "xpack.monitoring.noData.blurbs.cloudDeploymentDescription": "次の場所に戻ってください: ", "xpack.monitoring.noData.blurbs.cloudDeploymentDescriptionMore": "Elastic Cloud での監視の詳細は、 ", "xpack.monitoring.noData.blurbs.cloudDeploymentTitle": "監視データはこちらに表示されません。", "xpack.monitoring.noData.explanations.exportersCloudDescription": "Elastic Cloud では、監視データが専用の監視クラスターに格納されます。", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.isInternalCollectorStatusTitle": "この Elasticsearch ノードの Metricbeat からの監視データが検出されていません。\n 引き続きバックグラウンドで確認を続けます。", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.partiallyMigratedStatusDescription": "検出には最長 {secondsAgo} 秒かかる場合がありますが、引き続きバックグラウンドで確認します。", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.isInternalCollectorStatusTitle": "この Kibana インスタンスの Metricbeat からの監視データが検出されていません。\n 引き続きバックグラウンドで確認を続けます。", "xpack.remoteClusters.addAction.clusterNameAlreadyExistsErrorMessage": "「{clusterName}」という名前のクラスターが既に存在します。", "xpack.remoteClusters.addAction.errorTitle": "クラスターの追加中にエラーが発生", "xpack.remoteClusters.addAction.failedDefaultErrorMessage": "{statusCode} エラーでリクエスト失敗: {message}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 649ead90b63561..31616c9e6db7cf 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8173,9 +8173,6 @@ "xpack.monitoring.elasticsearch.nodes.diskFreeSpaceColumnTitle": "磁盘可用空间", "xpack.monitoring.elasticsearch.nodes.jvmMemoryColumnTitle": "{javaVirtualMachine} 内存", "xpack.monitoring.elasticsearch.nodes.loadAverageColumnTitle": "负载平均值", - "xpack.monitoring.elasticsearch.nodes.metribeatMigration.disableInternalCollectionDescription": "系统将使用 Metricbeat 监测所有 Elasticsearch 服务器,\n 但您需要禁用内部收集以完成迁移。", - "xpack.monitoring.elasticsearch.nodes.metribeatMigration.disableInternalCollectionMigrationButtonLabel": "禁用并完成迁移", - "xpack.monitoring.elasticsearch.nodes.metribeatMigration.disableInternalCollectionTitle": "禁用内部收集以完成迁移", "xpack.monitoring.elasticsearch.nodes.monitoringTablePlaceholder": "筛选节点……", "xpack.monitoring.elasticsearch.nodes.nameColumnTitle": "名称", "xpack.monitoring.elasticsearch.nodes.routeTitle": "Elasticsearch - 节点", @@ -8243,10 +8240,6 @@ "xpack.monitoring.euiTable.isFullyMigratedLabel": "Metricbeat 收集", "xpack.monitoring.euiTable.isInternalCollectorLabel": "内部收集", "xpack.monitoring.euiTable.isPartiallyMigratedLabel": "内部收集和 Metricbeat 收集", - "xpack.monitoring.euiTable.migrateButtonLabel": "迁移", - "xpack.monitoring.euiTable.migrationStatusUnknown": "不适用", - "xpack.monitoring.euiTable.setupActionTitle": "设置操作", - "xpack.monitoring.euiTable.setupStatusTitle": "设置状态", "xpack.monitoring.feature.reserved.description": "要向用户授予访问权限,还应分配 monitoring_user 角色。", "xpack.monitoring.featureRegistry.monitoringFeatureName": "堆栈监测", "xpack.monitoring.formatNumbers.notAvailableLabel": "不适用", @@ -8368,58 +8361,32 @@ "xpack.monitoring.logstashNavigation.pipelineVersionDescription": "活动版本 {relativeLastSeen} 和首次看到 {relativeFirstSeen}", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.configureMetricbeatDescription": "在 {file} 文件中进行这些更改。", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.configureMetricbeatTitle": "配置 Metricbeat 以发送至监测集群", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.disableInternalCollection.fullyMigratedStatusDescription": "我们未看到任何来自内部收集的文档。迁移完成!", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.disableInternalCollection.fullyMigratedStatusTitle": "恭喜您!", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.disableInternalCollection.partiallyMigratedStatusDescription": "上次内部收集发生于 {secondsSinceLastInternalCollectionLabel}前。", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.disableInternalCollectionDescription": "禁用 Elasticsearch 监测指标的内部收集。在生产集群中的每个服务器上将 {monospace} 设置为 false。", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.disableInternalCollectionTitle": "禁用 Elasticsearch 监测指标的内部收集", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.enableMetricbeatModuleDescription": "默认情况下,该模块将从 {url} 收集 Elasticsearch 监测指标。如果本地 Elasticsearch 服务器有不同的地址,则必须通过 {module} 文件中的 hosts 设置来进行指定。", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.enableMetricbeatModuleTitle": "在 Metricbeat 中启用并配置 Elasticsearch x-pack 模块", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.fullyMigratedStatusDescription": "我们现在看到来自 Metricbeat 的监测数据传送!", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.fullyMigratedStatusTitle": "恭喜您!", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.installMetricbeatLinkText": "按照此处的说明执行操作", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.installMetricbeatTitle": "在安装 Elasticsearch 的同一台服务器上安装 Metricbeat", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.metricbeatSecuritySetup": "如果已启用安全功能,则可能需要更多的设置。{link}", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.metricbeatSecuritySetupLinkText": "查看更多信息。", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.partiallyMigratedStatusTitle": "我们仍看到数据来自 Elasticsearch 的内部收集。", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.startMetricbeatLinkText": "按照此处的说明执行操作", "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.startMetricbeatTitle": "启动 Metricbeat", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.statusTitle": "迁移状态", "xpack.monitoring.metricbeatMigration.flyout.closeButtonLabel": "关闭", "xpack.monitoring.metricbeatMigration.flyout.doneButtonLabel": "完成", - "xpack.monitoring.metricbeatMigration.flyout.elasticsearchNode": "节点", - "xpack.monitoring.metricbeatMigration.flyout.elasticsearchNodesTitle": "Elasticsearch 节点", - "xpack.monitoring.metricbeatMigration.flyout.flyoutTitle": "将 {instanceName} {instanceType} 迁移到 Metricbeat", - "xpack.monitoring.metricbeatMigration.flyout.kibanaInstance": "实例", "xpack.monitoring.metricbeatMigration.flyout.nextButtonLabel": "下一个", "xpack.monitoring.metricbeatMigration.flyout.step1.monitoringUrlHelpText": "这通常是单个实例,但如果您有多个,请输入所有实例 url,以逗号分隔。\n 切记运行的 Metricbeat 实例需要能够与这些 Elasticsearch 实例通信。", "xpack.monitoring.metricbeatMigration.flyout.step1.monitoringUrlLabel": "监测集群 URL", "xpack.monitoring.metricbeatMigration.kibanaInstructions.configureMetricbeatDescription": "在 {file} 文件中进行这些更改。", "xpack.monitoring.metricbeatMigration.kibanaInstructions.configureMetricbeatTitle": "配置 Metricbeat 以发送至监测集群", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.checkingStatusButtonLabel": "正在检查......", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.checkStatusButtonLabel": "检查", "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.description": "在 Kibana 配置文件 ({file}) 中添加以下设置:", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.fullyMigratedStatusDescription": "我们未看到任何来自内部收集的文档。迁移完成!", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.fullyMigratedStatusTitle": "恭喜您!", "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.note": "将 {config} 设置为其默认值 ({defaultValue})。", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.partiallyMigratedStatusDescription": "上次内部收集发生于 {secondsSinceLastInternalCollectionLabel}前。", "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.restartNote": "此步骤需要您重新启动 Kibana 服务器。在服务器再次运行之前应会看到错误。", "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.restartWarningTitle": "警告", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.statusDescription": "确认没有文档来自内部收集。", "xpack.monitoring.metricbeatMigration.kibanaInstructions.disableInternalCollection.title": "禁用 Kibana 监测指标的内部收集", "xpack.monitoring.metricbeatMigration.kibanaInstructions.enableMetricbeatModuleDescription": "该模块将默认从 http://localhost:5601 收集 Kibana 监测指标。如果本地 Kibana 实例有不同的地址,则必须通过 {file} 文件中的 {hosts} 设置进行指定。", "xpack.monitoring.metricbeatMigration.kibanaInstructions.enableMetricbeatModuleTitle": "在 Metricbeat 中启用并配置 Kibana x-pack 模块", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.fullyMigratedStatusDescription": "我们现在看到来自 Metricbeat 的监测数据传送!", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.fullyMigratedStatusTitle": "恭喜您!", "xpack.monitoring.metricbeatMigration.kibanaInstructions.installMetricbeatLinkText": "按照此处的说明执行操作", "xpack.monitoring.metricbeatMigration.kibanaInstructions.installMetricbeatTitle": "在安装 Kibana 的同一台服务器上安装 Metricbeat", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.metricbeatSecuritySetup": "如果已启用安全功能,则可能需要更多的设置。{link}", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.metricbeatSecuritySetupLinkText": "查看更多信息。", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.partiallyMigratedStatusDescription": "请注意,检测最多花费 {secondsAgo} 秒,但我们在后台每 {timePeriod} 秒检查一次。", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.partiallyMigratedStatusTitle": "我们仍看到数据来自 Kibana 的内部收集。", "xpack.monitoring.metricbeatMigration.kibanaInstructions.startMetricbeatLinkText": "按照此处的说明执行操作", "xpack.monitoring.metricbeatMigration.kibanaInstructions.startMetricbeatTitle": "启动 Metricbeat", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.statusTitle": "迁移状态", "xpack.monitoring.metrics.apm.outputAckedEventsRate.ackedDescription": "输出处理的事件(包括重试)", "xpack.monitoring.metrics.apm.outputAckedEventsRate.ackedLabel": "已确认", "xpack.monitoring.metrics.apm.outputAckedEventsRateTitle": "输出已确认事件速率", @@ -8959,131 +8926,52 @@ "xpack.monitoring.summaryStatus.statusIconLabel": "状态:{status}", "xpack.monitoring.summaryStatus.statusIconTitle": "状态:{statusIcon}", "xpack.monitoring.uiExportsDescription": "Elastic Stack 的 Monitoring 组件", - "xpack.monitoring.apm.instances.metricbeatMigration.detectedInstanceDescription": "基于您的索引,我们认为您可能有 APM Server。单击下面的“设置监测”\n 按钮以开始监测此 APM Server。", - "xpack.monitoring.apm.instances.metricbeatMigration.detectedInstanceTitle": "检测到 APM Server", - "xpack.monitoring.apm.metricbeatMigration.setupNewButtonLabel": "为新的 APM Server 设置监测", - "xpack.monitoring.beats.instances.metricbeatMigration.detectedInstanceDescription": "基于您的索引,我们认为您可能有 Beats 实例。单击下面的“设置监测”\n 按钮以开始监测此实例。", - "xpack.monitoring.beats.instances.metricbeatMigration.detectedInstanceTitle": "检测到 Beats 实例", - "xpack.monitoring.beats.metricbeatMigration.setupNewButtonLabel": "为新的 Beats 实例设置监测", "xpack.monitoring.chart.timeSeries.zoomOut": "缩小", - "xpack.monitoring.cluster.overview.apmPanel.setupModeNodesTooltip.disableInternal": "正在使用 Metricbeat 监测所有服务器,但内部收集仍需要\n 关闭。单击旗帜图标可访问服务器列表页面以及禁用内部收集。", - "xpack.monitoring.cluster.overview.apmPanel.setupModeNodesTooltip.oneInternal": "至少有一个服务器未使用 Metricbeat 进行监测。单击旗帜\n 图标可访问服务器列表页面以及详细了解每个服务器的状态。", - "xpack.monitoring.cluster.overview.beatsPanel.setupModeNodesTooltip.disableInternal": "正在使用 Metricbeat 监测所有实例,但内部收集仍需要\n 关闭。单击旗帜图标可访问实例列表页面以及禁用内部收集。", - "xpack.monitoring.cluster.overview.beatsPanel.setupModeNodesTooltip.oneInternal": "至少有一个实例未使用 Metricbeat 进行监测。单击旗帜\n 图标可访问实例列表页面以及详细了解每个实例的状态。", - "xpack.monitoring.cluster.overview.elasticsearchPanel.setupModeNodesTooltip.disableInternal": "正在使用 Metricbeat 监测所有节点,但内部收集仍需要关闭。单击\n 旗帜图标可访问节点列表页面以及禁用内部收集。", - "xpack.monitoring.cluster.overview.elasticsearchPanel.setupModeNodesTooltip.oneInternal": "至少有一个节点未使用 Metricbeat 进行监测。单击旗帜图标可访问节点\n 列表页面以及详细了解每个节点的状态。", - "xpack.monitoring.cluster.overview.kibanaPanel.setupModeNodesTooltip.disableInternal": "正在使用 Metricbeat 监测所有实例,但内部收集仍需要\n 关闭。单击旗帜图标可访问实例列表页面以及禁用内部收集。", - "xpack.monitoring.cluster.overview.kibanaPanel.setupModeNodesTooltip.oneInternal": "至少有一个实例未使用 Metricbeat 进行监测。单击旗帜\n 图标可访问实例列表页面以及详细了解每个实例的状态。", - "xpack.monitoring.cluster.overview.logstashPanel.setupModeNodesTooltip.disableInternal": "正在使用 Metricbeat 监测所有节点,但内部收集仍需要\n 关闭。单击旗帜图标可访问节点列表页面以及禁用内部收集。", - "xpack.monitoring.cluster.overview.logstashPanel.setupModeNodesTooltip.oneInternal": "至少有一个节点未使用 Metricbeat 进行监测。单击旗帜\n 图标可访问节点列表页面以及详细了解每个节点的状态。", - "xpack.monitoring.elasticsearch.metricbeatMigration.setupNewButtonLabel": "为新的 Elasticsearch 节点设置监测", - "xpack.monitoring.elasticsearch.nodes.metribeatMigration.netNewUserDescription": "我们未检测到任何监测数据,但我们却检测到以下 Elasticsearch 节点。\n 检测到的每个节点与相应的“设置”按钮在下面一起列出。单击此按钮,系统将指导您完成\n 为每个节点启用监测的过程。", - "xpack.monitoring.elasticsearch.nodes.metribeatMigration.netNewUserTitle": "未检测到任何监测数据", "xpack.monitoring.errors.monitoringLicenseErrorDescription": "无法找到集群“{clusterId}”的许可信息。请在集群的主节点服务器日志中查看相关错误或警告。", "xpack.monitoring.errors.monitoringLicenseErrorTitle": "监测许可错误", - "xpack.monitoring.euiTable.isNetNewUserLabel": "未检测到监测", - "xpack.monitoring.euiTable.setupButtonLabel": "设置", - "xpack.monitoring.kibana.metricbeatMigration.setupNewButtonLabel": "为新的 Kibana 实例设置监测", - "xpack.monitoring.kibana.nodes.metribeatMigration.netNewUserDescription": "我们未检测到任何监测数据,但我们却检测到以下 Kibana 实例。\n 该检测到的实例与相应的“设置”按钮在下面一起列出。单击此按钮,系统将指导您完成\n 为此实例启用监测的过程。", - "xpack.monitoring.kibana.nodes.metribeatMigration.netNewUserTitle": "未检测到任何监测数据", "xpack.monitoring.logs.reason.defaultMessage": "我们未找到任何日志数据,我们无法诊断原因。{link}", "xpack.monitoring.logs.reason.defaultMessageLink": "请确认您的设置正确。", "xpack.monitoring.logs.reason.defaultTitle": "未找到任何日志数据", - "xpack.monitoring.logstash.metricbeatMigration.setupNewButtonLabel": "为新的 Logstash 节点设置监测", - "xpack.monitoring.logstash.nodes.metribeatMigration.netNewUserDescription": "基于您的索引,我们认为您可能有 Logstash 节点。单击下面的“设置监测”\n 按钮以开始监测此节点。", - "xpack.monitoring.logstash.nodes.metribeatMigration.netNewUserTitle": "未检测到任何监测数据", "xpack.monitoring.metricbeatMigration.apmInstructions.configureMetricbeatDescription": "在 {file} 文件中进行这些更改。", "xpack.monitoring.metricbeatMigration.apmInstructions.configureMetricbeatTitle": "配置 Metricbeat 以发送至监测集群", - "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.checkingStatusButtonLabel": "正在检查......", - "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.checkStatusButtonLabel": "检查", "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.description": "在 APM Server 的配置文件 ({file}) 中添加以下设置:", - "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.fullyMigratedStatusDescription": "我们未看到任何来自内部收集的文档。迁移完成!", - "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.fullyMigratedStatusTitle": "恭喜您!", "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.note": "进行此更改后,需要重新启动 APM Server。", - "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.partiallyMigratedStatusDescription": "上次内部收集发生于 {secondsSinceLastInternalCollectionLabel}前。", - "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.statusDescription": "确认没有文档来自内部收集。", "xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.title": "禁用 APM Server 监测指标的内部收集", "xpack.monitoring.metricbeatMigration.apmInstructions.enableMetricbeatModuleDescription": "该模块将默认从 http://localhost:5066 收集 APM Server 监测指标。如果本地 APM Server 有不同的地址,则必须通过 {file} 文件中的 {hosts} 设置进行指定。", "xpack.monitoring.metricbeatMigration.apmInstructions.enableMetricbeatModuleTitle": "在 Metricbeat 中启用并配置 Beat x-pack 模块", - "xpack.monitoring.metricbeatMigration.apmInstructions.fullyMigratedStatusDescription": "我们现在看到来自 Metricbeat 的监测数据传送!", - "xpack.monitoring.metricbeatMigration.apmInstructions.fullyMigratedStatusTitle": "恭喜您!", "xpack.monitoring.metricbeatMigration.apmInstructions.installMetricbeatLinkText": "按照此处的说明执行操作", "xpack.monitoring.metricbeatMigration.apmInstructions.installMetricbeatTitle": "在安装 APM Server 的同一台服务器上安装 Metricbeat", - "xpack.monitoring.metricbeatMigration.apmInstructions.isInternalCollectorStatusTitle": "我们未检测到任何监测数据来自此 APM Server 的 Metricbeat。\n 我们将持续在后台检查。", - "xpack.monitoring.metricbeatMigration.apmInstructions.metricbeatSecuritySetup": "如果已启用安全功能,则可能需要更多的设置。{link}", - "xpack.monitoring.metricbeatMigration.apmInstructions.metricbeatSecuritySetupLinkText": "查看更多信息。", - "xpack.monitoring.metricbeatMigration.apmInstructions.partiallyMigratedStatusDescription": "请注意,检测最多花费 {secondsAgo} 秒,但我们在后台每 {timePeriod} 秒检查一次。", - "xpack.monitoring.metricbeatMigration.apmInstructions.partiallyMigratedStatusTitle": "我们仍看到数据来自此 APM Server 的内部收集。", "xpack.monitoring.metricbeatMigration.apmInstructions.startMetricbeatLinkText": "按照此处的说明执行操作", "xpack.monitoring.metricbeatMigration.apmInstructions.startMetricbeatTitle": "启动 Metricbeat", - "xpack.monitoring.metricbeatMigration.apmInstructions.statusTitle": "迁移状态", "xpack.monitoring.metricbeatMigration.beatsInstructions.configureMetricbeatDescription": "在 {file} 文件中进行这些更改。", "xpack.monitoring.metricbeatMigration.beatsInstructions.configureMetricbeatTitle": "配置 Metricbeat 以发送至监测集群", - "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.checkingStatusButtonLabel": "正在检查......", - "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.checkStatusButtonLabel": "检查", "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.description": "在 {beatType} 的配置文件 ({file}) 中添加以下设置:", - "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.fullyMigratedStatusDescription": "我们未看到任何来自内部收集的文档。迁移完成!", - "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.fullyMigratedStatusTitle": "恭喜您!", "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.note": "进行此更改后,您需要重新启动 {beatType}。", - "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.partiallyMigratedStatusDescription": "上次内部收集发生于 {secondsSinceLastInternalCollectionLabel}前。", - "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.statusDescription": "确认没有文档来自内部收集。", "xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.title": "禁用 {beatType} 监测指标的内部收集", "xpack.monitoring.metricbeatMigration.beatsInstructions.enableMetricbeatModuleDescription": "该模块将默认从 http://localhost:5066 收集 {beatType} 监测指标。如果正在监测的 {beatType} 实例有不同的地址,则必须通过 {file} 文件中的 {hosts} 设置进行指定。", "xpack.monitoring.metricbeatMigration.beatsInstructions.enableMetricbeatModuleHttpEnabledDirections": "要使 Metricbeat 从正在运行的 {beatType} 收集指标,需要{link}。", "xpack.monitoring.metricbeatMigration.beatsInstructions.enableMetricbeatModuleHttpEnabledDirectionsLinkText": "为正在监测的 {beatType} 实例启用 HTTP 终端节点", "xpack.monitoring.metricbeatMigration.beatsInstructions.enableMetricbeatModuleTitle": "在 Metricbeat 中启用并配置 Beat x-pack 模块", - "xpack.monitoring.metricbeatMigration.beatsInstructions.fullyMigratedStatusDescription": "我们现在看到来自 Metricbeat 的监测数据传送!", - "xpack.monitoring.metricbeatMigration.beatsInstructions.fullyMigratedStatusTitle": "恭喜您!", "xpack.monitoring.metricbeatMigration.beatsInstructions.installMetricbeatLinkText": "按照此处的说明执行操作", "xpack.monitoring.metricbeatMigration.beatsInstructions.installMetricbeatTitle": "在安装此 {beatType} 的同一台服务器上安装 Metricbeat", - "xpack.monitoring.metricbeatMigration.beatsInstructions.isInternalCollectorStatusTitle": "我们未检测到任何监测数据来自此 Beat 的 Metricbeat。\n 我们将持续在后台检查。", - "xpack.monitoring.metricbeatMigration.beatsInstructions.metricbeatSecuritySetup": "如果已启用安全功能,则可能需要更多的设置。{link}", - "xpack.monitoring.metricbeatMigration.beatsInstructions.metricbeatSecuritySetupLinkText": "查看更多信息。", - "xpack.monitoring.metricbeatMigration.beatsInstructions.partiallyMigratedStatusDescription": "请注意,检测最多花费 {secondsAgo} 秒,但我们在后台每 {timePeriod} 秒检查一次。", - "xpack.monitoring.metricbeatMigration.beatsInstructions.partiallyMigratedStatusTitle": "我们仍看到数据来自此 Beat 的内部收集。", "xpack.monitoring.metricbeatMigration.beatsInstructions.startMetricbeatLinkText": "按照此处的说明执行操作", "xpack.monitoring.metricbeatMigration.beatsInstructions.startMetricbeatTitle": "启动 Metricbeat", - "xpack.monitoring.metricbeatMigration.beatsInstructions.statusTitle": "迁移状态", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.statusTitleNewUser": "监测状态", - "xpack.monitoring.metricbeatMigration.flyout.flyoutTitleNewUser": "使用 Metricbeat 监测 {instanceName} {instanceType}", - "xpack.monitoring.metricbeatMigration.flyout.instance": "实例", - "xpack.monitoring.metricbeatMigration.flyout.noClusterUuidCheckboxLabel": "是的,我明白我将需要在独立集群中寻找\n 此 {productName} {typeText}。", - "xpack.monitoring.metricbeatMigration.flyout.noClusterUuidDescription": "此 {productName} {typeText} 未连接到 Elasticsearch 集群,因此完全迁移后,此 {productName} {typeText} 将显示在独立集群中,而非此集群中。{link}", "xpack.monitoring.metricbeatMigration.flyout.noClusterUuidTitle": "未检测到集群", - "xpack.monitoring.metricbeatMigration.flyout.node": "节点", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.statusTitleNewUser": "监测状态", "xpack.monitoring.metricbeatMigration.logstashInstructions.configureMetricbeatDescription": "在 {file} 文件中进行这些更改。", "xpack.monitoring.metricbeatMigration.logstashInstructions.configureMetricbeatTitle": "配置 Metricbeat 以发送至监测集群", - "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.checkingStatusButtonLabel": "正在检查......", - "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.checkStatusButtonLabel": "检查", "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.description": "在 Logstash 配置文件 ({file}) 中添加以下设置:", - "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.fullyMigratedStatusDescription": "我们未看到任何来自内部收集的文档。迁移完成!", - "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.fullyMigratedStatusTitle": "恭喜您!", "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.note": "进行此更改后,您需要重新启动 Logstash。", - "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.partiallyMigratedStatusDescription": "上次内部收集发生于 {secondsSinceLastInternalCollectionLabel}前。", - "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.statusDescription": "确认没有文档来自内部收集。", "xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.title": "禁用 Logstash 监测指标的内部收集", "xpack.monitoring.metricbeatMigration.logstashInstructions.enableMetricbeatModuleDescription": "该模块将默认从 http://localhost:9600 收集 Logstash 监测指标。如果本地 Logstash 实例有不同的地址,则必须通过 {file} 文件中的 {hosts} 设置进行指定。", "xpack.monitoring.metricbeatMigration.logstashInstructions.enableMetricbeatModuleTitle": "在 Metricbeat 中启用并配置 Logstash x-pack 模块", - "xpack.monitoring.metricbeatMigration.logstashInstructions.fullyMigratedStatusDescription": "我们现在看到来自 Metricbeat 的监测数据传送!", - "xpack.monitoring.metricbeatMigration.logstashInstructions.fullyMigratedStatusTitle": "恭喜您!", "xpack.monitoring.metricbeatMigration.logstashInstructions.installMetricbeatLinkText": "按照此处的说明执行操作", "xpack.monitoring.metricbeatMigration.logstashInstructions.installMetricbeatTitle": "在安装 Logstash 的同一台服务器上安装 Metricbeat", - "xpack.monitoring.metricbeatMigration.logstashInstructions.isInternalCollectorStatusTitle": "我们未检测到任何监测数据来自此 Logstash 节点的 Metricbeat。\n 我们将持续在后台检查。", - "xpack.monitoring.metricbeatMigration.logstashInstructions.metricbeatSecuritySetup": "如果已启用安全功能,则可能需要更多的设置。{link}", - "xpack.monitoring.metricbeatMigration.logstashInstructions.metricbeatSecuritySetupLinkText": "查看更多信息。", - "xpack.monitoring.metricbeatMigration.logstashInstructions.partiallyMigratedStatusDescription": "请注意,检测最多花费 {secondsAgo} 秒,但我们在后台每 {timePeriod} 秒检查一次。", - "xpack.monitoring.metricbeatMigration.logstashInstructions.partiallyMigratedStatusTitle": "我们仍看到数据来自 Logstash 的内部收集。", "xpack.monitoring.metricbeatMigration.logstashInstructions.startMetricbeatLinkText": "按照此处的说明执行操作", "xpack.monitoring.metricbeatMigration.logstashInstructions.startMetricbeatTitle": "启动 Metricbeat", - "xpack.monitoring.metricbeatMigration.logstashInstructions.statusTitle": "迁移状态", "xpack.monitoring.noData.blurbs.cloudDeploymentDescription": "请返回到您的 ", "xpack.monitoring.noData.blurbs.cloudDeploymentDescriptionMore": "有关在 Elastic Cloud 中监测的详情,请参阅 ", "xpack.monitoring.noData.blurbs.cloudDeploymentTitle": "此处没有您的监测数据。", "xpack.monitoring.noData.explanations.exportersCloudDescription": "在 Elastic Cloud 中,您的监测数据将存储在专用监测集群中。", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.isInternalCollectorStatusTitle": "我们未检测到任何监测数据来自此 Elasticsearch 节点的 Metricbeat。\n 我们将持续在后台检查。", - "xpack.monitoring.metricbeatMigration.elasticsearchInstructions.partiallyMigratedStatusDescription": "请注意,检测最多花费 {secondsAgo} 秒,但我们将在后台持续检查。", - "xpack.monitoring.metricbeatMigration.kibanaInstructions.isInternalCollectorStatusTitle": "我们未检测到任何监测数据来自此 Kibana 实例的 Metricbeat。\n 我们将持续在后台检查。", "xpack.remoteClusters.addAction.clusterNameAlreadyExistsErrorMessage": "名为 “{clusterName}” 的集群已存在。", "xpack.remoteClusters.addAction.errorTitle": "添加集群时出错", "xpack.remoteClusters.addAction.failedDefaultErrorMessage": "请求失败,显示 {statusCode} 错误。{message}", diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_apm.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_apm.json index 518c074bc985d7..a791c2b2b7419d 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_apm.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_apm.json @@ -3,6 +3,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "doesExist": true }, @@ -12,6 +13,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -21,6 +23,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -30,6 +33,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": true }, @@ -39,6 +43,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "doesExist": true }, diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats.json index 219b2194d04db1..3ce2f20415b5f2 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats.json @@ -3,6 +3,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 1, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": null, "byUuid": { "5b2de169-2785-441b-ae8c-186a1936b17d": { @@ -17,6 +18,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": true }, @@ -26,6 +28,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -35,6 +38,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -44,6 +48,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 1, + "totalUniqueInternallyCollectedCount": 0, "detected": null, "byUuid": { "agI8JhXhShasvuDgq0VxRg": { diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats_management.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats_management.json index 72e44a3227728e..a64e2f40b33dc9 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats_management.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats_management.json @@ -3,6 +3,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "doesExist": true }, @@ -12,6 +13,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": true }, @@ -21,6 +23,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -30,6 +33,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -39,6 +43,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "doesExist": true }, diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash.json index a30ee6c04640d4..cc870216d405bd 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash.json @@ -3,6 +3,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "doesExist": true }, @@ -12,6 +13,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -21,6 +23,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": true }, @@ -30,6 +33,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -39,6 +43,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "doesExist": true }, diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash_management.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash_management.json index a30ee6c04640d4..cc870216d405bd 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash_management.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash_management.json @@ -3,6 +3,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "doesExist": true }, @@ -12,6 +13,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -21,6 +23,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": true }, @@ -30,6 +33,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -39,6 +43,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "doesExist": true }, diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_exclusive_mb.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_exclusive_mb.json index 28f90aeba7ceb2..4ae753aca52251 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_exclusive_mb.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_exclusive_mb.json @@ -3,6 +3,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 1, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": null, "byUuid": { "5b2de169-2785-441b-ae8c-186a1936b17d": { @@ -17,6 +18,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 1, "detected": null, "byUuid": { "8eba4902-df80-43b0-b6c2-ed8ca290984e": { @@ -32,6 +34,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 1, "detected": null, "byUuid": { "4134a00e-89e4-4896-a3d4-c3a9aa03a594": { @@ -46,6 +49,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -55,6 +59,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 1, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": null, "byUuid": { "agI8JhXhShasvuDgq0VxRg": { diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_mb.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_mb.json index 16d079ea9b7e94..6935060b4d5f7e 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_mb.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_mb.json @@ -3,6 +3,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 1, + "totalUniqueInternallyCollectedCount": 0, "detected": null, "byUuid": { "5b2de169-2785-441b-ae8c-186a1936b17d": { @@ -17,6 +18,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 1, "detected": null, "byUuid": { "8eba4902-df80-43b0-b6c2-ed8ca290984e": { @@ -32,6 +34,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 1, "detected": null, "byUuid": { "4134a00e-89e4-4896-a3d4-c3a9aa03a594": { @@ -46,6 +49,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -55,6 +59,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 1, + "totalUniqueInternallyCollectedCount": 0, "detected": null, "byUuid": { "agI8JhXhShasvuDgq0VxRg": { diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_exclusive_mb.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_exclusive_mb.json index 7f5e8cb8897825..161ce32e8ff5f3 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_exclusive_mb.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_exclusive_mb.json @@ -3,6 +3,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 1, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": null, "byUuid": { "5b2de169-2785-441b-ae8c-186a1936b17d": { @@ -17,6 +18,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 1, "detected": null, "byUuid": { "8eba4902-df80-43b0-b6c2-ed8ca290984e": { @@ -32,6 +34,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 1, "detected": null, "byUuid": { "4134a00e-89e4-4896-a3d4-c3a9aa03a594": { @@ -46,6 +49,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -55,6 +59,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 1, + "totalUniqueInternallyCollectedCount": 0, "detected": null, "byUuid": { "agI8JhXhShasvuDgq0VxRg": { diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_mb.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_mb.json index 3c6da934bbf94e..b93edacd82b310 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_mb.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_mb.json @@ -3,6 +3,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 1, + "totalUniqueInternallyCollectedCount": 0, "detected": null, "byUuid": { "5b2de169-2785-441b-ae8c-186a1936b17d": { @@ -17,6 +18,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 1, "detected": null, "byUuid": { "8eba4902-df80-43b0-b6c2-ed8ca290984e": { @@ -32,6 +34,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 1, "detected": null, "byUuid": { "4134a00e-89e4-4896-a3d4-c3a9aa03a594": { @@ -46,6 +49,7 @@ "totalUniqueInstanceCount": 0, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 0, "detected": { "mightExist": false }, @@ -55,6 +59,7 @@ "totalUniqueInstanceCount": 1, "totalUniqueFullyMigratedCount": 0, "totalUniquePartiallyMigratedCount": 0, + "totalUniqueInternallyCollectedCount": 1, "detected": null, "byUuid": { "agI8JhXhShasvuDgq0VxRg": { diff --git a/x-pack/test/functional/apps/monitoring/elasticsearch/index_detail.js b/x-pack/test/functional/apps/monitoring/elasticsearch/index_detail.js index 7fbb8990d1f27d..c3fe5f9273a897 100644 --- a/x-pack/test/functional/apps/monitoring/elasticsearch/index_detail.js +++ b/x-pack/test/functional/apps/monitoring/elasticsearch/index_detail.js @@ -45,8 +45,8 @@ export default function ({ getService, getPageObjects }) { dataSize: 'Total\n8.8 MB', dataSizePrimaries: 'Primaries\n4.4 MB', documentCount: 'Documents\n628', - totalShards: 'Total Shards\n10', - unassignedShards: 'Unassigned Shards\n0', + totalShards: 'Total shards\n10', + unassignedShards: 'Unassigned shards\n0', health: 'Health: green', }); }); @@ -58,8 +58,8 @@ export default function ({ getService, getPageObjects }) { dataSize: 'Total\n4.8 KB', dataSizePrimaries: 'Primaries\n4.8 KB', documentCount: 'Documents\n1', - totalShards: 'Total Shards\n1', - unassignedShards: 'Unassigned Shards\n0', + totalShards: 'Total shards\n1', + unassignedShards: 'Unassigned shards\n0', health: 'Health: green', }); }); @@ -71,8 +71,8 @@ export default function ({ getService, getPageObjects }) { dataSize: 'Total\n1.2 MB', dataSizePrimaries: 'Primaries\n657.6 KB', documentCount: 'Documents\n10', - totalShards: 'Total Shards\n10', - unassignedShards: 'Unassigned Shards\n1', + totalShards: 'Total shards\n10', + unassignedShards: 'Unassigned shards\n1', health: 'Health: yellow', }); }); diff --git a/x-pack/test/functional/apps/monitoring/elasticsearch/indices.js b/x-pack/test/functional/apps/monitoring/elasticsearch/indices.js index fc3669079dbce2..ff809b95a834e1 100644 --- a/x-pack/test/functional/apps/monitoring/elasticsearch/indices.js +++ b/x-pack/test/functional/apps/monitoring/elasticsearch/indices.js @@ -35,8 +35,8 @@ export default function ({ getService, getPageObjects }) { nodesCount: 'Nodes\n1', indicesCount: 'Indices\n19', memory: 'Memory\n267.7 MB / 676.8 MB', - totalShards: 'Total Shards\n46', - unassignedShards: 'Unassigned Shards\n23', + totalShards: 'Total shards\n46', + unassignedShards: 'Unassigned shards\n23', documentCount: 'Documents\n4,535', dataSize: 'Data\n8.6 MB', health: 'Health: red', diff --git a/x-pack/test/functional/apps/monitoring/elasticsearch/nodes.js b/x-pack/test/functional/apps/monitoring/elasticsearch/nodes.js index 81f683dc9a4cee..86f47775e50cdd 100644 --- a/x-pack/test/functional/apps/monitoring/elasticsearch/nodes.js +++ b/x-pack/test/functional/apps/monitoring/elasticsearch/nodes.js @@ -39,8 +39,8 @@ export default function ({ getService, getPageObjects }) { nodesCount: 'Nodes\n2', indicesCount: 'Indices\n20', memory: 'Memory\n696.6 MB / 1.3 GB', - totalShards: 'Total Shards\n79', - unassignedShards: 'Unassigned Shards\n7', + totalShards: 'Total shards\n79', + unassignedShards: 'Unassigned shards\n7', documentCount: 'Documents\n25,758', dataSize: 'Data\n100.0 MB', health: 'Health: yellow', @@ -214,8 +214,8 @@ export default function ({ getService, getPageObjects }) { nodesCount: 'Nodes\n3', indicesCount: 'Indices\n20', memory: 'Memory\n575.3 MB / 2.0 GB', - totalShards: 'Total Shards\n80', - unassignedShards: 'Unassigned Shards\n5', + totalShards: 'Total shards\n80', + unassignedShards: 'Unassigned shards\n5', documentCount: 'Documents\n25,927', dataSize: 'Data\n101.6 MB', health: 'Health: yellow', diff --git a/x-pack/test/functional/apps/monitoring/elasticsearch/overview.js b/x-pack/test/functional/apps/monitoring/elasticsearch/overview.js index 3f90c6a096e901..d86127b3e6fb8d 100644 --- a/x-pack/test/functional/apps/monitoring/elasticsearch/overview.js +++ b/x-pack/test/functional/apps/monitoring/elasticsearch/overview.js @@ -35,8 +35,8 @@ export default function ({ getService, getPageObjects }) { nodesCount: 'Nodes\n3', indicesCount: 'Indices\n20', memory: 'Memory\n575.3 MB / 2.0 GB', - totalShards: 'Total Shards\n80', - unassignedShards: 'Unassigned Shards\n5', + totalShards: 'Total shards\n80', + unassignedShards: 'Unassigned shards\n5', documentCount: 'Documents\n25,927', dataSize: 'Data\n101.6 MB', health: 'Health: yellow', diff --git a/x-pack/test/functional/services/monitoring/no_data.js b/x-pack/test/functional/services/monitoring/no_data.js index a0a1998689ee1c..81ed366ce94b2a 100644 --- a/x-pack/test/functional/services/monitoring/no_data.js +++ b/x-pack/test/functional/services/monitoring/no_data.js @@ -10,6 +10,7 @@ export function MonitoringNoDataProvider({ getService }) { return new class NoData { async enableMonitoring() { + await testSubjects.click('useInternalCollection'); await testSubjects.click('enableCollectionEnabled'); } From 3f7c3e0d55bc5a863596a80b41c066d52b887e71 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Wed, 2 Oct 2019 12:43:16 -0400 Subject: [PATCH 34/59] [Monitoring] Ensure all charts use the configured timezone (#45949) * Consistently apply dateFormat:tz to all monitoring time-series data * Ensure browser timezone works properly * Fix tests * Fix other test * Simplfy timezone fetching * Fix tests --- .../plugins/monitoring/common/formatting.js | 6 +++-- .../public/components/chart/chart_target.js | 8 +++--- .../components/chart/get_chart_options.js | 7 ++++-- .../monitoring/public/components/logs/logs.js | 4 +-- .../lib/details/__test__/get_metrics.test.js | 5 +++- .../server/lib/details/get_metrics.js | 6 +++-- .../server/lib/details/get_series.js | 14 +++++++---- .../monitoring/server/lib/format_timezone.js | 25 +++++++++++++++++++ .../monitoring/server/lib/get_timezone.js | 9 +++++++ .../monitoring/server/lib/logs/get_logs.js | 7 +++++- .../logs/fixtures/index_detail.json | 2 +- .../monitoring/logs/fixtures/node_detail.json | 20 +++++++-------- 12 files changed, 83 insertions(+), 30 deletions(-) create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/format_timezone.js create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/get_timezone.js diff --git a/x-pack/legacy/plugins/monitoring/common/formatting.js b/x-pack/legacy/plugins/monitoring/common/formatting.js index e94ed44efff050..a3b3ce07c8c760 100644 --- a/x-pack/legacy/plugins/monitoring/common/formatting.js +++ b/x-pack/legacy/plugins/monitoring/common/formatting.js @@ -17,8 +17,10 @@ export const LARGE_ABBREVIATED = '0,0.[0]a'; * @param date Either a numeric Unix timestamp or a {@code Date} object * @returns The date formatted using 'LL LTS' */ -export function formatDateTimeLocal(date) { - return moment.tz(date, moment.tz.guess()).format('LL LTS'); +export function formatDateTimeLocal(date, useUTC = false) { + return useUTC + ? moment.utc(date).format('LL LTS') + : moment.tz(date, moment.tz.guess()).format('LL LTS'); } /** diff --git a/x-pack/legacy/plugins/monitoring/public/components/chart/chart_target.js b/x-pack/legacy/plugins/monitoring/public/components/chart/chart_target.js index 9f425a81d86673..9d5ebd274ea9ec 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/chart/chart_target.js +++ b/x-pack/legacy/plugins/monitoring/public/components/chart/chart_target.js @@ -76,8 +76,8 @@ export class ChartTarget extends React.Component { .value(); } - getOptions() { - const opts = getChartOptions({ + async getOptions() { + const opts = await getChartOptions({ yaxis: { tickFormatter: this.props.tickFormatter }, xaxis: this.props.timeRange }); @@ -88,12 +88,12 @@ export class ChartTarget extends React.Component { }; } - renderChart() { + async renderChart() { const { target } = this.refs; const { series } = this.props; const data = this.filterData(series, this.props.seriesToShow); - this.plot = $.plot(target, data, this.getOptions()); + this.plot = $.plot(target, data, await this.getOptions()); this._handleResize = () => { if (!this.plot) { return; } diff --git a/x-pack/legacy/plugins/monitoring/public/components/chart/get_chart_options.js b/x-pack/legacy/plugins/monitoring/public/components/chart/get_chart_options.js index cd81aff14701aa..7f54b7ec0d2a76 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/chart/get_chart_options.js +++ b/x-pack/legacy/plugins/monitoring/public/components/chart/get_chart_options.js @@ -4,17 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import chrome from 'ui/chrome'; import { merge } from 'lodash'; import { CHART_LINE_COLOR, CHART_TEXT_COLOR } from '../../../common/constants'; -export function getChartOptions(axisOptions) { +export async function getChartOptions(axisOptions) { + const $injector = await chrome.dangerouslyGetActiveInjector(); + const timezone = $injector.get('config').get('dateFormat:tz'); const opts = { legend: { show: false }, xaxis: { color: CHART_LINE_COLOR, - timezone: 'browser', + timezone: timezone === 'Browser' ? 'browser' : 'utc', mode: 'time', // requires `time` flot plugin font: { color: CHART_TEXT_COLOR diff --git a/x-pack/legacy/plugins/monitoring/public/components/logs/logs.js b/x-pack/legacy/plugins/monitoring/public/components/logs/logs.js index 8a5e067a8a93ec..c59a3d595b14fc 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logs/logs.js +++ b/x-pack/legacy/plugins/monitoring/public/components/logs/logs.js @@ -50,7 +50,7 @@ const columns = [ field: 'timestamp', name: columnTimestampTitle, width: '12%', - render: timestamp => formatDateTimeLocal(timestamp), + render: timestamp => formatDateTimeLocal(timestamp, true), }, { field: 'level', @@ -80,7 +80,7 @@ const clusterColumns = [ field: 'timestamp', name: columnTimestampTitle, width: '12%', - render: timestamp => formatDateTimeLocal(timestamp), + render: timestamp => formatDateTimeLocal(timestamp, true), }, { field: 'level', diff --git a/x-pack/legacy/plugins/monitoring/server/lib/details/__test__/get_metrics.test.js b/x-pack/legacy/plugins/monitoring/server/lib/details/__test__/get_metrics.test.js index f7caf9de0d1fe5..5af714f7b3ba26 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/details/__test__/get_metrics.test.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/details/__test__/get_metrics.test.js @@ -51,7 +51,10 @@ function getMockReq(metricsBuckets = []) { }, params: { clusterUuid: '1234xyz' - } + }, + getUiSettingsService: () => ({ + get: () => 'Browser' + }) }; } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/details/get_metrics.js b/x-pack/legacy/plugins/monitoring/server/lib/details/get_metrics.js index 57c936f960212b..c5d2ee2032b018 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/details/get_metrics.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/details/get_metrics.js @@ -10,8 +10,9 @@ import Promise from 'bluebird'; import { checkParam } from '../error_missing_required'; import { getSeries } from './get_series'; import { calculateTimeseriesInterval } from '../calculate_timeseries_interval'; +import { getTimezone } from '../get_timezone'; -export function getMetrics(req, indexPattern, metricSet = [], filters = []) { +export async function getMetrics(req, indexPattern, metricSet = [], filters = []) { checkParam(indexPattern, 'indexPattern in details/getMetrics'); checkParam(metricSet, 'metricSet in details/getMetrics'); @@ -21,6 +22,7 @@ export function getMetrics(req, indexPattern, metricSet = [], filters = []) { const max = moment.utc(req.payload.timeRange.max).valueOf(); const minIntervalSeconds = config.get('xpack.monitoring.min_interval_seconds'); const bucketSize = calculateTimeseriesInterval(min, max, minIntervalSeconds); + const timezone = await getTimezone(req); return Promise.map(metricSet, metric => { // metric names match the literal metric name, but they can be supplied in groups or individually @@ -33,7 +35,7 @@ export function getMetrics(req, indexPattern, metricSet = [], filters = []) { } return Promise.map(metricNames, metricName => { - return getSeries(req, indexPattern, metricName, filters, { min, max, bucketSize }); + return getSeries(req, indexPattern, metricName, filters, { min, max, bucketSize, timezone }); }); }) .then(rows => { diff --git a/x-pack/legacy/plugins/monitoring/server/lib/details/get_series.js b/x-pack/legacy/plugins/monitoring/server/lib/details/get_series.js index 306e93273c157d..e66878f522ecb8 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/details/get_series.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/details/get_series.js @@ -14,6 +14,7 @@ import { NORMALIZED_DERIVATIVE_UNIT, CALCULATE_DURATION_UNTIL } from '../../../common/constants'; +import { formatUTCTimestampForTimezone } from '../format_timezone'; /** * Derivative metrics for the first two agg buckets are unusable. For the first bucket, there @@ -177,7 +178,7 @@ const formatBucketSize = bucketSizeInSeconds => { return formatTimestampToDuration(timestamp, CALCULATE_DURATION_UNTIL, now); }; -function handleSeries(metric, min, max, bucketSizeInSeconds, response) { +function handleSeries(metric, min, max, bucketSizeInSeconds, timezone, response) { const { derivative, calculation: customCalculation } = metric; const buckets = get(response, 'aggregations.check.buckets', []); const firstUsableBucketIndex = findFirstUsableBucketIndex(buckets, min); @@ -193,14 +194,17 @@ function handleSeries(metric, min, max, bucketSizeInSeconds, response) { data = buckets .slice(firstUsableBucketIndex, lastUsableBucketIndex + 1) // take only the buckets we know are usable .map(bucket => ([ - bucket.key, + formatUTCTimestampForTimezone(bucket.key, timezone), calculation(bucket, key, metric, bucketSizeInSeconds) ])); // map buckets to X/Y coords for Flot charting } return { bucket_size: formatBucketSize(bucketSizeInSeconds), - timeRange: { min, max }, + timeRange: { + min: formatUTCTimestampForTimezone(min, timezone), + max: formatUTCTimestampForTimezone(max, timezone), + }, metric: metric.serialize(), data }; @@ -217,7 +221,7 @@ function handleSeries(metric, min, max, bucketSizeInSeconds, response) { * @param {Array} filters Any filters that should be applied to the query. * @return {Promise} The object response containing the {@code timeRange}, {@code metric}, and {@code data}. */ -export async function getSeries(req, indexPattern, metricName, filters, { min, max, bucketSize }) { +export async function getSeries(req, indexPattern, metricName, filters, { min, max, bucketSize, timezone }) { checkParam(indexPattern, 'indexPattern in details/getSeries'); const metric = metrics[metricName]; @@ -226,5 +230,5 @@ export async function getSeries(req, indexPattern, metricName, filters, { min, m } const response = await fetchSeries(req, indexPattern, metric, min, max, bucketSize, filters); - return handleSeries(metric, min, max, bucketSize, response); + return handleSeries(metric, min, max, bucketSize, timezone, response); } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/format_timezone.js b/x-pack/legacy/plugins/monitoring/server/lib/format_timezone.js new file mode 100644 index 00000000000000..334477ac1c3596 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/format_timezone.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import moment from 'moment'; + + +/** + * This function is designed to offset a UTC timestamp based on the provided timezone + * For example, EST is UTC-4h so this function will subtract (4 * 60 * 60 * 1000)ms + * from the UTC timestamp. This allows us to allow users to view monitoring data + * in various timezones without needing to not store UTC dates. + * + * @param {*} utcTimestamp UTC timestamp + * @param {*} timezone The timezone to convert into + */ +export const formatUTCTimestampForTimezone = (utcTimestamp, timezone) => { + if (timezone === 'Browser') { + return utcTimestamp; + } + const offsetInMinutes = moment.tz(timezone).utcOffset(); + const offsetTimestamp = utcTimestamp + (offsetInMinutes * 1 * 60 * 1000); + return offsetTimestamp; +}; diff --git a/x-pack/legacy/plugins/monitoring/server/lib/get_timezone.js b/x-pack/legacy/plugins/monitoring/server/lib/get_timezone.js new file mode 100644 index 00000000000000..0da15bf6b28e17 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/get_timezone.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export async function getTimezone(req) { + return await req.getUiSettingsService().get('dateFormat:tz'); +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/logs/get_logs.js b/x-pack/legacy/plugins/monitoring/server/lib/logs/get_logs.js index 80d7f21fc45db2..0d45b8c6a1c4e7 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/logs/get_logs.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/logs/get_logs.js @@ -4,10 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import moment from 'moment'; import { get } from 'lodash'; import { checkParam } from '../error_missing_required'; import { createTimeFilter } from '../create_query'; import { detectReason } from './detect_reason'; +import { formatUTCTimestampForTimezone } from '../format_timezone'; +import { getTimezone } from '../get_timezone'; async function handleResponse(response, req, filebeatIndexPattern, opts) { const result = { @@ -15,15 +18,17 @@ async function handleResponse(response, req, filebeatIndexPattern, opts) { logs: [] }; + const timezone = await getTimezone(req); const hits = get(response, 'hits.hits', []); if (hits.length) { result.enabled = true; result.logs = hits.map(hit => { const source = hit._source; const type = get(source, 'event.dataset').split('.')[1]; + const utcTimestamp = moment(get(source, '@timestamp')).valueOf(); return { - timestamp: get(source, '@timestamp'), + timestamp: formatUTCTimestampForTimezone(utcTimestamp, timezone), component: get(source, 'elasticsearch.component'), node: get(source, 'elasticsearch.node.name'), index: get(source, 'elasticsearch.index.name'), diff --git a/x-pack/test/api_integration/apis/monitoring/logs/fixtures/index_detail.json b/x-pack/test/api_integration/apis/monitoring/logs/fixtures/index_detail.json index abba0d12dbef5d..2e9fd359962db9 100644 --- a/x-pack/test/api_integration/apis/monitoring/logs/fixtures/index_detail.json +++ b/x-pack/test/api_integration/apis/monitoring/logs/fixtures/index_detail.json @@ -1,7 +1,7 @@ { "enabled": true, "logs": [{ - "timestamp": "2019-03-15T17:07:21.089Z", + "timestamp": 1552669641089, "component": "o.e.n.Node", "node": "Elastic-MBP.local", "index": ".monitoring-es", diff --git a/x-pack/test/api_integration/apis/monitoring/logs/fixtures/node_detail.json b/x-pack/test/api_integration/apis/monitoring/logs/fixtures/node_detail.json index ef197266273ec3..c79fd672057510 100644 --- a/x-pack/test/api_integration/apis/monitoring/logs/fixtures/node_detail.json +++ b/x-pack/test/api_integration/apis/monitoring/logs/fixtures/node_detail.json @@ -1,21 +1,21 @@ { "enabled": true, "logs": [{ - "timestamp": "2019-03-15T17:19:07.365Z", + "timestamp": 1552670347365, "component": "o.e.d.x.m.r.a.RestMonitoringBulkAction", "node": "Elastic-MBP.local", "level": "WARN", "type": "deprecation", "message": "[POST /_xpack/monitoring/_bulk] is deprecated! Use [POST /_monitoring/bulk] instead." }, { - "timestamp": "2019-03-15T17:18:57.366Z", + "timestamp": 1552670337366, "component": "o.e.d.x.m.r.a.RestMonitoringBulkAction", "node": "Elastic-MBP.local", "level": "WARN", "type": "deprecation", "message": "[POST /_xpack/monitoring/_bulk] is deprecated! Use [POST /_monitoring/bulk] instead." }, { - "timestamp": "2019-03-15T17:18:47.400Z", + "timestamp": 1552670327400, "component": "o.e.c.m.MetaDataCreateIndexService", "node": "Elastic-MBP.local", "index": ".monitoring-beats-7-2019.03.15", @@ -23,14 +23,14 @@ "type": "server", "message": "creating index, cause [auto(bulk api)], templates [.monitoring-beats], shards [1]/[0], mappings [_doc]" }, { - "timestamp": "2019-03-15T17:18:47.387Z", + "timestamp": 1552670327387, "component": "o.e.d.x.m.r.a.RestMonitoringBulkAction", "node": "Elastic-MBP.local", "level": "WARN", "type": "deprecation", "message": "[POST /_xpack/monitoring/_bulk] is deprecated! Use [POST /_monitoring/bulk] instead." }, { - "timestamp": "2019-03-15T17:18:42.084Z", + "timestamp": 1552670322084, "component": "o.e.c.m.MetaDataMappingService", "node": "Elastic-MBP.local", "index": "filebeat-8.0.0-2019.03.15-000001", @@ -38,7 +38,7 @@ "type": "server", "message": "update_mapping [_doc]" }, { - "timestamp": "2019-03-15T17:18:41.811Z", + "timestamp": 1552670321811, "component": "o.e.c.m.MetaDataMappingService", "node": "Elastic-MBP.local", "index": "filebeat-8.0.0-2019.03.15-000001", @@ -46,7 +46,7 @@ "type": "server", "message": "update_mapping [_doc]" }, { - "timestamp": "2019-03-15T17:18:41.447Z", + "timestamp": 1552670321447, "component": "o.e.c.m.MetaDataCreateIndexService", "node": "Elastic-MBP.local", "index": "filebeat-8.0.0-2019.03.15-000001", @@ -54,21 +54,21 @@ "type": "server", "message": "creating index, cause [api], templates [filebeat-8.0.0], shards [1]/[1], mappings [_doc]" }, { - "timestamp": "2019-03-15T17:18:41.385Z", + "timestamp": 1552670321385, "component": "o.e.c.m.MetaDataIndexTemplateService", "node": "Elastic-MBP.local", "level": "INFO", "type": "server", "message": "adding template [filebeat-8.0.0] for index patterns [filebeat-8.0.0-*]" }, { - "timestamp": "2019-03-15T17:18:41.185Z", + "timestamp": 1552670321185, "component": "o.e.x.i.a.TransportPutLifecycleAction", "node": "Elastic-MBP.local", "level": "INFO", "type": "server", "message": "adding index lifecycle policy [filebeat-8.0.0]" }, { - "timestamp": "2019-03-15T17:18:36.137Z", + "timestamp": 1552670316137, "component": "o.e.c.r.a.AllocationService", "node": "Elastic-MBP.local", "level": "INFO", From 6f09ecc0d9f189a805ca2e875916167b748ea449 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Wed, 2 Oct 2019 11:58:22 -0500 Subject: [PATCH 35/59] Upgrade EUI to 14.4.0 (#46949) * eui to 14.4.0 * euicard ts updates * snaps --- package.json | 2 +- .../__snapshots__/dashboard_listing.test.js.snap | 6 ------ .../embeddables/contact_card/contact_card.tsx | 15 +++++++-------- .../__snapshots__/data_view.test.tsx.snap | 1 - .../plugins/kbn_tp_run_pipeline/package.json | 2 +- .../kbn_tp_custom_visualizations/package.json | 2 +- .../kbn_tp_embeddable_explorer/package.json | 2 +- .../kbn_tp_sample_panel_action/package.json | 2 +- .../kbn_tp_visualize_embedding/package.json | 2 +- typings/@elastic/eui/index.d.ts | 1 - .../__snapshots__/NoServicesMessage.test.tsx.snap | 4 ---- .../components/element_card/element_card.tsx | 8 ++------ .../__snapshots__/upgrade_failure.test.js.snap | 4 ---- .../__snapshots__/transform_list.test.tsx.snap | 1 - .../explorer_no_influencers_found.test.js.snap | 1 - .../explorer_no_jobs_found.test.js.snap | 1 - .../explorer_no_results_found.test.js.snap | 1 - .../__snapshots__/roles_grid_page.test.tsx.snap | 1 - .../index_patterns_missing_prompt.test.tsx.snap | 1 - .../__snapshots__/checkup_tab.test.tsx.snap | 1 - .../__snapshots__/data_missing.test.tsx.snap | 1 - .../__snapshots__/empty_state.test.tsx.snap | 4 ---- x-pack/package.json | 2 +- x-pack/typings/@elastic/eui/index.d.ts | 1 - yarn.lock | 8 ++++---- 25 files changed, 20 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index 6f54c8683410a7..be43e242ce569c 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "@babel/register": "^7.5.5", "@elastic/charts": "^12.0.2", "@elastic/datemath": "5.0.2", - "@elastic/eui": "14.3.0", + "@elastic/eui": "14.4.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "2.3.3", diff --git a/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap b/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap index 9b819443808c93..89b8e2ac83ec10 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap @@ -13,7 +13,6 @@ exports[`after fetch hideWriteControls 1`] = ` noItemsFragment={
@@ -106,7 +105,6 @@ exports[`after fetch initialFilter 1`] = `

} - iconColor="subdued" iconType="dashboardApp" title={

@@ -199,7 +197,6 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = `

} - iconColor="subdued" iconType="dashboardApp" title={

@@ -292,7 +289,6 @@ exports[`after fetch renders table rows 1`] = `

} - iconColor="subdued" iconType="dashboardApp" title={

@@ -385,7 +381,6 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = `

} - iconColor="subdued" iconType="dashboardApp" title={

@@ -478,7 +473,6 @@ exports[`renders empty page in before initial fetch to avoid flickering 1`] = `

} - iconColor="subdued" iconType="dashboardApp" title={

diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx index a83364f22021a7..51640749bc2b41 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx @@ -17,13 +17,7 @@ * under the License. */ import React from 'react'; -import { - // @ts-ignore - EuiCard, - EuiFlexItem, - EuiFlexGroup, - EuiFormRow, -} from '@elastic/eui'; +import { EuiCard, EuiFlexItem, EuiFlexGroup, EuiFormRow } from '@elastic/eui'; import { Subscription } from 'rxjs'; import { EuiButton } from '@elastic/eui'; @@ -96,7 +90,12 @@ export class ContactCardEmbeddableComponent extends React.Component + ); } } diff --git a/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap index 37c77c97fe39de..adea7831d6b805 100644 --- a/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap @@ -250,7 +250,6 @@ exports[`Inspector Data View component should render empty state 1`] = `

} - iconColor="subdued" title={

; export const EuiCodeEditor: React.SFC; export const Query: any; - export const EuiCard: any; export interface EuiTableCriteria { page: { index: number; size: number }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap index de8e109e62324a..209b88f73b9e2c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap @@ -33,7 +33,6 @@ exports[`NoServicesMessage status: pending and historicalDataFound: false 1`] =

} - iconColor="subdued" title={
Looks like you don't have any APM services installed. Let's add some! @@ -45,7 +44,6 @@ exports[`NoServicesMessage status: pending and historicalDataFound: false 1`] = exports[`NoServicesMessage status: pending and historicalDataFound: true 1`] = ` No services found @@ -80,7 +78,6 @@ exports[`NoServicesMessage status: success and historicalDataFound: false 1`] =

} - iconColor="subdued" title={
Looks like you don't have any APM services installed. Let's add some! @@ -92,7 +89,6 @@ exports[`NoServicesMessage status: success and historicalDataFound: false 1`] = exports[`NoServicesMessage status: success and historicalDataFound: true 1`] = ` No services found diff --git a/x-pack/legacy/plugins/canvas/public/components/element_card/element_card.tsx b/x-pack/legacy/plugins/canvas/public/components/element_card/element_card.tsx index 9262a67cf393cc..819282d5881292 100644 --- a/x-pack/legacy/plugins/canvas/public/components/element_card/element_card.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/element_card/element_card.tsx @@ -5,11 +5,7 @@ */ import React from 'react'; import PropTypes from 'prop-types'; -import { - // @ts-ignore unconverted EUI component - EuiCard, - EuiIcon, -} from '@elastic/eui'; +import { EuiCard, EuiIcon } from '@elastic/eui'; import { TagList } from '../tag_list/'; export interface Props { @@ -45,7 +41,7 @@ export const ElementCard = ({ title, description, image, tags = [], onClick, ... description={description} footer={} image={image} - icon={image ? null : } + icon={image ? undefined : } onClick={onClick} {...rest} /> diff --git a/x-pack/legacy/plugins/logstash/public/components/upgrade_failure/__snapshots__/upgrade_failure.test.js.snap b/x-pack/legacy/plugins/logstash/public/components/upgrade_failure/__snapshots__/upgrade_failure.test.js.snap index b31bae263fa8f4..4d752888d3df4f 100644 --- a/x-pack/legacy/plugins/logstash/public/components/upgrade_failure/__snapshots__/upgrade_failure.test.js.snap +++ b/x-pack/legacy/plugins/logstash/public/components/upgrade_failure/__snapshots__/upgrade_failure.test.js.snap @@ -146,7 +146,6 @@ exports[`UpgradeFailure component passes expected text for new pipeline 1`] = ` Before you can add a pipeline, we need to upgrade your configuration.

} - iconColor="subdued" title={ } - iconColor="subdued" title={ } - iconColor="subdued" title={ } - iconColor="subdued" title={ Minimal initializ ] } data-test-subj="mlNoDataFrameTransformsFound" - iconColor="subdued" title={

No data frame transforms found diff --git a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/__snapshots__/explorer_no_influencers_found.test.js.snap b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/__snapshots__/explorer_no_influencers_found.test.js.snap index f6e083e31984ac..77821663783cfa 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/__snapshots__/explorer_no_influencers_found.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/__snapshots__/explorer_no_influencers_found.test.js.snap @@ -2,7 +2,6 @@ exports[`ExplorerNoInfluencersFound snapshot 1`] = ` } data-test-subj="mlNoJobsFound" - iconColor="subdued" iconType="alert" title={

diff --git a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap index a7eb6d8db8a59a..dc7e567380fdf9 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap @@ -13,7 +13,6 @@ exports[`ExplorerNoInfluencersFound snapshot 1`] = `

} - iconColor="subdued" iconType="iInCircle" title={

diff --git a/x-pack/legacy/plugins/security/public/views/management/roles_grid/components/__snapshots__/roles_grid_page.test.tsx.snap b/x-pack/legacy/plugins/security/public/views/management/roles_grid/components/__snapshots__/roles_grid_page.test.tsx.snap index 5e3625a1f0fc4b..048fa74a72818f 100644 --- a/x-pack/legacy/plugins/security/public/views/management/roles_grid/components/__snapshots__/roles_grid_page.test.tsx.snap +++ b/x-pack/legacy/plugins/security/public/views/management/roles_grid/components/__snapshots__/roles_grid_page.test.tsx.snap @@ -31,7 +31,6 @@ exports[` renders permission denied if required 1`] = ` />

} - iconColor="subdued" iconType="securityApp" title={

diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/__snapshots__/index_patterns_missing_prompt.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/embeddables/__snapshots__/index_patterns_missing_prompt.test.tsx.snap index 937ba229b88835..f482a864bed6dc 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/__snapshots__/index_patterns_missing_prompt.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/__snapshots__/index_patterns_missing_prompt.test.tsx.snap @@ -56,7 +56,6 @@ exports[`IndexPatternsMissingPrompt renders correctly against snapshot 1`] = `

} - iconColor="subdued" iconType="gisApp" title={

diff --git a/x-pack/legacy/plugins/upgrade_assistant/public/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap b/x-pack/legacy/plugins/upgrade_assistant/public/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap index 5f826ba30262c0..6f92d475ae6c50 100644 --- a/x-pack/legacy/plugins/upgrade_assistant/public/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap +++ b/x-pack/legacy/plugins/upgrade_assistant/public/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap @@ -457,7 +457,6 @@ exports[`CheckupTab render without deprecations 1`] = `

} - iconColor="subdued" iconType="faceHappy" title={

diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap index 30b2e7204e4047..b17d28f19335b5 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap @@ -36,7 +36,6 @@ exports[`DataMissing component renders basePath and headingMessage 1`] = ` />

} - iconColor="subdued" iconType="uptimeApp" title={

} - iconColor="subdued" iconType="uptimeApp" title={ } - iconColor="subdued" >

} - iconColor="subdued" iconType="uptimeApp" title={ } - iconColor="subdued" >
; export const EuiCodeEditor: React.SFC; export const Query: any; - export const EuiCard: any; } declare module '@elastic/eui/lib/services' { diff --git a/yarn.lock b/yarn.lock index 824618215a8be7..9d73d21a044bf0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1145,10 +1145,10 @@ tabbable "^1.1.0" uuid "^3.1.0" -"@elastic/eui@14.3.0": - version "14.3.0" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-14.3.0.tgz#256e1af8f6b15717904f8959742a23b3495ff0bb" - integrity sha512-gAbPNezBmndInYqqw6EvRYLn2VMYQgYuPQYA5UZ7TyHzwvoBiMpUw5nFzYhS2A/Xcmq/ON5Mu8RY3LGRAVBOvQ== +"@elastic/eui@14.4.0": + version "14.4.0" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-14.4.0.tgz#ac09a476798dcdb1005616cccc149eda23ea2a90" + integrity sha512-dR7lYwUaIRXZjlUrJBq8GcGLPh6QfM3waQxUFI8lOnMVayJe3OOMNADCn8Oty6wNYIOrBWzZbW6w4bzInWF6oA== dependencies: "@types/lodash" "^4.14.116" "@types/numeral" "^0.0.25" From e6ace31c0e719067b689b9c83e850fc68210c015 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Wed, 2 Oct 2019 13:21:39 -0400 Subject: [PATCH 36/59] [Lens] Make horizontal bar chart a first-class chart (#47062) --- .../xy_visualization.test.ts.snap | 3 -- .../xy_visualization_plugin/state_helpers.ts | 26 +++++++++ .../xy_visualization_plugin/to_expression.ts | 1 - .../public/xy_visualization_plugin/types.ts | 27 ++++++++-- .../xy_config_panel.test.tsx | 51 +++++++----------- .../xy_config_panel.tsx | 51 ++++-------------- .../xy_expression.test.tsx | 6 +-- .../xy_visualization_plugin/xy_expression.tsx | 20 +++---- .../xy_suggestions.test.ts | 13 +---- .../xy_visualization_plugin/xy_suggestions.ts | 30 +++-------- .../xy_visualization.test.ts | 49 +++++++++++++++-- .../xy_visualization.tsx | 17 ++++-- .../es_archives/lens/reporting/data.json.gz | Bin 4356 -> 4359 bytes 13 files changed, 161 insertions(+), 133 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/xy_visualization_plugin/state_helpers.ts diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap index 12902f548e45b7..76af8328673add 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap @@ -5,9 +5,6 @@ Object { "chain": Array [ Object { "arguments": Object { - "isHorizontal": Array [ - false, - ], "layers": Array [ Object { "chain": Array [ diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/state_helpers.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/state_helpers.ts new file mode 100644 index 00000000000000..eb7fd688bab5a4 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/state_helpers.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { SeriesType, visualizationTypes } from './types'; + +export function isHorizontalSeries(seriesType: SeriesType) { + return seriesType === 'bar_horizontal' || seriesType === 'bar_horizontal_stacked'; +} + +export function isHorizontalChart(layers: Array<{ seriesType: SeriesType }>) { + return layers.every(l => isHorizontalSeries(l.seriesType)); +} + +export function getIconForSeries(type: SeriesType): EuiIconType { + const definition = visualizationTypes.find(t => t.id === type); + + if (!definition) { + throw new Error(`Unknown series type ${type}`); + } + + return (definition.icon as EuiIconType) || 'empty'; +} diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts index ff5f7eb08f2db0..f0e932d14f281b 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts @@ -131,7 +131,6 @@ export const buildExpression = ( arguments: { xTitle: [xTitle], yTitle: [yTitle], - isHorizontal: [state.isHorizontal], legend: [ { type: 'expression', diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts index 742cc36be4ea66..28f72f60c3a2de 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts @@ -175,7 +175,14 @@ export const layerConfig: ExpressionFunction< }, }; -export type SeriesType = 'bar' | 'line' | 'area' | 'bar_stacked' | 'area_stacked'; +export type SeriesType = + | 'bar' + | 'bar_horizontal' + | 'line' + | 'area' + | 'bar_stacked' + | 'bar_horizontal_stacked' + | 'area_stacked'; export interface LayerConfig { hide?: boolean; @@ -199,7 +206,6 @@ export interface XYArgs { yTitle: string; legend: LegendConfig; layers: LayerArgs[]; - isHorizontal: boolean; } // Persisted parts of the state @@ -207,7 +213,6 @@ export interface XYState { preferredSeriesType: SeriesType; legend: LegendConfig; layers: LayerConfig[]; - isHorizontal: boolean; } export type State = XYState; @@ -221,13 +226,27 @@ export const visualizationTypes: VisualizationType[] = [ defaultMessage: 'Bar', }), }, + { + id: 'bar_horizontal', + icon: 'visBarHorizontal', + label: i18n.translate('xpack.lens.xyVisualization.barHorizontalLabel', { + defaultMessage: 'Horizontal Bar', + }), + }, { id: 'bar_stacked', icon: 'visBarVertical', - label: i18n.translate('xpack.lens.xyVisualization.stackedBarLabel', { + label: i18n.translate('xpack.lens.xyVisualization.stackedBar', { defaultMessage: 'Stacked Bar', }), }, + { + id: 'bar_horizontal_stacked', + icon: 'visBarHorizontal', + label: i18n.translate('xpack.lens.xyVisualization.stackedBarHorizontalLabel', { + defaultMessage: 'Stacked Horizontal Bar', + }), + }, { id: 'line', icon: 'visLine', diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx index ad08b8949f3b91..5cdf1031a22b04 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FormEvent } from 'react'; +import React from 'react'; import { ReactWrapper } from 'enzyme'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { EuiButtonGroupProps } from '@elastic/eui'; @@ -15,7 +15,6 @@ import { Position } from '@elastic/charts'; import { NativeRendererProps } from '../native_renderer'; import { generateId } from '../id_generator'; import { createMockFramePublicAPI, createMockDatasource } from '../editor_frame_plugin/mocks'; -import { act } from 'react-test-renderer'; jest.mock('../id_generator'); @@ -28,7 +27,6 @@ describe('XYConfigPanel', () => { return { legend: { isVisible: true, position: Position.Right }, preferredSeriesType: 'bar', - isHorizontal: false, layers: [ { seriesType: 'bar', @@ -64,50 +62,48 @@ describe('XYConfigPanel', () => { }; }); - test.skip('toggles axis position when going from horizontal bar to any other type', () => {}); test.skip('allows toggling of legend visibility', () => {}); test.skip('allows changing legend position', () => {}); test.skip('allows toggling the y axis gridlines', () => {}); test.skip('allows toggling the x axis gridlines', () => {}); - test('puts the horizontal toggle in a popover', () => { + test('enables stacked chart types even when there is no split series', () => { const state = testState(); - const setState = jest.fn(); const component = mount( ); - component - .find(`[data-test-subj="lnsXY_chart_settings"]`) + openComponentPopover(component, 'first'); + + const options = component + .find('[data-test-subj="lnsXY_seriesType"]') .first() - .simulate('click'); + .prop('options') as EuiButtonGroupProps['options']; - act(() => { - component - .find('[data-test-subj="lnsXY_chart_horizontal"]') - .first() - .prop('onChange')!({} as FormEvent); - }); + expect(options!.map(({ id }) => id)).toEqual([ + 'bar', + 'bar_stacked', + 'line', + 'area', + 'area_stacked', + ]); - expect(setState).toHaveBeenCalledWith({ - ...state, - isHorizontal: true, - }); + expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); }); - test('enables stacked chart types even when there is no split series', () => { + test('shows only horizontal bar options when in horizontal mode', () => { const state = testState(); const component = mount( ); @@ -118,14 +114,7 @@ describe('XYConfigPanel', () => { .first() .prop('options') as EuiButtonGroupProps['options']; - expect(options!.map(({ id }) => id)).toEqual([ - 'bar', - 'bar_stacked', - 'line', - 'area', - 'area_stacked', - ]); - + expect(options!.map(({ id }) => id)).toEqual(['bar_horizontal', 'bar_horizontal_stacked']); expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); }); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx index 7170a41a168800..e268c099ddc24d 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx @@ -17,7 +17,6 @@ import { EuiPanel, EuiButtonIcon, EuiPopover, - EuiSwitch, EuiSpacer, EuiButtonEmpty, EuiPopoverFooter, @@ -27,6 +26,7 @@ import { VisualizationProps, OperationMetadata } from '../types'; import { NativeRenderer } from '../native_renderer'; import { MultiColumnEditor } from '../multi_column_editor'; import { generateId } from '../id_generator'; +import { isHorizontalChart, isHorizontalSeries } from './state_helpers'; const isNumericMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; const isBucketed = (op: OperationMetadata) => op.isBucketed; @@ -55,10 +55,12 @@ function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig { function LayerSettings({ layer, + horizontalOnly, setSeriesType, removeLayer, }: { layer: LayerConfig; + horizontalOnly: boolean; setSeriesType: (seriesType: SeriesType) => void; removeLayer: () => void; }) { @@ -96,10 +98,12 @@ function LayerSettings({ name="chartType" className="eui-displayInlineBlock" data-test-subj="lnsXY_seriesType" - options={visualizationTypes.map(t => ({ - ...t, - iconType: t.icon || 'empty', - }))} + options={visualizationTypes + .filter(t => isHorizontalSeries(t.id as SeriesType) === horizontalOnly) + .map(t => ({ + ...t, + iconType: t.icon || 'empty', + }))} idSelected={layer.seriesType} onChange={seriesType => setSeriesType(seriesType as SeriesType)} isIconOnly @@ -124,44 +128,10 @@ function LayerSettings({ export function XYConfigPanel(props: VisualizationProps) { const { state, setState, frame } = props; - const [isChartOptionsOpen, setIsChartOptionsOpen] = useState(false); + const horizontalOnly = isHorizontalChart(state.layers); return ( - setIsChartOptionsOpen(false)} - button={ - setIsChartOptionsOpen(!isChartOptionsOpen)} - aria-label={i18n.translate('xpack.lens.xyChart.chartSettings', { - defaultMessage: 'Chart Settings', - })} - title={i18n.translate('xpack.lens.xyChart.chartSettings', { - defaultMessage: 'Chart Settings', - })} - /> - } - > - { - setState({ - ...state, - isHorizontal: !state.isHorizontal, - }); - }} - data-test-subj="lnsXY_chart_horizontal" - /> - - {state.layers.map((layer, index) => ( ) { setState(updateLayer(state, { ...layer, seriesType }, index)) } diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx index 0ac286c7bb83c7..8770ee5b5e1c9b 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx @@ -35,7 +35,6 @@ function sampleArgs() { const args: XYArgs = { xTitle: '', yTitle: '', - isHorizontal: false, legend: { isVisible: false, position: Position.Top, @@ -161,7 +160,7 @@ describe('xy_expression', () => { const component = shallow( @@ -208,8 +207,7 @@ describe('xy_expression', () => { data={data} args={{ ...args, - isHorizontal: true, - layers: [{ ...args.layers[0], seriesType: 'bar_stacked' }], + layers: [{ ...args.layers[0], seriesType: 'bar_horizontal_stacked' }], }} formatFactory={getFormatSpy} timeZone="UTC" diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx index e559cdd514bc66..43452ff4327677 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx @@ -27,6 +27,7 @@ import { IInterpreterRenderFunction } from '../../../../../../src/legacy/core_pl import { LensMultiTable } from '../types'; import { XYArgs, SeriesType, visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; +import { isHorizontalChart } from './state_helpers'; export interface XYChartProps { data: LensMultiTable; @@ -75,10 +76,6 @@ export const xyChart: ExpressionFunction<'lens_xy_chart', LensMultiTable, XYArgs help: 'Layers of visual series', multi: true, }, - isHorizontal: { - types: ['boolean'], - help: 'Render horizontally', - }, }, context: { types: ['lens_multitable'], @@ -140,7 +137,7 @@ export function XYChartReportable(props: XYChartRenderProps) { } export function XYChart({ data, args, formatFactory, timeZone }: XYChartRenderProps) { - const { legend, layers, isHorizontal } = args; + const { legend, layers } = args; if (Object.values(data.tables).every(table => table.rows.length === 0)) { const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar'; @@ -176,18 +173,20 @@ export function XYChart({ data, args, formatFactory, timeZone }: XYChartRenderPr } } + const shouldRotate = isHorizontalChart(layers); + return ( - ) : seriesType === 'bar' || seriesType === 'bar_stacked' ? ( + ) : seriesType === 'bar' || + seriesType === 'bar_stacked' || + seriesType === 'bar_horizontal' || + seriesType === 'bar_horizontal_stacked' ? ( ) : ( diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts index ed44c741233161..a205fe433106aa 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts @@ -199,7 +199,6 @@ describe('xy_suggestions', () => { changeType: 'reduced', }, state: { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ @@ -235,7 +234,6 @@ describe('xy_suggestions', () => { test('only makes a seriesType suggestion for unchanged table without split', () => { (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); const currentState: XYState = { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ @@ -270,7 +268,6 @@ describe('xy_suggestions', () => { test('suggests seriesType and stacking when there is a split', () => { const currentState: XYState = { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ @@ -311,7 +308,6 @@ describe('xy_suggestions', () => { test('suggests a flipped chart for unchanged table and existing bar chart on ordinal x axis', () => { (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); const currentState: XYState = { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ @@ -335,16 +331,13 @@ describe('xy_suggestions', () => { }); expect(rest).toHaveLength(0); - expect(suggestion.state).toEqual({ - ...currentState, - isHorizontal: true, - }); + expect(suggestion.state.preferredSeriesType).toEqual('bar_horizontal'); + expect(suggestion.state.layers.every(l => l.seriesType === 'bar_horizontal')).toBeTruthy(); expect(suggestion.title).toEqual('Flip'); }); test('suggests stacking for unchanged table that has a split', () => { const currentState: XYState = { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ @@ -379,7 +372,6 @@ describe('xy_suggestions', () => { test('keeps column to dimension mappings on extended tables', () => { const currentState: XYState = { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ @@ -418,7 +410,6 @@ describe('xy_suggestions', () => { test('overwrites column to dimension mappings if a date dimension is added', () => { (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); const currentState: XYState = { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts index 2f28e20ebd274a..7c7e9caddd31b7 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts @@ -7,7 +7,6 @@ import { i18n } from '@kbn/i18n'; import { partition } from 'lodash'; import { Position } from '@elastic/charts'; -import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; import { SuggestionRequest, VisualizationSuggestion, @@ -17,6 +16,7 @@ import { } from '../types'; import { State, SeriesType, XYState } from './types'; import { generateId } from '../id_generator'; +import { getIconForSeries } from './state_helpers'; const columnSortOrder = { date: 0, @@ -26,21 +26,6 @@ const columnSortOrder = { number: 4, }; -function getIconForSeries(type: SeriesType): EuiIconType { - switch (type) { - case 'area': - case 'area_stacked': - return 'visArea'; - case 'bar': - case 'bar_stacked': - return 'visBarVertical'; - case 'line': - return 'visLine'; - default: - throw new Error('unknown series type'); - } -} - /** * Generate suggestions for the xy chart. * @@ -163,10 +148,8 @@ function getSuggestionsForLayer( ): VisualizationSuggestion | Array> { const title = getSuggestionTitle(yValues, xValue, tableLabel); const seriesType: SeriesType = getSeriesType(currentState, layerId, xValue, changeType); - const isHorizontal = currentState ? currentState.isHorizontal : false; const options = { - isHorizontal, currentState, seriesType, layerId, @@ -186,14 +169,18 @@ function getSuggestionsForLayer( const sameStateSuggestions: Array> = []; // if current state is using the same data, suggest same chart with different presentational configuration - if (seriesType !== 'line' && xValue.operation.scale === 'ordinal') { // flip between horizontal/vertical for ordinal scales sameStateSuggestions.push( buildSuggestion({ ...options, title: i18n.translate('xpack.lens.xySuggestions.flipTitle', { defaultMessage: 'Flip' }), - isHorizontal: !options.isHorizontal, + seriesType: + seriesType === 'bar_horizontal' + ? 'bar' + : seriesType === 'bar_horizontal_stacked' + ? 'bar_stacked' + : 'bar_horizontal', }) ); } else { @@ -328,7 +315,6 @@ function getSuggestionTitle( } function buildSuggestion({ - isHorizontal, currentState, seriesType, layerId, @@ -339,7 +325,6 @@ function buildSuggestion({ xValue, }: { currentState: XYState | undefined; - isHorizontal: boolean; seriesType: SeriesType; title: string; yValues: TableSuggestionColumn[]; @@ -358,7 +343,6 @@ function buildSuggestion({ }; const state: State = { - isHorizontal, legend: currentState ? currentState.legend : { isVisible: true, position: Position.Right }, preferredSeriesType: seriesType, layers: [ diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts index 8bc7b0c9116f70..5cd0791ae3da9e 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts @@ -7,7 +7,7 @@ import { xyVisualization } from './xy_visualization'; import { Position } from '@elastic/charts'; import { Operation } from '../types'; -import { State } from './types'; +import { State, SeriesType } from './types'; import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_plugin/mocks'; import { generateId } from '../id_generator'; import { Ast } from '@kbn/interpreter/target/common'; @@ -16,7 +16,6 @@ jest.mock('../id_generator'); function exampleState(): State { return { - isHorizontal: false, legend: { position: Position.Bottom, isVisible: true }, preferredSeriesType: 'bar', layers: [ @@ -32,6 +31,51 @@ function exampleState(): State { } describe('xy_visualization', () => { + describe('getDescription', () => { + function mixedState(...types: SeriesType[]) { + const state = exampleState(); + return { + ...state, + layers: types.map((t, i) => ({ + ...state.layers[0], + layerId: `layer_${i}`, + seriesType: t, + })), + }; + } + + it('should show mixed xy chart when multilple series types', () => { + const desc = xyVisualization.getDescription(mixedState('bar', 'line')); + + expect(desc.label).toEqual('Mixed XY Chart'); + }); + + it('should show mixed horizontal bar chart when multiple horizontal bar types', () => { + const desc = xyVisualization.getDescription( + mixedState('bar_horizontal', 'bar_horizontal_stacked') + ); + + expect(desc.label).toEqual('Mixed Horizontal Bar Chart'); + }); + + it('should show bar chart when bar only', () => { + const desc = xyVisualization.getDescription(mixedState('bar_horizontal', 'bar_horizontal')); + + expect(desc.label).toEqual('Horizontal Bar Chart'); + }); + + it('should show the chart description if not mixed', () => { + expect(xyVisualization.getDescription(mixedState('area')).label).toEqual('Area Chart'); + expect(xyVisualization.getDescription(mixedState('line')).label).toEqual('Line Chart'); + expect(xyVisualization.getDescription(mixedState('area_stacked')).label).toEqual( + 'Stacked Area Chart' + ); + expect(xyVisualization.getDescription(mixedState('bar_horizontal_stacked')).label).toEqual( + 'Stacked Horizontal Bar Chart' + ); + }); + }); + describe('#initialize', () => { it('loads default state', () => { (generateId as jest.Mock) @@ -48,7 +92,6 @@ describe('xy_visualization', () => { expect(initialState).toMatchInlineSnapshot(` Object { - "isHorizontal": false, "layers": Array [ Object { "accessors": Array [ diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index 69cb93bb1903d0..29c5e5d5e42975 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -16,6 +16,7 @@ import { Visualization } from '../types'; import { State, PersistableState, SeriesType, visualizationTypes } from './types'; import { toExpression, toPreviewExpression } from './to_expression'; import { generateId } from '../id_generator'; +import { isHorizontalChart } from './state_helpers'; const defaultIcon = 'visBarVertical'; const defaultSeriesType = 'bar_stacked'; @@ -25,7 +26,7 @@ function getDescription(state?: State) { return { icon: defaultIcon, label: i18n.translate('xpack.lens.xyVisualization.xyLabel', { - defaultMessage: 'XY Chart', + defaultMessage: 'XY', }), }; } @@ -42,8 +43,12 @@ function getDescription(state?: State) { label: seriesTypes.length === 1 ? visualizationType.label + : isHorizontalChart(state.layers) + ? i18n.translate('xpack.lens.xyVisualization.mixedBarHorizontalLabel', { + defaultMessage: 'Mixed Horizontal Bar', + }) : i18n.translate('xpack.lens.xyVisualization.mixedLabel', { - defaultMessage: 'Mixed XY Chart', + defaultMessage: 'Mixed XY', }), }; } @@ -55,9 +60,14 @@ export const xyVisualization: Visualization = { getDescription(state) { const { icon, label } = getDescription(state); + const chartLabel = i18n.translate('xpack.lens.xyVisualization.chartLabel', { + defaultMessage: '{label} Chart', + values: { label }, + }); + return { icon: icon || defaultIcon, - label, + label: chartLabel, }; }, @@ -75,7 +85,6 @@ export const xyVisualization: Visualization = { return ( state || { title: 'Empty XY Chart', - isHorizontal: false, legend: { isVisible: true, position: Position.Right }, preferredSeriesType: defaultSeriesType, layers: [ diff --git a/x-pack/test/functional/es_archives/lens/reporting/data.json.gz b/x-pack/test/functional/es_archives/lens/reporting/data.json.gz index b59717330488afadc06fd319e810fdcfe6363a60..93ceaf3d8f6f5daa843f9fd8fb1e43a0eff23805 100644 GIT binary patch literal 4359 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 XeTrPHwRcO{Z3JU_-i12~5AS~fbnSYJ delta 4260 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 G0ssJeX)LM$ From f8810d12ac100b88fdf1aa1c49c88192d4ff02d0 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 2 Oct 2019 11:46:55 -0600 Subject: [PATCH 37/59] [SIEM] Start of deprecated lifecycle refactor (#46293) --- .../timeline/data_providers.spec.ts | 6 +- .../timeline/flyout_button.spec.ts | 2 +- .../autocomplete_field/suggestion_item.tsx | 13 +- .../drag_drop_context_wrapper.tsx | 60 ++- .../drag_and_drop/draggable_wrapper.tsx | 42 +- .../components/edit_data_provider/index.tsx | 196 ++++---- .../components/embeddables/embedded_map.tsx | 2 +- .../event_details/stateful_event_details.tsx | 37 +- .../events_viewer/events_viewer.test.tsx | 11 +- .../components/events_viewer/index.test.tsx | 10 + .../public/components/events_viewer/index.tsx | 2 +- .../fields_browser/field_browser.tsx | 163 ++++--- .../components/fields_browser/index.tsx | 251 +++++------ .../public/components/flyout/pane/index.tsx | 58 ++- .../public/components/help_menu/help_menu.tsx | 52 ++- .../components/lazy_accordion/index.tsx | 77 ++-- .../__snapshots__/index.test.tsx.snap | 104 ----- .../components/load_more_table/index.mock.tsx | 118 ----- .../components/load_more_table/index.test.tsx | 360 --------------- .../components/load_more_table/index.tsx | 320 ------------- .../load_more_table/translations.ts | 23 - .../permissions/ml_capabilities_provider.tsx | 2 +- .../get_anomalies_host_table_columns.test.tsx | 2 +- .../get_anomalies_host_table_columns.tsx | 2 +- ...t_anomalies_network_table_columns.test.tsx | 2 +- .../get_anomalies_network_table_columns.tsx | 2 +- .../components/navigation/index.test.tsx | 4 +- .../public/components/navigation/index.tsx | 124 ++--- .../navigation/tab_navigation/index.test.tsx | 10 +- .../navigation/tab_navigation/index.tsx | 103 ++--- .../siem/public/components/notes/index.tsx | 28 +- .../components/notes/note_cards/index.tsx | 55 +-- .../delete_timeline_modal.test.tsx | 22 +- .../delete_timeline_modal.tsx | 6 +- .../delete_timeline_modal/index.test.tsx | 30 -- .../delete_timeline_modal/index.tsx | 53 +-- .../components/open_timeline/index.test.tsx | 190 ++++---- .../public/components/open_timeline/index.tsx | 424 ++++++++---------- .../open_timeline_modal/index.test.tsx | 23 +- .../open_timeline_modal/index.tsx | 124 ++--- .../components/page/add_to_kql/index.tsx | 28 +- .../page/hosts/hosts_table/index.tsx | 124 +++-- .../page/network/domains_table/columns.tsx | 2 +- .../network/network_dns_table/columns.tsx | 2 +- .../network_top_n_flow_table/columns.tsx | 2 +- .../network_top_n_flow_table/index.tsx | 83 ++-- .../page/network/tls_table/columns.tsx | 2 +- .../page/network/users_table/columns.tsx | 2 +- .../components/paginated_table/index.tsx | 12 +- .../public/components/resize_handle/index.tsx | 147 +++--- .../components/super_date_picker/index.tsx | 264 +++++------ .../body/column_headers/header/index.tsx | 77 ++-- .../body/data_driven_columns/index.tsx | 10 +- .../timeline/body/events/stateful_event.tsx | 192 ++++---- .../timeline/body/stateful_body.tsx | 138 +++--- .../data_providers/provider_item_badge.tsx | 92 ++-- .../components/timeline/footer/index.test.tsx | 60 ++- .../components/timeline/footer/index.tsx | 190 +++----- .../timeline/footer/last_updated.tsx | 71 ++- .../siem/public/components/timeline/index.tsx | 276 ++++++------ .../components/timeline/properties/index.tsx | 150 +++---- .../timeline/search_or_filter/index.tsx | 31 +- .../components/url_state/use_url_state.tsx | 2 +- .../components/with_hover_actions/index.tsx | 40 +- .../siem/public/containers/hosts/filter.tsx | 115 +++-- .../containers/hosts/first_last_seen/index.ts | 6 +- .../containers/kuery_autocompletion/index.tsx | 117 +++-- .../siem/public/containers/network/filter.tsx | 124 +++-- .../siem/public/containers/source/index.tsx | 81 ++-- .../public/containers/timeline/all/index.tsx | 90 ++-- .../containers/timeline/details/index.tsx | 22 +- .../public/pages/timelines/timelines_page.tsx | 34 +- .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - 74 files changed, 2091 insertions(+), 3616 deletions(-) delete mode 100644 x-pack/legacy/plugins/siem/public/components/load_more_table/__snapshots__/index.test.tsx.snap delete mode 100644 x-pack/legacy/plugins/siem/public/components/load_more_table/index.mock.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/load_more_table/index.test.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/load_more_table/index.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/load_more_table/translations.ts diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts index 7c9a0edebe53e9..236d5a53481b7f 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts @@ -61,7 +61,7 @@ describe('timeline data providers', () => { cy.get(TIMELINE_DATA_PROVIDERS).should( 'have.css', 'background', - 'rgba(125, 226, 209, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' + 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' ); }); @@ -81,7 +81,7 @@ describe('timeline data providers', () => { cy.get(TIMELINE_DATA_PROVIDERS_EMPTY).should( 'have.css', 'background', - 'rgba(125, 226, 209, 0.2) none repeat scroll 0% 0% / auto padding-box border-box' + 'rgba(1, 125, 115, 0.2) none repeat scroll 0% 0% / auto padding-box border-box' ); }); @@ -101,7 +101,7 @@ describe('timeline data providers', () => { cy.get(TIMELINE_DATA_PROVIDERS).should( 'have.css', 'border', - '3.1875px dashed rgb(125, 226, 209)' + '3.1875px dashed rgb(1, 125, 115)' ); }); }); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts index 811c529b8bec59..c1c35e497d0815 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts @@ -41,7 +41,7 @@ describe('timeline flyout button', () => { cy.get(TIMELINE_NOT_READY_TO_DROP_BUTTON).should( 'have.css', 'background', - 'rgba(125, 226, 209, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' + 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' ); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/autocomplete_field/suggestion_item.tsx b/x-pack/legacy/plugins/siem/public/components/autocomplete_field/suggestion_item.tsx index 997a19b0e8a2ec..aaf7be2f7f5a6d 100644 --- a/x-pack/legacy/plugins/siem/public/components/autocomplete_field/suggestion_item.tsx +++ b/x-pack/legacy/plugins/siem/public/components/autocomplete_field/suggestion_item.tsx @@ -18,13 +18,8 @@ interface SuggestionItemProps { suggestion: AutocompleteSuggestion; } -export class SuggestionItem extends React.PureComponent { - public static defaultProps: Partial = { - isSelected: false, - }; - - public render() { - const { isSelected, onClick, onMouseEnter, suggestion } = this.props; +export const SuggestionItem = React.memo( + ({ isSelected = false, onClick, onMouseEnter, suggestion }) => { return ( { ); } -} +); + +SuggestionItem.displayName = 'SuggestionItem'; const SuggestionItemContainer = euiStyled.div<{ isSelected?: boolean; diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx index aab83ec7908fe0..11b604571378b1 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx @@ -6,7 +6,7 @@ import { defaultTo, noop } from 'lodash/fp'; import * as React from 'react'; -import { DragDropContext, DropResult, ResponderProvided, DragStart } from 'react-beautiful-dnd'; +import { DragDropContext, DropResult, DragStart } from 'react-beautiful-dnd'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; @@ -57,43 +57,39 @@ const onDragEndHandler = ({ /** * DragDropContextWrapperComponent handles all drag end events */ -export class DragDropContextWrapperComponent extends React.Component { - public shouldComponentUpdate = ({ children, dataProviders }: Props) => - children === this.props.children && dataProviders !== this.props.dataProviders // prevent re-renders when data providers are added or removed, but all other props are the same - ? false - : true; - - public render() { - const { children } = this.props; - +export const DragDropContextWrapperComponent = React.memo( + ({ browserFields, children, dataProviders, dispatch }) => { + function onDragEnd(result: DropResult) { + enableScrolling(); + + if (dataProviders != null) { + onDragEndHandler({ + browserFields, + result, + dataProviders, + dispatch, + }); + } + + if (!draggableIsField(result)) { + document.body.classList.remove(IS_DRAGGING_CLASS_NAME); + } + } return ( - + {children} ); + }, + (prevProps, nextProps) => { + return ( + prevProps.children === nextProps.children && + prevProps.dataProviders === nextProps.dataProviders + ); // prevent re-renders when data providers are added or removed, but all other props are the same } +); - private onDragEnd: (result: DropResult, provided: ResponderProvided) => void = ( - result: DropResult - ) => { - const { browserFields, dataProviders, dispatch } = this.props; - - enableScrolling(); - - if (dataProviders != null) { - onDragEndHandler({ - browserFields, - result, - dataProviders, - dispatch, - }); - } - - if (!draggableIsField(result)) { - document.body.classList.remove(IS_DRAGGING_CLASS_NAME); - } - }; -} +DragDropContextWrapperComponent.displayName = 'DragDropContextWrapperComponent'; const emptyDataProviders: dragAndDropModel.IdToDataProvider = {}; // stable reference diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx index 0755ef0e5592cf..8a12a5035fc3a8 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx @@ -5,7 +5,7 @@ */ import { isEqual } from 'lodash/fp'; -import * as React from 'react'; +import React, { useEffect } from 'react'; import { Draggable, DraggableProvided, @@ -161,28 +161,15 @@ type Props = OwnProps & DispatchProps; * Wraps a draggable component to handle registration / unregistration of the * data provider associated with the item being dropped */ -class DraggableWrapperComponent extends React.Component { - public shouldComponentUpdate = ({ dataProvider, render, truncate }: Props) => - isEqual(dataProvider, this.props.dataProvider) && - render !== this.props.render && - truncate === this.props.truncate - ? false - : true; - - public componentDidMount() { - const { dataProvider, registerProvider } = this.props; - - registerProvider!({ provider: dataProvider }); - } - - public componentWillUnmount() { - const { dataProvider, unRegisterProvider } = this.props; - - unRegisterProvider!({ id: dataProvider.id }); - } - public render() { - const { dataProvider, render, truncate } = this.props; +const DraggableWrapperComponent = React.memo( + ({ dataProvider, registerProvider, render, truncate, unRegisterProvider }) => { + useEffect(() => { + registerProvider!({ provider: dataProvider }); + return () => { + unRegisterProvider!({ id: dataProvider.id }); + }; + }, []); return ( @@ -223,8 +210,17 @@ class DraggableWrapperComponent extends React.Component { ); + }, + (prevProps, nextProps) => { + return ( + isEqual(prevProps.dataProvider, nextProps.dataProvider) && + prevProps.render !== nextProps.render && + prevProps.truncate === nextProps.truncate + ); } -} +); + +DraggableWrapperComponent.displayName = 'DraggableWrapperComponent'; export const DraggableWrapper = connect( null, diff --git a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx index 10b4340b6a88d2..dc7f2185c26b72 100644 --- a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx @@ -9,15 +9,15 @@ import { EuiButton, EuiComboBox, EuiComboBoxOptionProps, + EuiFieldText, EuiFlexGroup, EuiFlexItem, - EuiFieldText, EuiFormRow, EuiPanel, EuiSpacer, EuiToolTip, } from '@elastic/eui'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import styled, { injectGlobal } from 'styled-components'; import { BrowserFields } from '../../containers/source'; @@ -37,8 +37,8 @@ import * as i18n from './translations'; const EDIT_DATA_PROVIDER_WIDTH = 400; const FIELD_COMBO_BOX_WIDTH = 195; const OPERATOR_COMBO_BOX_WIDTH = 160; -const VALUE_INPUT_CLASS_NAME = 'edit-data-provider-value'; const SAVE_CLASS_NAME = 'edit-data-provider-save'; +const VALUE_INPUT_CLASS_NAME = 'edit-data-provider-value'; export const HeaderContainer = styled.div` width: ${EDIT_DATA_PROVIDER_WIDTH}; @@ -68,12 +68,6 @@ interface Props { value: string | number; } -interface State { - updatedField: EuiComboBoxOptionProps[]; - updatedOperator: EuiComboBoxOptionProps[]; - updatedValue: string | number; -} - const sanatizeValue = (value: string | number): string => Array.isArray(value) ? `${value[0]}` : `${value}`; // fun fact: value should never be an array @@ -88,37 +82,80 @@ export const getInitialOperatorLabel = ( } }; -export class StatefulEditDataProvider extends React.PureComponent { - constructor(props: Props) { - super(props); +export const StatefulEditDataProvider = React.memo( + ({ + andProviderId, + browserFields, + field, + isExcluded, + onDataProviderEdited, + operator, + providerId, + timelineId, + value, + }) => { + const [updatedField, setUpdatedField] = useState([{ label: field }]); + const [updatedOperator, setUpdatedOperator] = useState( + getInitialOperatorLabel(isExcluded, operator) + ); + const [updatedValue, setUpdatedValue] = useState(value); - const { field, isExcluded, operator, value } = props; + /** Focuses the Value input if it is visible, falling back to the Save button if it's not */ + function focusInput() { + const elements = document.getElementsByClassName(VALUE_INPUT_CLASS_NAME); - this.state = { - updatedField: [{ label: field }], - updatedOperator: getInitialOperatorLabel(isExcluded, operator), - updatedValue: value, - }; - } + if (elements.length > 0) { + (elements[0] as HTMLElement).focus(); // this cast is required because focus() does not exist on every `Element` returned by `getElementsByClassName` + } else { + const saveElements = document.getElementsByClassName(SAVE_CLASS_NAME); - public componentDidMount() { - this.disableScrolling(); - this.focusInput(); - } + if (saveElements.length > 0) { + (saveElements[0] as HTMLElement).focus(); + } + } + } - public componentWillUnmount() { - this.enableScrolling(); - } + function onFieldSelected(selectedField: EuiComboBoxOptionProps[]) { + setUpdatedField(selectedField); + + focusInput(); + } + + function onOperatorSelected(operatorSelected: EuiComboBoxOptionProps[]) { + setUpdatedOperator(operatorSelected); + + focusInput(); + } + + function onValueChange(e: React.ChangeEvent) { + setUpdatedValue(e.target.value); + } + + function disableScrolling() { + const x = + window.pageXOffset !== undefined + ? window.pageXOffset + : (document.documentElement || document.body.parentNode || document.body).scrollLeft; - public render() { - const { - andProviderId, - browserFields, - onDataProviderEdited, - providerId, - timelineId, - } = this.props; - const { updatedField, updatedOperator, updatedValue } = this.state; + const y = + window.pageYOffset !== undefined + ? window.pageYOffset + : (document.documentElement || document.body.parentNode || document.body).scrollTop; + + window.onscroll = () => window.scrollTo(x, y); + } + + function enableScrolling() { + window.onscroll = () => noop; + } + + useEffect(() => { + disableScrolling(); + focusInput(); + return () => { + enableScrolling(); + }; + }, []); return ( @@ -127,18 +164,14 @@ export class StatefulEditDataProvider extends React.PureComponent - 0 ? this.state.updatedField[0].label : null - } - > + 0 ? updatedField[0].label : null}> @@ -151,10 +184,10 @@ export class StatefulEditDataProvider extends React.PureComponent @@ -167,17 +200,17 @@ export class StatefulEditDataProvider extends React.PureComponent - {this.state.updatedOperator.length > 0 && - this.state.updatedOperator[0].label !== i18n.EXISTS && - this.state.updatedOperator[0].label !== i18n.DOES_NOT_EXIST ? ( + {updatedOperator.length > 0 && + updatedOperator[0].label !== i18n.EXISTS && + updatedOperator[0].label !== i18n.DOES_NOT_EXIST ? ( @@ -196,6 +229,13 @@ export class StatefulEditDataProvider extends React.PureComponent color="primary" data-test-subj="save" fill={true} + isDisabled={ + !selectionsAreValid({ + browserFields, + selectedField: updatedField, + selectedOperator: updatedOperator, + }) + } onClick={() => { onDataProviderEdited({ andProviderId, @@ -207,13 +247,6 @@ export class StatefulEditDataProvider extends React.PureComponent value: updatedValue, }); }} - isDisabled={ - !selectionsAreValid({ - browserFields: this.props.browserFields, - selectedField: updatedField, - selectedOperator: updatedOperator, - }) - } size="s" > {i18n.SAVE} @@ -225,53 +258,6 @@ export class StatefulEditDataProvider extends React.PureComponent ); } +); - /** Focuses the Value input if it is visible, falling back to the Save button if it's not */ - private focusInput = () => { - const elements = document.getElementsByClassName(VALUE_INPUT_CLASS_NAME); - - if (elements.length > 0) { - (elements[0] as HTMLElement).focus(); // this cast is required because focus() does not exist on every `Element` returned by `getElementsByClassName` - } else { - const saveElements = document.getElementsByClassName(SAVE_CLASS_NAME); - - if (saveElements.length > 0) { - (saveElements[0] as HTMLElement).focus(); - } - } - }; - - private onFieldSelected = (selectedField: EuiComboBoxOptionProps[]) => { - this.setState({ updatedField: selectedField }); - - this.focusInput(); - }; - - private onOperatorSelected = (updatedOperator: EuiComboBoxOptionProps[]) => { - this.setState({ updatedOperator }); - - this.focusInput(); - }; - - private onValueChange = (e: React.ChangeEvent) => { - this.setState({ - updatedValue: e.target.value, - }); - }; - - private disableScrolling = () => { - const x = - window.pageXOffset !== undefined - ? window.pageXOffset - : (document.documentElement || document.body.parentNode || document.body).scrollLeft; - - const y = - window.pageYOffset !== undefined - ? window.pageYOffset - : (document.documentElement || document.body.parentNode || document.body).scrollTop; - - window.onscroll = () => window.scrollTo(x, y); - }; - - private enableScrolling = () => (window.onscroll = () => noop); -} +StatefulEditDataProvider.displayName = 'StatefulEditDataProvider'; diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx index 86696503dbda38..18040a35a52807 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx @@ -43,7 +43,7 @@ export interface EmbeddedMapProps { } export const EmbeddedMap = React.memo( - ({ applyFilterQueryFromKueryExpression, queryExpression, startDate, endDate, setQuery }) => { + ({ applyFilterQueryFromKueryExpression, endDate, queryExpression, setQuery, startDate }) => { const [embeddable, setEmbeddable] = React.useState(null); const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx b/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx index ec76d8f90c3de9..cb677368298782 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx +++ b/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; +import React, { useState } from 'react'; import { BrowserFields } from '../../containers/source'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; @@ -23,43 +23,24 @@ interface Props { toggleColumn: (column: ColumnHeader) => void; } -interface State { - view: View; -} - -export class StatefulEventDetails extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { view: 'table-view' }; - } +export const StatefulEventDetails = React.memo( + ({ browserFields, columnHeaders, data, id, onUpdateColumns, timelineId, toggleColumn }) => { + const [view, setView] = useState('table-view'); - public onViewSelected = (view: View): void => { - this.setState({ view }); - }; - - public render() { - const { - browserFields, - columnHeaders, - data, - id, - onUpdateColumns, - timelineId, - toggleColumn, - } = this.props; return ( setView(newView)} timelineId={timelineId} toggleColumn={toggleColumn} + view={view} /> ); } -} +); + +StatefulEventDetails.displayName = 'StatefulEventDetails'; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx index 03fb37760bc356..d85231b564da8a 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx @@ -20,8 +20,17 @@ jest.mock('../../lib/settings/use_kibana_ui_setting'); const from = 1566943856794; const to = 1566857456791; - +// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769 +/* eslint-disable no-console */ +const originalError = console.error; describe('EventsViewer', () => { + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); test('it renders the "Showing..." subtitle with the expected event count', async () => { const wrapper = mount( diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx index bef5e66faecd16..dc0e1288f40f83 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx @@ -20,7 +20,17 @@ jest.mock('../../lib/settings/use_kibana_ui_setting'); const from = 1566943856794; const to = 1566857456791; +// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769 +/* eslint-disable no-console */ +const originalError = console.error; describe('StatefulEventsViewer', () => { + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); test('it renders the events viewer', async () => { const wrapper = mount( diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx index 52b724525f5a94..d572d6dd4913b7 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx @@ -87,7 +87,7 @@ const StatefulEventsViewerComponent = React.memo( updateItemsPerPage, upsertColumn, }) => { - const [showInspect, setShowInspect] = useState(false); + const [showInspect, setShowInspect] = useState(false); useEffect(() => { if (createTimeline != null) { diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx index 17785ff582a3c2..fb47672512de56 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx @@ -5,8 +5,8 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiOutsideClickDetector } from '@elastic/eui'; +import React, { useEffect } from 'react'; import { noop } from 'lodash/fp'; -import * as React from 'react'; import styled, { css } from 'styled-components'; import { BrowserFields } from '../../containers/source'; @@ -22,7 +22,7 @@ import { getFieldBrowserSearchInputClassName, PANES_FLEX_GROUP_WIDTH, } from './helpers'; -import { FieldBrowserProps, OnFieldSelected, OnHideFieldBrowser } from './types'; +import { FieldBrowserProps, OnHideFieldBrowser } from './types'; const FieldsBrowserContainer = styled.div<{ width: number }>` ${({ theme, width }) => css` @@ -102,34 +102,80 @@ type Props = Pick< * This component has no internal state, but it uses lifecycle methods to * set focus to the search input, scroll to the selected category, etc */ -export class FieldsBrowser extends React.PureComponent { - public componentDidMount() { - this.scrollViews(); - this.focusInput(); - } +export const FieldsBrowser = React.memo( + ({ + browserFields, + columnHeaders, + filteredBrowserFields, + isEventViewer, + isSearching, + onCategorySelected, + onFieldSelected, + onHideFieldBrowser, + onSearchInputChange, + onOutsideClick, + onUpdateColumns, + searchInput, + selectedCategoryId, + timelineId, + toggleColumn, + width, + }) => { + /** Focuses the input that filters the field browser */ + function focusInput() { + const elements = document.getElementsByClassName( + getFieldBrowserSearchInputClassName(timelineId) + ); - public componentDidUpdate() { - this.scrollViews(); - this.focusInput(); // always re-focus the input to enable additional filtering - } + if (elements.length > 0) { + (elements[0] as HTMLElement).focus(); // this cast is required because focus() does not exist on every `Element` returned by `getElementsByClassName` + } + } + + /** Invoked when the user types in the input to filter the field browser */ + function onInputChange(event: React.ChangeEvent) { + onSearchInputChange(event.target.value); + } + + function selectFieldAndHide(fieldId: string) { + if (onFieldSelected != null) { + onFieldSelected(fieldId); + } - public render() { - const { - columnHeaders, - browserFields, - filteredBrowserFields, - searchInput, - isEventViewer, - isSearching, - onCategorySelected, - onFieldSelected, - onOutsideClick, - onUpdateColumns, - selectedCategoryId, - timelineId, - toggleColumn, - width, - } = this.props; + onHideFieldBrowser(); + } + + function scrollViews() { + if (selectedCategoryId !== '') { + const categoryPaneTitles = document.getElementsByClassName( + getCategoryPaneCategoryClassName({ + categoryId: selectedCategoryId, + timelineId, + }) + ); + + if (categoryPaneTitles.length > 0) { + categoryPaneTitles[0].scrollIntoView(); + } + + const fieldPaneTitles = document.getElementsByClassName( + getFieldBrowserCategoryTitleClassName({ + categoryId: selectedCategoryId, + timelineId, + }) + ); + + if (fieldPaneTitles.length > 0) { + fieldPaneTitles[0].scrollIntoView(); + } + } + + focusInput(); // always re-focus the input to enable additional filtering + } + + useEffect(() => { + scrollViews(); + }, [selectedCategoryId, timelineId]); return ( { isEventViewer={isEventViewer} isSearching={isSearching} onOutsideClick={onOutsideClick} - onSearchInputChange={this.onInputChange} + onSearchInputChange={onInputChange} onUpdateColumns={onUpdateColumns} searchInput={searchInput} timelineId={timelineId} @@ -170,7 +216,7 @@ export class FieldsBrowser extends React.PureComponent { data-test-subj="fields-pane" filteredBrowserFields={filteredBrowserFields} onCategorySelected={onCategorySelected} - onFieldSelected={this.selectFieldAndHide} + onFieldSelected={selectFieldAndHide} onUpdateColumns={onUpdateColumns} searchInput={searchInput} selectedCategoryId={selectedCategoryId} @@ -184,59 +230,4 @@ export class FieldsBrowser extends React.PureComponent { ); } - - /** Focuses the input that filters the field browser */ - private focusInput = () => { - const elements = document.getElementsByClassName( - getFieldBrowserSearchInputClassName(this.props.timelineId) - ); - - if (elements.length > 0) { - (elements[0] as HTMLElement).focus(); // this cast is required because focus() does not exist on every `Element` returned by `getElementsByClassName` - } - }; - - /** Invoked when the user types in the input to filter the field browser */ - private onInputChange = (event: React.ChangeEvent) => - this.props.onSearchInputChange(event.target.value); - - private selectFieldAndHide: OnFieldSelected = (fieldId: string) => { - const { onFieldSelected, onHideFieldBrowser } = this.props; - - if (onFieldSelected != null) { - onFieldSelected(fieldId); - } - - onHideFieldBrowser(); - }; - - private scrollViews = () => { - const { selectedCategoryId, timelineId } = this.props; - - if (this.props.selectedCategoryId !== '') { - const categoryPaneTitles = document.getElementsByClassName( - getCategoryPaneCategoryClassName({ - categoryId: selectedCategoryId, - timelineId, - }) - ); - - if (categoryPaneTitles.length > 0) { - categoryPaneTitles[0].scrollIntoView(); - } - - const fieldPaneTitles = document.getElementsByClassName( - getFieldBrowserCategoryTitleClassName({ - categoryId: selectedCategoryId, - timelineId, - }) - ); - - if (fieldPaneTitles.length > 0) { - fieldPaneTitles[0].scrollIntoView(); - } - } - - this.focusInput(); // always re-focus the input to enable additional filtering - }; -} +); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx index 69720c76cab803..7d21e1f44d04b4 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx @@ -6,7 +6,7 @@ import { EuiButtonEmpty, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import * as React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { connect } from 'react-redux'; import styled from 'styled-components'; import { ActionCreator } from 'typescript-fsa'; @@ -15,7 +15,6 @@ import { BrowserFields } from '../../containers/source'; import { timelineActions } from '../../store/actions'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { DEFAULT_CATEGORY_NAME } from '../timeline/body/column_headers/default_headers'; -import { OnUpdateColumns } from '../timeline/events'; import { FieldsBrowser } from './field_browser'; import { filterBrowserFieldsByFieldName, mergeBrowserFieldsWithDefaultCategory } from './helpers'; import * as i18n from './translations'; @@ -26,19 +25,6 @@ const fieldsButtonClassName = 'fields-button'; /** wait this many ms after the user completes typing before applying the filter input */ const INPUT_TIMEOUT = 250; -interface State { - /** all field names shown in the field browser must contain this string (when specified) */ - filterInput: string; - /** all fields in this collection have field names that match the filterInput */ - filteredBrowserFields: BrowserFields | null; - /** when true, show a spinner in the input to indicate the field browser is searching for matching field names */ - isSearching: boolean; - /** this category will be displayed in the right-hand pane of the field browser */ - selectedCategoryId: string; - /** show the field browser */ - show: boolean; -} - const FieldsBrowserButtonContainer = styled.div` position: relative; `; @@ -60,52 +46,110 @@ interface DispatchProps { /** * Manages the state of the field browser */ -export class StatefulFieldsBrowserComponent extends React.PureComponent< - FieldBrowserProps & DispatchProps, - State -> { - /** tracks the latest timeout id from `setTimeout`*/ - private inputTimeoutId: number = 0; - - constructor(props: FieldBrowserProps) { - super(props); - - this.state = { - filterInput: '', - filteredBrowserFields: null, - isSearching: false, - selectedCategoryId: DEFAULT_CATEGORY_NAME, - show: false, - }; - } +export const StatefulFieldsBrowserComponent = React.memo( + ({ + columnHeaders, + browserFields, + height, + isEventViewer = false, + onFieldSelected, + onUpdateColumns, + timelineId, + toggleColumn, + width, + }) => { + /** tracks the latest timeout id from `setTimeout`*/ + const inputTimeoutId = useRef(0); + + /** all field names shown in the field browser must contain this string (when specified) */ + const [filterInput, setFilterInput] = useState(''); + /** all fields in this collection have field names that match the filterInput */ + const [filteredBrowserFields, setFilteredBrowserFields] = useState(null); + /** when true, show a spinner in the input to indicate the field browser is searching for matching field names */ + const [isSearching, setIsSearching] = useState(false); + /** this category will be displayed in the right-hand pane of the field browser */ + const [selectedCategoryId, setSelectedCategoryId] = useState(DEFAULT_CATEGORY_NAME); + /** show the field browser */ + const [show, setShow] = useState(false); + useEffect(() => { + return () => { + if (inputTimeoutId.current !== 0) { + // ⚠️ mutation: cancel any remaining timers and zero-out the timer id: + clearTimeout(inputTimeoutId.current); + inputTimeoutId.current = 0; + } + }; + }, []); + + /** Shows / hides the field browser */ + function toggleShow() { + setShow(!show); + } + + /** Invoked when the user types in the filter input */ + function updateFilter(newFilterInput: string) { + setFilterInput(newFilterInput); + setIsSearching(true); + + if (inputTimeoutId.current !== 0) { + clearTimeout(inputTimeoutId.current); // ⚠️ mutation: cancel any previous timers + } + + // ⚠️ mutation: schedule a new timer that will apply the filter when it fires: + inputTimeoutId.current = window.setTimeout(() => { + const newFilteredBrowserFields = filterBrowserFieldsByFieldName({ + browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields), + substring: filterInput, + }); + + setFilteredBrowserFields(newFilteredBrowserFields); + setIsSearching(false); + + const newSelectedCategoryId = + filterInput === '' || Object.keys(newFilteredBrowserFields).length === 0 + ? DEFAULT_CATEGORY_NAME + : Object.keys(newFilteredBrowserFields) + .sort() + .reduce( + (selected, category) => + newFilteredBrowserFields[category].fields != null && + newFilteredBrowserFields[selected].fields != null && + newFilteredBrowserFields[category].fields!.length > + newFilteredBrowserFields[selected].fields!.length + ? category + : selected, + Object.keys(newFilteredBrowserFields)[0] + ); + setSelectedCategoryId(newSelectedCategoryId); + }, INPUT_TIMEOUT); + } - public componentWillUnmount() { - if (this.inputTimeoutId !== 0) { - // ⚠️ mutation: cancel any remaining timers and zero-out the timer id: - clearTimeout(this.inputTimeoutId); - this.inputTimeoutId = 0; + /** + * Invoked when the user clicks a category name in the left-hand side of + * the field browser + */ + function updateSelectedCategoryId(categoryId: string) { + setSelectedCategoryId(categoryId); } - } - public render() { - const { - columnHeaders, - browserFields, - height, - isEventViewer = false, - onFieldSelected, - timelineId, - toggleColumn, - width, - } = this.props; - const { - filterInput, - filteredBrowserFields, - isSearching, - selectedCategoryId, - show, - } = this.state; + /** + * Invoked when the user clicks on the context menu to view a category's + * columns in the timeline, this function dispatches the action that + * causes the timeline display those columns. + */ + function updateColumnsAndSelectCategoryId(columns: ColumnHeader[]) { + onUpdateColumns(columns); // show the category columns in the timeline + } + /** Invoked when the field browser should be hidden */ + function hideFieldBrowser() { + setFilterInput(''); + setFilterInput(''); + setFilteredBrowserFields(null); + setIsSearching(false); + setSelectedCategoryId(DEFAULT_CATEGORY_NAME); + setShow(false); + } // only merge in the default category if the field browser is visible const browserFieldsWithDefaultCategory = show ? mergeBrowserFieldsWithDefaultCategory(browserFields) @@ -121,14 +165,14 @@ export class StatefulFieldsBrowserComponent extends React.PureComponent< className={fieldsButtonClassName} data-test-subj="show-field-browser-gear" iconType="list" - onClick={this.toggleShow} + onClick={toggleShow} /> ) : ( {i18n.FIELDS} @@ -148,12 +192,12 @@ export class StatefulFieldsBrowserComponent extends React.PureComponent< height={height} isEventViewer={isEventViewer} isSearching={isSearching} - onCategorySelected={this.updateSelectedCategoryId} + onCategorySelected={updateSelectedCategoryId} onFieldSelected={onFieldSelected} - onHideFieldBrowser={this.hideFieldBrowser} - onOutsideClick={show ? this.hideFieldBrowser : noop} - onSearchInputChange={this.updateFilter} - onUpdateColumns={this.updateColumnsAndSelectCategoryId} + onHideFieldBrowser={hideFieldBrowser} + onOutsideClick={show ? hideFieldBrowser : noop} + onSearchInputChange={updateFilter} + onUpdateColumns={updateColumnsAndSelectCategoryId} searchInput={filterInput} selectedCategoryId={selectedCategoryId} timelineId={timelineId} @@ -165,84 +209,9 @@ export class StatefulFieldsBrowserComponent extends React.PureComponent< ); } +); - /** Shows / hides the field browser */ - private toggleShow = () => { - this.setState(({ show }) => ({ - show: !show, - })); - }; - - /** Invoked when the user types in the filter input */ - private updateFilter = (filterInput: string): void => { - this.setState({ - filterInput, - isSearching: true, - }); - - if (this.inputTimeoutId !== 0) { - clearTimeout(this.inputTimeoutId); // ⚠️ mutation: cancel any previous timers - } - - // ⚠️ mutation: schedule a new timer that will apply the filter when it fires: - this.inputTimeoutId = window.setTimeout(() => { - const filteredBrowserFields = filterBrowserFieldsByFieldName({ - browserFields: mergeBrowserFieldsWithDefaultCategory(this.props.browserFields), - substring: this.state.filterInput, - }); - - this.setState(currentState => ({ - filteredBrowserFields, - isSearching: false, - selectedCategoryId: - currentState.filterInput === '' || Object.keys(filteredBrowserFields).length === 0 - ? DEFAULT_CATEGORY_NAME - : Object.keys(filteredBrowserFields) - .sort() - .reduce( - (selected, category) => - filteredBrowserFields[category].fields != null && - filteredBrowserFields[selected].fields != null && - filteredBrowserFields[category].fields!.length > - filteredBrowserFields[selected].fields!.length - ? category - : selected, - Object.keys(filteredBrowserFields)[0] - ), - })); - }, INPUT_TIMEOUT); - }; - - /** - * Invoked when the user clicks a category name in the left-hand side of - * the field browser - */ - private updateSelectedCategoryId = (categoryId: string): void => { - this.setState({ - selectedCategoryId: categoryId, - }); - }; - - /** - * Invoked when the user clicks on the context menu to view a category's - * columns in the timeline, this function dispatches the action that - * causes the timeline display those columns. - */ - private updateColumnsAndSelectCategoryId: OnUpdateColumns = (columns: ColumnHeader[]): void => { - this.props.onUpdateColumns(columns); // show the category columns in the timeline - }; - - /** Invoked when the field browser should be hidden */ - private hideFieldBrowser = () => { - this.setState({ - filterInput: '', - filteredBrowserFields: null, - isSearching: false, - selectedCategoryId: DEFAULT_CATEGORY_NAME, - show: false, - }); - }; -} +StatefulFieldsBrowserComponent.displayName = 'StatefulFieldsBrowserComponent'; export const StatefulFieldsBrowser = connect( null, diff --git a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx index 15ce42c6a16b64..ceaff289f776cb 100644 --- a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx @@ -111,18 +111,30 @@ const FlyoutHeaderWithCloseButton = React.memo<{ FlyoutHeaderWithCloseButton.displayName = 'FlyoutHeaderWithCloseButton'; -class FlyoutPaneComponent extends React.PureComponent { - public render() { - const { - children, - flyoutHeight, - headerHeight, - onClose, - timelineId, - usersViewing, - width, - } = this.props; - +const FlyoutPaneComponent = React.memo( + ({ + applyDeltaToWidth, + children, + flyoutHeight, + headerHeight, + onClose, + timelineId, + usersViewing, + width, + }) => { + const renderFlyout = () => <>; + + const onResize: OnResize = ({ delta, id }) => { + const bodyClientWidthPixels = document.body.clientWidth; + + applyDeltaToWidth({ + bodyClientWidthPixels, + delta, + id, + maxWidthPercent, + minWidthPixels, + }); + }; return ( { } id={timelineId} - onResize={this.onResize} - render={this.renderFlyout} + onResize={onResize} + render={renderFlyout} /> { ); } +); - private renderFlyout = () => <>; - - private onResize: OnResize = ({ delta, id }) => { - const { applyDeltaToWidth } = this.props; - - const bodyClientWidthPixels = document.body.clientWidth; - - applyDeltaToWidth({ - bodyClientWidthPixels, - delta, - id, - maxWidthPercent, - minWidthPixels, - }); - }; -} +FlyoutPaneComponent.displayName = 'FlyoutPaneComponent'; export const Pane = connect( null, diff --git a/x-pack/legacy/plugins/siem/public/components/help_menu/help_menu.tsx b/x-pack/legacy/plugins/siem/public/components/help_menu/help_menu.tsx index 90af0d56c15820..b59753e8add6a7 100644 --- a/x-pack/legacy/plugins/siem/public/components/help_menu/help_menu.tsx +++ b/x-pack/legacy/plugins/siem/public/components/help_menu/help_menu.tsx @@ -16,30 +16,28 @@ export const Icon = styled(EuiIcon)` Icon.displayName = 'Icon'; -export class HelpMenuComponent extends React.PureComponent { - public render() { - return ( - <> - - - - - -
- - -
-
- - - - - - - - ); - } -} +export const HelpMenuComponent = React.memo(() => ( + <> + + + + + +
+ + +
+
+ + + + + + + +)); + +HelpMenuComponent.displayName = 'HelpMenuComponent'; diff --git a/x-pack/legacy/plugins/siem/public/components/lazy_accordion/index.tsx b/x-pack/legacy/plugins/siem/public/components/lazy_accordion/index.tsx index 5ed9a3b623c1ce..da2e7334756e4e 100644 --- a/x-pack/legacy/plugins/siem/public/components/lazy_accordion/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/lazy_accordion/index.tsx @@ -5,7 +5,7 @@ */ import { EuiAccordion, EuiAccordionProps } from '@elastic/eui'; -import * as React from 'react'; +import React, { useState } from 'react'; type Props = Pick> & { forceExpand?: boolean; @@ -14,10 +14,6 @@ type Props = Pick React.ReactNode; }; -interface State { - expanded: boolean; -} - /** * An accordion that doesn't render it's content unless it's expanded. * This component was created because `EuiAccordion`'s eager rendering of @@ -33,29 +29,36 @@ interface State { * TODO: animate the expansion and collapse of content rendered "below" * the real `EuiAccordion`. */ -export class LazyAccordion extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { - expanded: false, +export const LazyAccordion = React.memo( + ({ + buttonContent, + buttonContentClassName, + extraAction, + forceExpand, + id, + onCollapse, + onExpand, + paddingSize, + renderExpandedContent, + }) => { + const [expanded, setExpanded] = useState(false); + const onCollapsedClick = () => { + setExpanded(true); + if (onExpand != null) { + onExpand(); + } }; - } - public render() { - const { - id, - buttonContentClassName, - buttonContent, - forceExpand, - extraAction, - renderExpandedContent, - paddingSize, - } = this.props; + const onExpandedClick = () => { + setExpanded(false); + if (onCollapse != null) { + onCollapse(); + } + }; return ( <> - {forceExpand || this.state.expanded ? ( + {forceExpand || expanded ? ( <> { extraAction={extraAction} id={id} initialIsOpen={true} - onClick={this.onExpandedClick} + onClick={onExpandedClick} paddingSize={paddingSize} > <> - {renderExpandedContent(this.state.expanded)} + {renderExpandedContent(expanded)} ) : ( { data-test-subj="lazy-accordion-placeholder" extraAction={extraAction} id={id} - onClick={this.onCollapsedClick} + onClick={onCollapsedClick} paddingSize={paddingSize} /> )} ); } +); - private onCollapsedClick = () => { - const { onExpand } = this.props; - - this.setState({ expanded: true }); - - if (onExpand != null) { - onExpand(); - } - }; - - private onExpandedClick = () => { - const { onCollapse } = this.props; - - this.setState({ expanded: false }); - - if (onCollapse != null) { - onCollapse(); - } - }; -} +LazyAccordion.displayName = 'LazyAccordion'; diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/load_more_table/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 4bf3f647502e2d..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,104 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Load More Table Component rendering it renders the default load more table 1`] = ` - - My test supplement. -

- } - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={ - Array [ - Object { - "numberOfRow": 2, - "text": "2 rows", - }, - Object { - "numberOfRow": 5, - "text": "5 rows", - }, - Object { - "numberOfRow": 10, - "text": "10 rows", - }, - Object { - "numberOfRow": 20, - "text": "20 rows", - }, - Object { - "numberOfRow": 50, - "text": "50 rows", - }, - ] - } - limit={1} - loadMore={[Function]} - loading={false} - pageOfItems={ - Array [ - Object { - "cursor": Object { - "value": "98966fa2013c396155c460d35c0902be", - }, - "host": Object { - "_id": "cPsuhGcB0WOhS6qyTKC0", - "firstSeen": "2018-12-06T15:40:53.319Z", - "name": "elrond.elstc.co", - "os": "Ubuntu", - "version": "18.04.1 LTS (Bionic Beaver)", - }, - }, - Object { - "cursor": Object { - "value": "aa7ca589f1b8220002f2fc61c64cfbf1", - }, - "host": Object { - "_id": "KwQDiWcB0WOhS6qyXmrW", - "firstSeen": "2018-12-07T14:12:38.560Z", - "name": "siem-kibana", - "os": "Debian GNU/Linux", - "version": "9 (stretch)", - }, - }, - ] - } - updateLimitPagination={[Function]} -/> -`; diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.mock.tsx b/x-pack/legacy/plugins/siem/public/components/load_more_table/index.mock.tsx deleted file mode 100644 index 02ec00a78bc91c..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.mock.tsx +++ /dev/null @@ -1,118 +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 { getOrEmptyTagFromValue } from '../empty_value'; - -import { Columns, ItemsPerRow } from './index'; - -export const mockData = { - Hosts: { - totalCount: 4, - edges: [ - { - host: { - _id: 'cPsuhGcB0WOhS6qyTKC0', - name: 'elrond.elstc.co', - os: 'Ubuntu', - version: '18.04.1 LTS (Bionic Beaver)', - firstSeen: '2018-12-06T15:40:53.319Z', - }, - cursor: { - value: '98966fa2013c396155c460d35c0902be', - }, - }, - { - host: { - _id: 'KwQDiWcB0WOhS6qyXmrW', - name: 'siem-kibana', - os: 'Debian GNU/Linux', - version: '9 (stretch)', - firstSeen: '2018-12-07T14:12:38.560Z', - }, - cursor: { - value: 'aa7ca589f1b8220002f2fc61c64cfbf1', - }, - }, - ], - pageInfo: { - endCursor: { - value: 'aa7ca589f1b8220002f2fc61c64cfbf1', - }, - hasNextPage: true, - }, - }, -}; - -export const getHostsColumns = (): [ - Columns, - Columns, - Columns, - Columns -] => [ - { - field: 'node.host.name', - name: 'Host', - truncateText: false, - hideForMobile: false, - render: (name: string) => getOrEmptyTagFromValue(name), - }, - { - field: 'node.host.firstSeen', - name: 'First seen', - truncateText: false, - hideForMobile: false, - render: (firstSeen: string) => getOrEmptyTagFromValue(firstSeen), - }, - { - field: 'node.host.os', - name: 'OS', - truncateText: false, - hideForMobile: false, - render: (os: string) => getOrEmptyTagFromValue(os), - }, - { - field: 'node.host.version', - name: 'Version', - truncateText: false, - hideForMobile: false, - render: (version: string) => getOrEmptyTagFromValue(version), - }, -]; - -export const sortedHosts: [ - Columns, - Columns, - Columns, - Columns -] = getHostsColumns().map(h => ({ ...h, sortable: true })) as [ - Columns, - Columns, - Columns, - Columns -]; - -export const rowItems: ItemsPerRow[] = [ - { - text: '2 rows', - numberOfRow: 2, - }, - { - text: '5 rows', - numberOfRow: 5, - }, - { - text: '10 rows', - numberOfRow: 10, - }, - { - text: '20 rows', - numberOfRow: 20, - }, - { - text: '50 rows', - numberOfRow: 50, - }, -]; diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/load_more_table/index.test.tsx deleted file mode 100644 index 3c42d3d2acfe32..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.test.tsx +++ /dev/null @@ -1,360 +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 { mount, shallow } from 'enzyme'; -import toJson from 'enzyme-to-json'; -import * as React from 'react'; - -import { Direction } from '../../graphql/types'; - -import { LoadMoreTable } from './index'; -import { getHostsColumns, mockData, rowItems, sortedHosts } from './index.mock'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { ThemeProvider } from 'styled-components'; - -describe('Load More Table Component', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - const loadMore = jest.fn(); - const updateLimitPagination = jest.fn(); - describe('rendering', () => { - test('it renders the default load more table', () => { - const wrapper = shallow( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(toJson(wrapper)).toMatchSnapshot(); - }); - - test('it renders the loading panel at the beginning ', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={[]} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect( - wrapper.find('[data-test-subj="initialLoadingPanelLoadMoreTable"]').exists() - ).toBeTruthy(); - }); - - test('it renders the over loading panel after data has been in the table ', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(wrapper.find('[data-test-subj="loadingPanelLoadMoreTable"]').exists()).toBeTruthy(); - }); - - test('it renders the loadMore button if need to fetch more', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect( - wrapper - .find('[data-test-subj="loadingMoreButton"]') - .first() - .text() - ).toContain('Load more'); - }); - - test('it renders the Loading... in the more load button when fetching new data', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect( - wrapper.find('[data-test-subj="initialLoadingPanelLoadMoreTable"]').exists() - ).toBeFalsy(); - expect( - wrapper - .find('[data-test-subj="loadingMoreButton"]') - .first() - .text() - ).toContain('Loading…'); - }); - - test('it does NOT render the loadMore button because there is nothing else to fetch', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(wrapper.find('[data-test-subj="loadingMoreButton"]').exists()).toBeFalsy(); - }); - - test('it render popover to select new limit in table', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - wrapper - .find('[data-test-subj="loadingMoreSizeRowPopover"] button') - .first() - .simulate('click'); - expect(wrapper.find('[data-test-subj="loadingMorePickSizeRow"]').exists()).toBeTruthy(); - }); - - test('it will NOT render popover to select new limit in table if props itemsPerRow is empty', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={[]} - limit={2} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeFalsy(); - }); - - test('It should render a sort icon if sorting is defined', () => { - const mockOnChange = jest.fn(); - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={jest.fn()} - onChange={mockOnChange} - pageOfItems={mockData.Hosts.edges} - sorting={{ direction: Direction.asc, field: 'node.host.name' }} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(wrapper.find('.euiTable thead tr th button svg')).toBeTruthy(); - }); - }); - - describe('Events', () => { - test('should call loadmore when clicking on the button load more', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - wrapper - .find('[data-test-subj="loadingMoreButton"]') - .first() - .simulate('click'); - - expect(loadMore).toBeCalled(); - }); - - test('Should call updateLimitPagination when you pick a new limit', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - wrapper - .find('[data-test-subj="loadingMoreSizeRowPopover"] button') - .first() - .simulate('click'); - - wrapper - .find('[data-test-subj="loadingMorePickSizeRow"] button') - .first() - .simulate('click'); - expect(updateLimitPagination).toBeCalled(); - }); - - test('Should call onChange when you choose a new sort in the table', () => { - const mockOnChange = jest.fn(); - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={jest.fn()} - onChange={mockOnChange} - pageOfItems={mockData.Hosts.edges} - sorting={{ direction: Direction.asc, field: 'node.host.name' }} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - wrapper - .find('.euiTable thead tr th button') - .first() - .simulate('click'); - - expect(mockOnChange).toBeCalled(); - expect(mockOnChange.mock.calls[0]).toEqual([ - { page: undefined, sort: { direction: 'desc', field: 'node.host.name' } }, - ]); - }); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/load_more_table/index.tsx deleted file mode 100644 index 0663246039cb8d..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.tsx +++ /dev/null @@ -1,320 +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 { - EuiBasicTable, - EuiButton, - EuiButtonEmpty, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingContent, - EuiPopover, -} from '@elastic/eui'; -import { isEmpty, noop } from 'lodash/fp'; -import React from 'react'; -import styled from 'styled-components'; - -import { Direction } from '../../graphql/types'; -import { HeaderPanel } from '../header_panel'; -import { Loader } from '../loader'; - -import * as i18n from './translations'; -import { Panel } from '../panel'; - -const DEFAULT_DATA_TEST_SUBJ = 'load-more-table'; - -export interface ItemsPerRow { - text: string; - numberOfRow: number; -} - -export interface SortingBasicTable { - field: string; - direction: Direction; - allowNeutralSort?: boolean; -} - -export interface Criteria { - page?: { index: number; size: number }; - sort?: SortingBasicTable; -} - -// Using telescoping templates to remove 'any' that was polluting downstream column type checks -interface BasicTableProps { - columns: - | [Columns] - | [Columns, Columns] - | [Columns, Columns, Columns] - | [Columns, Columns, Columns, Columns] - | [Columns, Columns, Columns, Columns, Columns] - | [Columns, Columns, Columns, Columns, Columns, Columns] - | [Columns, Columns, Columns, Columns, Columns, Columns, Columns] - | [ - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns - ] - | [ - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns - ]; - hasNextPage: boolean; - dataTestSubj?: string; - headerCount: number; - headerSupplement?: React.ReactElement; - headerTitle: string | React.ReactElement; - headerTooltip?: string; - headerUnit: string | React.ReactElement; - id?: string; - itemsPerRow?: ItemsPerRow[]; - limit: number; - loading: boolean; - loadMore: () => void; - onChange?: (criteria: Criteria) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - pageOfItems: any[]; - sorting?: SortingBasicTable; - updateLimitPagination: (limit: number) => void; -} - -interface BasicTableState { - loadingInitial: boolean; - isPopoverOpen: boolean; - showInspect: boolean; -} - -type Func = (arg: T) => string | number; - -export interface Columns { - field?: string; - align?: string; - name: string | React.ReactNode; - isMobileHeader?: boolean; - sortable?: boolean | Func; - truncateText?: boolean; - hideForMobile?: boolean; - render?: (item: T, node: U) => React.ReactNode; - width?: string; -} - -export class LoadMoreTable extends React.PureComponent< - BasicTableProps, - BasicTableState -> { - public readonly state = { - loadingInitial: this.props.headerCount === -1, - isPopoverOpen: false, - showInspect: false, - }; - - static getDerivedStateFromProps( - props: BasicTableProps, - state: BasicTableState - ) { - if (state.loadingInitial && props.headerCount >= 0) { - return { - ...state, - loadingInitial: false, - }; - } - return null; - } - - public render() { - const { - columns, - dataTestSubj = DEFAULT_DATA_TEST_SUBJ, - hasNextPage, - headerCount, - headerSupplement, - headerTitle, - headerTooltip, - headerUnit, - id, - itemsPerRow, - limit, - loading, - onChange = noop, - pageOfItems, - sorting = null, - updateLimitPagination, - } = this.props; - const { loadingInitial } = this.state; - - const button = ( - - {`${i18n.ROWS}: ${limit}`} - - ); - - const rowItems = - itemsPerRow && - itemsPerRow.map(item => ( - { - this.closePopover(); - updateLimitPagination(item.numberOfRow); - }} - > - {item.text} - - )); - - return ( - - = 0 ? headerCount.toLocaleString() : 0} ${headerUnit}` - } - title={headerTitle} - tooltip={headerTooltip} - > - {!loadingInitial && headerSupplement} - - - {loadingInitial ? ( - - ) : ( - <> - - - {hasNextPage && ( - - - {!isEmpty(itemsPerRow) && ( - - - - )} - - - - - {loading ? `${i18n.LOADING}` : i18n.LOAD_MORE} - - - - )} - - {loading && } - - )} - - ); - } - - private mouseEnter = () => { - this.setState(prevState => ({ - ...prevState, - showInspect: true, - })); - }; - - private mouseLeave = () => { - this.setState(prevState => ({ - ...prevState, - showInspect: false, - })); - }; - - private onButtonClick = () => { - this.setState(prevState => ({ - ...prevState, - isPopoverOpen: !prevState.isPopoverOpen, - })); - }; - - private closePopover = () => { - this.setState(prevState => ({ - ...prevState, - isPopoverOpen: false, - })); - }; -} - -const BasicTable = styled(EuiBasicTable)` - tbody { - th, - td { - vertical-align: top; - } - - .euiTableCellContent { - display: block; - } - } -`; - -BasicTable.displayName = 'BasicTable'; - -const FooterAction = styled(EuiFlexGroup).attrs({ - alignItems: 'center', - responsive: false, -})` - margin-top: ${props => props.theme.eui.euiSizeXS}; -`; - -FooterAction.displayName = 'FooterAction'; diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/translations.ts b/x-pack/legacy/plugins/siem/public/components/load_more_table/translations.ts deleted file mode 100644 index ec093f97216247..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/translations.ts +++ /dev/null @@ -1,23 +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 { i18n } from '@kbn/i18n'; - -export const LOADING = i18n.translate('xpack.siem.loadMoreTable.loadingButtonLabel', { - defaultMessage: 'Loading…', -}); - -export const LOAD_MORE = i18n.translate('xpack.siem.loadMoreTable.loadMoreButtonLabel', { - defaultMessage: 'Load more', -}); - -export const SHOWING = i18n.translate('xpack.siem.loadMoreTable.showingSubtitle', { - defaultMessage: 'Showing', -}); - -export const ROWS = i18n.translate('xpack.siem.loadMoreTable.rowsButtonLabel', { - defaultMessage: 'Rows per page', -}); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx b/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx index b2470bc0f5abd4..0956e93829e5ab 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx @@ -21,7 +21,7 @@ export const MlCapabilitiesContext = React.createContext(emptyMl MlCapabilitiesContext.displayName = 'MlCapabilitiesContext'; export const MlCapabilitiesProvider = React.memo<{ children: JSX.Element }>(({ children }) => { - const [capabilities, setCapabilities] = useState(emptyMlCapabilities); + const [capabilities, setCapabilities] = useState(emptyMlCapabilities); const [, dispatchToaster] = useStateToaster(); const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.test.tsx index 3a1fcbb653efec..04fed8e4fff3f0 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.test.tsx @@ -8,7 +8,7 @@ import { getAnomaliesHostTableColumnsCurated } from './get_anomalies_host_table_ import { HostsType } from '../../../store/hosts/model'; import * as i18n from './translations'; import { AnomaliesByHost, Anomaly } from '../types'; -import { Columns } from '../../load_more_table'; +import { Columns } from '../../paginated_table'; import { TestProviders } from '../../../mock'; import { mount } from 'enzyme'; import React from 'react'; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx index f0cf2e5a6e662d..6650449dd8200d 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import moment from 'moment'; -import { Columns } from '../../load_more_table'; +import { Columns } from '../../paginated_table'; import { AnomaliesByHost, Anomaly, NarrowDateRange } from '../types'; import { getRowItemDraggable } from '../../tables/helpers'; import { EntityDraggable } from '../entity_draggable'; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.test.tsx index d063ed023bca6a..768c7af8f4b2c9 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.test.tsx @@ -8,7 +8,7 @@ import { getAnomaliesNetworkTableColumnsCurated } from './get_anomalies_network_ import { NetworkType } from '../../../store/network/model'; import * as i18n from './translations'; import { AnomaliesByNetwork, Anomaly } from '../types'; -import { Columns } from '../../load_more_table'; +import { Columns } from '../../paginated_table'; import { mount } from 'enzyme'; import React from 'react'; import { TestProviders } from '../../../mock'; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx index fb43175686e3d7..1e1628fb077dd4 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import moment from 'moment'; -import { Columns } from '../../load_more_table'; +import { Columns } from '../../paginated_table'; import { Anomaly, NarrowDateRange, AnomaliesByNetwork } from '../types'; import { getRowItemDraggable } from '../../tables/helpers'; import { EntityDraggable } from '../entity_draggable'; diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx index 25ebb8ad89ecd1..96cb85b246a49b 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; +import { mount } from 'enzyme'; import * as React from 'react'; import { CONSTANTS } from '../url_state/constants'; @@ -63,7 +63,7 @@ describe('SIEM Navigation', () => { }, [CONSTANTS.timelineId]: '', }; - const wrapper = shallow(); + const wrapper = mount(); test('it calls setBreadcrumbs with correct path on mount', () => { expect(setBreadcrumbs).toHaveBeenNthCalledWith(1, { detailName: undefined, diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/index.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/index.tsx index 6c5cac1464e79c..06f7a2ffb05661 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/index.tsx @@ -5,7 +5,7 @@ */ import { isEqual } from 'lodash/fp'; -import React from 'react'; +import React, { useEffect } from 'react'; import { compose } from 'redux'; import { connect } from 'react-redux'; @@ -27,78 +27,23 @@ import { TabNavigation } from './tab_navigation'; import { TabNavigationProps } from './tab_navigation/types'; import { SiemNavigationComponentProps } from './types'; -export class SiemNavigationComponent extends React.Component { - public shouldComponentUpdate(nextProps: Readonly): boolean { - if ( - this.props.pathName === nextProps.pathName && - this.props.search === nextProps.search && - isEqual(this.props.hosts, nextProps.hosts) && - isEqual(this.props.hostDetails, nextProps.hostDetails) && - isEqual(this.props.network, nextProps.network) && - isEqual(this.props.navTabs, nextProps.navTabs) && - isEqual(this.props.timerange, nextProps.timerange) && - isEqual(this.props.timelineId, nextProps.timelineId) - ) { - return false; - } - return true; - } - - public componentWillMount(): void { - const { - detailName, - hosts, - hostDetails, - navTabs, - network, - pageName, - pathName, - search, - tabName, - timerange, - timelineId, - } = this.props; - if (pathName) { - setBreadcrumbs({ - detailName, - hosts, - hostDetails, - navTabs, - network, - pageName, - pathName, - search, - tabName, - timerange, - timelineId, - }); - } - } - - public componentWillReceiveProps(nextProps: Readonly): void { - if ( - this.props.pathName !== nextProps.pathName || - this.props.search !== nextProps.search || - !isEqual(this.props.hosts, nextProps.hosts) || - !isEqual(this.props.hostDetails, nextProps.hostDetails) || - !isEqual(this.props.network, nextProps.network) || - !isEqual(this.props.navTabs, nextProps.navTabs) || - !isEqual(this.props.timerange, nextProps.timerange) || - !isEqual(this.props.timelineId, nextProps.timelineId) - ) { - const { - detailName, - hosts, - hostDetails, - navTabs, - network, - pageName, - pathName, - search, - tabName, - timelineId, - timerange, - } = nextProps; +export const SiemNavigationComponent = React.memo( + ({ + detailName, + display, + hostDetails, + hosts, + navTabs, + network, + pageName, + pathName, + search, + showBorder, + tabName, + timelineId, + timerange, + }) => { + useEffect(() => { if (pathName) { setBreadcrumbs({ detailName, @@ -114,23 +59,8 @@ export class SiemNavigationComponent extends React.Component ); + }, + (prevProps, nextProps) => { + return ( + prevProps.pathName === nextProps.pathName && + prevProps.search === nextProps.search && + isEqual(prevProps.hosts, nextProps.hosts) && + isEqual(prevProps.hostDetails, nextProps.hostDetails) && + isEqual(prevProps.network, nextProps.network) && + isEqual(prevProps.navTabs, nextProps.navTabs) && + isEqual(prevProps.timerange, nextProps.timerange) && + isEqual(prevProps.timelineId, nextProps.timelineId) + ); } -} +); + +SiemNavigationComponent.displayName = 'SiemNavigationComponent'; const makeMapStateToProps = () => { const getInputsSelector = inputsSelectors.inputsSelector(); diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.test.tsx index b6ec9e5ee0e029..61a0ec9c06c2d0 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import * as React from 'react'; import { TabNavigation } from './'; @@ -74,8 +74,8 @@ describe('Tab Navigation', () => { expect(hostsTab.prop('isSelected')).toBeTruthy(); }); test('it changes active tab when nav changes by props', () => { - const wrapper = shallow(); - const networkTab = () => wrapper.find('[data-test-subj="navigation-network"]'); + const wrapper = mount(); + const networkTab = () => wrapper.find('[data-test-subj="navigation-network"]').first(); expect(networkTab().prop('isSelected')).toBeFalsy(); wrapper.setProps({ pageName: 'network', @@ -151,9 +151,9 @@ describe('Tab Navigation', () => { expect(tableNavigationTab.prop('isSelected')).toBeTruthy(); }); test('it changes active tab when nav changes by props', () => { - const wrapper = shallow(); + const wrapper = mount(); const tableNavigationTab = () => - wrapper.find(`[data-test-subj="navigation-${HostsTableType.events}"]`); + wrapper.find(`[data-test-subj="navigation-${HostsTableType.events}"]`).first(); expect(tableNavigationTab().prop('isSelected')).toBeFalsy(); wrapper.setProps({ pageName: SiemPageName.hosts, diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.tsx index 9a409b9f53d8ce..c62335ea1c06db 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.tsx @@ -4,14 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiTab, EuiTabs, EuiLink } from '@elastic/eui'; -import { get, getOr } from 'lodash/fp'; +import { getOr } from 'lodash/fp'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import styled from 'styled-components'; import classnames from 'classnames'; import { trackUiAction as track, METRIC_TYPE, TELEMETRY_EVENT } from '../../../lib/track_usage'; -import { HostsTableType } from '../../../store/hosts/model'; import { getSearch } from '../helpers'; import { TabNavigationProps } from './types'; @@ -36,71 +35,51 @@ const TabContainer = styled.div` TabContainer.displayName = 'TabContainer'; -interface TabNavigationState { - selectedTabId: string; -} - -export class TabNavigation extends React.PureComponent { - constructor(props: TabNavigationProps) { - super(props); - const selectedTabId = this.mapLocationToTab(props.pageName, props.tabName); - this.state = { selectedTabId }; - } - public componentWillReceiveProps(nextProps: TabNavigationProps): void { - const selectedTabId = this.mapLocationToTab(nextProps.pageName, nextProps.tabName); - - if (this.state.selectedTabId !== selectedTabId) { - this.setState(prevState => ({ - ...prevState, - selectedTabId, - })); - } - } - public render() { - const { display = 'condensed' } = this.props; - return ( - - {this.renderTabs()} - - ); - } - - public mapLocationToTab = (pageName: string, tabName?: HostsTableType): string => { - const { navTabs } = this.props; +export const TabNavigation = React.memo(props => { + const { display = 'condensed', navTabs, pageName, showBorder, tabName } = props; + const mapLocationToTab = (): string => { return getOr( '', 'id', Object.values(navTabs).find(item => tabName === item.id || pageName === item.id) ); }; + const [selectedTabId, setSelectedTabId] = useState(mapLocationToTab()); + useEffect(() => { + const currentTabSelected = mapLocationToTab(); - private renderTabs = (): JSX.Element[] => { - const { navTabs } = this.props; - return Object.keys(navTabs).map(tabName => { - const tab = get(tabName, navTabs); - return ( - + Object.values(navTabs).map(tab => ( + + - { + track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.TAB_CLICKED}${tab.id}`); + }} > - { - track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.TAB_CLICKED}${tab.id}`); - }} - > - {tab.name} - - - - ); - }); - }; -} + {tab.name} + + + + )); + return ( + + {renderTabs()} + + ); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/notes/index.tsx b/x-pack/legacy/plugins/siem/public/components/notes/index.tsx index 8eaf368058631b..29f7686ade88b5 100644 --- a/x-pack/legacy/plugins/siem/public/components/notes/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/notes/index.tsx @@ -5,7 +5,7 @@ */ import { EuiInMemoryTable, EuiModalBody, EuiModalHeader, EuiPanel, EuiSpacer } from '@elastic/eui'; -import * as React from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; import { Note } from '../../lib/note'; @@ -23,10 +23,6 @@ interface Props { updateNote: UpdateNote; } -interface State { - newNote: string; -} - const NotesPanel = styled(EuiPanel)` height: ${NOTES_PANEL_HEIGHT}px; width: ${NOTES_PANEL_WIDTH}px; @@ -47,15 +43,9 @@ const InMemoryTable = styled(EuiInMemoryTable)` InMemoryTable.displayName = 'InMemoryTable'; /** A view for entering and reviewing notes */ -export class Notes extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { newNote: '' }; - } - - public render() { - const { associateNote, getNotesByIds, getNewNoteId, noteIds, updateNote } = this.props; +export const Notes = React.memo( + ({ associateNote, getNotesByIds, getNewNoteId, noteIds, updateNote }) => { + const [newNote, setNewNote] = useState(''); return ( @@ -67,8 +57,8 @@ export class Notes extends React.PureComponent { @@ -84,8 +74,6 @@ export class Notes extends React.PureComponent { ); } +); - private updateNewNote = (newNote: string): void => { - this.setState({ newNote }); - }; -} +Notes.displayName = 'Notes'; diff --git a/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx b/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx index 51992e00313a4a..aa9415aadeda16 100644 --- a/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx @@ -5,7 +5,7 @@ */ import { EuiFlexGroup, EuiPanel } from '@elastic/eui'; -import * as React from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; import { Note } from '../../../lib/note'; @@ -53,27 +53,23 @@ interface Props { updateNote: UpdateNote; } -interface State { - newNote: string; -} - /** A view for entering and reviewing notes */ -export class NoteCards extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { newNote: '' }; - } - - public render() { - const { - getNotesByIds, - getNewNoteId, - noteIds, - showAddNote, - toggleShowAddNote, - updateNote, - } = this.props; +export const NoteCards = React.memo( + ({ + associateNote, + getNotesByIds, + getNewNoteId, + noteIds, + showAddNote, + toggleShowAddNote, + updateNote, + }) => { + const [newNote, setNewNote] = useState(''); + + const associateNoteAndToggleShow = (noteId: string) => { + associateNote(noteId); + toggleShowAddNote(); + }; return ( @@ -90,11 +86,11 @@ export class NoteCards extends React.PureComponent { {showAddNote ? ( @@ -102,13 +98,6 @@ export class NoteCards extends React.PureComponent { ); } +); - private associateNoteAndToggleShow = (noteId: string) => { - this.props.associateNote(noteId); - this.props.toggleShowAddNote(); - }; - - private updateNewNote = (newNote: string): void => { - this.setState({ newNote }); - }; -} +NoteCards.displayName = 'NoteCards'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx index e91feed536f93c..917ec3f1bf0b86 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx @@ -17,7 +17,7 @@ describe('DeleteTimelineModal', () => { ); @@ -34,7 +34,7 @@ describe('DeleteTimelineModal', () => { ); @@ -48,7 +48,7 @@ describe('DeleteTimelineModal', () => { test('it displays `Untitled Timeline` in the title when title is undefined', () => { const wrapper = mountWithIntl( - + ); expect( @@ -61,7 +61,7 @@ describe('DeleteTimelineModal', () => { test('it displays `Untitled Timeline` in the title when title is null', () => { const wrapper = mountWithIntl( - + ); expect( @@ -74,7 +74,7 @@ describe('DeleteTimelineModal', () => { test('it displays `Untitled Timeline` in the title when title is just whitespace', () => { const wrapper = mountWithIntl( - + ); expect( @@ -90,7 +90,7 @@ describe('DeleteTimelineModal', () => { ); @@ -102,14 +102,14 @@ describe('DeleteTimelineModal', () => { ).toEqual(i18n.DELETE_WARNING); }); - test('it invokes toggleShowModal when the Cancel button is clicked', () => { - const toggleShowModal = jest.fn(); + test('it invokes closeModal when the Cancel button is clicked', () => { + const closeModal = jest.fn(); const wrapper = mountWithIntl( ); @@ -118,7 +118,7 @@ describe('DeleteTimelineModal', () => { .first() .simulate('click'); - expect(toggleShowModal).toBeCalled(); + expect(closeModal).toBeCalled(); }); test('it invokes onDelete when the Delete button is clicked', () => { @@ -128,7 +128,7 @@ describe('DeleteTimelineModal', () => { ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 9c416419066e66..e9e438a8c5e2e2 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -14,7 +14,7 @@ import * as i18n from '../translations'; interface Props { title?: string | null; onDelete: () => void; - toggleShowModal: () => void; + closeModal: () => void; } export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px @@ -22,7 +22,7 @@ export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px /** * Renders a modal that confirms deletion of a timeline */ -export const DeleteTimelineModal = pure(({ title, toggleShowModal, onDelete }) => ( +export const DeleteTimelineModal = pure(({ title, closeModal, onDelete }) => ( (({ title, toggleShowModal, onDele }} /> } - onCancel={toggleShowModal} + onCancel={closeModal} onConfirm={onDelete} cancelButtonText={i18n.CANCEL} confirmButtonText={i18n.DELETE} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx index 1700e86f57c844..561eac000bbf76 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx @@ -5,7 +5,6 @@ */ import { EuiButtonIconProps } from '@elastic/eui'; -import { get } from 'lodash/fp'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import * as React from 'react'; @@ -79,35 +78,6 @@ describe('DeleteTimelineModal', () => { expect(props.isDisabled).toBe(false); }); - test('it defaults showModal to false until the trash button is clicked', () => { - const wrapper = mountWithIntl( - - ); - - expect(get('showModal', wrapper.state())).toBe(false); - }); - - test('it sets showModal to true when the trash button is clicked', () => { - const wrapper = mountWithIntl( - - ); - - wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .simulate('click'); - - expect(get('showModal', wrapper.state())).toBe(true); - }); - test('it does NOT render the modal when showModal is false', () => { const wrapper = mountWithIntl( { - constructor(props: Props) { - super(props); +export const DeleteTimelineModalButton = React.memo( + ({ deleteTimelines, savedObjectId, title }) => { + const [showModal, setShowModal] = useState(false); - this.state = { showModal: false }; - } + const openModal = () => setShowModal(true); + const closeModal = () => setShowModal(false); - public render() { - const { deleteTimelines, savedObjectId, title } = this.props; + const onDelete = () => { + if (deleteTimelines != null && savedObjectId != null) { + deleteTimelines([savedObjectId]); + } + closeModal(); + }; return ( <> @@ -44,19 +43,19 @@ export class DeleteTimelineModalButton extends React.PureComponent iconSize="s" iconType="trash" isDisabled={deleteTimelines == null || savedObjectId == null || savedObjectId === ''} - onClick={this.toggleShowModal} + onClick={openModal} size="s" /> - {this.state.showModal ? ( + {showModal ? ( - + @@ -64,20 +63,6 @@ export class DeleteTimelineModalButton extends React.PureComponent ); } +); - private toggleShowModal = () => { - this.setState(state => ({ - showModal: !state.showModal, - })); - }; - - private onDelete = () => { - const { deleteTimelines, savedObjectId } = this.props; - - if (deleteTimelines != null && savedObjectId != null) { - deleteTimelines([savedObjectId]); - } - - this.toggleShowModal(); - }; -} +DeleteTimelineModalButton.displayName = 'DeleteTimelineModalButton'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx index 62de2ea30542a0..7a0caf14af302e 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx @@ -5,8 +5,7 @@ */ import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { mount, ReactWrapper } from 'enzyme'; -import { get } from 'lodash/fp'; +import { mount } from 'enzyme'; import { MockedProvider } from 'react-apollo/test-utils'; import * as React from 'react'; import { ThemeProvider } from 'styled-components'; @@ -22,21 +21,11 @@ import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; jest.mock('../../lib/settings/use_kibana_ui_setting'); -const getStateChildComponent = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - wrapper: ReactWrapper, React.Component<{}, {}, any>> -): // eslint-disable-next-line @typescript-eslint/no-explicit-any -React.Component<{}, {}, any> => - wrapper - .find('[data-test-subj="stateful-timeline"]') - .last() - .instance(); - describe('StatefulOpenTimeline', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); const title = 'All Timelines / Open Timelines'; - test('it has the expected initial state', async () => { + test('it has the expected initial state', () => { const wrapper = mount( @@ -53,15 +42,18 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - wrapper.update(); + const componentProps = wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .props(); - expect(getStateChildComponent(wrapper).state).toEqual({ + expect(componentProps).toEqual({ + ...componentProps, itemIdToExpandedNotesRowMap: {}, onlyFavorites: false, pageIndex: 0, pageSize: 10, - search: '', + query: '', selectedItems: [], sortDirection: 'desc', sortField: 'updated', @@ -69,7 +61,7 @@ describe('StatefulOpenTimeline', () => { }); describe('#onQueryChange', () => { - test('it updates the query state with the expected trimmed value when the user enters a query', async () => { + test('it updates the query state with the expected trimmed value when the user enters a query', () => { const wrapper = mount( @@ -85,26 +77,15 @@ describe('StatefulOpenTimeline', () => { ); - - await wait(); - wrapper.update(); - wrapper .find('[data-test-subj="search-bar"] input') .simulate('keyup', { keyCode: 13, target: { value: ' abcd ' } }); - - wrapper.update(); - - expect(getStateChildComponent(wrapper).state).toEqual({ - itemIdToExpandedNotesRowMap: {}, - onlyFavorites: false, - pageIndex: 0, - pageSize: 10, - search: 'abcd', - selectedItems: [], - sortDirection: 'desc', - sortField: 'updated', - }); + expect( + wrapper + .find('[data-test-subj="search-row"]') + .first() + .prop('query') + ).toEqual('abcd'); }); test('it appends the word "with" to the Showing in Timelines message when the user enters a query', async () => { @@ -129,8 +110,6 @@ describe('StatefulOpenTimeline', () => { .find('[data-test-subj="search-bar"] input') .simulate('keyup', { keyCode: 13, target: { value: ' abcd ' } }); - wrapper.update(); - expect( wrapper .find('[data-test-subj="query-message"]') @@ -161,8 +140,6 @@ describe('StatefulOpenTimeline', () => { .find('[data-test-subj="search-bar"] input') .simulate('keyup', { keyCode: 13, target: { value: ' abcd ' } }); - wrapper.update(); - expect( wrapper .find('[data-test-subj="selectable-query-text"]') @@ -226,7 +203,6 @@ describe('StatefulOpenTimeline', () => { .find('.euiCheckbox__input') .first() .simulate('change', { target: { checked: true } }); - wrapper.update(); wrapper .find('[data-test-subj="favorite-selected"]') @@ -273,7 +249,6 @@ describe('StatefulOpenTimeline', () => { .find('.euiCheckbox__input') .first() .simulate('change', { target: { checked: true } }); - wrapper.update(); wrapper .find('[data-test-subj="delete-selected"]') @@ -319,14 +294,17 @@ describe('StatefulOpenTimeline', () => { .first() .simulate('change', { target: { checked: true } }); - wrapper.update(); + const selectedItems: [] = wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('selectedItems'); - expect(get('selectedItems', getStateChildComponent(wrapper).state).length).toEqual(13); // 13 because we did mock 13 timelines in the query + expect(selectedItems.length).toEqual(13); // 13 because we did mock 13 timelines in the query }); }); describe('#onTableChange', () => { - test('it updates the sort state when the user clicks on a column to sort it', async () => { + test('it updates the sort state when the user clicks on a column to sort it', () => { const wrapper = mount( @@ -343,32 +321,29 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - - wrapper.update(); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('sortDirection') + ).toEqual('desc'); wrapper .find('thead tr th button') .at(0) .simulate('click'); - wrapper.update(); - - expect(getStateChildComponent(wrapper).state).toEqual({ - itemIdToExpandedNotesRowMap: {}, - onlyFavorites: false, - pageIndex: 0, - pageSize: 10, - search: '', - selectedItems: [], - sortDirection: 'asc', - sortField: 'updated', - }); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('sortDirection') + ).toEqual('asc'); }); }); describe('#onToggleOnlyFavorites', () => { - test('it updates the onlyFavorites state when the user clicks the Only Favorites button', async () => { + test('it updates the onlyFavorites state when the user clicks the Only Favorites button', () => { const wrapper = mount( @@ -385,25 +360,24 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('onlyFavorites') + ).toEqual(false); wrapper .find('[data-test-subj="only-favorites-toggle"]') .first() .simulate('click'); - wrapper.update(); - - expect(getStateChildComponent(wrapper).state).toEqual({ - itemIdToExpandedNotesRowMap: {}, - onlyFavorites: true, - pageIndex: 0, - pageSize: 10, - search: '', - selectedItems: [], - sortDirection: 'desc', - sortField: 'updated', - }); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('onlyFavorites') + ).toEqual(true); }); }); @@ -426,38 +400,38 @@ describe('StatefulOpenTimeline', () => { ); await wait(); - wrapper.update(); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('itemIdToExpandedNotesRowMap') + ).toEqual({}); + wrapper .find('[data-test-subj="expand-notes"]') .first() .simulate('click'); - wrapper.update(); - expect(getStateChildComponent(wrapper).state).toEqual({ - itemIdToExpandedNotesRowMap: { - '10849df0-7b44-11e9-a608-ab3d811609': ( - ({ ...note, savedObjectId: note.noteId }) - ) - : [] - } - /> - ), - }, - onlyFavorites: false, - pageIndex: 0, - pageSize: 10, - search: '', - selectedItems: [], - sortDirection: 'desc', - sortField: 'updated', + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('itemIdToExpandedNotesRowMap') + ).toEqual({ + '10849df0-7b44-11e9-a608-ab3d811609': ( + ({ ...note, savedObjectId: note.noteId }) + ) + : [] + } + /> + ), }); }); @@ -487,8 +461,6 @@ describe('StatefulOpenTimeline', () => { .first() .simulate('click'); - wrapper.update(); - expect( wrapper .find('[data-test-subj="note-previews-container"]') @@ -543,25 +515,23 @@ describe('StatefulOpenTimeline', () => { ); - + const getSelectedItem = (): [] => + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('selectedItems'); await wait(); - - wrapper.update(); - + expect(getSelectedItem().length).toEqual(0); wrapper .find('.euiCheckbox__input') .first() .simulate('change', { target: { checked: true } }); - wrapper.update(); - + expect(getSelectedItem().length).toEqual(13); wrapper .find('[data-test-subj="delete-selected"]') .first() .simulate('click'); - - wrapper.update(); - - expect(get('selectedItems', getStateChildComponent(wrapper).state).length).toEqual(0); + expect(getSelectedItem().length).toEqual(0); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx index c686228ed31e8f..d101d1f4d39f41 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx @@ -5,7 +5,7 @@ */ import ApolloClient from 'apollo-client'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; @@ -43,25 +43,6 @@ import { import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; -export interface OpenTimelineState { - /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ - itemIdToExpandedNotesRowMap: Record; - /** Only query for favorite timelines when true */ - onlyFavorites: boolean; - /** The requested page of results */ - pageIndex: number; - /** The requested size of each page of search results */ - pageSize: number; - /** The current search criteria */ - search: string; - /** The currently-selected timelines in the table */ - selectedItems: OpenTimelineResult[]; - /** The requested sort direction of the query results */ - sortDirection: 'asc' | 'desc'; - /** The requested field to sort on */ - sortField: string; -} - interface OwnProps { apolloClient: ApolloClient; /** Displays open timeline in modal */ @@ -85,70 +66,208 @@ export const getSelectedTimelineIds = (selectedItems: OpenTimelineResult[]): str ); /** Manages the state (e.g table selection) of the (pure) `OpenTimeline` component */ -export class StatefulOpenTimelineComponent extends React.PureComponent< - OpenTimelineOwnProps, - OpenTimelineState -> { - constructor(props: OpenTimelineOwnProps) { - super(props); +export const StatefulOpenTimelineComponent = React.memo( + ({ + defaultPageSize, + isModal = false, + title, + apolloClient, + closeModalTimeline, + updateTimeline, + updateIsLoading, + timeline, + createNewTimeline, + }) => { + /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ + const [itemIdToExpandedNotesRowMap, setItemIdToExpandedNotesRowMap] = useState< + Record + >({}); + /** Only query for favorite timelines when true */ + const [onlyFavorites, setOnlyFavorites] = useState(false); + /** The requested page of results */ + const [pageIndex, setPageIndex] = useState(0); + /** The requested size of each page of search results */ + const [pageSize, setPageSize] = useState(defaultPageSize); + /** The current search criteria */ + const [search, setSearch] = useState(''); + /** The currently-selected timelines in the table */ + const [selectedItems, setSelectedItems] = useState([]); + /** The requested sort direction of the query results */ + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(DEFAULT_SORT_DIRECTION); + /** The requested field to sort on */ + const [sortField, setSortField] = useState(DEFAULT_SORT_FIELD); + + /** Invoked when the user presses enters to submit the text in the search input */ + const onQueryChange: OnQueryChange = (query: EuiSearchBarQuery) => { + setSearch(query.queryText.trim()); + }; + + /** Focuses the input that filters the field browser */ + const focusInput = () => { + const elements = document.querySelector(`.${OPEN_TIMELINE_CLASS_NAME} input`); - this.state = { - itemIdToExpandedNotesRowMap: {}, - onlyFavorites: false, - search: '', - pageIndex: 0, - pageSize: props.defaultPageSize, - sortField: DEFAULT_SORT_FIELD, - sortDirection: DEFAULT_SORT_DIRECTION, - selectedItems: [], + if (elements != null) { + elements.focus(); + } }; - } - public componentDidMount() { - this.focusInput(); - } + /* This feature will be implemented in the near future, so we are keeping it to know what to do */ + + /** Invoked when the user clicks the action to add the selected timelines to favorites */ + // const onAddTimelinesToFavorites: OnAddTimelinesToFavorites = () => { + // const { addTimelinesToFavorites } = this.props; + // const { selectedItems } = this.state; + // if (addTimelinesToFavorites != null) { + // addTimelinesToFavorites(getSelectedTimelineIds(selectedItems)); + // TODO: it's not possible to clear the selection state of the newly-favorited + // items, because we can't pass the selection state as props to the table. + // See: https://github.com/elastic/eui/issues/1077 + // TODO: the query must re-execute to show the results of the mutation + // } + // }; + + const onDeleteOneTimeline: OnDeleteOneTimeline = (timelineIds: string[]) => { + deleteTimelines(timelineIds, { + search, + pageInfo: { + pageIndex: pageIndex + 1, + pageSize, + }, + sort: { + sortField: sortField as SortFieldTimeline, + sortOrder: sortDirection as Direction, + }, + onlyUserFavorite: onlyFavorites, + }); + }; + + /** Invoked when the user clicks the action to delete the selected timelines */ + const onDeleteSelected: OnDeleteSelected = () => { + deleteTimelines(getSelectedTimelineIds(selectedItems), { + search, + pageInfo: { + pageIndex: pageIndex + 1, + pageSize, + }, + sort: { + sortField: sortField as SortFieldTimeline, + sortOrder: sortDirection as Direction, + }, + onlyUserFavorite: onlyFavorites, + }); + + // NOTE: we clear the selection state below, but if the server fails to + // delete a timeline, it will remain selected in the table: + resetSelectionState(); + + // TODO: the query must re-execute to show the results of the deletion + }; + + /** Invoked when the user selects (or de-selects) timelines */ + const onSelectionChange: OnSelectionChange = (newSelectedItems: OpenTimelineResult[]) => { + setSelectedItems(newSelectedItems); // <-- this is NOT passed down as props to the table: https://github.com/elastic/eui/issues/1077 + }; + + /** Invoked by the EUI table implementation when the user interacts with the table (i.e. to update sorting) */ + const onTableChange: OnTableChange = ({ page, sort }: OnTableChangeParams) => { + const { index, size } = page; + const { field, direction } = sort; + setPageIndex(index); + setPageSize(size); + setSortDirection(direction); + setSortField(field); + }; + + /** Invoked when the user toggles the option to only view favorite timelines */ + const onToggleOnlyFavorites: OnToggleOnlyFavorites = () => { + setOnlyFavorites(!onlyFavorites); + }; + + /** Invoked when the user toggles the expansion or collapse of inline notes in a table row */ + const onToggleShowNotes: OnToggleShowNotes = ( + newItemIdToExpandedNotesRowMap: Record + ) => { + setItemIdToExpandedNotesRowMap(newItemIdToExpandedNotesRowMap); + }; + + /** Resets the selection state such that all timelines are unselected */ + const resetSelectionState = () => { + setSelectedItems([]); + }; + + const openTimeline: OnOpenTimeline = ({ + duplicate, + timelineId, + }: { + duplicate: boolean; + timelineId: string; + }) => { + if (isModal && closeModalTimeline != null) { + closeModalTimeline(); + } + + queryTimelineById({ + apolloClient, + duplicate, + timelineId, + updateIsLoading, + updateTimeline, + }); + }; + + const deleteTimelines: DeleteTimelines = ( + timelineIds: string[], + variables?: AllTimelinesVariables + ) => { + if (timelineIds.includes(timeline.savedObjectId || '')) { + createNewTimeline({ id: 'timeline-1', columns: defaultHeaders, show: false }); + } + apolloClient.mutate({ + mutation: deleteTimelineMutation, + fetchPolicy: 'no-cache', + variables: { id: timelineIds }, + refetchQueries: [ + { + query: allTimelinesQuery, + variables, + }, + ], + }); + }; + useEffect(() => { + focusInput(); + }, []); - public render() { - const { defaultPageSize, isModal = false, title } = this.props; - const { - itemIdToExpandedNotesRowMap, - onlyFavorites, - pageIndex, - pageSize, - search: query, - selectedItems, - sortDirection, - sortField, - } = this.state; return ( {({ timelines, loading, totalCount }) => { return !isModal ? ( ) : ( ); } - - /** Invoked when the user presses enters to submit the text in the search input */ - private onQueryChange: OnQueryChange = (query: EuiSearchBarQuery) => { - this.setState({ - search: query.queryText.trim(), - }); - }; - - /** Focuses the input that filters the field browser */ - private focusInput = () => { - const elements = document.querySelector(`.${OPEN_TIMELINE_CLASS_NAME} input`); - - if (elements != null) { - elements.focus(); - } - }; - - /* This feature will be implemented in the near future, so we are keeping it to know what to do */ - - /** Invoked when the user clicks the action to add the selected timelines to favorites */ - // private onAddTimelinesToFavorites: OnAddTimelinesToFavorites = () => { - // const { addTimelinesToFavorites } = this.props; - // const { selectedItems } = this.state; - // if (addTimelinesToFavorites != null) { - // addTimelinesToFavorites(getSelectedTimelineIds(selectedItems)); - // TODO: it's not possible to clear the selection state of the newly-favorited - // items, because we can't pass the selection state as props to the table. - // See: https://github.com/elastic/eui/issues/1077 - // TODO: the query must re-execute to show the results of the mutation - // } - // }; - - private onDeleteOneTimeline: OnDeleteOneTimeline = (timelineIds: string[]) => { - const { onlyFavorites, pageIndex, pageSize, search, sortDirection, sortField } = this.state; - - this.deleteTimelines(timelineIds, { - search, - pageInfo: { - pageIndex: pageIndex + 1, - pageSize, - }, - sort: { - sortField: sortField as SortFieldTimeline, - sortOrder: sortDirection as Direction, - }, - onlyUserFavorite: onlyFavorites, - }); - }; - - /** Invoked when the user clicks the action to delete the selected timelines */ - private onDeleteSelected: OnDeleteSelected = () => { - const { selectedItems, onlyFavorites } = this.state; - - this.deleteTimelines(getSelectedTimelineIds(selectedItems), { - search: this.state.search, - pageInfo: { - pageIndex: this.state.pageIndex + 1, - pageSize: this.state.pageSize, - }, - sort: { - sortField: this.state.sortField as SortFieldTimeline, - sortOrder: this.state.sortDirection as Direction, - }, - onlyUserFavorite: onlyFavorites, - }); - - // NOTE: we clear the selection state below, but if the server fails to - // delete a timeline, it will remain selected in the table: - this.resetSelectionState(); - - // TODO: the query must re-execute to show the results of the deletion - }; - - /** Invoked when the user selects (or de-selects) timelines */ - private onSelectionChange: OnSelectionChange = (selectedItems: OpenTimelineResult[]) => { - this.setState({ selectedItems }); // <-- this is NOT passed down as props to the table: https://github.com/elastic/eui/issues/1077 - }; - - /** Invoked by the EUI table implementation when the user interacts with the table (i.e. to update sorting) */ - private onTableChange: OnTableChange = ({ page, sort }: OnTableChangeParams) => { - const { index: pageIndex, size: pageSize } = page; - const { field: sortField, direction: sortDirection } = sort; - - this.setState({ - pageIndex, - pageSize, - sortDirection, - sortField, - }); - }; - - /** Invoked when the user toggles the option to only view favorite timelines */ - private onToggleOnlyFavorites: OnToggleOnlyFavorites = () => { - this.setState(state => ({ - onlyFavorites: !state.onlyFavorites, - })); - }; - - /** Invoked when the user toggles the expansion or collapse of inline notes in a table row */ - private onToggleShowNotes: OnToggleShowNotes = ( - itemIdToExpandedNotesRowMap: Record - ) => { - this.setState(() => ({ - itemIdToExpandedNotesRowMap, - })); - }; - - /** Resets the selection state such that all timelines are unselected */ - private resetSelectionState = () => { - this.setState({ - selectedItems: [], - }); - }; - - private openTimeline: OnOpenTimeline = ({ - duplicate, - timelineId, - }: { - duplicate: boolean; - timelineId: string; - }) => { - const { - apolloClient, - closeModalTimeline, - isModal, - updateTimeline, - updateIsLoading, - } = this.props; - - if (isModal && closeModalTimeline != null) { - closeModalTimeline(); - } - - queryTimelineById({ - apolloClient, - duplicate, - timelineId, - updateIsLoading, - updateTimeline, - }); - }; - - private deleteTimelines: DeleteTimelines = ( - timelineIds: string[], - variables?: AllTimelinesVariables - ) => { - if (timelineIds.includes(this.props.timeline.savedObjectId || '')) { - this.props.createNewTimeline({ id: 'timeline-1', columns: defaultHeaders, show: false }); - } - this.props.apolloClient.mutate< - DeleteTimelineMutation.Mutation, - DeleteTimelineMutation.Variables - >({ - mutation: deleteTimelineMutation, - fetchPolicy: 'no-cache', - variables: { id: timelineIds }, - refetchQueries: [ - { - query: allTimelinesQuery, - variables, - }, - ], - }); - }; -} +); const makeMapStateToProps = () => { const getTimeline = timelineSelectors.getTimelineByIdSelector(); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx index bcafed20a50ffd..146afa85e10a76 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx @@ -5,8 +5,7 @@ */ import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { get } from 'lodash/fp'; -import { mount, ReactWrapper } from 'enzyme'; +import { mount } from 'enzyme'; import * as React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; import { ThemeProvider } from 'styled-components'; @@ -20,12 +19,6 @@ import { OpenTimelineModalButton } from '.'; jest.mock('../../../lib/settings/use_kibana_ui_setting'); -const getStateChildComponent = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - wrapper: ReactWrapper, React.Component<{}, {}, any>> -): // eslint-disable-next-line @typescript-eslint/no-explicit-any -React.Component<{}, {}, any> => wrapper.find('[data-test-subj="state-child-component"]').instance(); - describe('OpenTimelineModalButton', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); @@ -56,10 +49,7 @@ describe('OpenTimelineModalButton', () => { - + @@ -69,7 +59,7 @@ describe('OpenTimelineModalButton', () => { wrapper.update(); - expect(get('showModal', getStateChildComponent(wrapper).state)).toEqual(false); + expect(wrapper.find('div[data-test-subj="open-timeline-modal"].euiModal').length).toEqual(0); }); test('it sets showModal to true when the button is clicked', async () => { @@ -151,10 +141,7 @@ describe('OpenTimelineModalButton', () => { - + @@ -169,7 +156,7 @@ describe('OpenTimelineModalButton', () => { wrapper.update(); - expect(get('showModal', getStateChildComponent(wrapper).state)).toEqual(true); + expect(wrapper.find('div[data-test-subj="open-timeline-modal"].euiModal').length).toEqual(1); }); test('it invokes the optional onToggle function provided as a prop when the open timeline button is clicked', async () => { diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx index 79fa747aee0817..41907e07d5c1bf 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx @@ -5,8 +5,7 @@ */ import { EuiButtonEmpty, EuiModal, EuiOverlayMask } from '@elastic/eui'; -import * as React from 'react'; -import styled from 'styled-components'; +import React, { useState } from 'react'; import { ApolloConsumer } from 'react-apollo'; import * as i18n from '../translations'; @@ -20,90 +19,61 @@ export interface OpenTimelineModalButtonProps { onToggle?: () => void; } -export interface OpenTimelineModalButtonState { - showModal: boolean; -} - const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; const OPEN_TIMELINE_MODAL_WIDTH = 1000; // px -// TODO: this container can be removed when -// the following EUI PR is available (in Kibana): -// https://github.com/elastic/eui/pull/1902/files#diff-d662c14c5dcd7e4b41028bf60b9bc77b -const ModalContainer = styled.div` - .euiModalBody { - display: flex; - flex-direction: column; - } -`; - -ModalContainer.displayName = 'ModalContainer'; - /** * Renders a button that when clicked, displays the `Open Timelines` modal */ -export class OpenTimelineModalButton extends React.PureComponent< - OpenTimelineModalButtonProps, - OpenTimelineModalButtonState -> { - constructor(props: OpenTimelineModalButtonProps) { - super(props); +export const OpenTimelineModalButton = React.memo(({ onToggle }) => { + const [showModal, setShowModal] = useState(false); - this.state = { showModal: false }; + /** shows or hides the `Open Timeline` modal */ + function toggleShowModal() { + if (onToggle != null) { + onToggle(); + } + setShowModal(!showModal); } - public render() { - return ( - - {client => ( - <> - - {i18n.OPEN_TIMELINE} - - - {this.state.showModal && ( - - - - - - - - )} - - )} - - ); + function closeModalTimeline() { + toggleShowModal(); } + return ( + + {client => ( + <> + + {i18n.OPEN_TIMELINE} + - /** shows or hides the `Open Timeline` modal */ - private toggleShowModal = () => { - if (this.props.onToggle != null) { - this.props.onToggle(); - } - - this.setState(state => ({ - showModal: !state.showModal, - })); - }; + {showModal && ( + + + + + + )} + + )} + + ); +}); - private closeModalTimeline = () => { - this.toggleShowModal(); - }; -} +OpenTimelineModalButton.displayName = 'OpenTimelineModalButton'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.tsx index 693dcf7516bc45..5c0b449916a1f9 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.tsx @@ -7,7 +7,6 @@ import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React from 'react'; -import { pure } from 'recompose'; import styled from 'styled-components'; import { StaticIndexPattern } from 'ui/index_patterns'; @@ -25,15 +24,21 @@ interface Props { filterQueryDraft: KueryFilterQuery; } -class AddToKqlComponent extends React.PureComponent { - public render() { - const { children } = this.props; +const AddToKqlComponent = React.memo( + ({ children, expression, filterQueryDraft, applyFilterQueryFromKueryExpression }) => { + const addToKql = () => { + applyFilterQueryFromKueryExpression( + filterQueryDraft && !isEmpty(filterQueryDraft.expression) + ? `${filterQueryDraft.expression} and ${expression}` + : expression + ); + }; return ( - + } @@ -41,16 +46,9 @@ class AddToKqlComponent extends React.PureComponent { /> ); } +); - private addToKql = () => { - const { expression, filterQueryDraft, applyFilterQueryFromKueryExpression } = this.props; - applyFilterQueryFromKueryExpression( - filterQueryDraft && !isEmpty(filterQueryDraft.expression) - ? `${filterQueryDraft.expression} and ${expression}` - : expression - ); - }; -} +AddToKqlComponent.displayName = 'AddToKqlComponent'; const HoverActionsContainer = styled(EuiPanel)` align-items: center; @@ -75,7 +73,7 @@ interface AddToKqlProps { type: networkModel.NetworkType | hostsModel.HostsType; } -export const AddToKql = pure( +export const AddToKql = React.memo( ({ children, expression, type, componentFilterType, indexPattern }) => { switch (componentFilterType) { case 'hosts': diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/index.tsx index 65c8e9a6546866..d4b3b5e8759892 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/index.tsx @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import memoizeOne from 'memoize-one'; -import React from 'react'; +import React, { useMemo } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; import { StaticIndexPattern } from 'ui/index_patterns'; @@ -38,8 +37,8 @@ interface OwnProps { data: HostsEdges[]; fakeTotalCount: number; id: string; - isInspect: boolean; indexPattern: StaticIndexPattern; + isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; showMorePagesIndicator: boolean; @@ -49,15 +48,15 @@ interface OwnProps { interface HostsTableReduxProps { activePage: number; + direction: Direction; limit: number; sortField: HostsFields; - direction: Direction; } interface HostsTableDispatchProps { updateHostsSort: ActionCreator<{ - sort: HostsSortField; hostsType: hostsModel.HostsType; + sort: HostsSortField; }>; updateTableActivePage: ActionCreator<{ activePage: number; @@ -65,8 +64,8 @@ interface HostsTableDispatchProps { tableType: hostsModel.HostsTableType; }>; updateTableLimit: ActionCreator<{ - limit: number; hostsType: hostsModel.HostsType; + limit: number; tableType: hostsModel.HostsTableType; }>; } @@ -90,47 +89,58 @@ const rowItems: ItemsPerRow[] = [ numberOfRow: 10, }, ]; +const getSorting = ( + trigger: string, + sortField: HostsFields, + direction: Direction +): SortingBasicTable => ({ field: getNodeField(sortField), direction }); + +const HostsTableComponent = React.memo( + ({ + activePage, + data, + direction, + fakeTotalCount, + id, + indexPattern, + isInspect, + limit, + loading, + loadPage, + showMorePagesIndicator, + sortField, + totalCount, + type, + updateHostsSort, + updateTableActivePage, + updateTableLimit, + }) => { + const onChange = (criteria: Criteria) => { + if (criteria.sort != null) { + const sort: HostsSortField = { + field: getSortField(criteria.sort.field), + direction: criteria.sort.direction, + }; + if (sort.direction !== direction || sort.field !== sortField) { + updateHostsSort({ + sort, + hostsType: type, + }); + } + } + }; -class HostsTableComponent extends React.PureComponent { - private memoizedColumns: ( - type: hostsModel.HostsType, - indexPattern: StaticIndexPattern - ) => HostsTableColumns; - private memoizedSorting: ( - trigger: string, - sortField: HostsFields, - direction: Direction - ) => SortingBasicTable; - - constructor(props: HostsTableProps) { - super(props); - this.memoizedColumns = memoizeOne(this.getMemoizeHostsColumns); - this.memoizedSorting = memoizeOne(this.getSorting); - } + const hostsColumns = useMemo(() => getHostsColumns(type, indexPattern), [type, indexPattern]); - public render() { - const { - activePage, - data, - direction, - fakeTotalCount, - id, - isInspect, - indexPattern, - limit, - loading, - loadPage, - showMorePagesIndicator, - totalCount, + const sorting = useMemo(() => getSorting(`${sortField}-${direction}`, sortField, direction), [ sortField, - type, - updateTableActivePage, - updateTableLimit, - } = this.props; + direction, + ]); + return ( { limit={limit} loading={loading} loadPage={newActivePage => loadPage(newActivePage)} - onChange={this.onChange} + onChange={onChange} pageOfItems={data} showMorePagesIndicator={showMorePagesIndicator} - sorting={this.memoizedSorting(`${sortField}-${direction}`, sortField, direction)} + sorting={sorting} totalCount={fakeTotalCount} updateLimitPagination={newLimit => updateTableLimit({ @@ -163,33 +173,9 @@ class HostsTableComponent extends React.PureComponent { /> ); } +); - private getSorting = ( - trigger: string, - sortField: HostsFields, - direction: Direction - ): SortingBasicTable => ({ field: getNodeField(sortField), direction }); - - private getMemoizeHostsColumns = ( - type: hostsModel.HostsType, - indexPattern: StaticIndexPattern - ): HostsTableColumns => getHostsColumns(type, indexPattern); - - private onChange = (criteria: Criteria) => { - if (criteria.sort != null) { - const sort: HostsSortField = { - field: getSortField(criteria.sort.field), - direction: criteria.sort.direction, - }; - if (sort.direction !== this.props.direction || sort.field !== this.props.sortField) { - this.props.updateHostsSort({ - sort, - hostsType: this.props.type, - }); - } - } - }; -} +HostsTableComponent.displayName = 'HostsTableComponent'; const getSortField = (field: string): HostsFields => { switch (field) { diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx index 24820b637d388e..cf5da3fbebba64 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx @@ -25,7 +25,7 @@ import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_ import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; import { defaultToEmptyTag, getEmptyTagValue } from '../../../empty_value'; import { PreferenceFormattedDate } from '../../../formatted_date'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import { LocalizedDateTooltip } from '../../../localized_date_tooltip'; import { IS_OPERATOR } from '../../../timeline/data_providers/data_provider'; import { PreferenceFormattedBytes } from '../../../formatted_bytes'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx index 1fdea3f2b03322..353699c5158bc4 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx @@ -12,7 +12,7 @@ import { networkModel } from '../../../../store'; import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_wrapper'; import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; import { defaultToEmptyTag, getEmptyTagValue } from '../../../empty_value'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import { IS_OPERATOR } from '../../../timeline/data_providers/data_provider'; import { PreferenceFormattedBytes } from '../../../formatted_bytes'; import { Provider } from '../../../timeline/data_providers/provider'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/columns.tsx index 38eda9810740c2..97fa601a49af17 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/columns.tsx @@ -21,7 +21,7 @@ import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_ import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; import { getEmptyTagValue } from '../../../empty_value'; import { IPDetailsLink } from '../../../links'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import { IS_OPERATOR } from '../../../timeline/data_providers/data_provider'; import { Provider } from '../../../timeline/data_providers/provider'; import * as i18n from './translations'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.tsx index 5abbdab9c980fe..714d3f7a8131c6 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.tsx @@ -79,26 +79,45 @@ const rowItems: ItemsPerRow[] = [ export const NetworkTopNFlowTableId = 'networkTopSourceFlow-top-talkers'; -class NetworkTopNFlowTableComponent extends React.PureComponent { - public render() { - const { - activePage, - data, - fakeTotalCount, - flowTargeted, - id, - indexPattern, - isInspect, - limit, - loading, - loadPage, - showMorePagesIndicator, - topNFlowSort, - totalCount, - type, - updateTopNFlowLimit, - updateTableActivePage, - } = this.props; +const NetworkTopNFlowTableComponent = React.memo( + ({ + activePage, + data, + fakeTotalCount, + flowTargeted, + id, + indexPattern, + isInspect, + limit, + loading, + loadPage, + showMorePagesIndicator, + topNFlowSort, + totalCount, + type, + updateTopNFlowLimit, + updateTopNFlowSort, + updateTableActivePage, + }) => { + const onChange = (criteria: Criteria, tableType: networkModel.TopNTableType) => { + if (criteria.sort != null) { + const splitField = criteria.sort.field.split('.'); + const field = last(splitField); + const newSortDirection = + field !== topNFlowSort.field ? Direction.desc : criteria.sort.direction; // sort by desc on init click + const newTopNFlowSort: NetworkTopNFlowSortField = { + field: field as NetworkTopNFlowFields, + direction: newSortDirection, + }; + if (!isEqual(newTopNFlowSort, topNFlowSort)) { + updateTopNFlowSort({ + topNFlowSort: newTopNFlowSort, + networkType: type, + tableType, + }); + } + } + }; let tableType: networkModel.TopNTableType; let headerTitle: string; @@ -136,7 +155,7 @@ class NetworkTopNFlowTableComponent extends React.PureComponent loadPage(newActivePage)} - onChange={criteria => this.onChange(criteria, tableType)} + onChange={criteria => onChange(criteria, tableType)} pageOfItems={data} showMorePagesIndicator={showMorePagesIndicator} sorting={{ field, direction: topNFlowSort.direction }} @@ -153,27 +172,9 @@ class NetworkTopNFlowTableComponent extends React.PureComponent ); } +); - private onChange = (criteria: Criteria, tableType: networkModel.TopNTableType) => { - if (criteria.sort != null) { - const splitField = criteria.sort.field.split('.'); - const field = last(splitField); - const newSortDirection = - field !== this.props.topNFlowSort.field ? Direction.desc : criteria.sort.direction; // sort by desc on init click - const newTopNFlowSort: NetworkTopNFlowSortField = { - field: field as NetworkTopNFlowFields, - direction: newSortDirection, - }; - if (!isEqual(newTopNFlowSort, this.props.topNFlowSort)) { - this.props.updateTopNFlowSort({ - topNFlowSort: newTopNFlowSort, - networkType: this.props.type, - tableType, - }); - } - } - }; -} +NetworkTopNFlowTableComponent.displayName = 'NetworkTopNFlowTableComponent'; const mapStateToProps = (state: State, ownProps: OwnProps) => networkSelectors.topNFlowSelector(ownProps.flowTargeted); diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx index 7578c5decc8519..aea8ee9e6b9e14 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx @@ -7,7 +7,7 @@ import React from 'react'; import moment from 'moment'; import { TlsNode } from '../../../../graphql/types'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import { getRowItemDraggables, getRowItemDraggable } from '../../../tables/helpers'; import { LocalizedDateTooltip } from '../../../localized_date_tooltip'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx index b17ec74fa05401..2c51fb8f94561a 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx @@ -6,7 +6,7 @@ import { FlowTarget, UsersItem } from '../../../../graphql/types'; import { defaultToEmptyTag } from '../../../empty_value'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import * as i18n from './translations'; import { getRowItemDraggables, getRowItemDraggable } from '../../../tables/helpers'; diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx index b5678a36c1eedf..257ee03c944bfb 100644 --- a/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx @@ -100,15 +100,17 @@ export interface BasicTableProps { updateActivePage: (activePage: number) => void; updateLimitPagination: (limit: number) => void; } +type Func = (arg: T) => string | number; -export interface Columns { +export interface Columns { + align?: string; field?: string; - name: string | React.ReactNode; + hideForMobile?: boolean; isMobileHeader?: boolean; - sortable?: boolean; + name: string | React.ReactNode; + render?: (item: T, node: U) => React.ReactNode; + sortable?: boolean | Func; truncateText?: boolean; - hideForMobile?: boolean; - render?: (item: T) => void; width?: string; } diff --git a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx b/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx index 40df2c134047f2..0a6203056fd20f 100644 --- a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; +import React, { useEffect, useRef } from 'react'; import { fromEvent, Observable, Subscription } from 'rxjs'; import { concatMap, takeUntil } from 'rxjs/operators'; import styled, { css } from 'styled-components'; @@ -41,10 +41,6 @@ interface Props extends ResizeHandleContainerProps { render: (isResizing: boolean) => React.ReactNode; } -interface State { - isResizing: boolean; -} - const ResizeHandleContainer = styled.div` ${({ bottom, height, left, positionAbsolute, right, theme, top }) => css` bottom: ${positionAbsolute && bottom}; @@ -67,88 +63,75 @@ export const removeGlobalResizeCursorStyleFromBody = () => { document.body.classList.remove(globalResizeCursorClassName); }; -export class Resizeable extends React.PureComponent { - private drag$: Observable | null; - private dragEventTargets: Array<{ htmlElement: HTMLElement; prevCursor: string }>; - private dragSubscription: Subscription | null; - private prevX: number = 0; - private ref: React.RefObject; - private upSubscription: Subscription | null; - - constructor(props: Props) { - super(props); - - // NOTE: the ref and observable below are NOT stored in component `State` - this.ref = React.createRef(); - this.drag$ = null; - this.dragSubscription = null; - this.upSubscription = null; - this.dragEventTargets = []; - - this.state = { - isResizing: false, +export const Resizeable = React.memo( + ({ bottom, handle, height, id, left, onResize, positionAbsolute, render, right, top }) => { + const drag$ = useRef | null>(null); + const dragEventTargets = useRef>([]); + const dragSubscription = useRef(null); + const prevX = useRef(0); + const ref = useRef>(React.createRef()); + const upSubscription = useRef(null); + const isResizingRef = useRef(false); + + const calculateDelta = (e: MouseEvent) => { + const deltaX = calculateDeltaX({ prevX: prevX.current, screenX: e.screenX }); + prevX.current = e.screenX; + return deltaX; }; - } - - public componentDidMount() { - const { id, onResize } = this.props; - - const move$ = fromEvent(document, 'mousemove'); - const down$ = fromEvent(this.ref.current!, 'mousedown'); - const up$ = fromEvent(document, 'mouseup'); - - this.drag$ = down$.pipe(concatMap(() => move$.pipe(takeUntil(up$)))); - this.dragSubscription = this.drag$.subscribe(event => { - // We do a feature detection of event.movementX here and if it is missing - // we calculate the delta manually. Browsers IE-11 and Safari will call calculateDelta - const delta = - event.movementX == null || isSafari ? this.calculateDelta(event) : event.movementX; - if (!this.state.isResizing) { - this.setState({ isResizing: true }); - } - onResize({ id, delta }); - if (event.target != null && event.target instanceof HTMLElement) { - const htmlElement: HTMLElement = event.target; - this.dragEventTargets = [ - ...this.dragEventTargets, - { htmlElement, prevCursor: htmlElement.style.cursor }, - ]; - htmlElement.style.cursor = resizeCursorStyle; - } - }); - - this.upSubscription = up$.subscribe(() => { - if (this.state.isResizing) { - this.dragEventTargets.reverse().forEach(eventTarget => { - eventTarget.htmlElement.style.cursor = eventTarget.prevCursor; + useEffect(() => { + const move$ = fromEvent(document, 'mousemove'); + const down$ = fromEvent(ref.current.current!, 'mousedown'); + const up$ = fromEvent(document, 'mouseup'); + + drag$.current = down$.pipe(concatMap(() => move$.pipe(takeUntil(up$)))); + dragSubscription.current = + drag$.current && + drag$.current.subscribe(event => { + // We do a feature detection of event.movementX here and if it is missing + // we calculate the delta manually. Browsers IE-11 and Safari will call calculateDelta + const delta = + event.movementX == null || isSafari ? calculateDelta(event) : event.movementX; + if (!isResizingRef.current) { + isResizingRef.current = true; + } + onResize({ id, delta }); + if (event.target != null && event.target instanceof HTMLElement) { + const htmlElement: HTMLElement = event.target; + dragEventTargets.current = [ + ...dragEventTargets.current, + { htmlElement, prevCursor: htmlElement.style.cursor }, + ]; + htmlElement.style.cursor = resizeCursorStyle; + } }); - this.dragEventTargets = []; - this.setState({ isResizing: false }); - } - }); - } - public componentWillUnmount() { - if (this.dragSubscription != null) { - this.dragSubscription.unsubscribe(); - } - - if (this.upSubscription != null) { - this.upSubscription.unsubscribe(); - } - } - - public render() { - const { bottom, handle, height, left, positionAbsolute, render, right, top } = this.props; + upSubscription.current = up$.subscribe(() => { + if (isResizingRef.current) { + dragEventTargets.current.reverse().forEach(eventTarget => { + eventTarget.htmlElement.style.cursor = eventTarget.prevCursor; + }); + dragEventTargets.current = []; + isResizingRef.current = false; + } + }); + return () => { + if (dragSubscription.current != null) { + dragSubscription.current.unsubscribe(); + } + if (upSubscription.current != null) { + upSubscription.current.unsubscribe(); + } + }; + }, []); return ( <> - {render(this.state.isResizing)} + {render(isResizingRef.current)} { ); } +); - private calculateDelta = (e: MouseEvent) => { - const deltaX = calculateDeltaX({ prevX: this.prevX, screenX: e.screenX }); - - this.prevX = e.screenX; - - return deltaX; - }; -} +Resizeable.displayName = 'Resizeable'; diff --git a/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx b/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx index 722cf9db731f72..fa695d76f9f3e5 100644 --- a/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx @@ -7,13 +7,13 @@ import dateMath from '@elastic/datemath'; import { EuiSuperDatePicker, - EuiSuperDatePickerProps, OnRefreshChangeProps, + EuiSuperDatePickerRecentRange, OnRefreshProps, OnTimeChangeProps, } from '@elastic/eui'; import { getOr, take } from 'lodash/fp'; -import React, { Component } from 'react'; +import React, { useState } from 'react'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; @@ -36,33 +36,17 @@ import { InputsRange, Policy } from '../../store/inputs/model'; const MAX_RECENTLY_USED_RANGES = 9; -type MyEuiSuperDatePickerProps = Pick< - EuiSuperDatePickerProps, - | 'end' - | 'isPaused' - | 'onTimeChange' - | 'onRefreshChange' - | 'onRefresh' - | 'recentlyUsedRanges' - | 'refreshInterval' - | 'showUpdateButton' - | 'start' -> & { - isLoading?: boolean; -}; -const MyEuiSuperDatePicker: React.SFC = EuiSuperDatePicker; - interface SuperDatePickerStateRedux { duration: number; - policy: Policy['kind']; - kind: string; - fromStr: string; - toStr: string; - start: number; end: number; + fromStr: string; isLoading: boolean; - queries: inputsModel.GlobalGraphqlQuery[]; + kind: string; kqlQuery: inputsModel.GlobalKqlQuery; + policy: Policy['kind']; + queries: inputsModel.GlobalGraphqlQuery[]; + start: number; + toStr: string; } interface UpdateReduxTime extends OnTimeChangeProps { @@ -85,145 +69,137 @@ type DispatchUpdateReduxTime = ({ }: UpdateReduxTime) => ReturnUpdateReduxTime; interface SuperDatePickerDispatchProps { + setDuration: ({ id, duration }: { id: InputsModelId; duration: number }) => void; startAutoReload: ({ id }: { id: InputsModelId }) => void; stopAutoReload: ({ id }: { id: InputsModelId }) => void; - setDuration: ({ id, duration }: { id: InputsModelId; duration: number }) => void; updateReduxTime: DispatchUpdateReduxTime; } interface OwnProps { - id: InputsModelId; disabled?: boolean; + id: InputsModelId; timelineId?: string; } -interface TimeArgs { - start: string; - end: string; -} - export type SuperDatePickerProps = OwnProps & SuperDatePickerDispatchProps & SuperDatePickerStateRedux; -export interface SuperDatePickerState { - isQuickSelection: boolean; - recentlyUsedRanges: TimeArgs[]; - showUpdateButton: boolean; -} +export const SuperDatePickerComponent = React.memo( + ({ + duration, + end, + fromStr, + id, + isLoading, + kind, + kqlQuery, + policy, + queries, + setDuration, + start, + startAutoReload, + stopAutoReload, + timelineId, + toStr, + updateReduxTime, + }) => { + const [isQuickSelection, setIsQuickSelection] = useState(true); + const [recentlyUsedRanges, setRecentlyUsedRanges] = useState( + [] + ); + const onRefresh = ({ start: newStart, end: newEnd }: OnRefreshProps): void => { + const { kqlHasBeenUpdated } = updateReduxTime({ + end: newEnd, + id, + isInvalid: false, + isQuickSelection, + kql: kqlQuery, + start: newStart, + timelineId, + }); + const currentStart = formatDate(newStart); + const currentEnd = isQuickSelection + ? formatDate(newEnd, { roundUp: true }) + : formatDate(newEnd); + if ( + !kqlHasBeenUpdated && + (!isQuickSelection || (start === currentStart && end === currentEnd)) + ) { + refetchQuery(queries); + } + }; + + const onRefreshChange = ({ isPaused, refreshInterval }: OnRefreshChangeProps): void => { + if (duration !== refreshInterval) { + setDuration({ id, duration: refreshInterval }); + } -export const SuperDatePickerComponent = class extends Component< - SuperDatePickerProps, - SuperDatePickerState -> { - constructor(props: SuperDatePickerProps) { - super(props); + if (isPaused && policy === 'interval') { + stopAutoReload({ id }); + } else if (!isPaused && policy === 'manual') { + startAutoReload({ id }); + } - this.state = { - isQuickSelection: true, - recentlyUsedRanges: [], - showUpdateButton: true, + if (!isPaused && (!isQuickSelection || (isQuickSelection && toStr !== 'now'))) { + refetchQuery(queries); + } + }; + + const refetchQuery = (newQueries: inputsModel.GlobalGraphqlQuery[]) => { + newQueries.forEach(q => q.refetch && (q.refetch as inputsModel.Refetch)()); }; - } - public render() { - const { duration, end, start, kind, fromStr, policy, toStr, isLoading } = this.props; + const onTimeChange = ({ + start: newStart, + end: newEnd, + isQuickSelection: newIsQuickSelection, + isInvalid, + }: OnTimeChangeProps) => { + if (!isInvalid) { + updateReduxTime({ + end: newEnd, + id, + isInvalid, + isQuickSelection: newIsQuickSelection, + kql: kqlQuery, + start: newStart, + timelineId, + }); + const newRecentlyUsedRanges = [ + { start: newStart, end: newEnd }, + ...take( + MAX_RECENTLY_USED_RANGES, + recentlyUsedRanges.filter( + recentlyUsedRange => + !(recentlyUsedRange.start === newStart && recentlyUsedRange.end === newEnd) + ) + ), + ]; + + setRecentlyUsedRanges(newRecentlyUsedRanges); + setIsQuickSelection(newIsQuickSelection); + } + }; const endDate = kind === 'relative' ? toStr : new Date(end).toISOString(); const startDate = kind === 'relative' ? fromStr : new Date(start).toISOString(); return ( - ); } - private onRefresh = ({ start, end }: OnRefreshProps): void => { - const { kqlHasBeenUpdated } = this.props.updateReduxTime({ - end, - id: this.props.id, - isInvalid: false, - isQuickSelection: this.state.isQuickSelection, - kql: this.props.kqlQuery, - start, - timelineId: this.props.timelineId, - }); - const currentStart = formatDate(start); - const currentEnd = this.state.isQuickSelection - ? formatDate(end, { roundUp: true }) - : formatDate(end); - if ( - !kqlHasBeenUpdated && - (!this.state.isQuickSelection || - (this.props.start === currentStart && this.props.end === currentEnd)) - ) { - this.refetchQuery(this.props.queries); - } - }; - - private onRefreshChange = ({ isPaused, refreshInterval }: OnRefreshChangeProps): void => { - const { id, duration, policy, stopAutoReload, startAutoReload } = this.props; - if (duration !== refreshInterval) { - this.props.setDuration({ id, duration: refreshInterval }); - } - - if (isPaused && policy === 'interval') { - stopAutoReload({ id }); - } else if (!isPaused && policy === 'manual') { - startAutoReload({ id }); - } - - if ( - !isPaused && - (!this.state.isQuickSelection || (this.state.isQuickSelection && this.props.toStr !== 'now')) - ) { - this.refetchQuery(this.props.queries); - } - }; - - private refetchQuery = (queries: inputsModel.GlobalGraphqlQuery[]) => { - queries.forEach(q => q.refetch && (q.refetch as inputsModel.Refetch)()); - }; - - private onTimeChange = ({ start, end, isQuickSelection, isInvalid }: OnTimeChangeProps) => { - if (!isInvalid) { - this.props.updateReduxTime({ - end, - id: this.props.id, - isInvalid, - isQuickSelection, - kql: this.props.kqlQuery, - start, - timelineId: this.props.timelineId, - }); - this.setState((prevState: SuperDatePickerState) => { - const recentlyUsedRanges = [ - { start, end }, - ...take( - MAX_RECENTLY_USED_RANGES, - prevState.recentlyUsedRanges.filter( - recentlyUsedRange => - !(recentlyUsedRange.start === start && recentlyUsedRange.end === end) - ) - ), - ]; - - return { - recentlyUsedRanges, - isQuickSelection, - }; - }); - } - }; -}; +); const formatDate = ( date: string, @@ -292,33 +268,35 @@ const dispatchUpdateReduxTime = (dispatch: Dispatch) => ({ }; export const makeMapStateToProps = () => { - const getPolicySelector = policySelector(); const getDurationSelector = durationSelector(); - const getKindSelector = kindSelector(); - const getStartSelector = startSelector(); const getEndSelector = endSelector(); const getFromStrSelector = fromStrSelector(); - const getToStrSelector = toStrSelector(); const getIsLoadingSelector = isLoadingSelector(); - const getQueriesSelector = queriesSelector(); + const getKindSelector = kindSelector(); const getKqlQuerySelector = kqlQuerySelector(); + const getPolicySelector = policySelector(); + const getQueriesSelector = queriesSelector(); + const getStartSelector = startSelector(); + const getToStrSelector = toStrSelector(); return (state: State, { id }: OwnProps) => { const inputsRange: InputsRange = getOr({}, `inputs.${id}`, state); return { - policy: getPolicySelector(inputsRange), duration: getDurationSelector(inputsRange), - kind: getKindSelector(inputsRange), - start: getStartSelector(inputsRange), end: getEndSelector(inputsRange), fromStr: getFromStrSelector(inputsRange), - toStr: getToStrSelector(inputsRange), isLoading: getIsLoadingSelector(inputsRange), - queries: getQueriesSelector(inputsRange), + kind: getKindSelector(inputsRange), kqlQuery: getKqlQuerySelector(inputsRange), + policy: getPolicySelector(inputsRange), + queries: getQueriesSelector(inputsRange), + start: getStartSelector(inputsRange), + toStr: getToStrSelector(inputsRange), }; }; }; +SuperDatePickerComponent.displayName = 'SuperDatePickerComponent'; + const mapDispatchToProps = (dispatch: Dispatch) => ({ startAutoReload: ({ id }: { id: InputsModelId }) => dispatch(inputsActions.startAutoReload({ id })), diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx index 62f58e1b585d9e..1e603b0c157793 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx @@ -91,53 +91,56 @@ interface Props { } /** Renders a header */ -export class Header extends React.PureComponent { - public render() { - const { header } = this.props; +export const Header = React.memo( + ({ + header, + onColumnRemoved, + onColumnResized, + onColumnSorted, + onFilterChange = noop, + setIsResizing, + sort, + }) => { + const onClick = () => { + onColumnSorted!({ + columnId: header.id, + sortDirection: getNewSortDirectionOnClick({ + clickedHeader: header, + currentSort: sort, + }), + }); + }; + + const onResize: OnResize = ({ delta, id }) => { + onColumnResized({ columnId: id, delta }); + }; + + const renderActions = (isResizing: boolean) => { + setIsResizing(isResizing); + return ( + <> + + + + + + + ); + }; return ( } id={header.id} - onResize={this.onResize} + onResize={onResize} positionAbsolute - render={this.renderActions} + render={renderActions} right="-1px" top={0} /> ); } +); - private renderActions = (isResizing: boolean) => { - const { header, onColumnRemoved, onFilterChange = noop, setIsResizing, sort } = this.props; - - setIsResizing(isResizing); - - return ( - <> - - - - - - - ); - }; - - private onClick = () => { - const { header, onColumnSorted, sort } = this.props; - - onColumnSorted!({ - columnId: header.id, - sortDirection: getNewSortDirectionOnClick({ - clickedHeader: header, - currentSort: sort, - }), - }); - }; - - private onResize: OnResize = ({ delta, id }) => { - this.props.onColumnResized({ columnId: id, delta }); - }; -} +Header.displayName = 'Header'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/index.tsx index 44d0480bc5f28e..2b2401519eb322 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/index.tsx @@ -22,10 +22,8 @@ interface Props { timelineId: string; } -export class DataDrivenColumns extends React.PureComponent { - public render() { - const { _id, columnHeaders, columnRenderers, data, timelineId } = this.props; - +export const DataDrivenColumns = React.memo( + ({ _id, columnHeaders, columnRenderers, data, timelineId }) => { // Passing the styles directly to the component because the width is // being calculated and is recommended by Styled Components for performance // https://github.com/styled-components/styled-components/issues/134#issuecomment-312415291 @@ -51,7 +49,9 @@ export class DataDrivenColumns extends React.PureComponent { ); } -} +); + +DataDrivenColumns.displayName = 'DataDrivenColumns'; const getMappedNonEcsValue = ({ data, diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx index 1e3f7303c2e1d6..766a75c05f17c6 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; -import VisibilitySensor from 'react-visibility-sensor'; +import React, { useEffect, useRef, useState } from 'react'; import uuid from 'uuid'; +import VisibilitySensor from 'react-visibility-sensor'; import { BrowserFields } from '../../../../containers/source'; import { TimelineDetailsComponentQuery } from '../../../../containers/timeline/details'; @@ -35,24 +35,18 @@ interface Props { columnRenderers: ColumnRenderer[]; event: TimelineItem; eventIdToNoteIds: Readonly>; - isEventViewer?: boolean; getNotesByIds: (noteIds: string[]) => Note[]; + isEventViewer?: boolean; + maxDelay?: number; onColumnResized: OnColumnResized; onPinEvent: OnPinEvent; - onUpdateColumns: OnUpdateColumns; onUnPinEvent: OnUnPinEvent; + onUpdateColumns: OnUpdateColumns; pinnedEventIds: Readonly>; rowRenderers: RowRenderer[]; timelineId: string; toggleColumn: (column: ColumnHeader) => void; updateNote: UpdateNote; - maxDelay?: number; -} - -interface State { - expanded: { [eventId: string]: boolean }; - showNotes: { [eventId: string]: boolean }; - initialRender: boolean; } export const getNewNoteId = (): string => uuid.v4(); @@ -105,69 +99,86 @@ const Attributes = React.memo(({ children }) => { ); }); -export class StatefulEvent extends React.Component { - private _isMounted: boolean = false; +export const StatefulEvent = React.memo( + ({ + actionsColumnWidth, + addNoteToEvent, + browserFields, + columnHeaders, + columnRenderers, + event, + eventIdToNoteIds, + getNotesByIds, + isEventViewer = false, + maxDelay = 0, + onColumnResized, + onPinEvent, + onUnPinEvent, + onUpdateColumns, + pinnedEventIds, + rowRenderers, + timelineId, + toggleColumn, + updateNote, + }) => { + const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({}); + const [initialRender, setInitialRender] = useState(false); + const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); - public readonly state: State = { - expanded: {}, - showNotes: {}, - initialRender: false, - }; + const divElement = useRef(null); - public divElement: HTMLDivElement | null = null; + const onToggleShowNotes = (eventId: string): (() => void) => () => { + setShowNotes({ ...showNotes, [eventId]: !showNotes[eventId] }); + }; - /** - * Incrementally loads the events when it mounts by trying to - * see if it resides within a window frame and if it is it will - * indicate to React that it should render its self by setting - * its initialRender to true. - */ - public componentDidMount() { - this._isMounted = true; + const onToggleExpanded = (eventId: string): (() => void) => () => { + setExpanded({ + ...expanded, + [eventId]: !expanded[eventId], + }); + }; - requestIdleCallbackViaScheduler( - () => { - if (!this.state.initialRender && this._isMounted) { - this.setState({ initialRender: true }); - } - }, - { timeout: this.props.maxDelay ? this.props.maxDelay : 0 } - ); - } + const associateNote = ( + eventId: string, + addNoteToEventChild: AddNoteToEvent, + onPinEventChild: OnPinEvent + ): ((noteId: string) => void) => (noteId: string) => { + addNoteToEventChild({ eventId, noteId }); + if (!eventIsPinned({ eventId, pinnedEventIds })) { + onPinEventChild(eventId); // pin the event, because it has notes + } + }; - componentWillUnmount() { - this._isMounted = false; - } + /** + * Incrementally loads the events when it mounts by trying to + * see if it resides within a window frame and if it is it will + * indicate to React that it should render its self by setting + * its initialRender to true. + */ - public render() { - const { - actionsColumnWidth, - addNoteToEvent, - browserFields, - columnHeaders, - columnRenderers, - event, - eventIdToNoteIds, - getNotesByIds, - isEventViewer = false, - onColumnResized, - onPinEvent, - onUpdateColumns, - onUnPinEvent, - pinnedEventIds, - rowRenderers, - timelineId, - toggleColumn, - updateNote, - } = this.props; + useEffect(() => { + let _isMounted = true; + + requestIdleCallbackViaScheduler( + () => { + if (!initialRender && _isMounted) { + setInitialRender(true); + } + }, + { timeout: maxDelay } + ); + return () => { + _isMounted = false; + }; + }, []); // Number of current columns plus one for actions. const columnCount = columnHeaders.length + 1; // If we are not ready to render yet, just return null - // see componentDidMount() for when it schedules the first + // see useEffect() for when it schedules the first // time this stateful component should be rendered. - if (!this.state.initialRender) { + if (!initialRender) { return ; } @@ -184,7 +195,7 @@ export class StatefulEvent extends React.Component { sourceId="default" indexName={event._index!} eventId={event._id} - executeQuery={!!this.state.expanded[event._id]} + executeQuery={!!expanded[event._id]} > {({ detailsData, loading }) => ( { data-test-subj="event" innerRef={c => { if (c != null) { - this.divElement = c; + divElement.current = c; } }} > @@ -201,26 +212,26 @@ export class StatefulEvent extends React.Component { data: event.ecs, children: ( ), @@ -231,9 +242,9 @@ export class StatefulEvent extends React.Component { { } else { // Height place holder for visibility detection as well as re-rendering sections. const height = - this.divElement != null ? this.divElement.clientHeight + 'px' : DEFAULT_ROW_HEIGHT; + divElement.current != null + ? `${divElement.current.clientHeight}px` + : DEFAULT_ROW_HEIGHT; // height is being inlined directly in here because of performance with StyledComponents // involving quick and constant changes to the DOM. @@ -257,33 +270,6 @@ export class StatefulEvent extends React.Component { ); } +); - private onToggleShowNotes = (eventId: string): (() => void) => () => { - this.setState(state => ({ - showNotes: { - ...state.showNotes, - [eventId]: !state.showNotes[eventId], - }, - })); - }; - - private onToggleExpanded = (eventId: string): (() => void) => () => { - this.setState(state => ({ - expanded: { - ...state.expanded, - [eventId]: !state.expanded[eventId], - }, - })); - }; - - private associateNote = ( - eventId: string, - addNoteToEvent: AddNoteToEvent, - onPinEvent: OnPinEvent - ): ((noteId: string) => void) => (noteId: string) => { - addNoteToEvent({ eventId, noteId }); - if (!eventIsPinned({ eventId, pinnedEventIds: this.props.pinnedEventIds })) { - onPinEvent(eventId); // pin the event, because it has notes - } - }; -} +StatefulEvent.displayName = 'StatefulEvent'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx index 871a60d18404ad..d93446b2af95b3 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx @@ -84,8 +84,10 @@ type StatefulBodyComponentProps = OwnProps & ReduxProps & DispatchProps; export const emptyColumnHeaders: ColumnHeader[] = []; -class StatefulBodyComponent extends React.Component { - public shouldComponentUpdate({ +const StatefulBodyComponent = React.memo( + ({ + addNoteToEvent, + applyDeltaToColumnWidth, browserFields, columnHeaders, data, @@ -93,45 +95,46 @@ class StatefulBodyComponent extends React.Component getNotesByIds, height, id, - isEventViewer, + isEventViewer = false, + pinEvent, pinnedEventIds, range, + removeColumn, sort, - }: StatefulBodyComponentProps) { - return ( - browserFields !== this.props.browserFields || - columnHeaders !== this.props.columnHeaders || - data !== this.props.data || - eventIdToNoteIds !== this.props.eventIdToNoteIds || - getNotesByIds !== this.props.getNotesByIds || - height !== this.props.height || - id !== this.props.id || - isEventViewer !== this.props.isEventViewer || - pinnedEventIds !== this.props.pinnedEventIds || - range !== this.props.range || - sort !== this.props.sort - ); - } + toggleColumn, + unPinEvent, + updateColumns, + updateNote, + updateSort, + }) => { + const onAddNoteToEvent: AddNoteToEvent = ({ + eventId, + noteId, + }: { + eventId: string; + noteId: string; + }) => addNoteToEvent!({ id, eventId, noteId }); + + const onColumnSorted: OnColumnSorted = sorted => { + updateSort!({ id, sort: sorted }); + }; - public render() { - const { - browserFields, - columnHeaders, - data, - eventIdToNoteIds, - getNotesByIds, - height, - id, - isEventViewer = false, - pinnedEventIds, - range, - sort, - toggleColumn, - } = this.props; + const onColumnRemoved: OnColumnRemoved = columnId => removeColumn!({ id, columnId }); + + const onColumnResized: OnColumnResized = ({ columnId, delta }) => + applyDeltaToColumnWidth!({ id, columnId, delta }); + + const onPinEvent: OnPinEvent = eventId => pinEvent!({ id, eventId }); + + const onUnPinEvent: OnUnPinEvent = eventId => unPinEvent!({ id, eventId }); + + const onUpdateNote: UpdateNote = (note: Note) => updateNote!({ note }); + + const onUpdateColumns: OnUpdateColumns = columns => updateColumns!({ id, columns }); return ( height={height} id={id} isEventViewer={isEventViewer} - onColumnResized={this.onColumnResized} - onColumnRemoved={this.onColumnRemoved} - onColumnSorted={this.onColumnSorted} + onColumnRemoved={onColumnRemoved} + onColumnResized={onColumnResized} + onColumnSorted={onColumnSorted} onFilterChange={noop} // TODO: this is the callback for column filters, which is out scope for this phase of delivery - onPinEvent={this.onPinEvent} - onUpdateColumns={this.onUpdateColumns} - onUnPinEvent={this.onUnPinEvent} + onPinEvent={onPinEvent} + onUnPinEvent={onUnPinEvent} + onUpdateColumns={onUpdateColumns} pinnedEventIds={pinnedEventIds} range={range!} rowRenderers={rowRenderers} sort={sort} toggleColumn={toggleColumn} - updateNote={this.onUpdateNote} + updateNote={onUpdateNote} /> ); + }, + (prevProps, nextProps) => { + return ( + prevProps.browserFields === nextProps.browserFields && + prevProps.columnHeaders === nextProps.columnHeaders && + prevProps.data === nextProps.data && + prevProps.eventIdToNoteIds === nextProps.eventIdToNoteIds && + prevProps.getNotesByIds === nextProps.getNotesByIds && + prevProps.height === nextProps.height && + prevProps.id === nextProps.id && + prevProps.isEventViewer === nextProps.isEventViewer && + prevProps.pinnedEventIds === nextProps.pinnedEventIds && + prevProps.range === nextProps.range && + prevProps.sort === nextProps.sort + ); } +); - private onAddNoteToEvent: AddNoteToEvent = ({ - eventId, - noteId, - }: { - eventId: string; - noteId: string; - }) => this.props.addNoteToEvent!({ id: this.props.id, eventId, noteId }); - - private onColumnSorted: OnColumnSorted = sorted => { - this.props.updateSort!({ id: this.props.id, sort: sorted }); - }; - - private onColumnRemoved: OnColumnRemoved = columnId => - this.props.removeColumn!({ id: this.props.id, columnId }); - - private onColumnResized: OnColumnResized = ({ columnId, delta }) => - this.props.applyDeltaToColumnWidth!({ id: this.props.id, columnId, delta }); - - private onPinEvent: OnPinEvent = eventId => this.props.pinEvent!({ id: this.props.id, eventId }); - - private onUnPinEvent: OnUnPinEvent = eventId => - this.props.unPinEvent!({ id: this.props.id, eventId }); - - private onUpdateNote: UpdateNote = (note: Note) => this.props.updateNote!({ note }); - - private onUpdateColumns: OnUpdateColumns = columns => - this.props.updateColumns!({ id: this.props.id, columns }); -} +StatefulBodyComponent.displayName = 'StatefulBodyComponent'; const makeMapStateToProps = () => { const memoizedColumnHeaders: ( @@ -201,9 +193,9 @@ const makeMapStateToProps = () => { return { columnHeaders: memoizedColumnHeaders(columns, browserFields), - id, eventIdToNoteIds, getNotesByIds: getNotesByIds(state), + id, pinnedEventIds, }; }; @@ -215,12 +207,12 @@ export const StatefulBody = connect( { addNoteToEvent: timelineActions.addNoteToEvent, applyDeltaToColumnWidth: timelineActions.applyDeltaToColumnWidth, - unPinEvent: timelineActions.unPinEvent, - updateColumns: timelineActions.updateColumns, - updateSort: timelineActions.updateSort, pinEvent: timelineActions.pinEvent, removeColumn: timelineActions.removeColumn, removeProvider: timelineActions.removeProvider, + unPinEvent: timelineActions.unPinEvent, + updateColumns: timelineActions.updateColumns, updateNote: appActions.updateNote, + updateSort: timelineActions.updateSort, } )(StatefulBodyComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx index 29417bd0b578b4..98cf0a78b1d1f8 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx @@ -5,7 +5,7 @@ */ import { noop } from 'lodash/fp'; -import React, { PureComponent } from 'react'; +import React, { useState } from 'react'; import { BrowserFields } from '../../../containers/source'; @@ -32,30 +32,42 @@ interface ProviderItemBadgeProps { val: string | number; } -interface OwnState { - isPopoverOpen: boolean; -} +export const ProviderItemBadge = React.memo( + ({ + andProviderId, + browserFields, + deleteProvider, + field, + kqlQuery, + isEnabled, + isExcluded, + onDataProviderEdited, + operator, + providerId, + timelineId, + toggleEnabledProvider, + toggleExcludedProvider, + val, + }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + function togglePopover() { + setIsPopoverOpen(!isPopoverOpen); + } -export class ProviderItemBadge extends PureComponent { - public readonly state = { - isPopoverOpen: false, - }; + function closePopover() { + setIsPopoverOpen(false); + } - public render() { - const { - andProviderId, - browserFields, - deleteProvider, - field, - kqlQuery, - isEnabled, - isExcluded, - onDataProviderEdited, - operator, - providerId, - timelineId, - val, - } = this.props; + function onToggleEnabledProvider() { + toggleEnabledProvider(); + closePopover(); + } + + function onToggleExcludedProvider() { + toggleExcludedProvider(); + closePopover(); + } return ( @@ -71,51 +83,31 @@ export class ProviderItemBadge extends PureComponent } - closePopover={this.closePopover} + closePopover={closePopover} deleteProvider={deleteProvider} field={field} kqlQuery={kqlQuery} isEnabled={isEnabled} isExcluded={isExcluded} isLoading={isLoading} - isOpen={this.state.isPopoverOpen} + isOpen={isPopoverOpen} onDataProviderEdited={onDataProviderEdited} operator={operator} providerId={providerId} timelineId={timelineId} - toggleEnabledProvider={this.toggleEnabledProvider} - toggleExcludedProvider={this.toggleExcludedProvider} + toggleEnabledProvider={onToggleEnabledProvider} + toggleExcludedProvider={onToggleExcludedProvider} value={val} /> )} ); } +); - private togglePopover = () => { - this.setState(prevState => ({ - isPopoverOpen: !prevState.isPopoverOpen, - })); - }; - - private closePopover = () => { - this.setState({ - isPopoverOpen: false, - }); - }; - - private toggleEnabledProvider = () => { - this.props.toggleEnabledProvider(); - this.closePopover(); - }; - - private toggleExcludedProvider = () => { - this.props.toggleExcludedProvider(); - this.closePopover(); - }; -} +ProviderItemBadge.displayName = 'ProviderItemBadge'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.test.tsx index 79f85103077b70..6e8a0e8cfb17fc 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.test.tsx @@ -11,7 +11,7 @@ import * as React from 'react'; import { TestProviders } from '../../../mock/test_providers'; -import { Footer } from './index'; +import { Footer, PagingControl } from './index'; import { mockData } from './mock'; describe('Footer Timeline Component', () => { @@ -93,38 +93,36 @@ describe('Footer Timeline Component', () => { }); test('it renders the Loading... in the more load button when fetching new data', () => { - const wrapper = mount( - -
- + const wrapper = shallow( + ); - wrapper - .find(Footer) - .instance() - .setState({ paginationLoading: true }); - wrapper.update(); + + const loadButton = wrapper + .find('[data-test-subj="TimelineMoreButton"]') + .dive() + .text(); expect(wrapper.find('[data-test-subj="LoadingPanelTimeline"]').exists()).toBeFalsy(); - expect( - wrapper - .find('[data-test-subj="TimelineMoreButton"]') - .first() - .text() - ).toContain('Loading...'); + expect(loadButton).toContain('Loading...'); + }); + + test('it renders the Load More in the more load button when fetching new data', () => { + const wrapper = shallow( + + ); + + const loadButton = wrapper + .find('[data-test-subj="TimelineMoreButton"]') + .dive() + .text(); + expect(loadButton).toContain('Load More'); }); test('it does NOT render the loadMore button because there is nothing else to fetch', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx index 104c8c9c03ddfd..c1772d9e555771 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx @@ -18,7 +18,7 @@ import { EuiToolTip, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import { pure } from 'recompose'; import styled from 'styled-components'; @@ -73,27 +73,21 @@ ServerSideEventCount.displayName = 'ServerSideEventCount'; export const footerHeight = 40; // px interface FooterProps { - itemsCount: number; + compact: boolean; + getUpdatedAt: () => number; + hasNextPage: boolean; + height: number; isEventViewer?: boolean; isLive: boolean; isLoading: boolean; + itemsCount: number; itemsPerPage: number; itemsPerPageOptions: number[]; - hasNextPage: boolean; - height: number; nextCursor: string; onChangeItemsPerPage: OnChangeItemsPerPage; onLoadMore: OnLoadMore; serverSideEventCount: number; tieBreaker: string; - getUpdatedAt: () => number; - compact: boolean; -} - -interface FooterState { - isPopoverOpen: boolean; - paginationLoading: boolean; - updatedAt: number | null; } /** Displays the server-side count of events */ @@ -144,7 +138,7 @@ export const EventsCount = pure<{ EventsCount.displayName = 'EventsCount'; -export const PagingControl = pure<{ +export const PagingControl = React.memo<{ hasNextPage: boolean; isLoading: boolean; loadMore: () => void; @@ -166,81 +160,49 @@ export const PagingControl = pure<{ PagingControl.displayName = 'PagingControl'; /** Renders a loading indicator and paging controls */ -export class Footer extends React.Component { - public readonly state = { - isPopoverOpen: false, - paginationLoading: false, - updatedAt: null, - }; - - public shouldComponentUpdate( - { - compact, - hasNextPage, - height, - isEventViewer, - isLive, - isLoading, - itemsCount, - itemsPerPage, - itemsPerPageOptions, - serverSideEventCount, - }: FooterProps, - { isPopoverOpen, paginationLoading, updatedAt }: FooterState - ) { - return ( - compact !== this.props.compact || - hasNextPage !== this.props.hasNextPage || - height !== this.props.height || - isEventViewer !== this.props.isEventViewer || - isLive !== this.props.isLive || - isLoading !== this.props.isLoading || - isPopoverOpen !== this.state.isPopoverOpen || - itemsCount !== this.props.itemsCount || - itemsPerPage !== this.props.itemsPerPage || - itemsPerPageOptions !== this.props.itemsPerPageOptions || - paginationLoading !== this.state.paginationLoading || - serverSideEventCount !== this.props.serverSideEventCount || - updatedAt !== this.state.updatedAt - ); - } - - public componentDidUpdate(prevProps: FooterProps) { - const { paginationLoading, updatedAt } = this.state; - const { isLoading, getUpdatedAt } = this.props; - if (paginationLoading && prevProps.isLoading && !isLoading) { - this.setState(prevState => ({ - ...prevState, - paginationLoading: false, - updatedAt: getUpdatedAt(), - })); - } +export const Footer = React.memo( + ({ + compact, + getUpdatedAt, + hasNextPage, + height, + isEventViewer, + isLive, + isLoading, + itemsCount, + itemsPerPage, + itemsPerPageOptions, + nextCursor, + onChangeItemsPerPage, + onLoadMore, + serverSideEventCount, + tieBreaker, + }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [paginationLoading, setPaginationLoading] = useState(false); + const [updatedAt, setUpdatedAt] = useState(null); + + const loadMore = () => { + setPaginationLoading(true); + onLoadMore(nextCursor, tieBreaker); + }; + + const onButtonClick = () => setIsPopoverOpen(!isPopoverOpen); + + const closePopover = () => setIsPopoverOpen(false); + + useEffect(() => { + if (paginationLoading && !isLoading) { + setPaginationLoading(false); + setUpdatedAt(getUpdatedAt()); + } - if (updatedAt === null || (prevProps.isLoading && !isLoading)) { - this.setState(prevState => ({ - ...prevState, - updatedAt: getUpdatedAt(), - })); - } - } + if (updatedAt === null || !isLoading) { + setUpdatedAt(getUpdatedAt()); + } + }, [isLoading]); - public render() { - const { - height, - isEventViewer, - isLive, - isLoading, - itemsCount, - itemsPerPage, - itemsPerPageOptions, - onChangeItemsPerPage, - serverSideEventCount, - hasNextPage, - getUpdatedAt, - compact, - } = this.props; - - if (isLoading && !this.state.paginationLoading) { + if (isLoading && !paginationLoading) { return ( { key={item} icon={itemsPerPage === item ? 'check' : 'empty'} onClick={() => { - this.closePopover(); + closePopover(); onChangeItemsPerPage(item); }} > {`${item} ${i18n.ROWS}`} )); + return ( <> { gutterSize="none" > @@ -327,44 +290,35 @@ export class Footer extends React.Component { data-test-subj="paging-control" hasNextPage={hasNextPage} isLoading={isLoading} - loadMore={this.loadMore} + loadMore={loadMore} /> )} - + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.compact === nextProps.compact && + prevProps.hasNextPage === nextProps.hasNextPage && + prevProps.height === nextProps.height && + prevProps.isEventViewer === nextProps.isEventViewer && + prevProps.isLive === nextProps.isLive && + prevProps.isLoading === nextProps.isLoading && + prevProps.itemsCount === nextProps.itemsCount && + prevProps.itemsPerPage === nextProps.itemsPerPage && + prevProps.itemsPerPageOptions === nextProps.itemsPerPageOptions && + prevProps.serverSideEventCount === nextProps.serverSideEventCount + ); } +); - private loadMore = () => { - this.setState(prevState => ({ - ...prevState, - paginationLoading: true, - })); - this.props.onLoadMore(this.props.nextCursor, this.props.tieBreaker); - }; - - private onButtonClick = () => { - this.setState(prevState => ({ - ...prevState, - isPopoverOpen: !prevState.isPopoverOpen, - })); - }; - - private closePopover = () => { - this.setState(prevState => ({ - ...prevState, - isPopoverOpen: false, - })); - }; -} +Footer.displayName = 'Footer'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/footer/last_updated.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/footer/last_updated.tsx index 0953341fe8a905..a17e5a6da6331c 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/footer/last_updated.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/footer/last_updated.tsx @@ -6,7 +6,7 @@ import { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; import { FormattedRelative } from '@kbn/i18n/react'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import { pure } from 'recompose'; import * as i18n from './translations'; @@ -16,10 +16,6 @@ interface LastUpdatedAtProps { updatedAt: number; } -interface LastUpdatedAtState { - date: number; -} - export const Updated = pure<{ date: number; prefix: string; updatedAt: number }>( ({ date, prefix, updatedAt }) => ( <> @@ -37,46 +33,37 @@ export const Updated = pure<{ date: number; prefix: string; updatedAt: number }> Updated.displayName = 'Updated'; -export class LastUpdatedAt extends React.PureComponent { - public readonly state = { - date: Date.now(), - }; - private timerID?: NodeJS.Timeout; +const prefix = ` ${i18n.UPDATED} `; - public componentDidMount() { - this.timerID = setInterval(() => this.tick(), 10000); - } +export const LastUpdatedAt = React.memo(({ compact = false, updatedAt }) => { + const [date, setDate] = useState(Date.now()); - public componentWillUnmount() { - clearInterval(this.timerID!); + function tick() { + setDate(Date.now()); } - public tick() { - this.setState({ - date: Date.now(), - }); - } + useEffect(() => { + const timerID = setInterval(() => tick(), 10000); + return () => { + clearInterval(timerID); + }; + }, []); - public render() { - const { compact = false } = this.props; - const prefix = ` ${i18n.UPDATED} `; + return ( + + + + } + > + + + {!compact ? : null} + + + ); +}); - return ( - - - - } - > - - - {!compact ? ( - - ) : null} - - - ); - } -} +LastUpdatedAt.displayName = 'LastUpdatedAt'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/index.tsx index 153ca2abd24d10..ab92f22a4c89fb 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/index.tsx @@ -5,7 +5,7 @@ */ import { isEqual } from 'lodash/fp'; -import * as React from 'react'; +import React, { useEffect } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; @@ -131,70 +131,113 @@ interface DispatchProps { type Props = OwnProps & StateReduxProps & DispatchProps; -class StatefulTimelineComponent extends React.Component { - public shouldComponentUpdate = ({ - id, - flyoutHeaderHeight, - flyoutHeight, - activePage, +const StatefulTimelineComponent = React.memo( + ({ columns, + createTimeline, dataProviders, end, + flyoutHeaderHeight, + flyoutHeight, + id, isLive, itemsPerPage, itemsPerPageOptions, kqlMode, kqlQueryExpression, - pageCount, - sort, - start, + onDataProviderEdited, + removeColumn, + removeProvider, show, showCallOutUnauthorizedMsg, - }: Props) => - id !== this.props.id || - flyoutHeaderHeight !== this.props.flyoutHeaderHeight || - flyoutHeight !== this.props.flyoutHeight || - activePage !== this.props.activePage || - !isEqual(columns, this.props.columns) || - !isEqual(dataProviders, this.props.dataProviders) || - end !== this.props.end || - isLive !== this.props.isLive || - itemsPerPage !== this.props.itemsPerPage || - !isEqual(itemsPerPageOptions, this.props.itemsPerPageOptions) || - kqlMode !== this.props.kqlMode || - kqlQueryExpression !== this.props.kqlQueryExpression || - pageCount !== this.props.pageCount || - !isEqual(sort, this.props.sort) || - start !== this.props.start || - show !== this.props.show || - showCallOutUnauthorizedMsg !== this.props.showCallOutUnauthorizedMsg; + sort, + start, + updateDataProviderEnabled, + updateDataProviderExcluded, + updateDataProviderKqlQuery, + updateHighlightedDropAndProviderId, + updateItemsPerPage, + upsertColumn, + }) => { + const onDataProviderRemoved: OnDataProviderRemoved = ( + providerId: string, + andProviderId?: string + ) => removeProvider!({ id, providerId, andProviderId }); - public componentDidMount() { - const { createTimeline, id } = this.props; + const onToggleDataProviderEnabled: OnToggleDataProviderEnabled = ({ + providerId, + enabled, + andProviderId, + }) => + updateDataProviderEnabled!({ + id, + enabled, + providerId, + andProviderId, + }); - if (createTimeline != null) { - createTimeline({ id, columns: defaultHeaders, show: false }); - } - } + const onToggleDataProviderExcluded: OnToggleDataProviderExcluded = ({ + providerId, + excluded, + andProviderId, + }) => + updateDataProviderExcluded!({ + id, + excluded, + providerId, + andProviderId, + }); - public render() { - const { - columns, - dataProviders, - end, - flyoutHeight, - flyoutHeaderHeight, - id, - isLive, - itemsPerPage, - itemsPerPageOptions, - kqlMode, - kqlQueryExpression, - show, - showCallOutUnauthorizedMsg, - start, - sort, - } = this.props; + const onDataProviderEditedLocal: OnDataProviderEdited = ({ + andProviderId, + excluded, + field, + operator, + providerId, + value, + }) => + onDataProviderEdited!({ + andProviderId, + excluded, + field, + id, + operator, + providerId, + value, + }); + const onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery = ({ providerId, kqlQuery }) => + updateDataProviderKqlQuery!({ id, kqlQuery, providerId }); + + const onChangeItemsPerPage: OnChangeItemsPerPage = itemsChangedPerPage => + updateItemsPerPage!({ id, itemsPerPage: itemsChangedPerPage }); + + const onChangeDroppableAndProvider: OnChangeDroppableAndProvider = providerId => + updateHighlightedDropAndProviderId!({ id, providerId }); + + const toggleColumn = (column: ColumnHeader) => { + const exists = columns.findIndex(c => c.id === column.id) !== -1; + + if (!exists && upsertColumn != null) { + upsertColumn({ + column, + id, + index: 1, + }); + } + + if (exists && removeColumn != null) { + removeColumn({ + columnId: column.id, + id, + }); + } + }; + + useEffect(() => { + if (createTimeline != null) { + createTimeline({ id, columns: defaultHeaders, show: false }); + } + }, []); return ( @@ -202,111 +245,58 @@ class StatefulTimelineComponent extends React.Component { )} ); + }, + (prevProps, nextProps) => { + return ( + prevProps.activePage === nextProps.activePage && + prevProps.end === nextProps.end && + prevProps.flyoutHeaderHeight === nextProps.flyoutHeaderHeight && + prevProps.flyoutHeight === nextProps.flyoutHeight && + prevProps.id === nextProps.id && + prevProps.isLive === nextProps.isLive && + prevProps.itemsPerPage === nextProps.itemsPerPage && + prevProps.kqlMode === nextProps.kqlMode && + prevProps.kqlQueryExpression === nextProps.kqlQueryExpression && + prevProps.pageCount === nextProps.pageCount && + prevProps.show === nextProps.show && + prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && + prevProps.start === nextProps.start && + isEqual(prevProps.columns, nextProps.columns) && + isEqual(prevProps.dataProviders, nextProps.dataProviders) && + isEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && + isEqual(prevProps.sort, nextProps.sort) + ); } +); - private onDataProviderRemoved: OnDataProviderRemoved = ( - providerId: string, - andProviderId?: string - ) => this.props.removeProvider!({ id: this.props.id, providerId, andProviderId }); - - private onToggleDataProviderEnabled: OnToggleDataProviderEnabled = ({ - providerId, - enabled, - andProviderId, - }) => - this.props.updateDataProviderEnabled!({ - id: this.props.id, - enabled, - providerId, - andProviderId, - }); - - private onToggleDataProviderExcluded: OnToggleDataProviderExcluded = ({ - providerId, - excluded, - andProviderId, - }) => - this.props.updateDataProviderExcluded!({ - id: this.props.id, - excluded, - providerId, - andProviderId, - }); - - private onDataProviderEdited: OnDataProviderEdited = ({ - andProviderId, - excluded, - field, - operator, - providerId, - value, - }) => - this.props.onDataProviderEdited!({ - andProviderId, - excluded, - field, - id: this.props.id, - operator, - providerId, - value, - }); - - private onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery = ({ providerId, kqlQuery }) => - this.props.updateDataProviderKqlQuery!({ id: this.props.id, kqlQuery, providerId }); - - private onChangeItemsPerPage: OnChangeItemsPerPage = itemsChangedPerPage => - this.props.updateItemsPerPage!({ id: this.props.id, itemsPerPage: itemsChangedPerPage }); - - private onChangeDroppableAndProvider: OnChangeDroppableAndProvider = providerId => - this.props.updateHighlightedDropAndProviderId!({ id: this.props.id, providerId }); - - private toggleColumn = (column: ColumnHeader) => { - const { columns, removeColumn, id, upsertColumn } = this.props; - const exists = columns.findIndex(c => c.id === column.id) !== -1; - - if (!exists && upsertColumn != null) { - upsertColumn({ - column, - id, - index: 1, - }); - } - - if (exists && removeColumn != null) { - removeColumn({ - columnId: column.id, - id, - }); - } - }; -} +StatefulTimelineComponent.displayName = 'StatefulTimelineComponent'; const makeMapStateToProps = () => { const getShowCallOutUnauthorizedMsg = timelineSelectors.getShowCallOutUnauthorizedMsg(); @@ -322,8 +312,8 @@ const makeMapStateToProps = () => { itemsPerPage, itemsPerPageOptions, kqlMode, - sort, show, + sort, } = timeline; const kqlQueryExpression = getKqlQueryTimeline(state, id); @@ -337,10 +327,10 @@ const makeMapStateToProps = () => { itemsPerPageOptions, kqlMode, kqlQueryExpression, + show, + showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state), sort, start: input.timerange.from, - showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state), - show, }; }; return mapStateToProps; @@ -352,6 +342,8 @@ export const StatefulTimeline = connect( addProvider: timelineActions.addProvider, createTimeline: timelineActions.createTimeline, onDataProviderEdited: timelineActions.dataProviderEdited, + removeColumn: timelineActions.removeColumn, + removeProvider: timelineActions.removeProvider, updateColumns: timelineActions.updateColumns, updateDataProviderEnabled: timelineActions.updateDataProviderEnabled, updateDataProviderExcluded: timelineActions.updateDataProviderExcluded, @@ -360,8 +352,6 @@ export const StatefulTimeline = connect( updateItemsPerPage: timelineActions.updateItemsPerPage, updateItemsPerPageOptions: timelineActions.updateItemsPerPageOptions, updateSort: timelineActions.updateSort, - removeProvider: timelineActions.removeProvider, - removeColumn: timelineActions.removeColumn, upsertColumn: timelineActions.upsertColumn, } )(StatefulTimelineComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/properties/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/properties/index.tsx index b5daad42f7c3a3..b983963c34f55e 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/properties/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/properties/index.tsx @@ -5,7 +5,7 @@ */ import { EuiAvatar, EuiFlexItem, EuiIcon } from '@elastic/eui'; -import * as React from 'react'; +import React, { useState } from 'react'; import styled, { injectGlobal } from 'styled-components'; import { Note } from '../../../lib/note'; @@ -62,28 +62,23 @@ HiddenFlexItem.displayName = 'HiddenFlexItem'; interface Props { associateNote: AssociateNote; createTimeline: CreateTimeline; + description: string; + getNotesByIds: (noteIds: string[]) => Note[]; isDataInTimeline: boolean; isDatepickerLocked: boolean; isFavorite: boolean; - title: string; - description: string; - getNotesByIds: (noteIds: string[]) => Note[]; noteIds: string[]; timelineId: string; + title: string; toggleLock: ToggleLock; updateDescription: UpdateDescription; updateIsFavorite: UpdateIsFavorite; - updateTitle: UpdateTitle; updateNote: UpdateNote; + updateTitle: UpdateTitle; usersViewing: string[]; width: number; } -interface State { - showActions: boolean; - showNotes: boolean; -} - const rightGutter = 60; // px export const datePickerThreshold = 600; export const showNotesThreshold = 810; @@ -96,51 +91,40 @@ const noteWidth = 130; const settingsWidth = 50; /** Displays the properties of a timeline, i.e. name, description, notes, etc */ -export class Properties extends React.PureComponent { - constructor(props: Props) { - super(props); +export const Properties = React.memo( + ({ + associateNote, + createTimeline, + description, + getNotesByIds, + isDataInTimeline, + isDatepickerLocked, + isFavorite, + noteIds, + timelineId, + title, + toggleLock, + updateDescription, + updateIsFavorite, + updateNote, + updateTitle, + usersViewing, + width, + }) => { + const [showActions, setShowActions] = useState(false); + const [showNotes, setShowNotes] = useState(false); + + const onButtonClick = () => { + setShowActions(!showActions); + }; - this.state = { - showActions: false, - showNotes: false, + const onToggleShowNotes = () => { + setShowNotes(!showNotes); }; - } - public onButtonClick = () => { - this.setState(prevState => ({ - showActions: !prevState.showActions, - })); - }; - - public onToggleShowNotes = () => { - this.setState(state => ({ showNotes: !state.showNotes })); - }; - - public onClosePopover = () => { - this.setState({ - showActions: false, - }); - }; - - public render() { - const { - associateNote, - createTimeline, - description, - getNotesByIds, - isFavorite, - isDataInTimeline, - isDatepickerLocked, - title, - noteIds, - timelineId, - updateDescription, - updateIsFavorite, - updateTitle, - updateNote, - usersViewing, - width, - } = this.props; + const onClosePopover = () => { + setShowActions(false); + }; const datePickerWidth = width - @@ -157,52 +141,52 @@ export class Properties extends React.PureComponent { return ( datePickerThreshold ? datePickerThreshold : datePickerWidth + } + description={description} + getNotesByIds={getNotesByIds} + isDatepickerLocked={isDatepickerLocked} isFavorite={isFavorite} - timelineId={timelineId} - updateIsFavorite={updateIsFavorite} + noteIds={noteIds} + onToggleShowNotes={onToggleShowNotes} showDescription={width >= showDescriptionThreshold} - description={description} + showNotes={showNotes} + showNotesFromWidth={width >= showNotesThreshold} + timelineId={timelineId} title={title} - updateTitle={updateTitle} + toggleLock={() => { + toggleLock({ linkToId: 'timeline' }); + }} updateDescription={updateDescription} - showNotes={this.state.showNotes} - showNotesFromWidth={width >= showNotesThreshold} - associateNote={associateNote} - getNotesByIds={getNotesByIds} - noteIds={noteIds} - onToggleShowNotes={this.onToggleShowNotes} + updateIsFavorite={updateIsFavorite} updateNote={updateNote} - isDatepickerLocked={isDatepickerLocked} - toggleLock={this.toggleLock} - datePickerWidth={ - datePickerWidth > datePickerThreshold ? datePickerThreshold : datePickerWidth - } + updateTitle={updateTitle} /> 0} - usersViewing={usersViewing} - description={description} + timelineId={timelineId} updateDescription={updateDescription} - associateNote={associateNote} - getNotesByIds={getNotesByIds} - noteIds={noteIds} - onToggleShowNotes={this.onToggleShowNotes} updateNote={updateNote} + usersViewing={usersViewing} /> ); } +); - private toggleLock = () => { - this.props.toggleLock({ linkToId: 'timeline' }); - }; -} +Properties.displayName = 'Properties'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/index.tsx index 9213ff79ccc509..91113a545821df 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/index.tsx @@ -45,19 +45,17 @@ interface DispatchProps { type Props = OwnProps & StateReduxProps & DispatchProps; -class StatefulSearchOrFilterComponent extends React.PureComponent { - public render() { - const { - applyKqlFilterQuery, - indexPattern, - filterQueryDraft, - isFilterQueryDraftValid, - kqlMode, - timelineId, - setKqlFilterQueryDraft, - updateKqlMode, - } = this.props; - +const StatefulSearchOrFilterComponent = React.memo( + ({ + applyKqlFilterQuery, + filterQueryDraft, + indexPattern, + isFilterQueryDraftValid, + kqlMode, + setKqlFilterQueryDraft, + timelineId, + updateKqlMode, + }) => { const applyFilterQueryFromKueryExpression = (expression: string) => applyKqlFilterQuery({ id: timelineId, @@ -86,13 +84,14 @@ class StatefulSearchOrFilterComponent extends React.PureComponent { indexPattern={indexPattern} isFilterQueryDraftValid={isFilterQueryDraftValid} kqlMode={kqlMode!} + setKqlFilterQueryDraft={setFilterQueryDraftFromKueryExpression!} timelineId={timelineId} updateKqlMode={updateKqlMode!} - setKqlFilterQueryDraft={setFilterQueryDraftFromKueryExpression!} /> ); } -} +); +StatefulSearchOrFilterComponent.displayName = 'StatefulSearchOrFilterComponent'; const makeMapStateToProps = () => { const getTimeline = timelineSelectors.getTimelineByIdSelector(); @@ -101,9 +100,9 @@ const makeMapStateToProps = () => { const mapStateToProps = (state: State, { timelineId }: OwnProps) => { const timeline: TimelineModel | {} = getTimeline(state, timelineId); return { - kqlMode: getOr('filter', 'kqlMode', timeline), filterQueryDraft: getKqlFilterQueryDraft(state, timelineId), isFilterQueryDraftValid: isFilterQueryDraftValid(state, timelineId), + kqlMode: getOr('filter', 'kqlMode', timeline), }; }; return mapStateToProps; diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/use_url_state.tsx b/x-pack/legacy/plugins/siem/public/components/url_state/use_url_state.tsx index 78c3850057fc10..26826ace6fcfd7 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/use_url_state.tsx +++ b/x-pack/legacy/plugins/siem/public/components/url_state/use_url_state.tsx @@ -33,7 +33,7 @@ import { } from './types'; function usePrevious(value: PreviousLocationUrlState) { - const ref = useRef(value); + const ref = useRef(value); useEffect(() => { ref.current = value; }); diff --git a/x-pack/legacy/plugins/siem/public/components/with_hover_actions/index.tsx b/x-pack/legacy/plugins/siem/public/components/with_hover_actions/index.tsx index 972ace6870d147..2569e8d303b69b 100644 --- a/x-pack/legacy/plugins/siem/public/components/with_hover_actions/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/with_hover_actions/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; +import React, { useState } from 'react'; import { pure } from 'recompose'; import styled from 'styled-components'; @@ -28,10 +28,6 @@ interface Props { render: (showHoverContent: boolean) => JSX.Element; } -interface State { - showHoverContent: boolean; -} - const HoverActionsPanelContainer = styled.div` color: ${props => props.theme.eui.textColors.default} height: 100%; @@ -67,31 +63,25 @@ WithHoverActionsContainer.displayName = 'WithHoverActionsContainer'; * component also passes `showHoverContent` as a render prop, which * provides a signal to the content that the user is in a hover state. */ -export class WithHoverActions extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { showHoverContent: false }; - } - - public render() { - const { alwaysShow = false, hoverContent, render } = this.props; +export const WithHoverActions = React.memo( + ({ alwaysShow = false, hoverContent, render }) => { + const [showHoverContent, setShowHoverContent] = useState(false); + function onMouseEnter() { + setShowHoverContent(true); + } + function onMouseLeave() { + setShowHoverContent(false); + } return ( - - <>{render(this.state.showHoverContent)} - + + <>{render(showHoverContent)} + {hoverContent != null ? hoverContent : <>} ); } +); - private onMouseEnter = () => { - this.setState({ showHoverContent: true }); - }; - - private onMouseLeave = () => { - this.setState({ showHoverContent: false }); - }; -} +WithHoverActions.displayName = 'WithHoverActions'; diff --git a/x-pack/legacy/plugins/siem/public/containers/hosts/filter.tsx b/x-pack/legacy/plugins/siem/public/containers/hosts/filter.tsx index 5a5d25e699528c..4bacb2f87c4581 100644 --- a/x-pack/legacy/plugins/siem/public/containers/hosts/filter.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/hosts/filter.tsx @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEqual } from 'lodash/fp'; +import React, { useEffect } from 'react'; import memoizeOne from 'memoize-one'; -import React from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; import { StaticIndexPattern } from 'ui/index_patterns'; @@ -61,80 +60,72 @@ interface HostsFilterDispatchProps { export type HostsFilterProps = OwnProps & HostsFilterReduxProps & HostsFilterDispatchProps; -class HostsFilterComponent extends React.PureComponent { - private memoizedApplyFilterQueryFromKueryExpression: (expression: string) => void; - private memoizedSetFilterQueryDraftFromKueryExpression: (expression: string) => void; - - constructor(props: HostsFilterProps) { - super(props); - this.memoizedApplyFilterQueryFromKueryExpression = memoizeOne( - this.applyFilterQueryFromKueryExpression - ); - this.memoizedSetFilterQueryDraftFromKueryExpression = memoizeOne( - this.setFilterQueryDraftFromKueryExpression - ); - } +const HostsFilterComponent = React.memo( + ({ + applyHostsFilterQuery, + children, + hostsFilterQueryDraft, + indexPattern, + isHostFilterQueryDraftValid, + kueryFilterQuery, + setHostsFilterQueryDraft, + setQuery, + type, + }) => { + const applyFilterQueryFromKueryExpression = (expression: string) => + applyHostsFilterQuery({ + filterQuery: { + kuery: { + kind: 'kuery', + expression, + }, + serializedQuery: convertKueryToElasticSearchQuery(expression, indexPattern), + }, + hostsType: type, + }); - public componentDidUpdate(prevProps: HostsFilterProps) { - const { indexPattern, hostsFilterQueryDraft, kueryFilterQuery, setQuery, type } = this.props; - if ( - setQuery && - (!isEqual(prevProps.hostsFilterQueryDraft, hostsFilterQueryDraft) || - !isEqual(prevProps.kueryFilterQuery, kueryFilterQuery) || - prevProps.type !== type) - ) { - setQuery({ - id: 'kql', - inspect: null, - loading: false, - refetch: useUpdateKql({ - indexPattern, - kueryFilterQuery, - kueryFilterQueryDraft: hostsFilterQueryDraft, - storeType: 'hostsType', - type, - }), + const setFilterQueryDraftFromKueryExpression = (expression: string) => + setHostsFilterQueryDraft({ + filterQueryDraft: { + kind: 'kuery', + expression, + }, + hostsType: type, }); - } - } - public render() { - const { children, hostsFilterQueryDraft, isHostFilterQueryDraftValid } = this.props; + const memoizedApplyFilter = memoizeOne(applyFilterQueryFromKueryExpression); + const memoizedSetFilter = memoizeOne(setFilterQueryDraftFromKueryExpression); + useEffect(() => { + if (setQuery) { + setQuery({ + id: 'kql', + inspect: null, + loading: false, + refetch: useUpdateKql({ + indexPattern, + kueryFilterQuery, + kueryFilterQueryDraft: hostsFilterQueryDraft, + storeType: 'hostsType', + type, + }), + }); + } + }, [hostsFilterQueryDraft, kueryFilterQuery, type]); return ( <> {children({ - applyFilterQueryFromKueryExpression: this.memoizedApplyFilterQueryFromKueryExpression, + applyFilterQueryFromKueryExpression: memoizedApplyFilter, filterQueryDraft: hostsFilterQueryDraft, isFilterQueryDraftValid: isHostFilterQueryDraftValid, - setFilterQueryDraftFromKueryExpression: this - .memoizedSetFilterQueryDraftFromKueryExpression, + setFilterQueryDraftFromKueryExpression: memoizedSetFilter, })} ); } +); - private applyFilterQueryFromKueryExpression = (expression: string) => - this.props.applyHostsFilterQuery({ - filterQuery: { - kuery: { - kind: 'kuery', - expression, - }, - serializedQuery: convertKueryToElasticSearchQuery(expression, this.props.indexPattern), - }, - hostsType: this.props.type, - }); - - private setFilterQueryDraftFromKueryExpression = (expression: string) => - this.props.setHostsFilterQueryDraft({ - filterQueryDraft: { - kind: 'kuery', - expression, - }, - hostsType: this.props.type, - }); -} +HostsFilterComponent.displayName = 'HostsFilterComponent'; const makeMapStateToProps = () => { const getHostsFilterQueryDraft = hostsSelectors.hostsFilterQueryDraft(); diff --git a/x-pack/legacy/plugins/siem/public/containers/hosts/first_last_seen/index.ts b/x-pack/legacy/plugins/siem/public/containers/hosts/first_last_seen/index.ts index 7d0451adcd18f0..042de56fbd99d9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/hosts/first_last_seen/index.ts +++ b/x-pack/legacy/plugins/siem/public/containers/hosts/first_last_seen/index.ts @@ -36,9 +36,9 @@ export function useFirstLastSeenHostQuery( apolloClient: ApolloClient ) { const [loading, updateLoading] = useState(false); - const [firstSeen, updateFirstSeen] = useState(null); - const [lastSeen, updateLastSeen] = useState(null); - const [errorMessage, updateErrorMessage] = useState(null); + const [firstSeen, updateFirstSeen] = useState(null); + const [lastSeen, updateLastSeen] = useState(null); + const [errorMessage, updateErrorMessage] = useState(null); async function fetchFirstLastSeenHost(signal: AbortSignal) { updateLoading(true); diff --git a/x-pack/legacy/plugins/siem/public/containers/kuery_autocompletion/index.tsx b/x-pack/legacy/plugins/siem/public/containers/kuery_autocompletion/index.tsx index 9235580563a533..9cf7331441da5e 100644 --- a/x-pack/legacy/plugins/siem/public/containers/kuery_autocompletion/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/kuery_autocompletion/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useState } from 'react'; import { npStart } from 'ui/new_platform'; import { StaticIndexPattern } from 'ui/index_patterns'; import { AutocompleteSuggestion } from '../../../../../../../src/plugins/data/public'; @@ -26,78 +26,65 @@ interface KueryAutocompletionCurrentRequest { cursorPosition: number; } -interface KueryAutocompletionLifecycleState { - // lacking cancellation support in the autocompletion api, - // this is used to keep older, slower requests from clobbering newer ones - currentRequest: KueryAutocompletionCurrentRequest | null; - suggestions: AutocompleteSuggestion[]; -} - const getAutocompleteProvider = (language: string) => npStart.plugins.data.autocomplete.getProvider(language); -export class KueryAutocompletion extends React.PureComponent< - KueryAutocompletionLifecycleProps, - KueryAutocompletionLifecycleState -> { - public readonly state: KueryAutocompletionLifecycleState = { - currentRequest: null, - suggestions: [], - }; - - public render() { - const { currentRequest, suggestions } = this.state; - return this.props.children({ - isLoadingSuggestions: currentRequest !== null, - loadSuggestions: this.loadSuggestions, - suggestions, - }); - } +export const KueryAutocompletion = React.memo( + ({ children, indexPattern }) => { + const [currentRequest, setCurrentRequest] = useState( + null + ); - private loadSuggestions = async ( - expression: string, - cursorPosition: number, - maxSuggestions?: number - ) => { - const { indexPattern } = this.props; - const autocompletionProvider = getAutocompleteProvider('kuery'); - const config = { - get: () => true, - }; - if (!autocompletionProvider) { - return; - } + const [suggestions, setSuggestions] = useState([]); - const getSuggestions = autocompletionProvider({ - config, - indexPatterns: [indexPattern], - boolFilter: [], - }); + const loadSuggestions = async ( + expression: string, + cursorPosition: number, + maxSuggestions?: number + ) => { + const autocompletionProvider = getAutocompleteProvider('kuery'); + const config = { + get: () => true, + }; + if (!autocompletionProvider) { + return; + } - this.setState({ - currentRequest: { + const getSuggestions = autocompletionProvider({ + config, + indexPatterns: [indexPattern], + boolFilter: [], + }); + const futureRequest = { expression, cursorPosition, - }, - suggestions: [], - }); + }; + setCurrentRequest({ + expression, + cursorPosition, + }); + setSuggestions([]); + const newSuggestions = await getSuggestions({ + query: expression, + selectionStart: cursorPosition, + selectionEnd: cursorPosition, + }); + if ( + futureRequest && + futureRequest.expression !== (currentRequest && currentRequest.expression) && + futureRequest.cursorPosition !== (currentRequest && currentRequest.cursorPosition) + ) { + setCurrentRequest(null); + setSuggestions(maxSuggestions ? newSuggestions.slice(0, maxSuggestions) : newSuggestions); + } + }; - const suggestions = await getSuggestions({ - query: expression, - selectionStart: cursorPosition, - selectionEnd: cursorPosition, + return children({ + isLoadingSuggestions: currentRequest !== null, + loadSuggestions, + suggestions, }); + } +); - this.setState(state => - state.currentRequest && - state.currentRequest.expression !== expression && - state.currentRequest.cursorPosition !== cursorPosition - ? state // ignore this result, since a newer request is in flight - : { - ...state, - currentRequest: null, - suggestions: maxSuggestions ? suggestions.slice(0, maxSuggestions) : suggestions, - } - ); - }; -} +KueryAutocompletion.displayName = 'KueryAutocompletion'; diff --git a/x-pack/legacy/plugins/siem/public/containers/network/filter.tsx b/x-pack/legacy/plugins/siem/public/containers/network/filter.tsx index cb2fb12a0ca52b..6ae2f3ef777e84 100644 --- a/x-pack/legacy/plugins/siem/public/containers/network/filter.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/network/filter.tsx @@ -4,13 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEqual } from 'lodash/fp'; -import React from 'react'; +import React, { useEffect } from 'react'; +import memoizeOne from 'memoize-one'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; import { StaticIndexPattern } from 'ui/index_patterns'; -import memoizeOne from 'memoize-one'; import { convertKueryToElasticSearchQuery } from '../../lib/keury'; import { KueryFilterQuery, @@ -33,13 +32,13 @@ export interface NetworkFilterArgs { interface OwnProps { children: (args: NetworkFilterArgs) => React.ReactNode; indexPattern: StaticIndexPattern; - type: networkModel.NetworkType; setQuery?: (params: { id: string; inspect: null; loading: boolean; refetch: inputsModel.Refetch | inputsModel.RefetchKql; }) => void; + type: networkModel.NetworkType; } interface NetworkFilterReduxProps { @@ -61,80 +60,71 @@ interface NetworkFilterDispatchProps { export type NetworkFilterProps = OwnProps & NetworkFilterReduxProps & NetworkFilterDispatchProps; -class NetworkFilterComponent extends React.PureComponent { - private memoizedApplyFilterQueryFromKueryExpression: (expression: string) => void; - private memoizedSetFilterQueryDraftFromKueryExpression: (expression: string) => void; - - constructor(props: NetworkFilterProps) { - super(props); - this.memoizedApplyFilterQueryFromKueryExpression = memoizeOne( - this.applyFilterQueryFromKueryExpression - ); - this.memoizedSetFilterQueryDraftFromKueryExpression = memoizeOne( - this.setFilterQueryDraftFromKueryExpression - ); - } - - public componentDidUpdate(prevProps: NetworkFilterProps) { - const { indexPattern, networkFilterQueryDraft, kueryFilterQuery, setQuery, type } = this.props; - - if ( - setQuery && - (!isEqual(prevProps.networkFilterQueryDraft, networkFilterQueryDraft) || - !isEqual(prevProps.kueryFilterQuery, kueryFilterQuery) || - prevProps.type !== type) - ) { - setQuery({ - id: 'kql', - inspect: null, - loading: false, - refetch: useUpdateKql({ - indexPattern, - kueryFilterQuery, - kueryFilterQueryDraft: networkFilterQueryDraft, - storeType: 'networkType', - type, - }), +const NetworkFilterComponent = React.memo( + ({ + applyNetworkFilterQuery, + children, + indexPattern, + isNetworkFilterQueryDraftValid, + kueryFilterQuery, + networkFilterQueryDraft, + setNetworkFilterQueryDraft, + setQuery, + type, + }) => { + const applyFilterQueryFromKueryExpression = (expression: string) => + applyNetworkFilterQuery({ + filterQuery: { + kuery: { + kind: 'kuery', + expression, + }, + serializedQuery: convertKueryToElasticSearchQuery(expression, indexPattern), + }, + networkType: type, }); - } - } - - public render() { - const { children, networkFilterQueryDraft, isNetworkFilterQueryDraftValid } = this.props; + const setFilterQueryDraftFromKueryExpression = (expression: string) => + setNetworkFilterQueryDraft({ + filterQueryDraft: { + kind: 'kuery', + expression, + }, + networkType: type, + }); + const memoizedApplyFilter = memoizeOne(applyFilterQueryFromKueryExpression); + const memoizedSetFilter = memoizeOne(setFilterQueryDraftFromKueryExpression); + + useEffect(() => { + if (setQuery) { + setQuery({ + id: 'kql', + inspect: null, + loading: false, + refetch: useUpdateKql({ + indexPattern, + kueryFilterQuery, + kueryFilterQueryDraft: networkFilterQueryDraft, + storeType: 'networkType', + type, + }), + }); + } + }, [networkFilterQueryDraft, kueryFilterQuery, type]); return ( <> {children({ - applyFilterQueryFromKueryExpression: this.memoizedApplyFilterQueryFromKueryExpression, + applyFilterQueryFromKueryExpression: memoizedApplyFilter, filterQueryDraft: networkFilterQueryDraft, isFilterQueryDraftValid: isNetworkFilterQueryDraftValid, - setFilterQueryDraftFromKueryExpression: this - .memoizedSetFilterQueryDraftFromKueryExpression, + setFilterQueryDraftFromKueryExpression: memoizedSetFilter, })} ); } - private applyFilterQueryFromKueryExpression = (expression: string) => - this.props.applyNetworkFilterQuery({ - filterQuery: { - kuery: { - kind: 'kuery', - expression, - }, - serializedQuery: convertKueryToElasticSearchQuery(expression, this.props.indexPattern), - }, - networkType: this.props.type, - }); +); - private setFilterQueryDraftFromKueryExpression = (expression: string) => - this.props.setNetworkFilterQueryDraft({ - filterQueryDraft: { - kind: 'kuery', - expression, - }, - networkType: this.props.type, - }); -} +NetworkFilterComponent.displayName = 'NetworkFilterComponent'; const makeMapStateToProps = () => { const getNetworkFilterQueryDraft = networkSelectors.networkFilterQueryDraft(); @@ -142,9 +132,9 @@ const makeMapStateToProps = () => { const getNetworkKueryFilterQuery = networkSelectors.networkFilterQueryAsKuery(); const mapStateToProps = (state: State, { type }: OwnProps) => { return { - networkFilterQueryDraft: getNetworkFilterQueryDraft(state, type), isNetworkFilterQueryDraftValid: getIsNetworkFilterQueryDraftValid(state, type), kueryFilterQuery: getNetworkKueryFilterQuery(state, type), + networkFilterQueryDraft: getNetworkFilterQueryDraft(state, type), }; }; return mapStateToProps; diff --git a/x-pack/legacy/plugins/siem/public/containers/source/index.tsx b/x-pack/legacy/plugins/siem/public/containers/source/index.tsx index 18b2641a160082..ead483baab43e2 100644 --- a/x-pack/legacy/plugins/siem/public/containers/source/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/source/index.tsx @@ -8,10 +8,10 @@ import { isUndefined } from 'lodash'; import { get, keyBy, pick, set } from 'lodash/fp'; import { Query } from 'react-apollo'; import React from 'react'; +import memoizeOne from 'memoize-one'; import { StaticIndexPattern } from 'ui/index_patterns'; import chrome from 'ui/chrome'; -import memoizeOne from 'memoize-one'; import { DEFAULT_INDEX_KEY } from '../../../common/constants'; import { IndexField, SourceQuery } from '../../graphql/types'; @@ -57,47 +57,8 @@ interface WithSourceProps { sourceId: string; } -export class WithSource extends React.PureComponent { - private memoizedIndexFields: (title: string, fields: IndexField[]) => StaticIndexPattern; - private memoizedBrowserFields: (fields: IndexField[]) => BrowserFields; - - constructor(props: WithSourceProps) { - super(props); - this.memoizedIndexFields = memoizeOne(this.getIndexFields); - this.memoizedBrowserFields = memoizeOne(this.getBrowserFields); - } - - public render() { - const { children, sourceId } = this.props; - - return ( - - query={sourceQuery} - fetchPolicy="cache-first" - notifyOnNetworkStatusChange - variables={{ - sourceId, - defaultIndex: chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY), - }} - > - {({ data }) => { - return children({ - indicesExist: get('source.status.indicesExist', data), - browserFields: this.memoizedBrowserFields(get('source.status.indexFields', data)), - indexPattern: this.memoizedIndexFields( - chrome - .getUiSettingsClient() - .get(DEFAULT_INDEX_KEY) - .join(), - get('source.status.indexFields', data) - ), - }); - }} - - ); - } - - private getIndexFields = (title: string, fields: IndexField[]): StaticIndexPattern => +export const WithSource = React.memo(({ children, sourceId }) => { + const getIndexFields = (title: string, fields: IndexField[]): StaticIndexPattern => fields && fields.length > 0 ? { fields: fields.map(field => pick(['name', 'searchable', 'type', 'aggregatable'], field)), @@ -105,7 +66,7 @@ export class WithSource extends React.PureComponent { } : { fields: [], title }; - private getBrowserFields = (fields: IndexField[]): BrowserFields => + const getBrowserFields = (fields: IndexField[]): BrowserFields => fields && fields.length > 0 ? fields.reduce( (accumulator: BrowserFields, field: IndexField) => @@ -113,7 +74,39 @@ export class WithSource extends React.PureComponent { {} ) : {}; -} + const getBrowserFieldsMemo: (fields: IndexField[]) => BrowserFields = memoizeOne( + getBrowserFields + ); + const getIndexFieldsMemo: ( + title: string, + fields: IndexField[] + ) => StaticIndexPattern = memoizeOne(getIndexFields); + return ( + + query={sourceQuery} + fetchPolicy="cache-first" + notifyOnNetworkStatusChange + variables={{ + sourceId, + defaultIndex: chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY), + }} + > + {({ data }) => + children({ + indicesExist: get('source.status.indicesExist', data), + browserFields: getBrowserFieldsMemo(get('source.status.indexFields', data)), + indexPattern: getIndexFieldsMemo( + chrome + .getUiSettingsClient() + .get(DEFAULT_INDEX_KEY) + .join(), + get('source.status.indexFields', data) + ), + }) + } + + ); +}); export const indicesExistOrDataTemporarilyUnavailable = (indicesExist: boolean | undefined) => indicesExist || isUndefined(indicesExist); diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx b/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx index 88bc333b66e97d..c3bff998fdefdf 100644 --- a/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx @@ -6,9 +6,10 @@ import { getOr } from 'lodash/fp'; import React from 'react'; +import memoizeOne from 'memoize-one'; + import { Query } from 'react-apollo'; -import memoizeOne from 'memoize-one'; import { OpenTimelineResult } from '../../../components/open_timeline/types'; import { GetAllTimeline, @@ -35,19 +36,43 @@ interface OwnProps extends AllTimelinesVariables { children?: (args: AllTimelinesArgs) => React.ReactNode; } -export class AllTimelinesQuery extends React.PureComponent { - private memoizedAllTimeline: ( - variables: string, - timelines: TimelineResult[] - ) => OpenTimelineResult[]; +const getAllTimeline = (variables: string, timelines: TimelineResult[]): OpenTimelineResult[] => + timelines.map(timeline => ({ + created: timeline.created, + description: timeline.description, + eventIdToNoteIds: + timeline.eventIdToNoteIds != null + ? timeline.eventIdToNoteIds.reduce((acc, note) => { + if (note.eventId != null) { + const notes = getOr([], note.eventId, acc); + return { ...acc, [note.eventId]: [...notes, note.noteId] }; + } + return acc; + }, {}) + : null, + favorite: timeline.favorite, + noteIds: timeline.noteIds, + notes: + timeline.notes != null + ? timeline.notes.map(note => ({ ...note, savedObjectId: note.noteId })) + : null, + pinnedEventIds: + timeline.pinnedEventIds != null + ? timeline.pinnedEventIds.reduce( + (acc, pinnedEventId) => ({ ...acc, [pinnedEventId]: true }), + {} + ) + : null, + savedObjectId: timeline.savedObjectId, + title: timeline.title, + updated: timeline.updated, + updatedBy: timeline.updatedBy, + })); - constructor(props: OwnProps) { - super(props); - this.memoizedAllTimeline = memoizeOne(this.getAllTimeline); - } +export const AllTimelinesQuery = React.memo( + ({ children, onlyUserFavorite, pageInfo, search, sort }) => { + const memoizedAllTimeline = memoizeOne(getAllTimeline); - public render() { - const { children, onlyUserFavorite, pageInfo, search, sort } = this.props; const variables: GetAllTimeline.Variables = { onlyUserFavorite, pageInfo, @@ -65,7 +90,7 @@ export class AllTimelinesQuery extends React.PureComponent { return children!({ loading, totalCount: getOr(0, 'getAllTimeline.totalCount', data), - timelines: this.memoizedAllTimeline( + timelines: memoizedAllTimeline( JSON.stringify(variables), getOr([], 'getAllTimeline.timeline', data) ), @@ -74,41 +99,4 @@ export class AllTimelinesQuery extends React.PureComponent { ); } - - private getAllTimeline = ( - variables: string, - timelines: TimelineResult[] - ): OpenTimelineResult[] => { - return timelines.map(timeline => ({ - created: timeline.created, - description: timeline.description, - eventIdToNoteIds: - timeline.eventIdToNoteIds != null - ? timeline.eventIdToNoteIds.reduce((acc, note) => { - if (note.eventId != null) { - const notes = getOr([], note.eventId, acc); - return { ...acc, [note.eventId]: [...notes, note.noteId] }; - } - return acc; - }, {}) - : null, - favorite: timeline.favorite, - noteIds: timeline.noteIds, - notes: - timeline.notes != null - ? timeline.notes.map(note => ({ ...note, savedObjectId: note.noteId })) - : null, - pinnedEventIds: - timeline.pinnedEventIds != null - ? timeline.pinnedEventIds.reduce( - (acc, pinnedEventId) => ({ ...acc, [pinnedEventId]: true }), - {} - ) - : null, - savedObjectId: timeline.savedObjectId, - title: timeline.title, - updated: timeline.updated, - updatedBy: timeline.updatedBy, - })); - }; -} +); diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/details/index.tsx b/x-pack/legacy/plugins/siem/public/containers/timeline/details/index.tsx index 9a038a61c9f883..54dd44063f5da6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/timeline/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/details/index.tsx @@ -21,30 +21,24 @@ export interface EventsArgs { } export interface TimelineDetailsProps { - children?: (args: EventsArgs) => React.ReactNode; + children?: (args: EventsArgs) => React.ReactElement; indexName: string; eventId: string; executeQuery: boolean; sourceId: string; } -export class TimelineDetailsComponentQuery extends React.PureComponent { - private memoizedDetailsEvents: (variables: string, detail: DetailItem[]) => DetailItem[]; +export const TimelineDetailsComponentQuery = React.memo( + ({ children, indexName, eventId, executeQuery, sourceId }) => { + const getDetailsEvent = (variables: string, detail: DetailItem[]): DetailItem[] => detail; + const getDetailsEventMemo = memoizeOne(getDetailsEvent); - constructor(props: TimelineDetailsProps) { - super(props); - this.memoizedDetailsEvents = memoizeOne(this.getDetailsEvent); - } - - public render() { - const { children, indexName, eventId, executeQuery, sourceId } = this.props; const variables: GetTimelineDetailsQuery.Variables = { sourceId, indexName, eventId, defaultIndex: chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY), }; - return executeQuery ? ( query={timelineDetailsQuery} @@ -55,7 +49,7 @@ export class TimelineDetailsComponentQuery extends React.PureComponent { return children!({ loading, - detailsData: this.memoizedDetailsEvents( + detailsData: getDetailsEventMemo( JSON.stringify(variables), getOr([], 'source.TimelineDetails.data', data) ), @@ -66,6 +60,4 @@ export class TimelineDetailsComponentQuery extends React.PureComponent detail; -} +); diff --git a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx index adc5471cc37a7a..90eae605de4b7e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx @@ -28,22 +28,18 @@ type OwnProps = TimelinesProps; export const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; -export class TimelinesPage extends React.PureComponent { - public render() { - return ( - <> - - - - - - - - ); - } -} +export const TimelinesPage = React.memo(({ apolloClient }) => ( + <> + + + + + + + +)); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index adc64130aab423..d4fb208bc858a6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10259,10 +10259,6 @@ "xpack.siem.kpiNetwork.uniquePrivateIps.sourceChartLabel": "Src.", "xpack.siem.kpiNetwork.uniquePrivateIps.sourceUnitLabel": "ソース", "xpack.siem.kpiNetwork.uniquePrivateIps.title": "固有のプライベート IP", - "xpack.siem.loadMoreTable.loadingButtonLabel": "読み込み中…", - "xpack.siem.loadMoreTable.loadMoreButtonLabel": "さらに読み込む", - "xpack.siem.loadMoreTable.rowsButtonLabel": "ページごとの行数", - "xpack.siem.loadMoreTable.showingSubtitle": "表示中", "xpack.siem.ml.score.anomalousEntityTitle": "異常エンティティ", "xpack.siem.ml.table.timestampTitle": "タイムスタンプ", "xpack.siem.modalAllErrors.close.button": "閉じる", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 31616c9e6db7cf..a002fdaa10257d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10261,10 +10261,6 @@ "xpack.siem.kpiNetwork.uniquePrivateIps.sourceChartLabel": "源", "xpack.siem.kpiNetwork.uniquePrivateIps.sourceUnitLabel": "源", "xpack.siem.kpiNetwork.uniquePrivateIps.title": "唯一专用 IP", - "xpack.siem.loadMoreTable.loadingButtonLabel": "正在加载……", - "xpack.siem.loadMoreTable.loadMoreButtonLabel": "加载更多", - "xpack.siem.loadMoreTable.rowsButtonLabel": "每页行数", - "xpack.siem.loadMoreTable.showingSubtitle": "显示", "xpack.siem.ml.score.anomalousEntityTitle": "异常实体", "xpack.siem.ml.table.timestampTitle": "时间戳", "xpack.siem.modalAllErrors.close.button": "关闭", From e81494fb5de95b96e988eb97b5d001213326d90d Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Wed, 2 Oct 2019 14:05:59 -0400 Subject: [PATCH 38/59] SR: SLM retention UI (#45193) --- .../client_integration/helpers/constant.ts | 7 +- .../helpers/http_requests.ts | 34 ++ .../client_integration/helpers/index.ts | 4 + .../helpers/policy_add.helpers.ts | 25 + .../helpers/policy_edit.helpers.ts | 26 + .../helpers/policy_form.helpers.ts | 56 +++ .../client_integration/helpers/providers.tsx | 10 +- .../helpers/setup_environment.ts | 9 +- .../__jest__/client_integration/home.test.ts | 70 ++- .../client_integration/policy_add.test.ts | 215 +++++++++ .../client_integration/policy_edit.test.ts | 136 ++++++ .../snapshot_restore/common/constants.ts | 7 + .../snapshot_restore/common/lib/index.ts | 2 + .../common/lib/policy_serialization.test.ts | 57 ++- .../common/lib/policy_serialization.ts | 40 +- .../common/lib/snapshot_serialization.ts | 76 ++- .../common/lib/time_serialization.test.ts | 29 ++ .../common/lib/time_serialization.ts | 33 ++ .../snapshot_restore/common/types/policy.ts | 22 +- .../snapshot_restore/common/types/snapshot.ts | 13 + .../public/app/components/index.ts | 4 + .../app/components/policy_form/navigation.tsx | 15 +- .../components/policy_form/policy_form.tsx | 19 +- .../app/components/policy_form/steps/index.ts | 1 + .../policy_form/steps/step_logistics.tsx | 2 +- .../policy_form/steps/step_retention.tsx | 238 +++++++++ .../policy_form/steps/step_review.tsx | 110 +++-- .../policy_form/steps/step_settings.tsx | 8 +- .../update_retention_modal_provider.tsx | 297 ++++++++++++ .../public/app/constants/index.ts | 4 + .../policy_details/tabs/tab_summary.tsx | 454 ++++++++++++------ .../sections/home/policy_list/policy_list.tsx | 23 +- .../policy_retention_schedule/index.ts | 7 + .../policy_retention_schedule.tsx | 175 +++++++ .../policy_list/policy_table/policy_table.tsx | 18 + .../snapshot_details/tabs/tab_failures.tsx | 4 +- .../app/sections/policy_add/policy_add.tsx | 11 +- .../app/sections/policy_edit/policy_edit.tsx | 39 +- .../documentation/documentation_links.ts | 22 +- .../app/services/http/policy_requests.ts | 24 +- .../public/app/services/text/text.ts | 27 +- .../services/validation/validate_policy.ts | 27 +- .../plugins/snapshot_restore/public/plugin.ts | 6 +- .../plugins/snapshot_restore/public/shim.ts | 2 - .../server/routes/api/policy.test.ts | 39 +- .../server/routes/api/policy.ts | 47 +- .../server/routes/api/register_routes.ts | 2 +- .../snapshot_restore/test/fixtures/index.ts | 1 + .../snapshot_restore/test/fixtures/policy.ts | 48 ++ .../translations/translations/ja-JP.json | 7 - .../translations/translations/zh-CN.json | 7 - x-pack/test_utils/testbed/types.ts | 2 +- 52 files changed, 2270 insertions(+), 291 deletions(-) create mode 100644 x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_add.helpers.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_edit.helpers.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_form.helpers.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_add.test.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_edit.test.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.test.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_retention.tsx create mode 100644 x-pack/legacy/plugins/snapshot_restore/public/app/components/update_retention_modal_provider.tsx create mode 100644 x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_retention_schedule/index.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_retention_schedule/policy_retention_schedule.tsx create mode 100644 x-pack/legacy/plugins/snapshot_restore/test/fixtures/policy.ts diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/constant.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/constant.ts index 372b5bc6f65296..1fb6aa8686b7e1 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/constant.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/constant.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getRepository } from '../../../test/fixtures'; +import { getRepository, getPolicy } from '../../../test/fixtures'; + export const REPOSITORY_NAME = 'my-test-repository'; export const REPOSITORY_EDIT = getRepository({ name: REPOSITORY_NAME }); + +export const POLICY_NAME = 'my-test-policy'; + +export const POLICY_EDIT = getPolicy({ name: POLICY_NAME }); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts index dd9d51a9990cc4..d9f2c1b510a145 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts @@ -72,6 +72,37 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ); }; + const setLoadIndicesResponse = (response: HttpResponse = {}) => { + const defaultResponse = { indices: [] }; + + server.respondWith( + 'GET', + `${API_BASE_PATH}policies/indices`, + response + ? mockResponse(defaultResponse, response) + : [200, { 'Content-Type': 'application/json' }, ''] + ); + }; + + const setAddPolicyResponse = (response?: HttpResponse, error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? JSON.stringify(error.body) : JSON.stringify(response); + + server.respondWith('PUT', `${API_BASE_PATH}policies`, [ + status, + { 'Content-Type': 'application/json' }, + body, + ]); + }; + + const setGetPolicyResponse = (response?: HttpResponse) => { + server.respondWith('GET', `${API_BASE_PATH}policy/:name`, [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(response), + ]); + }; + return { setLoadRepositoriesResponse, setLoadRepositoryTypesResponse, @@ -79,6 +110,9 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { setSaveRepositoryResponse, setLoadSnapshotsResponse, setGetSnapshotResponse, + setLoadIndicesResponse, + setAddPolicyResponse, + setGetPolicyResponse, }; }; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/index.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/index.ts index d8bb3d4c25e102..e6fea41d869286 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/index.ts @@ -7,6 +7,8 @@ import { setup as homeSetup } from './home.helpers'; import { setup as repositoryAddSetup } from './repository_add.helpers'; import { setup as repositoryEditSetup } from './repository_edit.helpers'; +import { setup as policyAddSetup } from './policy_add.helpers'; +import { setup as policyEditSetup } from './policy_edit.helpers'; export { nextTick, getRandomString, findTestSubject, TestBed } from '../../../../../../test_utils'; @@ -16,4 +18,6 @@ export const pageHelpers = { home: { setup: homeSetup }, repositoryAdd: { setup: repositoryAddSetup }, repositoryEdit: { setup: repositoryEditSetup }, + policyAdd: { setup: policyAddSetup }, + policyEdit: { setup: policyEditSetup }, }; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_add.helpers.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_add.helpers.ts new file mode 100644 index 00000000000000..ff59bd83dc1e83 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_add.helpers.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { registerTestBed, TestBedConfig } from '../../../../../../test_utils'; +import { PolicyAdd } from '../../../public/app/sections/policy_add'; +import { WithProviders } from './providers'; +import { formSetup, PolicyFormTestSubjects } from './policy_form.helpers'; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: ['/add_policy'], + componentRoutePath: '/add_policy', + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed( + WithProviders(PolicyAdd), + testBedConfig +); + +export const setup = formSetup.bind(null, initTestBed); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_edit.helpers.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_edit.helpers.ts new file mode 100644 index 00000000000000..b2c0e4242a3fd4 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_edit.helpers.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { registerTestBed, TestBedConfig } from '../../../../../../test_utils'; +import { PolicyEdit } from '../../../public/app/sections/policy_edit'; +import { WithProviders } from './providers'; +import { POLICY_NAME } from './constant'; +import { formSetup, PolicyFormTestSubjects } from './policy_form.helpers'; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [`/edit_policy/${POLICY_NAME}`], + componentRoutePath: '/edit_policy/:name', + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed( + WithProviders(PolicyEdit), + testBedConfig +); + +export const setup = formSetup.bind(null, initTestBed); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_form.helpers.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_form.helpers.ts new file mode 100644 index 00000000000000..302af7a1ec7f04 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_form.helpers.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 { TestBed, SetupFunc } from '../../../../../../test_utils'; + +export interface PolicyFormTestBed extends TestBed { + actions: { + clickNextButton: () => void; + clickSubmitButton: () => void; + }; +} + +export const formSetup = async ( + initTestBed: SetupFunc +): Promise => { + const testBed = await initTestBed(); + + // User actions + const clickNextButton = () => { + testBed.find('nextButton').simulate('click'); + }; + + const clickSubmitButton = () => { + testBed.find('submitButton').simulate('click'); + }; + + return { + ...testBed, + actions: { + clickNextButton, + clickSubmitButton, + }, + }; +}; + +export type PolicyFormTestSubjects = + | 'advancedCronInput' + | 'allIndicesToggle' + | 'backButton' + | 'deselectIndicesLink' + | 'expireAfterValueInput' + | 'expireAfterUnitSelect' + | 'ignoreUnavailableIndicesToggle' + | 'nameInput' + | 'maxCountInput' + | 'minCountInput' + | 'nextButton' + | 'pageTitle' + | 'savePolicyApiError' + | 'selectIndicesLink' + | 'showAdvancedCronLink' + | 'snapshotNameInput' + | 'submitButton'; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/providers.tsx b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/providers.tsx index 5257c030518bae..187d2da0d7a3db 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/providers.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/providers.tsx @@ -10,7 +10,15 @@ import { setAppDependencies } from '../../../public/app/index'; const { core, plugins } = createShim(); const appDependencies = { - core, + core: { + ...core, + chrome: { + ...core.chrome, + // mock getInjected() to return true + // this is used so the policy tab renders (slmUiEnabled config) + getInjected: () => true, + }, + }, plugins, }; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.ts index dcfcdb1031dd50..e914f06d8e16f2 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.ts @@ -9,11 +9,15 @@ import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { i18n } from '@kbn/i18n'; +import { docTitle } from 'ui/doc_title/doc_title'; import { httpService } from '../../../public/app/services/http'; -import { breadcrumbService } from '../../../public/app/services/navigation'; +import { breadcrumbService, docTitleService } from '../../../public/app/services/navigation'; import { textService } from '../../../public/app/services/text'; import { chrome } from '../../../public/test/mocks'; import { init as initHttpRequests } from './http_requests'; +import { uiMetricService } from '../../../public/app/services/ui_metric'; +import { documentationLinksService } from '../../../public/app/services/documentation'; +import { createUiStatsReporter } from '../../../../../../../src/legacy/core_plugins/ui_metric/public'; export const setupEnvironment = () => { httpService.init(axios.create({ adapter: axiosXhrAdapter }), { @@ -21,6 +25,9 @@ export const setupEnvironment = () => { }); breadcrumbService.init(chrome, {}); textService.init(i18n); + uiMetricService.init(createUiStatsReporter); + documentationLinksService.init('', ''); + docTitleService.init(docTitle.change); const { server, httpRequestsMockHelpers } = initHttpRequests(); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts index 1cbafab69da7c7..7f4860e74bafea 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts @@ -17,6 +17,7 @@ import { } from './helpers'; import { HomeTestBed } from './helpers/home.helpers'; import { REPOSITORY_NAME } from './helpers/constant'; +import moment from 'moment-timezone'; const { setup } = pageHelpers.home; @@ -51,7 +52,7 @@ describe.skip('', () => { test('should set the correct app title', () => { const { exists, find } = testBed; expect(exists('appTitle')).toBe(true); - expect(find('appTitle').text()).toEqual('Snapshot Repositories'); + expect(find('appTitle').text()).toEqual('Snapshot and Restore'); }); test('should display a loading while fetching the repositories', () => { @@ -63,7 +64,7 @@ describe.skip('', () => { test('should have a link to the documentation', () => { const { exists, find } = testBed; expect(exists('documentationLink')).toBe(true); - expect(find('documentationLink').text()).toBe('Snapshot docs'); + expect(find('documentationLink').text()).toBe('Snapshot and Restore docs'); }); describe('tabs', () => { @@ -77,14 +78,19 @@ describe.skip('', () => { }); }); - test('should have 2 tabs', () => { + test('should have 4 tabs', () => { const { find } = testBed; - expect(find('tab').length).toBe(2); - expect(find('tab').map(t => t.text())).toEqual(['Snapshots', 'Repositories']); + expect(find('tab').length).toBe(4); + expect(find('tab').map(t => t.text())).toEqual([ + 'Snapshots', + 'Repositories', + 'Policies', + 'Restore Status', + ]); }); - test('should navigate to snapshot list tab', () => { + test('should navigate to snapshot list tab', async () => { const { exists, actions } = testBed; expect(exists('repositoryList')).toBe(true); @@ -92,6 +98,12 @@ describe.skip('', () => { actions.selectTab('snapshots'); + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + testBed.component.update(); + }); + expect(exists('repositoryList')).toBe(false); expect(exists('snapshotList')).toBe(true); }); @@ -264,6 +276,11 @@ describe.skip('', () => { expect(exists('repositoryDetail')).toBe(false); await actions.clickRepositoryAt(0); + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + testBed.component.update(); + }); expect(exists('repositoryDetail')).toBe(true); }); @@ -454,14 +471,19 @@ describe.skip('', () => { const { tableCellsValues } = table.getMetaData('snapshotTable'); tableCellsValues.forEach((row, i) => { const snapshot = snapshots[i]; + const startTime = moment(new Date(snapshot.startTimeInMillis)); + const timezone = moment.tz.guess(); + expect(row).toEqual([ + '', // Checkbox snapshot.snapshot, // Snapshot REPOSITORY_NAME, // Repository - 'foo', // TODO: fix this with FormattedDateTime value - `${Math.ceil(snapshot.durationInMillis / 1000).toString()}s`, // Duration snapshot.indices.length.toString(), // Indices snapshot.shards.total.toString(), // Shards snapshot.shards.failed.toString(), // Failed shards + startTime.tz(timezone).format('MMMM D, YYYY h:mm A z'), // Start time + `${Math.ceil(snapshot.durationInMillis / 1000).toString()}s`, // Duration + '', ]); }); }); @@ -590,22 +612,38 @@ describe.skip('', () => { describe('summary tab', () => { test('should set the correct summary values', () => { + const { + version, + versionId, + uuid, + indices, + endTimeInMillis, + startTimeInMillis, + } = snapshot1; + const { find } = testBed; + const startTime = moment(new Date(startTimeInMillis)); + const endTime = moment(new Date(endTimeInMillis)); + const timezone = moment.tz.guess(); expect(find('snapshotDetail.version.value').text()).toBe( - `${snapshot1.version} / ${snapshot1.versionId}` + `${version} / ${versionId}` ); - expect(find('snapshotDetail.uuid.value').text()).toBe(snapshot1.uuid); + expect(find('snapshotDetail.uuid.value').text()).toBe(uuid); expect(find('snapshotDetail.state.value').text()).toBe('Snapshot complete'); expect(find('snapshotDetail.includeGlobalState.value').text()).toBe('Yes'); expect(find('snapshotDetail.indices.title').text()).toBe( - `Indices (${snapshot1.indices.length})` + `Indices (${indices.length})` + ); + expect(find('snapshotDetail.indices.value').text()).toContain( + indices.splice(0, 10).join('') + ); + expect(find('snapshotDetail.startTime.value').text()).toBe( + startTime.tz(timezone).format('MMMM D, YYYY h:mm A z') ); - expect(find('snapshotDetail.indices.value').text()).toBe( - snapshot1.indices.join('') + expect(find('snapshotDetail.endTime.value').text()).toBe( + endTime.tz(timezone).format('MMMM D, YYYY h:mm A z') ); - expect(find('snapshotDetail.startTime.value').text()).toBe('foo'); // TODO: fix this with FormattedDateTime value - expect(find('snapshotDetail.endTime.value').text()).toBe('foo'); // TODO: fix this with FormattedDateTime value }); test('should indicate the different snapshot states', async () => { @@ -647,7 +685,7 @@ describe.skip('', () => { [SNAPSHOT_STATE.INCOMPATIBLE]: 'Incompatible version ', }; - // Call sequencially each state and verify that the message is ok + // Call sequentially each state and verify that the message is ok return Object.entries(mapStateToMessage).reduce((promise, [state, message]) => { return promise.then(async () => expectMessageForSnapshotState(state, message)); }, Promise.resolve()); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_add.test.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_add.test.ts new file mode 100644 index 00000000000000..19feb85e4f04ee --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_add.test.ts @@ -0,0 +1,215 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import * as fixtures from '../../test/fixtures'; + +import { setupEnvironment, pageHelpers, nextTick, getRandomString } from './helpers'; +import { PolicyFormTestBed } from './helpers/policy_form.helpers'; +import { DEFAULT_POLICY_SCHEDULE } from '../../public/app/constants'; + +const { setup } = pageHelpers.policyAdd; + +jest.mock('ui/i18n', () => { + const I18nContext = ({ children }: any) => children; + return { I18nContext }; +}); + +const POLICY_NAME = 'my_policy'; +const SNAPSHOT_NAME = 'my_snapshot'; +const MIN_COUNT = '5'; +const MAX_COUNT = '10'; +const EXPIRE_AFTER_VALUE = '30'; +const repository = fixtures.getRepository({ name: `a${getRandomString()}`, type: 'fs' }); + +// We need to skip the tests until react 16.9.0 is released +// which supports asynchronous code inside act() +describe.skip('', () => { + let testBed: PolicyFormTestBed; + + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + describe('on component mount', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadRepositoriesResponse({ repositories: [repository] }); + httpRequestsMockHelpers.setLoadIndicesResponse({ indices: ['my_index'] }); + + testBed = await setup(); + await nextTick(); + testBed.component.update(); + }); + + test('should set the correct page title', () => { + const { exists, find } = testBed; + expect(exists('pageTitle')).toBe(true); + expect(find('pageTitle').text()).toEqual('Create policy'); + }); + + test('should not let the user go to the next step if required fields are missing', () => { + const { find } = testBed; + + expect(find('nextButton').props().disabled).toBe(true); + }); + + describe('form validation', () => { + describe('logistics (step 1)', () => { + test('should require a policy name', async () => { + const { form, find } = testBed; + + form.setInputValue('nameInput', ''); + find('nameInput').simulate('blur'); + + expect(form.getErrorsMessages()).toEqual(['Policy name is required.']); + }); + + test('should require a snapshot name', () => { + const { form, find } = testBed; + + form.setInputValue('snapshotNameInput', ''); + find('snapshotNameInput').simulate('blur'); + + expect(form.getErrorsMessages()).toEqual(['Snapshot name is required.']); + }); + + it('should require a schedule', () => { + const { form, find } = testBed; + + find('showAdvancedCronLink').simulate('click'); + form.setInputValue('advancedCronInput', ''); + find('advancedCronInput').simulate('blur'); + + expect(form.getErrorsMessages()).toEqual(['Schedule is required.']); + }); + }); + + describe('snapshot settings (step 2)', () => { + beforeEach(() => { + const { form, actions } = testBed; + // Complete step 1 + form.setInputValue('nameInput', POLICY_NAME); + form.setInputValue('snapshotNameInput', SNAPSHOT_NAME); + actions.clickNextButton(); + }); + + test('should require at least one index', async () => { + const { find, form, component } = testBed; + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + // Toggle "All indices" switch + form.toggleEuiSwitch('allIndicesToggle', false); + await nextTick(); + component.update(); + }); + + // Deselect all indices from list + find('deselectIndicesLink').simulate('click'); + + expect(form.getErrorsMessages()).toEqual(['You must select at least one index.']); + }); + }); + + describe('retention (step 3)', () => { + beforeEach(() => { + const { form, actions } = testBed; + // Complete step 1 + form.setInputValue('nameInput', POLICY_NAME); + form.setInputValue('snapshotNameInput', SNAPSHOT_NAME); + actions.clickNextButton(); + + // Complete step 2 + actions.clickNextButton(); + }); + + test('should not allow the minimum count be greater than the maximum count', () => { + const { find, form } = testBed; + + form.setInputValue('minCountInput', MAX_COUNT + 1); + find('minCountInput').simulate('blur'); + + form.setInputValue('maxCountInput', MAX_COUNT); + find('maxCountInput').simulate('blur'); + + expect(form.getErrorsMessages()).toEqual(['Min count cannot be greater than max count.']); + }); + }); + }); + + describe('form payload & api errors', () => { + beforeEach(async () => { + const { actions, form } = testBed; + + // Complete step 1 + form.setInputValue('nameInput', POLICY_NAME); + form.setInputValue('snapshotNameInput', SNAPSHOT_NAME); + actions.clickNextButton(); + + // Complete step 2 + actions.clickNextButton(); + + // Complete step 3 + form.setInputValue('expireAfterValueInput', EXPIRE_AFTER_VALUE); + form.setInputValue('minCountInput', MIN_COUNT); + form.setInputValue('maxCountInput', MAX_COUNT); + actions.clickNextButton(); + }); + + it('should send the correct payload', async () => { + const { actions } = testBed; + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + actions.clickSubmitButton(); + await nextTick(); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + const expected = { + config: {}, + name: POLICY_NAME, + repository: repository.name, + retention: { + expireAfterUnit: 'd', // default + expireAfterValue: Number(EXPIRE_AFTER_VALUE), + maxCount: Number(MAX_COUNT), + minCount: Number(MIN_COUNT), + }, + schedule: DEFAULT_POLICY_SCHEDULE, + snapshotName: SNAPSHOT_NAME, + }; + + expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + }); + + it('should surface the API errors from the put HTTP request', async () => { + const { component, actions, find, exists } = testBed; + + const error = { + status: 409, + error: 'Conflict', + message: `There is already a policy with name '${POLICY_NAME}'`, + }; + + httpRequestsMockHelpers.setAddPolicyResponse(undefined, { body: error }); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + actions.clickSubmitButton(); + await nextTick(); + component.update(); + }); + + expect(exists('savePolicyApiError')).toBe(true); + expect(find('savePolicyApiError').text()).toContain(error.message); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_edit.test.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_edit.test.ts new file mode 100644 index 00000000000000..efcb338e6d2684 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_edit.test.ts @@ -0,0 +1,136 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import { setupEnvironment, pageHelpers, nextTick } from './helpers'; +import { PolicyForm } from '../../public/app/components/policy_form'; +import { PolicyFormTestBed } from './helpers/policy_form.helpers'; +import { POLICY_EDIT } from './helpers/constant'; + +const { setup } = pageHelpers.policyEdit; +const { setup: setupPolicyAdd } = pageHelpers.policyAdd; + +const EXPIRE_AFTER_VALUE = '5'; +const EXPIRE_AFTER_UNIT = 'm'; + +jest.mock('ui/i18n', () => { + const I18nContext = ({ children }: any) => children; + return { I18nContext }; +}); + +// We need to skip the tests until react 16.9.0 is released +// which supports asynchronous code inside act() +describe.skip('', () => { + let testBed: PolicyFormTestBed; + let testBedPolicyAdd: PolicyFormTestBed; + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + describe('on component mount', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setGetPolicyResponse({ policy: POLICY_EDIT }); + httpRequestsMockHelpers.setLoadIndicesResponse({ indices: ['my_index'] }); + httpRequestsMockHelpers.setLoadRepositoriesResponse({ + repositories: [{ name: POLICY_EDIT.repository }], + }); + + testBed = await setup(); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + testBed.component.update(); + }); + }); + + test('should set the correct page title', () => { + const { exists, find } = testBed; + expect(exists('pageTitle')).toBe(true); + expect(find('pageTitle').text()).toEqual('Edit policy'); + }); + + /** + * As the "edit" policy component uses the same form underneath that + * the "create" policy, we won't test it again but simply make sure that + * the same form component is indeed shared between the 2 app sections. + */ + test('should use the same Form component as the "" section', async () => { + testBedPolicyAdd = await setupPolicyAdd(); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + testBedPolicyAdd.component.update(); + }); + + const formEdit = testBed.component.find(PolicyForm); + const formAdd = testBedPolicyAdd.component.find(PolicyForm); + + expect(formEdit.length).toBe(1); + expect(formAdd.length).toBe(1); + }); + + test('should disable the policy name field', () => { + const { find } = testBed; + + const nameInput = find('nameInput'); + expect(nameInput.props().disabled).toEqual(true); + }); + + describe('form payload', () => { + beforeEach(async () => { + const { form, actions } = testBed; + + const { snapshotName } = POLICY_EDIT; + + // Complete step 1 + form.setInputValue('snapshotNameInput', `${snapshotName}-edited`); + actions.clickNextButton(); + + // Complete step 2 + // console.log(testBed.component.debug()); + form.toggleEuiSwitch('ignoreUnavailableIndicesToggle'); + actions.clickNextButton(); + + // Complete step 3 + form.setInputValue('expireAfterValueInput', EXPIRE_AFTER_VALUE); + form.setInputValue('expireAfterUnitSelect', EXPIRE_AFTER_UNIT); + actions.clickNextButton(); + }); + + it('should send the correct payload with changed values', async () => { + const { actions } = testBed; + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + actions.clickSubmitButton(); + await nextTick(); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + const expected = { + ...POLICY_EDIT, + ...{ + config: { + ignoreUnavailable: true, + }, + retention: { + expireAfterValue: Number(EXPIRE_AFTER_VALUE), + expireAfterUnit: EXPIRE_AFTER_UNIT, + }, + snapshotName: `${POLICY_EDIT.snapshotName}-edited`, + }, + }; + expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/snapshot_restore/common/constants.ts b/x-pack/legacy/plugins/snapshot_restore/common/constants.ts index a881bf3081c5e6..f04a5d6dc6e75b 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/constants.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/constants.ts @@ -54,3 +54,10 @@ export const APP_REQUIRED_CLUSTER_PRIVILEGES = [ ]; export const APP_RESTORE_INDEX_PRIVILEGES = ['monitor']; export const APP_SLM_CLUSTER_PRIVILEGES = ['manage_slm']; + +export const TIME_UNITS: { [key: string]: 'd' | 'h' | 'm' | 's' } = { + DAY: 'd', + HOUR: 'h', + MINUTE: 'm', + SECOND: 's', +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/index.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/index.ts index bede2689bb855c..579dae02659392 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/lib/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/index.ts @@ -12,5 +12,7 @@ export { deserializeSnapshotDetails, deserializeSnapshotConfig, serializeSnapshotConfig, + deserializeSnapshotRetention, + serializeSnapshotRetention, } from './snapshot_serialization'; export { deserializePolicy, serializePolicy } from './policy_serialization'; diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.test.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.test.ts index 86adde4db7f999..9ce9367bc0e0ea 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.test.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { deserializePolicy } from './policy_serialization'; +import { deserializePolicy, serializePolicy } from './policy_serialization'; describe('repository_serialization', () => { describe('deserializePolicy()', () => { @@ -25,6 +25,11 @@ describe('repository_serialization', () => { foo: 'bar', }, }, + retention: { + expire_after: '14d', + max_count: 30, + min_count: 4, + }, }, next_execution: '2019-07-11T01:30:00.000Z', next_execution_millis: 1562722200000, @@ -45,6 +50,12 @@ describe('repository_serialization', () => { foo: 'bar', }, }, + retention: { + expireAfterValue: 14, + expireAfterUnit: 'd', + maxCount: 30, + minCount: 4, + }, nextExecution: '2019-07-11T01:30:00.000Z', nextExecutionMillis: 1562722200000, }); @@ -112,4 +123,48 @@ describe('repository_serialization', () => { }); }); }); + + describe('serializePolicy()', () => { + it('should serialize a slm policy', () => { + expect( + serializePolicy({ + name: 'my-snapshot-policy', + snapshotName: 'my-backups-snapshots', + schedule: '0 30 1 * * ?', + repository: 'my-backups', + config: { + indices: ['kibana-*'], + includeGlobalState: false, + ignoreUnavailable: false, + metadata: { + foo: 'bar', + }, + }, + retention: { + expireAfterValue: 14, + expireAfterUnit: 'd', + maxCount: 30, + minCount: 4, + }, + }) + ).toEqual({ + name: 'my-backups-snapshots', + schedule: '0 30 1 * * ?', + repository: 'my-backups', + config: { + indices: ['kibana-*'], + include_global_state: false, + ignore_unavailable: false, + metadata: { + foo: 'bar', + }, + }, + retention: { + expire_after: '14d', + max_count: 30, + min_count: 4, + }, + }); + }); + }); }); diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.ts index dc527656705406..4652ac4bc5cc45 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.ts @@ -4,19 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ import { SlmPolicy, SlmPolicyEs, SlmPolicyPayload } from '../types'; -import { deserializeSnapshotConfig, serializeSnapshotConfig } from './'; +import { + deserializeSnapshotConfig, + serializeSnapshotConfig, + deserializeSnapshotRetention, + serializeSnapshotRetention, +} from './'; export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolicy => { const { version, modified_date: modifiedDate, modified_date_millis: modifiedDateMillis, - policy: { name: snapshotName, schedule, repository, config }, + policy: { name: snapshotName, schedule, repository, config, retention }, next_execution: nextExecution, next_execution_millis: nextExecutionMillis, last_failure: lastFailure, last_success: lastSuccess, in_progress: inProgress, + stats, } = esPolicy; const policy: SlmPolicy = { @@ -35,6 +41,10 @@ export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolic policy.config = deserializeSnapshotConfig(config); } + if (retention) { + policy.retention = deserializeSnapshotRetention(retention); + } + if (lastFailure) { const { snapshot_name: failureSnapshotName, @@ -82,11 +92,27 @@ export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolic }; } + if (stats) { + const { + snapshots_taken: snapshotsTaken, + snapshots_failed: snapshotsFailed, + snapshots_deleted: snapshotsDeleted, + snapshot_deletion_failures: snapshotDeletionFailures, + } = stats; + + policy.stats = { + snapshotsTaken, + snapshotsFailed, + snapshotsDeleted, + snapshotDeletionFailures, + }; + } + return policy; }; export const serializePolicy = (policy: SlmPolicyPayload): SlmPolicyEs['policy'] => { - const { snapshotName: name, schedule, repository, config } = policy; + const { snapshotName: name, schedule, repository, config, retention } = policy; const policyEs: SlmPolicyEs['policy'] = { name, schedule, @@ -97,5 +123,13 @@ export const serializePolicy = (policy: SlmPolicyPayload): SlmPolicyEs['policy'] policyEs.config = serializeSnapshotConfig(config); } + if (retention) { + const serializedRetention = serializeSnapshotRetention(retention); + + if (serializedRetention) { + policyEs.retention = serializeSnapshotRetention(retention); + } + } + return policyEs; }; diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/snapshot_serialization.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/snapshot_serialization.ts index b1f6d2005a2e3c..50fdef4175787c 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/lib/snapshot_serialization.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/snapshot_serialization.ts @@ -6,7 +6,16 @@ import { sortBy } from 'lodash'; -import { SnapshotDetails, SnapshotDetailsEs, SnapshotConfig, SnapshotConfigEs } from '../types'; +import { + SnapshotDetails, + SnapshotDetailsEs, + SnapshotConfig, + SnapshotConfigEs, + SnapshotRetention, + SnapshotRetentionEs, +} from '../types'; + +import { deserializeTime, serializeTime } from './time_serialization'; export function deserializeSnapshotDetails( repository: string, @@ -128,3 +137,68 @@ export function serializeSnapshotConfig(snapshotConfig: SnapshotConfig): Snapsho return config; }, {}); } + +export function deserializeSnapshotRetention( + snapshotRetentionEs: SnapshotRetentionEs +): SnapshotRetention { + const { + expire_after: expireAfter, + max_count: maxCount, + min_count: minCount, + } = snapshotRetentionEs; + + let expireAfterValue; + let expireAfterUnit; + + if (expireAfter) { + const { timeValue, timeUnit } = deserializeTime(expireAfter); + + if (timeValue && timeUnit) { + expireAfterValue = timeValue; + expireAfterUnit = timeUnit; + } + } + + const snapshotRetention: SnapshotRetention = { + expireAfterValue, + expireAfterUnit, + maxCount, + minCount, + }; + + return Object.entries(snapshotRetention).reduce((retention: any, [key, value]) => { + if (value !== undefined) { + retention[key] = value; + } + return retention; + }, {}); +} + +export function serializeSnapshotRetention( + snapshotRetention: SnapshotRetention +): SnapshotRetentionEs | undefined { + const { expireAfterValue, expireAfterUnit, minCount, maxCount } = snapshotRetention; + + const snapshotRetentionEs: SnapshotRetentionEs = { + expire_after: + expireAfterValue && expireAfterUnit + ? serializeTime(expireAfterValue, expireAfterUnit) + : undefined, + min_count: !minCount ? undefined : minCount, + max_count: !maxCount ? undefined : maxCount, + }; + + const flattenedSnapshotRetentionEs = Object.entries(snapshotRetentionEs).reduce( + (retention: any, [key, value]) => { + if (value !== undefined) { + retention[key] = value; + } + return retention; + }, + {} + ); + + return Object.entries(flattenedSnapshotRetentionEs).length + ? flattenedSnapshotRetentionEs + : undefined; +} diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.test.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.test.ts new file mode 100644 index 00000000000000..f661c0c585852e --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { deserializeTime, serializeTime } from './time_serialization'; +import { TIME_UNITS } from '../constants'; + +describe('time_serialization', () => { + describe('deserializeTime()', () => { + it('should deserialize valid ES time', () => { + Object.values(TIME_UNITS).forEach(unit => { + expect(deserializeTime(`15${unit}`)).toEqual({ + timeValue: 15, + timeUnit: unit, + }); + }); + }); + it('should return an empty object if time unit is invalid', () => { + expect(deserializeTime('15foobar')).toEqual({}); + expect(deserializeTime('15minutes')).toEqual({}); + }); + }); + describe('serializeTime()', () => { + it('should serialize ES time', () => { + expect(serializeTime(15, 'd')).toEqual('15d'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.ts new file mode 100644 index 00000000000000..5f65ec861e81b1 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TIME_UNITS } from '../constants'; + +export const deserializeTime = (time: string) => { + const timeUnits = Object.values(TIME_UNITS); + + const timeUnit = timeUnits.find(unit => { + const unitIndex = time.indexOf(unit); + return unitIndex !== -1 && unitIndex === time.length - 1; + }); + + if (timeUnit) { + const timeValue = Number(time.replace(timeUnit, '')); + + if (!isNaN(timeValue)) { + return { + timeValue, + timeUnit, + }; + } + } + + return {}; +}; + +export const serializeTime = (timeValue: number, timeUnit: string) => { + return `${timeValue}${timeUnit}`; // e.g., '15d' +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/common/types/policy.ts b/x-pack/legacy/plugins/snapshot_restore/common/types/policy.ts index 888cad13d213b7..ed67b1eb77063d 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/types/policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/types/policy.ts @@ -4,14 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotConfig, SnapshotConfigEs } from './snapshot'; - +import { + SnapshotConfig, + SnapshotConfigEs, + SnapshotRetention, + SnapshotRetentionEs, +} from './snapshot'; export interface SlmPolicyPayload { name: string; snapshotName: string; schedule: string; repository: string; config?: SnapshotConfig; + retention?: SnapshotRetention; } export interface SlmPolicy extends SlmPolicyPayload { @@ -34,6 +39,12 @@ export interface SlmPolicy extends SlmPolicyPayload { inProgress?: { snapshotName: string; }; + stats?: { + snapshotsTaken: number; + snapshotsFailed: number; + snapshotsDeleted: number; + snapshotDeletionFailures: number; + }; } export interface SlmPolicyEs { @@ -45,6 +56,7 @@ export interface SlmPolicyEs { schedule: string; repository: string; config?: SnapshotConfigEs; + retention?: SnapshotRetentionEs; }; next_execution: string; next_execution_millis: number; @@ -66,4 +78,10 @@ export interface SlmPolicyEs { start_time: string; start_time_millis: number; }; + stats?: { + snapshots_taken: number; + snapshots_failed: number; + snapshots_deleted: number; + snapshot_deletion_failures: number; + }; } diff --git a/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts b/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts index dd561bd50d352c..46713c937fd3f0 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts @@ -79,3 +79,16 @@ interface SnapshotDetailsShardsStatusEs { failed: number; successful: number; } + +export interface SnapshotRetention { + expireAfterValue?: number | ''; + expireAfterUnit?: string; + maxCount?: number | ''; + minCount?: number | ''; +} + +export interface SnapshotRetentionEs { + expire_after?: string; + max_count?: number; + min_count?: number; +} diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts index 85d543642a92bf..e94aa9287e0dd3 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts @@ -16,4 +16,8 @@ export { SnapshotDeleteProvider } from './snapshot_delete_provider'; export { RestoreSnapshotForm } from './restore_snapshot_form'; export { PolicyExecuteProvider } from './policy_execute_provider'; export { PolicyDeleteProvider } from './policy_delete_provider'; +export { + UpdateRetentionModalProvider, + UpdateRetentionSetting, +} from './update_retention_modal_provider'; export { PolicyForm } from './policy_form'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/navigation.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/navigation.tsx index ba9877a9e9f411..6bb376b9298ed2 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/navigation.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/navigation.tsx @@ -41,14 +41,23 @@ export const PolicyNavigation: React.FunctionComponent = ({ onClick: () => updateCurrentStep(2), }, { - title: i18n.translate('xpack.snapshotRestore.policyForm.navigation.stepReviewName', { - defaultMessage: 'Review', + title: i18n.translate('xpack.snapshotRestore.policyForm.navigation.stepRetentionName', { + defaultMessage: 'Snapshot retention', }), - isComplete: maxCompletedStep >= 2, + isComplete: maxCompletedStep >= 3, isSelected: currentStep === 3, disabled: maxCompletedStep < 2, onClick: () => updateCurrentStep(3), }, + { + title: i18n.translate('xpack.snapshotRestore.policyForm.navigation.stepReviewName', { + defaultMessage: 'Review', + }), + isComplete: maxCompletedStep >= 3, + isSelected: currentStep === 4, + disabled: maxCompletedStep < 3, + onClick: () => updateCurrentStep(4), + }, ]; return ; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/policy_form.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/policy_form.tsx index 6c631ab8e6c69b..7e55cee63a0ac4 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/policy_form.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/policy_form.tsx @@ -13,9 +13,15 @@ import { EuiSpacer, } from '@elastic/eui'; import { SlmPolicyPayload } from '../../../../common/types'; +import { TIME_UNITS } from '../../../../common/constants'; import { PolicyValidation, validatePolicy } from '../../services/validation'; import { useAppDependencies } from '../../index'; -import { PolicyStepLogistics, PolicyStepSettings, PolicyStepReview } from './steps'; +import { + PolicyStepLogistics, + PolicyStepSettings, + PolicyStepRetention, + PolicyStepReview, +} from './steps'; import { PolicyNavigation } from './navigation'; interface Props { @@ -53,7 +59,8 @@ export const PolicyForm: React.FunctionComponent = ({ const stepMap: { [key: number]: any } = { 1: PolicyStepLogistics, 2: PolicyStepSettings, - 3: PolicyStepReview, + 3: PolicyStepRetention, + 4: PolicyStepReview, }; const CurrentStepForm = stepMap[currentStep]; @@ -63,6 +70,11 @@ export const PolicyForm: React.FunctionComponent = ({ config: { ...(originalPolicy.config || {}), }, + retention: { + ...(originalPolicy.retention || { + expireAfterUnit: TIME_UNITS.DAY, + }), + }, }); // Policy validation state @@ -161,7 +173,9 @@ export const PolicyForm: React.FunctionComponent = ({ fill iconType="arrowRight" onClick={() => onNext()} + iconSide="right" disabled={!validation.isValid} + data-test-subj="nextButton" > = ({ iconType="check" onClick={() => savePolicy()} isLoading={isSaving} + data-test-subj="submitButton" > {isSaving ? ( = ({ }} onBlur={() => setTouched({ ...touched, schedule: true })} placeholder={DEFAULT_POLICY_SCHEDULE} - data-test-subj="snapshotNameInput" + data-test-subj="advancedCronInput" /> diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_retention.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_retention.tsx new file mode 100644 index 00000000000000..b32d5796501342 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_retention.tsx @@ -0,0 +1,238 @@ +/* + * 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, { Fragment, useState } from 'react'; + +import { + EuiDescribedFormGroup, + EuiTitle, + EuiFormRow, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiFieldNumber, + EuiSelect, +} from '@elastic/eui'; + +import { SlmPolicyPayload } from '../../../../../common/types'; +import { TIME_UNITS } from '../../../../../common/constants'; +import { documentationLinksService } from '../../../services/documentation'; +import { useAppDependencies } from '../../../index'; +import { StepProps } from './'; +import { textService } from '../../../services/text'; + +const getExpirationTimeOptions = (unitSize = '0') => + Object.entries(TIME_UNITS).map(([_key, value]) => ({ + text: textService.getTimeUnitLabel(value, unitSize), + value, + })); + +export const PolicyStepRetention: React.FunctionComponent = ({ + policy, + updatePolicy, + errors, +}) => { + const { + core: { i18n }, + } = useAppDependencies(); + const { FormattedMessage } = i18n; + + const { retention = {} } = policy; + + const updatePolicyRetention = (updatedFields: Partial): void => { + const newRetention = { ...retention, ...updatedFields }; + updatePolicy({ + retention: newRetention, + }); + }; + + // State for touched inputs + const [touched, setTouched] = useState({ + expireAfterValue: false, + minCount: false, + maxCount: false, + }); + + const renderExpireAfterField = () => ( + +

+ +

+ + } + description={ + + } + idAria="expirationDescription" + fullWidth + > + + } + describedByIds={['expirationDescription']} + isInvalid={touched.expireAfterValue && Boolean(errors.expireAfterValue)} + error={errors.expireAfter} + fullWidth + > + + + setTouched({ ...touched, expireAfterValue: true })} + onChange={e => { + const value = e.target.value; + updatePolicyRetention({ + expireAfterValue: value !== '' ? Number(value) : value, + }); + }} + data-test-subj="expireAfterValueInput" + /> + + + { + updatePolicyRetention({ + expireAfterUnit: e.target.value, + }); + }} + data-test-subj="expireAfterUnitSelect" + /> + + + +
+ ); + + const renderCountFields = () => ( + +

+ +

+ + } + description={ + + } + idAria="countDescription" + fullWidth + > + + + + } + describedByIds={['countDescription']} + isInvalid={touched.minCount && Boolean(errors.minCount)} + error={errors.minCount} + fullWidth + > + setTouched({ ...touched, minCount: true })} + onChange={e => { + const value = e.target.value; + updatePolicyRetention({ + minCount: value !== '' ? Number(value) : value, + }); + }} + data-test-subj="minCountInput" + /> + + + + + } + describedByIds={['countDescription']} + error={errors.maxCount} + fullWidth + > + setTouched({ ...touched, maxCount: true })} + onChange={e => { + const value = e.target.value; + updatePolicyRetention({ + maxCount: value !== '' ? Number(value) : value, + }); + }} + data-test-subj="maxCountInput" + /> + + + +
+ ); + + return ( + + {/* Step title and doc link */} + + + +

+ +

+
+
+ + + + + + +
+ + {renderExpireAfterField()} + {renderCountFields()} +
+ ); +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_review.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_review.tsx index 2599aa4b19bb1c..b2f9a4231e8539 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_review.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_review.tsx @@ -13,11 +13,11 @@ import { EuiDescriptionListDescription, EuiSpacer, EuiTabbedContent, + EuiText, EuiTitle, EuiLink, EuiIcon, EuiToolTip, - EuiText, } from '@elastic/eui'; import { serializePolicy } from '../../../../../common/lib'; import { useAppDependencies } from '../../../index'; @@ -31,7 +31,7 @@ export const PolicyStepReview: React.FunctionComponent = ({ core: { i18n }, } = useAppDependencies(); const { FormattedMessage } = i18n; - const { name, snapshotName, schedule, repository, config } = policy; + const { name, snapshotName, schedule, repository, config, retention } = policy; const { indices, includeGlobalState, ignoreUnavailable, partial } = config || { indices: undefined, includeGlobalState: undefined, @@ -48,8 +48,27 @@ export const PolicyStepReview: React.FunctionComponent = ({ const hiddenIndicesCount = displayIndices && displayIndices.length > 10 ? displayIndices.length - 10 : 0; + const serializedPolicy = serializePolicy(policy); + const { retention: serializedRetention } = serializedPolicy; + + const EditStepTooltip = ({ step }: { step: number }) => ( + + } + > + updateCurrentStep(step)}> + + + + ); + const renderSummaryTab = () => ( + {/* Logistics summary */}

@@ -57,18 +76,7 @@ export const PolicyStepReview: React.FunctionComponent = ({ id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.sectionLogisticsTitle" defaultMessage="Logistics" />{' '} - - } - > - updateCurrentStep(1)}> - - - +

@@ -125,24 +133,15 @@ export const PolicyStepReview: React.FunctionComponent = ({ + + {/* Snapshot settings summary */}

{' '} - - } - > - updateCurrentStep(2)}> - - - +

@@ -279,12 +278,69 @@ export const PolicyStepReview: React.FunctionComponent = ({ + + {/* Retention summary */} + {serializedRetention ? ( + + + +

+ {' '} + +

+
+ + + + {retention!.expireAfterValue && ( + + + + + + {retention!.expireAfterValue} + {retention!.expireAfterUnit} + + + )} + {retention!.minCount && ( + + + + + {retention!.minCount} + + )} + {retention!.maxCount && ( + + + + + {retention!.maxCount} + + )} + +
+ ) : null}
); const renderRequestTab = () => { const endpoint = `PUT _slm/policy/${name}`; - const json = JSON.stringify(serializePolicy(policy), null, 2); + const json = JSON.stringify(serializedPolicy, null, 2); + return ( diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_settings.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_settings.tsx index 642440a8c5e911..6f1b2ed2cef4dc 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_settings.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_settings.tsx @@ -109,6 +109,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ /> } checked={isAllIndices} + data-test-subj="allIndicesToggle" onChange={e => { const isChecked = e.target.checked; setIsAllIndices(isChecked); @@ -162,6 +163,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ { setSelectIndicesMode('list'); updatePolicyConfig({ indices: indicesSelection }); @@ -186,6 +188,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ selectOrDeselectAllLink: config.indices && config.indices.length > 0 ? ( { // TODO: Change this to setIndicesOptions() when https://github.com/elastic/eui/issues/2071 is fixed indicesOptions.forEach((option: Option) => { @@ -313,6 +316,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ fullWidth > = ({ > } @@ -397,6 +402,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ fullWidth > React.ReactElement; +} + +export type UpdateRetentionSetting = ( + retentionSchedule?: string, + onSuccess?: OnSuccessCallback +) => void; + +type OnSuccessCallback = () => void; + +export const UpdateRetentionModalProvider: React.FunctionComponent = ({ children }) => { + const { + core: { + i18n, + notification: { toastNotifications }, + }, + } = useAppDependencies(); + const { FormattedMessage } = i18n; + + const [retentionSchedule, setRetentionSchedule] = useState(DEFAULT_RETENTION_SCHEDULE); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [isAdvancedCronVisible, setIsAdvancedCronVisible] = useState(false); + + const onSuccessCallback = useRef(null); + + const [simpleCron, setSimpleCron] = useState<{ + expression: string; + frequency: string; + }>({ + expression: DEFAULT_RETENTION_SCHEDULE, + frequency: DEFAULT_RETENTION_FREQUENCY, + }); + + const [fieldToPreferredValueMap, setFieldToPreferredValueMap] = useState({}); + + const [isInvalid, setIsInvalid] = useState(false); + + const updateRetentionPrompt: UpdateRetentionSetting = ( + originalRetentionSchedule, + onSuccess = () => undefined + ) => { + setIsModalOpen(true); + + setIsAdvancedCronVisible( + Boolean(originalRetentionSchedule && originalRetentionSchedule !== DEFAULT_RETENTION_SCHEDULE) + ); + + if (originalRetentionSchedule) { + setIsEditing(true); + setRetentionSchedule(originalRetentionSchedule); + } + + onSuccessCallback.current = onSuccess; + }; + + const closeModal = () => { + setIsModalOpen(false); + }; + + const updateRetentionSetting = async () => { + if (!retentionSchedule) { + setIsInvalid(true); + return; + } + + setIsSaving(true); + setSaveError(null); + + const { error } = await updateRetentionSchedule(retentionSchedule); + + setIsSaving(false); + + if (error) { + setSaveError(error); + } else { + closeModal(); + + toastNotifications.addSuccess( + i18n.translate( + 'xpack.snapshotRestore.policyForm.stepRetention.policyUpdateRetentionSuccessMessage', + { + defaultMessage: 'Updated retention schedule', + } + ) + ); + + if (onSuccessCallback.current) { + onSuccessCallback.current(); + } + } + }; + + const renderModal = () => { + if (!isModalOpen) { + return null; + } + + return ( + + + + + {isEditing ? ( + + ) : ( + + )} + + + + + {saveError && ( + + + } + color="danger" + iconType="alert" + > + {saveError.data && saveError.data.message ? ( +

{saveError.data.message}

+ ) : null} +
+ +
+ )} + {isAdvancedCronVisible ? ( + + + } + isInvalid={isInvalid} + error={i18n.translate( + 'xpack.snapshotRestore.policyForm.stepRetention.policyUpdateRetentionScheduleFieldErrorMessage', + { + defaultMessage: 'Retention schedule is required.', + } + )} + helpText={ + + +
+ ), + }} + /> + } + fullWidth + > + setRetentionSchedule(e.target.value)} + /> + + + + + + { + setIsAdvancedCronVisible(false); + setRetentionSchedule(simpleCron.expression); + }} + data-test-subj="showBasicCronLink" + > + + + +
+ ) : ( + + { + setSimpleCron({ + expression, + frequency, + }); + setFieldToPreferredValueMap(newFieldToPreferredValueMap); + setRetentionSchedule(expression); + }} + /> + + + + + { + setIsAdvancedCronVisible(true); + }} + data-test-subj="showAdvancedCronLink" + > + + + + + )} + + + + + + + + + + + + + + ); + }; + + return ( + + {children(updateRetentionPrompt)} + {renderModal()} + + ); +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts index d95c243aeed628..56da4d8a50972c 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts @@ -91,6 +91,9 @@ export const REMOVE_INDEX_SETTINGS_SUGGESTIONS: string[] = INDEX_SETTING_SUGGEST export const DEFAULT_POLICY_SCHEDULE = '0 30 1 * * ?'; export const DEFAULT_POLICY_FREQUENCY = DAY; +export const DEFAULT_RETENTION_SCHEDULE = '0 30 1 * * ?'; +export const DEFAULT_RETENTION_FREQUENCY = DAY; + // UI Metric constants export const UIM_APP_NAME = 'snapshot_restore'; export const UIM_REPOSITORY_LIST_LOAD = 'repository_list_load'; @@ -119,3 +122,4 @@ export const UIM_POLICY_DELETE = 'policy_delete'; export const UIM_POLICY_DELETE_MANY = 'policy_delete_many'; export const UIM_POLICY_CREATE = 'policy_create'; export const UIM_POLICY_UPDATE = 'policy_update'; +export const UIM_POLICY_RETENTION_SETTINGS_UPDATE = 'policy_retention_settings_update'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/tabs/tab_summary.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/tabs/tab_summary.tsx index ea29d6492cb4b5..68dc9fb164c706 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/tabs/tab_summary.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/tabs/tab_summary.tsx @@ -3,7 +3,7 @@ * 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, useEffect } from 'react'; +import React, { useState, useEffect, Fragment } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -14,6 +14,10 @@ import { EuiDescriptionListDescription, EuiIcon, EuiText, + EuiPanel, + EuiStat, + EuiSpacer, + EuiHorizontalRule, } from '@elastic/eui'; import { SlmPolicy } from '../../../../../../../common/types'; @@ -40,6 +44,8 @@ export const TabSummary: React.FunctionComponent = ({ policy }) => { schedule, nextExecutionMillis, config, + stats, + retention, } = policy; const { includeGlobalState, ignoreUnavailable, indices, partial } = config || { includeGlobalState: undefined, @@ -123,176 +129,306 @@ export const TabSummary: React.FunctionComponent = ({ policy }) => { }, []); return ( - - - - - - - - - {version} - - - - - - - - - - - - - - - - - - - - - - {snapshotName} - - - - - - - - - - {repository} - - - - - - - - - - - - {schedule} - - - - - - - - - - - - - - - - - - - - - - {isShowingFullIndicesList ? fullIndicesList : shortIndicesList} - - - - - - - - - - {ignoreUnavailable ? ( + + {/** Stats panel */} + {stats && ( + + + + + + + + + + + + + + + + + + + + + )} + + {/** General description list */} + +

+ +

+
+ + + + + + - ) : ( + + + + {version} + + + + + - )} -
-
-
- - - - - - - - - {partial ? ( + + + + + + + + + + + - ) : ( + + + + {snapshotName} + + + + + - )} - - - - - - - - - - {includeGlobalState === false ? ( + + + + {repository} + + + + + + + + + + + + {schedule} + + + + + + + + + + + + + + + + + - ) : ( + + + + {isShowingFullIndicesList ? fullIndicesList : shortIndicesList} + + + + + + + + + + {ignoreUnavailable ? ( + + ) : ( + + )} + + + + + + + + + + + {partial ? ( + + ) : ( + + )} + + + + + + + + + + {includeGlobalState === false ? ( + + ) : ( + + )} + + + +
+ + {retention && ( + + + + + {/** Retention description list */} + +

+ +

+
+ + + + {retention.expireAfterValue && ( + + + + + + {retention.expireAfterValue} + {retention.expireAfterUnit} + + + )} + {retention.minCount && ( + + + + + {retention.minCount} + + )} + {retention.maxCount && ( + + + + + {retention.maxCount} + )} - - - - + +
+ )} + ); }; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx index 270eb8d43aa99c..a1688b8e35486f 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx @@ -13,13 +13,14 @@ import { APP_SLM_CLUSTER_PRIVILEGES } from '../../../../../common/constants'; import { SectionError, SectionLoading, Error } from '../../../components'; import { BASE_PATH, UIM_POLICY_LIST_LOAD } from '../../../constants'; import { useAppDependencies } from '../../../index'; -import { useLoadPolicies } from '../../../services/http'; +import { useLoadPolicies, useLoadRetentionSettings } from '../../../services/http'; import { uiMetricService } from '../../../services/ui_metric'; import { linkToAddPolicy, linkToPolicy } from '../../../services/navigation'; import { WithPrivileges, NotAuthorizedSection } from '../../../lib/authorization'; import { PolicyDetails } from './policy_details'; import { PolicyTable } from './policy_table'; +import { PolicyRetentionSchedule } from './policy_retention_schedule'; interface MatchParams { policyName?: SlmPolicy['name']; @@ -46,6 +47,14 @@ export const PolicyList: React.FunctionComponent { return linkToPolicy(newPolicyName); }; @@ -137,6 +146,8 @@ export const PolicyList: React.FunctionComponent policy.schedule); const hasDuplicateSchedules = policySchedules.length > new Set(policySchedules).size; + const hasRetention = Boolean(policies.find((policy: SlmPolicy) => policy.retention)); + content = ( {hasDuplicateSchedules ? ( @@ -159,6 +170,16 @@ export const PolicyList: React.FunctionComponent ) : null} + + {hasRetention ? ( + + ) : null} + void; + isLoading: boolean; + error: any; +} + +export const PolicyRetentionSchedule: React.FunctionComponent = ({ + retentionSettings, + onRetentionScheduleUpdated, + isLoading, + error, +}) => { + const { + core: { i18n }, + } = useAppDependencies(); + + const { FormattedMessage } = i18n; + + if (isLoading) { + return ( + + + + + + + ); + } + + if (error) { + return ( + + + } + color="danger" + iconType="alert" + > + {error.data && error.data.message ?

{error.data.message}

: null} + + + +
+ +
+ ); + } + + if (retentionSettings && retentionSettings.retentionSchedule) { + const { retentionSchedule } = retentionSettings; + + return ( + + + + + +

+ {retentionSchedule} }} + /> +

+
+
+ + + {(updateRetentionPrompt: UpdateRetentionSetting) => { + return ( + + } + > + + updateRetentionPrompt(retentionSchedule, onRetentionScheduleUpdated) + } + aria-label={i18n.translate( + 'xpack.snapshotRestore.policyRetentionSchedulePanel.retentionScheduleEditLinkAriaLabel', + { + defaultMessage: 'Edit retention schedule', + } + )} + /> + + ); + }} + + +
+
+ +
+ ); + } else { + return ( + + + } + color="warning" + iconType="alert" + > +

+ +

+ + {(updateRetentionPrompt: UpdateRetentionSetting) => { + return ( + updateRetentionPrompt(undefined, onRetentionScheduleUpdated)} + > + + + ); + }} + +
+ +
+ ); + } +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx index 19239a282eb296..ee906eb9f87473 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx @@ -147,6 +147,24 @@ export const PolicyTable: React.FunctionComponent = ({ truncateText: true, sortable: true, }, + { + field: 'retention', + name: i18n.translate('xpack.snapshotRestore.policyList.table.retentionColumnTitle', { + defaultMessage: 'Retention', + }), + render: (retention: SlmPolicy['retention']) => + retention ? ( + + ) : null, + }, { field: 'nextExecutionMillis', name: i18n.translate('xpack.snapshotRestore.policyList.table.nextExecutionColumnTitle', { diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/tabs/tab_failures.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/tabs/tab_failures.tsx index 1af3cfb4d133e5..ea2b8b9904d8f8 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/tabs/tab_failures.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/tabs/tab_failures.tsx @@ -70,8 +70,8 @@ export const TabFailures: React.SFC = ({ indexFailures, snapshotState })

- - {status}: {reason} + + {`${status}: ${reason}`} {failuresCount < failures.length - 1 ? : undefined} diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_add/policy_add.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_add/policy_add.tsx index 30a4c337fb9ba3..191d31cfba6293 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_add/policy_add.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_add/policy_add.tsx @@ -8,12 +8,13 @@ import { RouteComponentProps } from 'react-router-dom'; import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; import { SlmPolicyPayload } from '../../../../common/types'; +import { TIME_UNITS } from '../../../../common/constants'; import { PolicyForm, SectionError, SectionLoading, Error } from '../../components'; import { useAppDependencies } from '../../index'; import { BASE_PATH, DEFAULT_POLICY_SCHEDULE } from '../../constants'; import { breadcrumbService, docTitleService } from '../../services/navigation'; -import { addPolicy, useLoadIndicies } from '../../services/http'; +import { addPolicy, useLoadIndices } from '../../services/http'; export const PolicyAdd: React.FunctionComponent = ({ history, @@ -33,7 +34,7 @@ export const PolicyAdd: React.FunctionComponent = ({ data: { indices } = { indices: [], }, - } = useLoadIndicies(); + } = useLoadIndices(); // Set breadcrumb and page title useEffect(() => { @@ -64,6 +65,12 @@ export const PolicyAdd: React.FunctionComponent = ({ schedule: DEFAULT_POLICY_SCHEDULE, repository: '', config: {}, + retention: { + expireAfterValue: '', + expireAfterUnit: TIME_UNITS.DAY, + maxCount: '', + minCount: '', + }, }; const renderSaveError = () => { diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_edit/policy_edit.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_edit/policy_edit.tsx index 8ecee219b66b92..0bfb84cef93b47 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_edit/policy_edit.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_edit/policy_edit.tsx @@ -3,17 +3,18 @@ * 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, Fragment } from 'react'; +import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; import { SlmPolicyPayload } from '../../../../common/types'; +import { TIME_UNITS } from '../../../../common/constants'; import { SectionError, SectionLoading, PolicyForm, Error } from '../../components'; import { BASE_PATH } from '../../constants'; import { useAppDependencies } from '../../index'; import { breadcrumbService, docTitleService } from '../../services/navigation'; -import { editPolicy, useLoadPolicy, useLoadIndicies } from '../../services/http'; +import { editPolicy, useLoadPolicy, useLoadIndices } from '../../services/http'; interface MatchParams { name: string; @@ -44,6 +45,12 @@ export const PolicyEdit: React.FunctionComponent - - + ); }; @@ -195,7 +200,7 @@ export const PolicyEdit: React.FunctionComponent -

+

{ }); }; -export const useLoadIndicies = () => { +export const useLoadIndices = () => { return useRequest({ path: httpService.addBasePath(`${API_BASE_PATH}policies/indices`), method: 'get', @@ -86,3 +87,24 @@ export const editPolicy = async (editedPolicy: SlmPolicyPayload) => { trackUiMetric(UIM_POLICY_UPDATE); return result; }; + +export const useLoadRetentionSettings = () => { + return useRequest({ + path: httpService.addBasePath(`${API_BASE_PATH}policies/retention_settings`), + method: 'get', + }); +}; + +export const updateRetentionSchedule = (retentionSchedule: string) => { + const result = sendRequest({ + path: httpService.addBasePath(`${API_BASE_PATH}policies/retention_settings`), + method: 'put', + body: { + retentionSchedule, + }, + }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(UIM_POLICY_RETENTION_SETTINGS_UPDATE); + return result; +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/text/text.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/text/text.ts index ec92250373a05e..e3b5b0115d687a 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/text/text.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/text/text.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { REPOSITORY_TYPES } from '../../../../common/constants'; +import { REPOSITORY_TYPES, TIME_UNITS } from '../../../../common/constants'; class TextService { public breadcrumbs: { [key: string]: string } = {}; @@ -112,6 +112,31 @@ class TextService { }, }); } + + public getTimeUnitLabel(timeUnit: 'd' | 'h' | 'm' | 's', timeValue: string) { + switch (timeUnit) { + case TIME_UNITS.SECOND: + return this.i18n.translate('xpack.snapshotRestore.policyForm.timeUnits.secondLabel', { + defaultMessage: '{timeValue, plural, one {second} other {seconds}}', + values: { timeValue }, + }); + case TIME_UNITS.MINUTE: + return this.i18n.translate('xpack.snapshotRestore.policyForm.timeUnits.minuteLabel', { + defaultMessage: '{timeValue, plural, one {minute} other {minutes}}', + values: { timeValue }, + }); + case TIME_UNITS.HOUR: + return this.i18n.translate('xpack.snapshotRestore.policyForm.timeUnits.hourLabel', { + defaultMessage: '{timeValue, plural, one {hour} other {hours}}', + values: { timeValue }, + }); + case TIME_UNITS.DAY: + return this.i18n.translate('xpack.snapshotRestore.policyForm.timeUnits.dayLabel', { + defaultMessage: '{timeValue, plural, one {day} other {days}}', + values: { timeValue }, + }); + } + } } export const textService = new TextService(); diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts index 53c62da97bdacd..8a60740b1610c7 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts @@ -18,7 +18,7 @@ const isStringEmpty = (str: string | null): boolean => { export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { const i18n = textService.i18n; - const { name, snapshotName, schedule, repository, config } = policy; + const { name, snapshotName, schedule, repository, config, retention } = policy; const validation: PolicyValidation = { isValid: true, @@ -28,12 +28,13 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { schedule: [], repository: [], indices: [], + minCount: [], }, }; if (isStringEmpty(name)) { validation.errors.name.push( - i18n.translate('xpack.snapshotRestore.policyValidation.nameRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.nameRequiredErroMessage', { defaultMessage: 'Policy name is required.', }) ); @@ -41,7 +42,7 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (isStringEmpty(snapshotName)) { validation.errors.snapshotName.push( - i18n.translate('xpack.snapshotRestore.policyValidation.snapshotNameRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.snapshotNameRequiredErrorMessage', { defaultMessage: 'Snapshot name is required.', }) ); @@ -49,7 +50,7 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (isStringEmpty(schedule)) { validation.errors.schedule.push( - i18n.translate('xpack.snapshotRestore.policyValidation.scheduleRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.scheduleRequiredErrorMessage', { defaultMessage: 'Schedule is required.', }) ); @@ -57,7 +58,7 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (isStringEmpty(repository)) { validation.errors.repository.push( - i18n.translate('xpack.snapshotRestore.policyValidation.repositoryRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.repositoryRequiredErrorMessage', { defaultMessage: 'Repository is required.', }) ); @@ -65,7 +66,7 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (config && typeof config.indices === 'string' && config.indices.trim().length === 0) { validation.errors.indices.push( - i18n.translate('xpack.snapshotRestore.policyValidation.indexPatternRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.indexPatternRequiredErrorMessage', { defaultMessage: 'At least one index pattern is required.', }) ); @@ -73,12 +74,24 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (config && Array.isArray(config.indices) && config.indices.length === 0) { validation.errors.indices.push( - i18n.translate('xpack.snapshotRestore.policyValidation.indicesRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.indicesRequiredErrorMessage', { defaultMessage: 'You must select at least one index.', }) ); } + if ( + retention && + retention.minCount && + retention.maxCount && + retention.minCount > retention.maxCount + ) { + validation.errors.minCount.push( + i18n.translate('xpack.snapshotRestore.policyValidation.invalidMinCountErrorMessage', { + defaultMessage: 'Min count cannot be greater than max count.', + }) + ); + } // Remove fields with no errors validation.errors = Object.entries(validation.errors) .filter(([key, value]) => value.length > 0) diff --git a/x-pack/legacy/plugins/snapshot_restore/public/plugin.ts b/x-pack/legacy/plugins/snapshot_restore/public/plugin.ts index 10c7a86d640e6c..77db8dd993c2e7 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/plugin.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/plugin.ts @@ -39,11 +39,7 @@ export class Plugin { textService.init(i18n); breadcrumbService.init(chrome, management.constants.BREADCRUMB); uiMetricService.init(uiMetric.createUiStatsReporter); - documentationLinksService.init( - documentation.esDocBasePath, - documentation.esPluginDocBasePath, - documentation.esStackOverviewDocBasePath - ); + documentationLinksService.init(documentation.esDocBasePath, documentation.esPluginDocBasePath); docTitleService.init(docTitle.change); const unmountReactApp = (): void => { diff --git a/x-pack/legacy/plugins/snapshot_restore/public/shim.ts b/x-pack/legacy/plugins/snapshot_restore/public/shim.ts index 02574890afffd0..595edbfd1cea4c 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/shim.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/shim.ts @@ -53,7 +53,6 @@ export interface Core extends AppCore { documentation: { esDocBasePath: string; esPluginDocBasePath: string; - esStackOverviewDocBasePath: string; }; docTitle: { change: typeof docTitle.change; @@ -113,7 +112,6 @@ export function createShim(): { core: Core; plugins: Plugins } { documentation: { esDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`, esPluginDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/plugins/${DOC_LINK_VERSION}/`, - esStackOverviewDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elastic-stack-overview/${DOC_LINK_VERSION}/`, }, docTitle: { change: docTitle.change, diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts index 52e6449559bcc1..c0016a4f643cd2 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts @@ -12,9 +12,10 @@ import { createHandler, updateHandler, getIndicesHandler, + updateRetentionSettingsHandler, } from './policy'; -describe('[Snapshot and Restore API Routes] Restore', () => { +describe('[Snapshot and Restore API Routes] Policy', () => { const mockRequest = {} as Request; const mockResponseToolkit = {} as ResponseToolkit; const mockEsPolicy = { @@ -25,6 +26,11 @@ describe('[Snapshot and Restore API Routes] Restore', () => { schedule: '0 30 1 * * ?', repository: 'my-backups', config: {}, + retention: { + expire_after: '15d', + min_count: 5, + max_count: 10, + }, }, next_execution_millis: 1562722200000, }; @@ -35,6 +41,12 @@ describe('[Snapshot and Restore API Routes] Restore', () => { schedule: '0 30 1 * * ?', repository: 'my-backups', config: {}, + retention: { + expireAfterValue: 15, + expireAfterUnit: 'd', + minCount: 5, + maxCount: 10, + }, nextExecutionMillis: 1562722200000, }; @@ -323,4 +335,29 @@ describe('[Snapshot and Restore API Routes] Restore', () => { ).rejects.toThrow(); }); }); + + describe('updateRetentionSettingsHandler()', () => { + const retentionSettings = { + retentionSchedule: '0 30 1 * * ?', + }; + const mockCreateRequest = ({ + payload: retentionSettings, + } as unknown) as Request; + + it('should return successful ES response', async () => { + const mockEsResponse = { acknowledged: true }; + const callWithRequest = jest.fn().mockReturnValueOnce(mockEsResponse); + const expectedResponse = { ...mockEsResponse }; + await expect( + updateRetentionSettingsHandler(mockCreateRequest, callWithRequest, mockResponseToolkit) + ).resolves.toEqual(expectedResponse); + }); + + it('should throw if ES error', async () => { + const callWithRequest = jest.fn().mockRejectedValueOnce(new Error()); + await expect( + updateRetentionSettingsHandler(mockCreateRequest, callWithRequest, mockResponseToolkit) + ).rejects.toThrow(); + }); + }); }); diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts index ed16a44bccdc6b..ef9e48190a5b7c 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts @@ -10,8 +10,12 @@ import { } from '../../../../../server/lib/create_router/error_wrappers'; import { SlmPolicyEs, SlmPolicy, SlmPolicyPayload } from '../../../common/types'; import { deserializePolicy, serializePolicy } from '../../../common/lib'; +import { Plugins } from '../../../shim'; -export function registerPolicyRoutes(router: Router) { +let callWithInternalUser: any; + +export function registerPolicyRoutes(router: Router, plugins: Plugins) { + callWithInternalUser = plugins.elasticsearch.getCluster('data').callWithInternalUser; router.get('policies', getAllHandler); router.get('policy/{name}', getOneHandler); router.post('policy/{name}/run', executeHandler); @@ -19,10 +23,12 @@ export function registerPolicyRoutes(router: Router) { router.put('policies', createHandler); router.put('policies/{name}', updateHandler); router.get('policies/indices', getIndicesHandler); + router.get('policies/retention_settings', getRetentionSettingsHandler); + router.put('policies/retention_settings', updateRetentionSettingsHandler); } export const getAllHandler: RouterRouteHandler = async ( - req, + _req, callWithRequest ): Promise<{ policies: SlmPolicy[]; @@ -144,7 +150,7 @@ export const updateHandler: RouterRouteHandler = async (req, callWithRequest) => }; export const getIndicesHandler: RouterRouteHandler = async ( - req, + _req, callWithRequest ): Promise<{ indices: string[]; @@ -161,3 +167,38 @@ export const getIndicesHandler: RouterRouteHandler = async ( indices: indices.map(({ index }) => index).sort(), }; }; + +export const getRetentionSettingsHandler: RouterRouteHandler = async (): Promise< + | { + [key: string]: string; + } + | undefined +> => { + const { persistent, transient, defaults } = await callWithInternalUser('cluster.getSettings', { + filterPath: '**.slm.retention*', + includeDefaults: true, + }); + const { slm: retentionSettings = undefined } = { + ...defaults, + ...persistent, + ...transient, + }; + + const { retention_schedule: retentionSchedule } = retentionSettings; + + return { retentionSchedule }; +}; + +export const updateRetentionSettingsHandler: RouterRouteHandler = async (req, callWithRequest) => { + const { retentionSchedule } = req.payload as { retentionSchedule: string }; + + return await callWithRequest('cluster.putSettings', { + body: { + persistent: { + slm: { + retention_schedule: retentionSchedule, + }, + }, + }, + }); +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts index 5a76f1c2681382..11a6cad86640e4 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts @@ -20,6 +20,6 @@ export const registerRoutes = (router: Router, plugins: Plugins): void => { registerRestoreRoutes(router); if (isSlmEnabled) { - registerPolicyRoutes(router); + registerPolicyRoutes(router, plugins); } }; diff --git a/x-pack/legacy/plugins/snapshot_restore/test/fixtures/index.ts b/x-pack/legacy/plugins/snapshot_restore/test/fixtures/index.ts index 1e744a96d81cfc..f3f2f0faa744df 100644 --- a/x-pack/legacy/plugins/snapshot_restore/test/fixtures/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/test/fixtures/index.ts @@ -6,3 +6,4 @@ export * from './repository'; export * from './snapshot'; +export * from './policy'; diff --git a/x-pack/legacy/plugins/snapshot_restore/test/fixtures/policy.ts b/x-pack/legacy/plugins/snapshot_restore/test/fixtures/policy.ts new file mode 100644 index 00000000000000..3dc5f78c42457e --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/test/fixtures/policy.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getRandomString, getRandomNumber } from '../../../../../test_utils'; +import { SlmPolicy } from '../../common/types'; +import { DEFAULT_POLICY_SCHEDULE } from '../../public/app/constants'; + +const dateNow = new Date(); +const randomModifiedDateMillis = new Date().setDate(dateNow.getDate() - 1); +const randomExecutionDateMillis = new Date().setDate(dateNow.getDate() + 1); + +const DEFAULT_STATS: SlmPolicy['stats'] = { + snapshotsTaken: 0, + snapshotsFailed: 0, + snapshotsDeleted: 0, + snapshotDeletionFailures: 0, +}; + +export const getPolicy = ({ + name = `policy-${getRandomString()}`, + config = {}, + modifiedDate = new Date(randomModifiedDateMillis).toString(), + modifiedDateMillis = randomModifiedDateMillis, + nextExecution = new Date(randomExecutionDateMillis).toString(), + nextExecutionMillis = randomExecutionDateMillis, + repository = `repo-${getRandomString()}`, + retention = {}, + schedule = DEFAULT_POLICY_SCHEDULE, + snapshotName = `snapshot-${getRandomString()}`, + stats = DEFAULT_STATS, + version = getRandomNumber(), +}: Partial = {}): SlmPolicy => ({ + name, + config, + modifiedDate, + modifiedDateMillis, + nextExecution, + nextExecutionMillis, + repository, + retention, + schedule, + snapshotName, + stats, + version, +}); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d4fb208bc858a6..97b509f0e3ffe3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10796,7 +10796,6 @@ "xpack.snapshotRestore.policyForm.stepSettings.indicesToggleListLink": "インデックスを選択", "xpack.snapshotRestore.policyForm.stepSettings.partialDescription": "利用不可能なプライマリシャードのインデックスのスナップショットを許可します。これが設定されていない場合、スナップショット全体がエラーになります。", "xpack.snapshotRestore.policyForm.stepSettings.partialDescriptionTitle": "部分インデックスを許可", - "xpack.snapshotRestore.policyForm.stepSettings.partialLabel": "部分インデックスを許可", "xpack.snapshotRestore.policyForm.stepSettings.policyIncludeGlobalStateLabel": "グローバルステータスを含める", "xpack.snapshotRestore.policyForm.stepSettings.selectAllIndicesLink": "すべて選択", "xpack.snapshotRestore.policyForm.stepSettings.selectIndicesHelpText": "{count} 件の{count, plural, one {インデックス} other {インデックス}}がバックアップされます。{selectOrDeselectAllLink}", @@ -10828,12 +10827,6 @@ "xpack.snapshotRestore.policyList.table.snapshotNameColumnTitle": "スナップショット名", "xpack.snapshotRestore.policyScheduleWarningDescription": "一度に 1 つのスナップショットしか撮影できません。スナップショットのエラーを避けるために、ポリシーを編集または削除してください。", "xpack.snapshotRestore.policyScheduleWarningTitle": "2 つ以上のポリシーに同じスケジュールが設定されています", - "xpack.snapshotRestore.policyValidation.indexPatternRequiredError": "インデックスパターンが最低 1 つ必要です。", - "xpack.snapshotRestore.policyValidation.indicesRequiredError": "1 つ以上のインデックスを選択する必要があります。", - "xpack.snapshotRestore.policyValidation.nameRequiredError": "ポリシー名が必要です。", - "xpack.snapshotRestore.policyValidation.repositoryRequiredError": "レポジトリが必要です。", - "xpack.snapshotRestore.policyValidation.scheduleRequiredError": "スケジュールが必要です。", - "xpack.snapshotRestore.policyValidation.snapshotNameRequiredError": "スナップショット名が必要です。", "xpack.snapshotRestore.repositories.breadcrumbTitle": "レポジトリ", "xpack.snapshotRestore.repositoryList.table.typeFilterLabel": "タイプ", "xpack.snapshotRestore.restoreForm.stepLogistics.indicesPatternLabel": "インデックスパターン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a002fdaa10257d..aaced82cb5b14f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10798,7 +10798,6 @@ "xpack.snapshotRestore.policyForm.stepSettings.indicesToggleListLink": "选择索引", "xpack.snapshotRestore.policyForm.stepSettings.partialDescription": "允许具有不可用主分片的索引的快照。否则,整个快照将失败。", "xpack.snapshotRestore.policyForm.stepSettings.partialDescriptionTitle": "允许部分索引", - "xpack.snapshotRestore.policyForm.stepSettings.partialLabel": "允许部分索引", "xpack.snapshotRestore.policyForm.stepSettings.policyIncludeGlobalStateLabel": "包括全局状态", "xpack.snapshotRestore.policyForm.stepSettings.selectAllIndicesLink": "全选", "xpack.snapshotRestore.policyForm.stepSettings.selectIndicesHelpText": "将备份 {count} 个 {count, plural, one {索引} other {索引}}。{selectOrDeselectAllLink}", @@ -10830,12 +10829,6 @@ "xpack.snapshotRestore.policyList.table.snapshotNameColumnTitle": "快照名称", "xpack.snapshotRestore.policyScheduleWarningDescription": "一次仅可以拍取一个快照。要避免快照失败,请编辑或删除策略。", "xpack.snapshotRestore.policyScheduleWarningTitle": "两个或更多策略有相同的计划", - "xpack.snapshotRestore.policyValidation.indexPatternRequiredError": "至少需要一个索引模式。", - "xpack.snapshotRestore.policyValidation.indicesRequiredError": "必须至少选择一个索引。", - "xpack.snapshotRestore.policyValidation.nameRequiredError": "策略名称必填。", - "xpack.snapshotRestore.policyValidation.repositoryRequiredError": "存储库必填。", - "xpack.snapshotRestore.policyValidation.scheduleRequiredError": "计划必填。", - "xpack.snapshotRestore.policyValidation.snapshotNameRequiredError": "快照名称必填。", "xpack.snapshotRestore.repositories.breadcrumbTitle": "存储库", "xpack.snapshotRestore.repositoryList.table.typeFilterLabel": "类型", "xpack.snapshotRestore.restoreForm.stepLogistics.indicesPatternLabel": "索引模式", diff --git a/x-pack/test_utils/testbed/types.ts b/x-pack/test_utils/testbed/types.ts index 31140c21cb5309..b9ced88f3774c3 100644 --- a/x-pack/test_utils/testbed/types.ts +++ b/x-pack/test_utils/testbed/types.ts @@ -85,7 +85,7 @@ export interface TestBed { * * @param switchTestSubject The test subject of the EuiSwitch (can be a nested path. e.g. "myForm.mySwitch"). */ - toggleEuiSwitch: (switchTestSubject: T) => void; + toggleEuiSwitch: (switchTestSubject: T, isChecked?: boolean) => void; /** * The EUI ComboBox is a special input as it needs the ENTER key to be pressed * in order to register the value set. This helpers automatically does that. From 96e40d6edec484ea321192b294bd459210d0d729 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Wed, 2 Oct 2019 15:09:25 -0400 Subject: [PATCH 39/59] [Monitoring] Use server side pagination for Logstash Pipelines page (#46587) * Basic version working for cluster pipelines * More support * Refactoring * Fixes * Fix sorting issues * Reduce the number of buckets too * Fix tests * This is actually not helping - it seems that the filter in the query doesn't work as expected - maybe related to the fact that are using nested fields * Add more data for metric.debug * Support sorting on throughput and node count * Fix broken test * Use getMetrics and support with numOfBuckets parameter * Fix test for realz * Fix logstash management pages by introducing a new api to just retrieve ids * We need this to go back to 1000 but it doesn't affect the number of created buckets * Fix issue with pagination data when filtering * Fix sorting by id not working * Make this a little more sturdy --- .../services/monitoring/monitoring_service.js | 4 +- .../pipeline_listing/pipeline_listing.js | 18 +- .../public/components/table/eui_table_ssp.js | 79 ++++++++ .../public/components/table/index.js | 1 + .../monitoring/public/views/alerts/index.js | 1 + .../public/views/base_controller.js | 11 +- .../public/views/base_eui_table_controller.js | 75 +++++++- .../views/logstash/node/pipelines/index.js | 19 +- .../public/views/logstash/pipelines/index.js | 18 +- .../lib/cluster/get_clusters_from_request.js | 2 +- .../server/lib/details/get_metrics.js | 11 +- .../server/lib/details/get_series.js | 59 ++++-- .../lib/logstash/__tests__/get_pipelines.js | 6 +- .../lib/logstash/get_paginated_pipelines.js | 69 +++++++ .../server/lib/logstash/get_pipeline_ids.js | 78 ++++++++ .../server/lib/logstash/get_pipelines.js | 37 ++-- .../server/lib/logstash/sort_pipelines.js | 14 ++ .../__snapshots__/metrics.test.js.snap | 172 +----------------- .../server/lib/metrics/logstash/classes.js | 58 +++--- .../server/lib/metrics/logstash/metrics.js | 2 +- .../server/lib/pagination/filter.js | 23 +++ .../server/lib/pagination/paginate.js | 10 + .../server/routes/api/v1/logstash/index.js | 1 + .../pipelines/cluster_pipeline_ids.js | 49 +++++ .../logstash/pipelines/cluster_pipelines.js | 42 ++++- .../v1/logstash/pipelines/node_pipelines.js | 43 ++++- .../monitoring/server/routes/api/v1/ui.js | 3 +- .../apps/monitoring/logstash/pipelines.js | 11 +- .../page_objects/monitoring_page.js | 3 +- .../services/monitoring/logstash_pipelines.js | 7 + 30 files changed, 653 insertions(+), 273 deletions(-) create mode 100644 x-pack/legacy/plugins/monitoring/public/components/table/eui_table_ssp.js create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipeline_ids.js create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/logstash/sort_pipelines.js create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/pagination/filter.js create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/pagination/paginate.js create mode 100644 x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js diff --git a/x-pack/legacy/plugins/logstash/public/services/monitoring/monitoring_service.js b/x-pack/legacy/plugins/logstash/public/services/monitoring/monitoring_service.js index a160408bcaa023..6a948ad2e39edf 100755 --- a/x-pack/legacy/plugins/logstash/public/services/monitoring/monitoring_service.js +++ b/x-pack/legacy/plugins/logstash/public/services/monitoring/monitoring_service.js @@ -32,7 +32,7 @@ export class MonitoringService { return this.clusterService.loadCluster() .then(cluster => { - const url = `${this.basePath}/v1/clusters/${cluster.uuid}/logstash/pipelines`; + const url = `${this.basePath}/v1/clusters/${cluster.uuid}/logstash/pipeline_ids`; const now = moment.utc(); const body = { timeRange: { @@ -42,7 +42,7 @@ export class MonitoringService { }; return this.$http.post(url, body); }) - .then(response => response.data.pipelines.map(pipeline => PipelineListItem.fromUpstreamMonitoringJSON(pipeline))) + .then(response => response.data.map(pipeline => PipelineListItem.fromUpstreamMonitoringJSON(pipeline))) .catch(() => []); } } diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js index 9dc336f24a40b4..ef306a9a2f06cd 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js @@ -11,7 +11,7 @@ import { EuiPage, EuiLink, EuiPageBody, EuiPageContent, EuiPanel, EuiSpacer, Eui import { formatMetric } from '../../../lib/format_number'; import { ClusterStatus } from '../cluster_status'; import { Sparkline } from 'plugins/monitoring/components/sparkline'; -import { EuiMonitoringTable } from '../../table'; +import { EuiMonitoringSSPTable } from '../../table'; import { i18n } from '@kbn/i18n'; export class PipelineListing extends Component { @@ -137,6 +137,7 @@ export class PipelineListing extends Component { sorting, pagination, onTableChange, + fetchMoreData, upgradeMessage, className } = this.props; @@ -151,31 +152,22 @@ export class PipelineListing extends Component { - diff --git a/x-pack/legacy/plugins/monitoring/public/components/table/eui_table_ssp.js b/x-pack/legacy/plugins/monitoring/public/components/table/eui_table_ssp.js new file mode 100644 index 00000000000000..868464e8703bee --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/table/eui_table_ssp.js @@ -0,0 +1,79 @@ +/* + * 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 { + EuiBasicTable, + EuiSpacer, + EuiSearchBar +} from '@elastic/eui'; + +export function EuiMonitoringSSPTable({ + rows: items, + search = {}, + pagination, + columns: _columns, + onTableChange, + fetchMoreData, + ...props +}) { + const [isLoading, setIsLoading] = React.useState(false); + const [queryText, setQueryText] = React.useState(''); + const [page, setPage] = React.useState({ + index: pagination.pageIndex, + size: pagination.pageSize + }); + const [sort, setSort] = React.useState(props.sorting); + + if (search.box && !search.box['data-test-subj']) { + search.box['data-test-subj'] = 'monitoringTableToolBar'; + } + + const columns = _columns.map(column => { + if (!column['data-test-subj']) { + column['data-test-subj'] = 'monitoringTableHasData'; + } + + if (!('sortable' in column)) { + column.sortable = true; + } + + return column; + }); + + const onChange = async ({ page, sort }) => { + setPage(page); + setSort({ sort }); + setIsLoading(true); + await fetchMoreData({ page, sort: { sort }, queryText }); + setIsLoading(false); + onTableChange({ page, sort }); + }; + + const onQueryChange = async ({ queryText }) => { + const newPage = { ...page, index: 0 }; + setPage(newPage); + setQueryText(queryText); + setIsLoading(true); + await fetchMoreData({ page: newPage, sort, queryText }); + setIsLoading(false); + }; + + return ( +
+ + + +
+ ); +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/table/index.js b/x-pack/legacy/plugins/monitoring/public/components/table/index.js index d807352ff14c8d..66bdb46904dba7 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/table/index.js +++ b/x-pack/legacy/plugins/monitoring/public/components/table/index.js @@ -5,4 +5,5 @@ */ export { EuiMonitoringTable } from './eui_table'; +export { EuiMonitoringSSPTable } from './eui_table_ssp'; export { tableStorageGetter, tableStorageSetter, euiTableStorageGetter, euiTableStorageSetter } from './storage'; diff --git a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js b/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js index 5f278d661db1db..8ffb30cee96235 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js @@ -66,6 +66,7 @@ uiRoutes.when('/alerts', { getPageData, $scope, $injector, + storageKey: 'alertsTable', reactNodeId: 'monitoringAlertsApp' }); diff --git a/x-pack/legacy/plugins/monitoring/public/views/base_controller.js b/x-pack/legacy/plugins/monitoring/public/views/base_controller.js index 15abe5ed3e949b..600e229b031bff 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/base_controller.js +++ b/x-pack/legacy/plugins/monitoring/public/views/base_controller.js @@ -70,7 +70,8 @@ export class MonitoringViewBaseController { reactNodeId = null, // WIP: https://github.com/elastic/x-pack-kibana/issues/5198 $scope, $injector, - options = {} + options = {}, + fetchDataImmediately = true }) { const titleService = $injector.get('title'); const $executor = $injector.get('$executor'); @@ -119,7 +120,7 @@ export class MonitoringViewBaseController { this.updateDataPromise = null; } const _api = apiUrlFn ? apiUrlFn() : api; - const promises = [_getPageData($injector, _api)]; + const promises = [_getPageData($injector, _api, this.getPaginationRouteOptions())]; const setupMode = getSetupModeState(); if (setupMode.enabled) { promises.push(updateSetupModeData()); @@ -132,7 +133,7 @@ export class MonitoringViewBaseController { }); }); }; - this.updateData(); + fetchDataImmediately && this.updateData(); $executor.register({ execute: () => this.updateData() @@ -175,4 +176,8 @@ export class MonitoringViewBaseController { render(component, document.getElementById(this.reactNodeId)); } } + + getPaginationRouteOptions() { + return {}; + } } diff --git a/x-pack/legacy/plugins/monitoring/public/views/base_eui_table_controller.js b/x-pack/legacy/plugins/monitoring/public/views/base_eui_table_controller.js index dfc548aeb97f20..fb712fa3e7c6c4 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/base_eui_table_controller.js +++ b/x-pack/legacy/plugins/monitoring/public/views/base_eui_table_controller.js @@ -8,6 +8,8 @@ import { MonitoringViewBaseController } from './'; import { euiTableStorageGetter, euiTableStorageSetter } from 'plugins/monitoring/components/table'; import { EUI_SORT_ASCENDING } from '../../common/constants'; +const PAGE_SIZE_OPTIONS = [5, 10, 20, 50]; + /** * Class to manage common instantiation behaviors in a view controller * And add persistent state to a table: @@ -42,17 +44,22 @@ export class MonitoringViewBaseEuiTableController extends MonitoringViewBaseCont const setLocalStorageData = euiTableStorageSetter(storageKey); const { page, sort } = getLocalStorageData(storage); - this.pagination = page || { + this.pagination = { + pageSize: 20, initialPageSize: 20, - pageSizeOptions: [5, 10, 20, 50] + pageIndex: 0, + initialPageIndex: 0, + pageSizeOptions: PAGE_SIZE_OPTIONS }; - this.sorting = sort || { - sort: { - field: 'name', - direction: EUI_SORT_ASCENDING + if (page) { + if (!PAGE_SIZE_OPTIONS.includes(page.size)) { + page.size = 20; } - }; + this.setPagination(page); + } + + this.setSorting(sort); this.onTableChange = ({ page, sort }) => { setLocalStorageData(storage, { @@ -62,5 +69,59 @@ export class MonitoringViewBaseEuiTableController extends MonitoringViewBaseCont } }); }; + + this.updateData(); + } + + setPagination(page) { + this.pagination = { + pageSize: page.size, + pageIndex: page.index, + pageSizeOptions: PAGE_SIZE_OPTIONS + }; + } + + setSorting(sort) { + this.sorting = sort || { sort: {} }; + + if (!this.sorting.sort.field) { + this.sorting.sort.field = 'name'; + } + if (!this.sorting.sort.direction) { + this.sorting.sort.direction = EUI_SORT_ASCENDING; + } + } + + setQueryText(queryText) { + this.queryText = queryText; + } + + getPaginationRouteOptions() { + if (!this.pagination || !this.sorting) { + return {}; + } + + return { + pagination: { + size: this.pagination.pageSize, + index: this.pagination.pageIndex + }, + ...this.sorting, + queryText: this.queryText, + }; + } + + getPaginationTableProps(pagination) { + return { + sorting: this.sorting, + pagination: pagination, + onTableChange: this.onTableChange, + fetchMoreData: async ({ page, sort, queryText }) => { + this.setPagination(page); + this.setSorting(sort); + this.setQueryText(queryText); + this.updateData(); + } + }; } } diff --git a/x-pack/legacy/plugins/monitoring/public/views/logstash/node/pipelines/index.js b/x-pack/legacy/plugins/monitoring/public/views/logstash/node/pipelines/index.js index 80fcde76427589..b8bdccab349772 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/logstash/node/pipelines/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/logstash/node/pipelines/index.js @@ -24,7 +24,7 @@ import { PipelineListing } from '../../../../components/logstash/pipeline_listin import { DetailStatus } from '../../../../components/logstash/detail_status'; import { CODE_PATH_LOGSTASH } from '../../../../../common/constants'; -const getPageData = ($injector) => { +const getPageData = ($injector, _api = undefined, routeOptions = {}) => { const $route = $injector.get('$route'); const $http = $injector.get('$http'); const globalState = $injector.get('globalState'); @@ -39,7 +39,8 @@ const getPageData = ($injector) => { timeRange: { min: timeBounds.min.toISOString(), max: timeBounds.max.toISOString() - } + }, + ...routeOptions }) .then(response => response.data) .catch((err) => { @@ -70,7 +71,6 @@ uiRoutes const routeInit = Private(routeInitProvider); return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); }, - pageData: getPageData }, controller: class extends MonitoringViewBaseEuiTableController { constructor($injector, $scope) { @@ -82,9 +82,11 @@ uiRoutes getPageData, reactNodeId: 'monitoringLogstashNodePipelinesApp', $scope, - $injector + $injector, + fetchDataImmediately: false // We want to apply pagination before sending the first request }); + $scope.$watch(() => this.data, data => { if (!data || !data.nodeSummary) { return; @@ -97,6 +99,11 @@ uiRoutes } })); + const pagination = { + ...this.pagination, + totalItemCount: data.totalPipelineCount + }; + this.renderReact( { +const getPageData = ($injector, _api = undefined, routeOptions = {}) => { const $http = $injector.get('$http'); const globalState = $injector.get('globalState'); const Private = $injector.get('Private'); @@ -37,7 +37,8 @@ const getPageData = ($injector) => { timeRange: { min: timeBounds.min.toISOString(), max: timeBounds.max.toISOString() - } + }, + ...routeOptions }) .then(response => response.data) .catch((err) => { @@ -64,7 +65,6 @@ uiRoutes const routeInit = Private(routeInitProvider); return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); }, - pageData: getPageData }, controller: class LogstashPipelinesList extends MonitoringViewBaseEuiTableController { constructor($injector, $scope) { @@ -74,7 +74,8 @@ uiRoutes getPageData, reactNodeId: 'monitoringLogstashPipelinesApp', $scope, - $injector + $injector, + fetchDataImmediately: false // We want to apply pagination before sending the first request }); const $route = $injector.get('$route'); @@ -93,6 +94,11 @@ uiRoutes ? makeUpgradeMessage(pageData.clusterStatus.versions, i18n) : null; + const pagination = { + ...this.pagination, + totalItemCount: pageData.totalPipelineCount + }; + super.renderReact( this.onBrush({ xaxis })} stats={pageData.clusterStatus} data={pageData.pipelines} - sorting={this.sorting} - pagination={this.pagination} - onTableChange={this.onTableChange} + {...this.getPaginationTableProps(pagination)} upgradeMessage={upgradeMessage} dateFormat={config.get('dateFormat')} angular={{ diff --git a/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js b/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js index 9b6a2380bf4ecf..9fc6866f0f579b 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js @@ -150,7 +150,7 @@ export async function getClustersFromRequest(req, indexPatterns, { clusterUuid, : []; const clusterPipelineNodesCount = isInCodePath(codePaths, [CODE_PATH_LOGSTASH]) - ? await getPipelines(req, lsIndexPattern, ['logstash_cluster_pipeline_nodes_count']) + ? await getPipelines(req, lsIndexPattern, null, ['logstash_cluster_pipeline_nodes_count']) : []; // add the logstash data to each cluster diff --git a/x-pack/legacy/plugins/monitoring/server/lib/details/get_metrics.js b/x-pack/legacy/plugins/monitoring/server/lib/details/get_metrics.js index c5d2ee2032b018..e11de68b55c1f0 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/details/get_metrics.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/details/get_metrics.js @@ -12,18 +12,23 @@ import { getSeries } from './get_series'; import { calculateTimeseriesInterval } from '../calculate_timeseries_interval'; import { getTimezone } from '../get_timezone'; -export async function getMetrics(req, indexPattern, metricSet = [], filters = []) { +export async function getMetrics(req, indexPattern, metricSet = [], filters = [], metricOptions = {}, numOfBuckets = 0) { checkParam(indexPattern, 'indexPattern in details/getMetrics'); checkParam(metricSet, 'metricSet in details/getMetrics'); const config = req.server.config(); // TODO: Pass in req parameters as explicit function parameters - const min = moment.utc(req.payload.timeRange.min).valueOf(); + let min = moment.utc(req.payload.timeRange.min).valueOf(); const max = moment.utc(req.payload.timeRange.max).valueOf(); const minIntervalSeconds = config.get('xpack.monitoring.min_interval_seconds'); const bucketSize = calculateTimeseriesInterval(min, max, minIntervalSeconds); const timezone = await getTimezone(req); + // If specified, adjust the time period to ensure we only return this many buckets + if (numOfBuckets > 0) { + min = max - (numOfBuckets * bucketSize * 1000); + } + return Promise.map(metricSet, metric => { // metric names match the literal metric name, but they can be supplied in groups or individually let metricNames; @@ -35,7 +40,7 @@ export async function getMetrics(req, indexPattern, metricSet = [], filters = [] } return Promise.map(metricNames, metricName => { - return getSeries(req, indexPattern, metricName, filters, { min, max, bucketSize, timezone }); + return getSeries(req, indexPattern, metricName, metricOptions, filters, { min, max, bucketSize, timezone }); }); }) .then(rows => { diff --git a/x-pack/legacy/plugins/monitoring/server/lib/details/get_series.js b/x-pack/legacy/plugins/monitoring/server/lib/details/get_series.js index e66878f522ecb8..059b02980a427a 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/details/get_series.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/details/get_series.js @@ -71,18 +71,27 @@ function createMetricAggs(metric) { return metric.aggs; } -function fetchSeries(req, indexPattern, metric, min, max, bucketSize, filters) { +function fetchSeries(req, indexPattern, metric, metricOptions, min, max, bucketSize, filters) { // if we're using a derivative metric, offset the min (also @see comment on offsetMinForDerivativeMetric function) const adjustedMin = metric.derivative ? offsetMinForDerivativeMetric(min, bucketSize) : min; - const dateHistogramSubAggs = metric.dateHistogramSubAggs || { - metric: { - [metric.metricAgg]: { - field: metric.field - } - }, - ...createMetricAggs(metric) - }; + let dateHistogramSubAggs = null; + if (metric.getDateHistogramSubAggs) { + dateHistogramSubAggs = metric.getDateHistogramSubAggs(metricOptions); + } + else if (metric.dateHistogramSubAggs) { + dateHistogramSubAggs = metric.dateHistogramSubAggs; + } + else { + dateHistogramSubAggs = { + metric: { + [metric.metricAgg]: { + field: metric.field + } + }, + ...createMetricAggs(metric) + }; + } const params = { index: indexPattern, @@ -178,6 +187,30 @@ const formatBucketSize = bucketSizeInSeconds => { return formatTimestampToDuration(timestamp, CALCULATE_DURATION_UNTIL, now); }; +function isObject(value) { + return typeof value === 'object' && !!value && !Array.isArray(value); +} + +function countBuckets(data, count = 0) { + if (data.buckets) { + count += data.buckets.length; + for (const bucket of data.buckets) { + for (const key of Object.keys(bucket)) { + if (isObject(bucket[key])) { + count = countBuckets(bucket[key], count); + } + } + } + } else { + for (const key of Object.keys(data)) { + if (isObject(data[key])) { + count = countBuckets(data[key], count); + } + } + } + return count; +} + function handleSeries(metric, min, max, bucketSizeInSeconds, timezone, response) { const { derivative, calculation: customCalculation } = metric; const buckets = get(response, 'aggregations.check.buckets', []); @@ -185,6 +218,10 @@ function handleSeries(metric, min, max, bucketSizeInSeconds, timezone, response) const lastUsableBucketIndex = findLastUsableBucketIndex(buckets, max, firstUsableBucketIndex, bucketSizeInSeconds * 1000); let data = []; + if (metric.debug) { + console.log(`metric.debug field=${metric.field} bucketsCreated: ${countBuckets(get(response, 'aggregations.check'))}`); + console.log(`metric.debug`, { bucketsLength: buckets.length, firstUsableBucketIndex, lastUsableBucketIndex }); + } if (firstUsableBucketIndex <= lastUsableBucketIndex) { // map buckets to values for charts @@ -221,14 +258,14 @@ function handleSeries(metric, min, max, bucketSizeInSeconds, timezone, response) * @param {Array} filters Any filters that should be applied to the query. * @return {Promise} The object response containing the {@code timeRange}, {@code metric}, and {@code data}. */ -export async function getSeries(req, indexPattern, metricName, filters, { min, max, bucketSize, timezone }) { +export async function getSeries(req, indexPattern, metricName, metricOptions, filters, { min, max, bucketSize, timezone }) { checkParam(indexPattern, 'indexPattern in details/getSeries'); const metric = metrics[metricName]; if (!metric) { throw new Error(`Not a valid metric: ${metricName}`); } - const response = await fetchSeries(req, indexPattern, metric, min, max, bucketSize, filters); + const response = await fetchSeries(req, indexPattern, metric, metricOptions, min, max, bucketSize, filters); return handleSeries(metric, min, max, bucketSize, timezone, response); } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/logstash/__tests__/get_pipelines.js b/x-pack/legacy/plugins/monitoring/server/lib/logstash/__tests__/get_pipelines.js index 47735e08c470a0..d4327049b2b412 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/logstash/__tests__/get_pipelines.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/logstash/__tests__/get_pipelines.js @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { - _handleResponse, + handleGetPipelinesResponse, processPipelinesAPIResponse } from '../get_pipelines'; describe('processPipelinesAPIResponse', () => { @@ -71,7 +71,7 @@ describe('get_pipelines', () => { }); it ('returns an empty array', () => { - const result = _handleResponse(fetchPipelinesWithMetricsResult); + const result = handleGetPipelinesResponse(fetchPipelinesWithMetricsResult); expect(result).to.eql([]); }); }); @@ -97,7 +97,7 @@ describe('get_pipelines', () => { }); it ('returns the correct structure for a non-empty response', () => { - const result = _handleResponse(fetchPipelinesWithMetricsResult); + const result = handleGetPipelinesResponse(fetchPipelinesWithMetricsResult); expect(result).to.eql([ { id: 'apache_logs', diff --git a/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js b/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js new file mode 100644 index 00000000000000..9d988c2da2224d --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { filter } from '../pagination/filter'; +import { getLogstashPipelineIds } from './get_pipeline_ids'; +import { handleGetPipelinesResponse } from './get_pipelines'; +import { sortPipelines } from './sort_pipelines'; +import { paginate } from '../pagination/paginate'; +import { getMetrics } from '../details/get_metrics'; + +/** + * This function performs an optimization around the pipeline listing tables in the UI. To avoid + * query performances in Elasticsearch (mainly thinking of `search.max_buckets` overflows), we do + * not want to fetch all time-series data for all pipelines. Instead, we only want to fetch the + * time-series data for the pipelines visible in the listing table. This function accepts + * pagination/sorting/filtering data to determine which pipelines will be visible in the table + * and returns that so the caller can perform their normal call to get the time-series data. + * + * @param {*} req - Server request object + * @param {*} lsIndexPattern - The index pattern to search against (`.monitoring-logstash-*`) + * @param {*} uuids - The optional `clusterUuid` and `logstashUuid` to filter the results from + * @param {*} metricSet - The array of metrics that are sortable in the UI + * @param {*} pagination - ({ index, size }) + * @param {*} sort - ({ field, direction }) + * @param {*} queryText - Text that will be used to filter out pipelines + */ +export async function getPaginatedPipelines(req, lsIndexPattern, { clusterUuid, logstashUuid }, metricSet, pagination, sort, queryText) { + const config = req.server.config(); + const size = config.get('xpack.monitoring.max_bucket_size'); + const pipelines = await getLogstashPipelineIds(req, lsIndexPattern, { clusterUuid, logstashUuid }, size); + + // `metricSet` defines a list of metrics that are sortable in the UI + // but we don't need to fetch all the data for these metrics to perform + // the necessary sort - we only need the last bucket of data so we + // fetch the last two buckets of data (to ensure we have a single full bucekt), + // then return the value from that last bucket + const metricSeriesData = await getMetrics(req, lsIndexPattern, metricSet, [], { pageOfPipelines: pipelines }, 2); + const pipelineAggregationsData = handleGetPipelinesResponse(metricSeriesData, pipelines.map(p => p.id)); + for (const pipelineAggregationData of pipelineAggregationsData) { + for (const pipeline of pipelines) { + if (pipelineAggregationData.id === pipeline.id) { + for (const metric of metricSet) { + const dataSeries = get(pipelineAggregationData, `metrics.${metric}.data`, [[]]); + pipeline[metric] = dataSeries[dataSeries.length - 1][1]; + } + } + } + } + + // Manually apply pagination/sorting/filtering concerns + + // Filtering + const filteredPipelines = filter(pipelines, queryText, ['id']); // We only support filtering by id right now + + // Sorting + const sortedPipelines = sortPipelines(filteredPipelines, sort); + + // Pagination + const pageOfPipelines = paginate(pagination, sortedPipelines); + + return { + pageOfPipelines, + totalPipelineCount: filteredPipelines.length + }; +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipeline_ids.js b/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipeline_ids.js new file mode 100644 index 00000000000000..f99925425abd3e --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipeline_ids.js @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import moment from 'moment'; +import { get } from 'lodash'; +import { createQuery } from '../create_query'; +import { LogstashMetric } from '../metrics'; + +export async function getLogstashPipelineIds(req, logstashIndexPattern, { clusterUuid, logstashUuid }, size) { + const start = moment.utc(req.payload.timeRange.min).valueOf(); + const end = moment.utc(req.payload.timeRange.max).valueOf(); + + const filters = []; + if (logstashUuid) { + filters.push({ term: { 'logstash_stats.logstash.uuid': logstashUuid } }); + } + + const params = { + index: logstashIndexPattern, + size: 0, + ignoreUnavailable: true, + filterPath: [ + 'aggregations.nested_context.composite_data.buckets' + ], + body: { + query: createQuery({ + start, + end, + metric: LogstashMetric.getMetricFields(), + clusterUuid, + filters, + }), + aggs: { + nested_context: { + nested: { + path: 'logstash_stats.pipelines' + }, + aggs: { + composite_data: { + composite: { + size, + sources: [ + { + id: { + terms: { + field: 'logstash_stats.pipelines.id', + } + } + }, + { + hash: { + terms: { + field: 'logstash_stats.pipelines.hash', + } + } + }, + { + ephemeral_id: { + terms: { + field: 'logstash_stats.pipelines.ephemeral_id', + } + } + } + ] + } + } + } + } + } + } + }; + + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); + const response = await callWithRequest(req, 'search', params); + return get(response, 'aggregations.nested_context.composite_data.buckets', []).map(bucket => bucket.key); +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipelines.js b/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipelines.js index 8cec101477ecf0..c059e62815917e 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipelines.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipelines.js @@ -3,12 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { cloneDeep, last, omit } from 'lodash'; import { checkParam } from '../error_missing_required'; import { getMetrics } from '../details/get_metrics'; -export function _handleResponse(response) { +export function handleGetPipelinesResponse(response, exclusivePipelineIds) { const pipelinesById = {}; const metrics = Object.keys(response); @@ -16,6 +15,9 @@ export function _handleResponse(response) { response[metric][0].data.forEach(([x, y]) => { const pipelineIds = Object.keys(y); pipelineIds.forEach(pipelineId => { + if (exclusivePipelineIds && !exclusivePipelineIds.includes(pipelineId)) { + return; + } // Create new pipeline object if necessary if (!pipelinesById.hasOwnProperty(pipelineId)) { pipelinesById[pipelineId] = { @@ -40,14 +42,24 @@ export function _handleResponse(response) { }); }); - // Convert pipelinesById map to array + // Convert pipelinesById map to array and preserve sorting const pipelines = []; - Object.keys(pipelinesById).forEach(pipelineId => { - pipelines.push({ - id: pipelineId, - ...pipelinesById[pipelineId] + if (exclusivePipelineIds) { + for (const exclusivePipelineId of exclusivePipelineIds) { + pipelines.push({ + id: exclusivePipelineId, + ...pipelinesById[exclusivePipelineId] + }); + } + } + else { + Object.keys(pipelinesById).forEach(pipelineId => { + pipelines.push({ + id: pipelineId, + ...pipelinesById[pipelineId] + }); }); - }); + } return pipelines; } @@ -71,10 +83,13 @@ export async function processPipelinesAPIResponse(response, throughputMetricKey, return processedResponse; } -export async function getPipelines(req, logstashIndexPattern, metricSet) { + +export async function getPipelines(req, logstashIndexPattern, pipelineIds, metricSet, metricOptions = {}) { checkParam(logstashIndexPattern, 'logstashIndexPattern in logstash/getPipelines'); checkParam(metricSet, 'metricSet in logstash/getPipelines'); - const metricsResponse = await getMetrics(req, logstashIndexPattern, metricSet); - return _handleResponse(metricsResponse); + const filters = []; + + const metricsResponse = await getMetrics(req, logstashIndexPattern, metricSet, filters, metricOptions); + return handleGetPipelinesResponse(metricsResponse, pipelineIds); } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/logstash/sort_pipelines.js b/x-pack/legacy/plugins/monitoring/server/lib/logstash/sort_pipelines.js new file mode 100644 index 00000000000000..994e910c8ec4b4 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/logstash/sort_pipelines.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { sortByOrder } from 'lodash'; + +export function sortPipelines(pipelines, sort) { + if (!sort) { + return pipelines; + } + + return sortByOrder(pipelines, pipeline => pipeline[sort.field], sort.direction); +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/metrics/__test__/__snapshots__/metrics.test.js.snap b/x-pack/legacy/plugins/monitoring/server/lib/metrics/__test__/__snapshots__/metrics.test.js.snap index a899dc9dfc7481..21fce7e64376c9 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/metrics/__test__/__snapshots__/metrics.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/server/lib/metrics/__test__/__snapshots__/metrics.test.js.snap @@ -2996,37 +2996,11 @@ Object { "logstash_cluster_pipeline_nodes_count": LogstashPipelineNodeCountMetric { "app": "logstash", "calculation": [Function], - "dateHistogramSubAggs": Object { - "pipelines_nested": Object { - "aggs": Object { - "by_pipeline_id": Object { - "aggs": Object { - "to_root": Object { - "aggs": Object { - "node_count": Object { - "cardinality": Object { - "field": "logstash_stats.logstash.uuid", - }, - }, - }, - "reverse_nested": Object {}, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.id", - "size": 1000, - }, - }, - }, - "nested": Object { - "path": "logstash_stats.pipelines", - }, - }, - }, "derivative": false, "description": "Number of nodes on which the Logstash pipeline is running.", "field": "logstash_stats.logstash.uuid", "format": "0,0.[00]", + "getDateHistogramSubAggs": [Function], "label": "Pipeline Node Count", "timestampField": "logstash_stats.timestamp", "units": "", @@ -3035,67 +3009,11 @@ Object { "logstash_cluster_pipeline_throughput": LogstashPipelineThroughputMetric { "app": "logstash", "calculation": [Function], - "dateHistogramSubAggs": Object { - "pipelines_nested": Object { - "aggs": Object { - "by_pipeline_id": Object { - "aggs": Object { - "by_pipeline_hash": Object { - "aggs": Object { - "by_ephemeral_id": Object { - "aggs": Object { - "events_stats": Object { - "stats": Object { - "field": "logstash_stats.pipelines.events.out", - }, - }, - "throughput": Object { - "bucket_script": Object { - "buckets_path": Object { - "max": "events_stats.max", - "min": "events_stats.min", - }, - "script": "params.max - params.min", - }, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.ephemeral_id", - "size": 1000, - }, - }, - "throughput": Object { - "sum_bucket": Object { - "buckets_path": "by_ephemeral_id>throughput", - }, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.hash", - "size": 1000, - }, - }, - "throughput": Object { - "sum_bucket": Object { - "buckets_path": "by_pipeline_hash>throughput", - }, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.id", - "size": 1000, - }, - }, - }, - "nested": Object { - "path": "logstash_stats.pipelines", - }, - }, - }, "derivative": false, "description": "Number of events emitted per second by the Logstash pipeline at the outputs stage.", "field": "logstash_stats.pipelines.events.out", "format": "0,0.[00]", + "getDateHistogramSubAggs": [Function], "label": "Pipeline Throughput", "timestampField": "logstash_stats.timestamp", "units": "e/s", @@ -3354,37 +3272,11 @@ Object { "logstash_node_pipeline_nodes_count": LogstashPipelineNodeCountMetric { "app": "logstash", "calculation": [Function], - "dateHistogramSubAggs": Object { - "pipelines_nested": Object { - "aggs": Object { - "by_pipeline_id": Object { - "aggs": Object { - "to_root": Object { - "aggs": Object { - "node_count": Object { - "cardinality": Object { - "field": "logstash_stats.logstash.uuid", - }, - }, - }, - "reverse_nested": Object {}, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.id", - "size": 1000, - }, - }, - }, - "nested": Object { - "path": "logstash_stats.pipelines", - }, - }, - }, "derivative": false, "description": "Number of nodes on which the Logstash pipeline is running.", "field": "logstash_stats.logstash.uuid", "format": "0,0.[00]", + "getDateHistogramSubAggs": [Function], "label": "Pipeline Node Count", "timestampField": "logstash_stats.timestamp", "units": "", @@ -3393,67 +3285,11 @@ Object { "logstash_node_pipeline_throughput": LogstashPipelineThroughputMetric { "app": "logstash", "calculation": [Function], - "dateHistogramSubAggs": Object { - "pipelines_nested": Object { - "aggs": Object { - "by_pipeline_id": Object { - "aggs": Object { - "by_pipeline_hash": Object { - "aggs": Object { - "by_ephemeral_id": Object { - "aggs": Object { - "events_stats": Object { - "stats": Object { - "field": "logstash_stats.pipelines.events.out", - }, - }, - "throughput": Object { - "bucket_script": Object { - "buckets_path": Object { - "max": "events_stats.max", - "min": "events_stats.min", - }, - "script": "params.max - params.min", - }, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.ephemeral_id", - "size": 1000, - }, - }, - "throughput": Object { - "sum_bucket": Object { - "buckets_path": "by_ephemeral_id>throughput", - }, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.hash", - "size": 1000, - }, - }, - "throughput": Object { - "sum_bucket": Object { - "buckets_path": "by_pipeline_hash>throughput", - }, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.id", - "size": 1000, - }, - }, - }, - "nested": Object { - "path": "logstash_stats.pipelines", - }, - }, - }, "derivative": false, "description": "Number of events emitted per second by the Logstash pipeline at the outputs stage.", "field": "logstash_stats.pipelines.events.out", "format": "0,0.[00]", + "getDateHistogramSubAggs": [Function], "label": "Pipeline Throughput", "timestampField": "logstash_stats.timestamp", "units": "e/s", diff --git a/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/classes.js b/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/classes.js index dade736cd53f85..3af726328aca8d 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/classes.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/classes.js @@ -270,7 +270,7 @@ export class LogstashPipelineThroughputMetric extends LogstashMetric { derivative: false }); - this.dateHistogramSubAggs = { + this.getDateHistogramSubAggs = ({ pageOfPipelines }) => ({ pipelines_nested: { nested: { path: 'logstash_stats.pipelines' @@ -279,7 +279,8 @@ export class LogstashPipelineThroughputMetric extends LogstashMetric { by_pipeline_id: { terms: { field: 'logstash_stats.pipelines.id', - size: 1000 + size: 1000, + include: pageOfPipelines.map(pipeline => pipeline.id), }, aggs: { throughput: { @@ -290,7 +291,8 @@ export class LogstashPipelineThroughputMetric extends LogstashMetric { by_pipeline_hash: { terms: { field: 'logstash_stats.pipelines.hash', - size: 1000 + size: 1000, + include: pageOfPipelines.map(pipeline => pipeline.hash), }, aggs: { throughput: { @@ -301,7 +303,8 @@ export class LogstashPipelineThroughputMetric extends LogstashMetric { by_ephemeral_id: { terms: { field: 'logstash_stats.pipelines.ephemeral_id', - size: 1000 + size: 1000, + include: pageOfPipelines.map(pipeline => pipeline.ephemeral_id), }, aggs: { events_stats: { @@ -326,7 +329,7 @@ export class LogstashPipelineThroughputMetric extends LogstashMetric { } } } - }; + }); this.calculation = (bucket, _key, _metric, bucketSizeInSeconds) => { const pipelineThroughputs = {}; @@ -353,24 +356,31 @@ export class LogstashPipelineNodeCountMetric extends LogstashMetric { derivative: false }); - this.dateHistogramSubAggs = { - pipelines_nested: { - nested: { - path: 'logstash_stats.pipelines' - }, - aggs: { - by_pipeline_id: { - terms: { - field: 'logstash_stats.pipelines.id', - size: 1000 - }, - aggs: { - to_root: { - reverse_nested: {}, - aggs: { - node_count: { - cardinality: { - field: this.field + this.getDateHistogramSubAggs = ({ pageOfPipelines }) => { + const termAggExtras = {}; + if (pageOfPipelines) { + termAggExtras.include = pageOfPipelines.map(pipeline => pipeline.id); + } + return { + pipelines_nested: { + nested: { + path: 'logstash_stats.pipelines' + }, + aggs: { + by_pipeline_id: { + terms: { + field: 'logstash_stats.pipelines.id', + size: 1000, + ...termAggExtras + }, + aggs: { + to_root: { + reverse_nested: {}, + aggs: { + node_count: { + cardinality: { + field: this.field + } } } } @@ -378,7 +388,7 @@ export class LogstashPipelineNodeCountMetric extends LogstashMetric { } } } - } + }; }; this.calculation = bucket => { diff --git a/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/metrics.js b/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/metrics.js index a87f79533da3b4..6c9bc315858068 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/metrics.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/metrics.js @@ -324,7 +324,7 @@ export const metrics = { label: pipelineThroughputLabel, description: pipelineThroughputDescription, format: LARGE_FLOAT, - units: eventsPerSecondUnitLabel + units: eventsPerSecondUnitLabel, }), logstash_node_pipeline_throughput: new LogstashPipelineThroughputMetric({ uuidField: 'logstash_stats.logstash.uuid', // TODO: add comment explaining why diff --git a/x-pack/legacy/plugins/monitoring/server/lib/pagination/filter.js b/x-pack/legacy/plugins/monitoring/server/lib/pagination/filter.js new file mode 100644 index 00000000000000..7cc91d8deeb326 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/pagination/filter.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { get } from 'lodash'; + +function defaultFilterFn(value, query) { + if (value.toLowerCase().includes(query.toLowerCase())) { + return true; + } + return false; +} + +export function filter(data, queryText, fields, filterFn = defaultFilterFn) { + return data.filter(item => { + for (const field of fields) { + if (filterFn(get(item, field), queryText)) { + return true; + } + } + }); +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/pagination/paginate.js b/x-pack/legacy/plugins/monitoring/server/lib/pagination/paginate.js new file mode 100644 index 00000000000000..b5e63fb862fe73 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/pagination/paginate.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function paginate({ size, index }, data) { + const start = index * size; + return data.slice(start, Math.min(data.length, start + size)); +} diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/index.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/index.js index 7f9e6d71621b91..796b5f29cef6cb 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/index.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/index.js @@ -10,3 +10,4 @@ export { logstashOverviewRoute } from './overview'; export { logstashPipelineRoute } from './pipeline'; export { logstashNodePipelinesRoute } from './pipelines/node_pipelines'; export { logstashClusterPipelinesRoute } from './pipelines/cluster_pipelines'; +export { logstashClusterPipelineIdsRoute } from './pipelines/cluster_pipeline_ids'; diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js new file mode 100644 index 00000000000000..066b89fc325bb6 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; +import { handleError } from '../../../../../lib/errors'; +import { prefixIndexPattern } from '../../../../../lib/ccs_utils'; +import { INDEX_PATTERN_LOGSTASH } from '../../../../../../common/constants'; +import { getLogstashPipelineIds } from '../../../../../lib/logstash/get_pipeline_ids'; + +/** + * Retrieve pipelines for a cluster + */ +export function logstashClusterPipelineIdsRoute(server) { + server.route({ + method: 'POST', + path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/pipeline_ids', + config: { + validate: { + params: Joi.object({ + clusterUuid: Joi.string().required() + }), + payload: Joi.object({ + ccs: Joi.string().optional(), + timeRange: Joi.object({ + min: Joi.date().required(), + max: Joi.date().required() + }).required() + }) + } + }, + handler: async (req) => { + const config = server.config(); + const { ccs } = req.payload; + const clusterUuid = req.params.clusterUuid; + const lsIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_LOGSTASH, ccs); + const size = config.get('xpack.monitoring.max_bucket_size'); + + try { + const pipelines = await getLogstashPipelineIds(req, lsIndexPattern, { clusterUuid }, size); + return pipelines; + } catch (err) { + throw handleError(err, req); + } + } + }); +} diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js index 247e734360dc91..c55f8c19037d58 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js @@ -10,6 +10,7 @@ import { getPipelines, processPipelinesAPIResponse } from '../../../../../lib/lo import { handleError } from '../../../../../lib/errors'; import { prefixIndexPattern } from '../../../../../lib/ccs_utils'; import { INDEX_PATTERN_LOGSTASH } from '../../../../../../common/constants'; +import { getPaginatedPipelines } from '../../../../../lib/logstash/get_paginated_pipelines'; /** * Retrieve pipelines for a cluster @@ -28,13 +29,22 @@ export function logstashClusterPipelinesRoute(server) { timeRange: Joi.object({ min: Joi.date().required(), max: Joi.date().required() - }).required() + }).required(), + pagination: Joi.object({ + index: Joi.number().required(), + size: Joi.number().required() + }).required(), + sort: Joi.object({ + field: Joi.string().required(), + direction: Joi.string().required() + }).optional(), + queryText: Joi.string().default('').allow('').optional(), }) } }, handler: async (req) => { const config = server.config(); - const { ccs } = req.payload; + const { ccs, pagination, sort, queryText } = req.payload; const clusterUuid = req.params.clusterUuid; const lsIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_LOGSTASH, ccs); @@ -46,16 +56,40 @@ export function logstashClusterPipelinesRoute(server) { nodesCountMetric ]; + // The client side fields do not match the server side metric names + // so adjust that here. See processPipelinesAPIResponse + const sortMetricSetMap = { + latestThroughput: throughputMetric, + latestNodesCount: nodesCountMetric + }; + if (sort) { + sort.field = sortMetricSetMap[sort.field] || sort.field; + } + + const { pageOfPipelines, totalPipelineCount } = + await getPaginatedPipelines(req, lsIndexPattern, {}, metricSet, pagination, sort, queryText); + + // Just the IDs for the rest + const pipelineIds = pageOfPipelines.map(pipeline => pipeline.id); + + const metricOptions = { + pageOfPipelines, + }; + try { + const pipelineData = await getPipelines(req, lsIndexPattern, pipelineIds, metricSet, metricOptions); const response = await processPipelinesAPIResponse( { - pipelines: await getPipelines(req, lsIndexPattern, metricSet), + pipelines: pipelineData, clusterStatus: await getClusterStatus(req, lsIndexPattern, { clusterUuid }) }, throughputMetric, nodesCountMetric ); - return response; + return { + ...response, + totalPipelineCount + }; } catch (err) { throw handleError(err, req); } diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js index faec0791d7c326..84f16267701276 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js @@ -10,6 +10,7 @@ import { getPipelines, processPipelinesAPIResponse } from '../../../../../lib/lo import { handleError } from '../../../../../lib/errors'; import { prefixIndexPattern } from '../../../../../lib/ccs_utils'; import { INDEX_PATTERN_LOGSTASH } from '../../../../../../common/constants'; +import { getPaginatedPipelines } from '../../../../../lib/logstash/get_paginated_pipelines'; /** * Retrieve pipelines for a node @@ -29,15 +30,25 @@ export function logstashNodePipelinesRoute(server) { timeRange: Joi.object({ min: Joi.date().required(), max: Joi.date().required() - }).required() + }).required(), + pagination: Joi.object({ + index: Joi.number().required(), + size: Joi.number().required() + }).required(), + sort: Joi.object({ + field: Joi.string().required(), + direction: Joi.string().required() + }).optional(), + queryText: Joi.string().default('').allow('').optional(), }) } }, handler: async (req) => { const config = server.config(); - const { ccs } = req.payload; + const { ccs, pagination, sort, queryText } = req.payload; const { clusterUuid, logstashUuid } = req.params; const lsIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_LOGSTASH, ccs); + const throughputMetric = 'logstash_node_pipeline_throughput'; const nodesCountMetric = 'logstash_node_pipeline_nodes_count'; const metricSet = [ @@ -45,16 +56,40 @@ export function logstashNodePipelinesRoute(server) { nodesCountMetric ]; + // The client side fields do not match the server side metric names + // so adjust that here. See processPipelinesAPIResponse + const sortMetricSetMap = { + latestThroughput: throughputMetric, + latestNodesCount: nodesCountMetric + }; + if (sort) { + sort.field = sortMetricSetMap[sort.field] || sort.field; + } + + const { pageOfPipelines, totalPipelineCount } + = await getPaginatedPipelines(req, lsIndexPattern, { clusterUuid, logstashUuid }, metricSet, pagination, sort, queryText); + + // Just the IDs for the rest + const pipelineIds = pageOfPipelines.map(pipeline => pipeline.id); + + const metricOptions = { + pageOfPipelines, + }; + try { + const pipelineData = await getPipelines(req, lsIndexPattern, pipelineIds, metricSet, metricOptions); const response = await processPipelinesAPIResponse( { - pipelines: await getPipelines(req, lsIndexPattern, metricSet), + pipelines: pipelineData, nodeSummary: await getNodeInfo(req, lsIndexPattern, { clusterUuid, logstashUuid }) }, throughputMetric, nodesCountMetric ); - return response; + return { + ...response, + totalPipelineCount + }; } catch (err) { throw handleError(err, req); } diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js index 302cdafc725612..dc0549a283972b 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js @@ -52,6 +52,7 @@ export { logstashNodeRoute, logstashNodesRoute, logstashOverviewRoute, - logstashPipelineRoute + logstashPipelineRoute, + logstashClusterPipelineIdsRoute } from './logstash'; export * from './setup'; diff --git a/x-pack/test/functional/apps/monitoring/logstash/pipelines.js b/x-pack/test/functional/apps/monitoring/logstash/pipelines.js index 4d2a7dbc688879..fb9fcf8bab8bb4 100644 --- a/x-pack/test/functional/apps/monitoring/logstash/pipelines.js +++ b/x-pack/test/functional/apps/monitoring/logstash/pipelines.js @@ -8,6 +8,8 @@ import expect from '@kbn/expect'; import { getLifecycleMethods } from '../_get_lifecycle_methods'; export default function ({ getService, getPageObjects }) { + const PageObjects = getPageObjects(['common']); + const retry = getService('retry'); const overview = getService('monitoringClusterOverview'); const pipelinesList = getService('monitoringLogstashPipelines'); const lsClusterSummaryStatus = getService('monitoringLogstashSummaryStatus'); @@ -43,6 +45,8 @@ export default function ({ getService, getPageObjects }) { const rows = await pipelinesList.getRows(); expect(rows.length).to.be(4); + await pipelinesList.clickIdCol(); + const pipelinesAll = await pipelinesList.getPipelinesAll(); const tableData = [ @@ -85,8 +89,11 @@ export default function ({ getService, getPageObjects }) { it('should filter for specific pipelines', async () => { await pipelinesList.setFilter('la'); - const rows = await pipelinesList.getRows(); - expect(rows.length).to.be(2); + await PageObjects.common.pressEnterKey(); + await retry.try(async () => { + const rows = await pipelinesList.getRows(); + expect(rows.length).to.be(2); + }); await pipelinesList.clearFilter(); }); diff --git a/x-pack/test/functional/page_objects/monitoring_page.js b/x-pack/test/functional/page_objects/monitoring_page.js index 9eef3dff01bbad..84c9981a1bcc99 100644 --- a/x-pack/test/functional/page_objects/monitoring_page.js +++ b/x-pack/test/functional/page_objects/monitoring_page.js @@ -50,7 +50,8 @@ export function MonitoringPageProvider({ getPageObjects, getService }) { } async tableSetFilter(subj, text) { - return await testSubjects.setValue(subj, text); + await testSubjects.setValue(subj, text); + await PageObjects.common.pressEnterKey(); } async tableClearFilter(subj) { diff --git a/x-pack/test/functional/services/monitoring/logstash_pipelines.js b/x-pack/test/functional/services/monitoring/logstash_pipelines.js index f2c3fb839f7af6..d4d367665ef5b8 100644 --- a/x-pack/test/functional/services/monitoring/logstash_pipelines.js +++ b/x-pack/test/functional/services/monitoring/logstash_pipelines.js @@ -18,6 +18,7 @@ export function MonitoringLogstashPipelinesProvider({ getService, getPageObjects const SUBJ_SEARCH_BAR = `${SUBJ_TABLE_CONTAINER} > monitoringTableToolBar`; const SUBJ_TABLE_SORT_EVENTS_EMITTED_RATE_COL = `${SUBJ_TABLE_CONTAINER} > tableHeaderCell_latestThroughput_1`; + const SUBJ_TABLE_SORT_ID_COL = `${SUBJ_TABLE_CONTAINER} > tableHeaderCell_id_0`; const SUBJ_PIPELINES_IDS = `${SUBJ_TABLE_CONTAINER} > id`; const SUBJ_PIPELINES_EVENTS_EMITTED_RATES = `${SUBJ_TABLE_CONTAINER} > eventsEmittedRate`; @@ -53,6 +54,12 @@ export function MonitoringLogstashPipelinesProvider({ getService, getPageObjects }, []); } + async clickIdCol() { + const headerCell = await testSubjects.find(SUBJ_TABLE_SORT_ID_COL); + const button = await headerCell.findByTagName('button'); + return button.click(); + } + async clickEventsEmittedRateCol() { const headerCell = await testSubjects.find(SUBJ_TABLE_SORT_EVENTS_EMITTED_RATE_COL); const button = await headerCell.findByTagName('button'); From bc840e678933a0e95ee69862f719f3975b63d7f7 Mon Sep 17 00:00:00 2001 From: Aaron Caldwell Date: Wed, 2 Oct 2019 13:34:20 -0600 Subject: [PATCH 40/59] [Maps] Add 'InjectedData' class and revise so File Upload Features are assigned to new InjectedData instances (#46381) * Add 'PushedData' class and revise so data is assigned to new instances * Update 'pushedData' refs to 'injectedData' * Set default visibility to true on injected data * Review feedback * Update tests to account for injectedData * Review feedback --- .../public/angular/get_initial_layers.test.js | 2 + .../plugins/maps/public/layers/layer.js | 16 +++++- .../client_file_source/geojson_file_source.js | 41 +++++---------- .../maps/public/layers/sources/source.js | 4 ++ .../maps/public/layers/util/injected_data.js | 21 ++++++++ .../maps/public/layers/vector_layer.js | 52 ++++++++++++++----- 6 files changed, 94 insertions(+), 42 deletions(-) create mode 100644 x-pack/legacy/plugins/maps/public/layers/util/injected_data.js diff --git a/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.test.js b/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.test.js index 5dc08751347e41..b15b94a49cebc5 100644 --- a/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.test.js +++ b/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.test.js @@ -44,6 +44,7 @@ describe('kibana.yml configured with map.tilemap.url', () => { expect(layers).toEqual([{ alpha: 1, __dataRequests: [], + __injectedData: null, id: layers[0].id, applyGlobalQuery: true, label: null, @@ -86,6 +87,7 @@ describe('EMS is enabled', () => { expect(layers).toEqual([{ alpha: 1, __dataRequests: [], + __injectedData: null, id: layers[0].id, applyGlobalQuery: true, label: null, diff --git a/x-pack/legacy/plugins/maps/public/layers/layer.js b/x-pack/legacy/plugins/maps/public/layers/layer.js index ab1af73634c600..c1e45d9b7c7a82 100644 --- a/x-pack/legacy/plugins/maps/public/layers/layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/layer.js @@ -9,7 +9,11 @@ import { EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; import turf from 'turf'; import turfBooleanContains from '@turf/boolean-contains'; import { DataRequest } from './util/data_request'; -import { MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER, SOURCE_DATA_ID_ORIGIN } from '../../common/constants'; +import { InjectedData } from './util/injected_data'; +import { + MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER, + SOURCE_DATA_ID_ORIGIN +} from '../../common/constants'; import uuid from 'uuid/v4'; import { copyPersistentState } from '../reducers/util'; import { i18n } from '@kbn/i18n'; @@ -28,6 +32,11 @@ export class AbstractLayer { } else { this._dataRequests = []; } + if (this._descriptor.__injectedData) { + this._injectedData = new InjectedData(this._descriptor.__injectedData); + } else { + this._injectedData = null; + } } static getBoundDataForSource(mbMap, sourceId) { @@ -39,6 +48,7 @@ export class AbstractLayer { const layerDescriptor = { ...options }; layerDescriptor.__dataRequests = _.get(options, '__dataRequests', []); + layerDescriptor.__injectedData = _.get(options, '__injectedData', null); layerDescriptor.id = _.get(options, 'id', uuid()); layerDescriptor.label = options.label && options.label.length > 0 ? options.label : null; layerDescriptor.minZoom = _.get(options, 'minZoom', 0); @@ -277,6 +287,10 @@ export class AbstractLayer { return this._dataRequests.find(dataRequest => dataRequest.getDataId() === id); } + getInjectedData() { + return this._injectedData ? this._injectedData.getData() : null; + } + isLayerLoading() { return this._dataRequests.some(dataRequest => dataRequest.isLoading()); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js index db255074fa261f..ba5993f28b360f 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js @@ -36,18 +36,9 @@ export class GeojsonFileSource extends AbstractVectorSource { applyGlobalQuery: DEFAULT_APPLY_GLOBAL_QUERY } - static createDescriptor(geoJson, name) { - // Wrap feature as feature collection if needed - const featureCollection = (geoJson.type === 'Feature') - ? { - type: 'FeatureCollection', - features: [{ ...geoJson }] - } - : geoJson; - + static createDescriptor(name) { return { type: GeojsonFileSource.type, - featureCollection, name }; } @@ -95,9 +86,16 @@ export class GeojsonFileSource extends AbstractVectorSource { onPreviewSource(null); return; } - const sourceDescriptor = GeojsonFileSource.createDescriptor(geojsonFile, name); + const sourceDescriptor = GeojsonFileSource.createDescriptor(name); const source = new GeojsonFileSource(sourceDescriptor, inspectorAdapters); - onPreviewSource(source); + const featureCollection = (geojsonFile.type === 'Feature') + ? { + type: 'FeatureCollection', + features: [{ ...geojsonFile }] + } + : geojsonFile; + + onPreviewSource(source, { __injectedData: featureCollection }); }; }; @@ -128,22 +126,6 @@ export class GeojsonFileSource extends AbstractVectorSource { ); } - async getGeoJsonWithMeta() { - const copiedPropsFeatures = this._descriptor.featureCollection.features - .map(feature => ({ - type: 'Feature', - geometry: feature.geometry, - properties: feature.properties ? { ...feature.properties } : {} - })); - return { - data: { - type: 'FeatureCollection', - features: copiedPropsFeatures - }, - meta: {} - }; - } - async getDisplayName() { return this._descriptor.name; } @@ -156,4 +138,7 @@ export class GeojsonFileSource extends AbstractVectorSource { return GeojsonFileSource.isIndexingSource; } + isInjectedData() { + return true; + } } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/source.js b/x-pack/legacy/plugins/maps/public/layers/sources/source.js index a029ffe2d2ab59..01479c4319d940 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/source.js @@ -115,6 +115,10 @@ export class AbstractSource { return AbstractSource.isIndexingSource; } + isInjectedData() { + return false; + } + supportsElasticsearchFilters() { return false; } diff --git a/x-pack/legacy/plugins/maps/public/layers/util/injected_data.js b/x-pack/legacy/plugins/maps/public/layers/util/injected_data.js new file mode 100644 index 00000000000000..8c18819e9f8b57 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/util/injected_data.js @@ -0,0 +1,21 @@ +/* + * 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 InjectedData { + + constructor(data) { + this._descriptor = { data }; + } + + getData() { + return this._descriptor.data; + } + + hasData() { + return !!this._descriptor.data; + } + +} + diff --git a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js index 7f7875d7fb995c..829078d98996e9 100644 --- a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js @@ -131,9 +131,20 @@ export class VectorLayer extends AbstractLayer { return true; } + getInjectedData() { + const featureCollection = super.getInjectedData(); + if (!featureCollection) { + return null; + } + // Set default visible property on data + featureCollection.features.forEach( + feature => _.set(feature, `properties.${FEATURE_VISIBLE_PROPERTY_NAME}`, true) + ); + return featureCollection; + } + getCustomIconAndTooltipContent() { - const sourceDataRequest = this.getSourceDataRequest(); - const featureCollection = sourceDataRequest ? sourceDataRequest.getData() : null; + const featureCollection = this._getSourceFeatureCollection(); const noResultsIcon = ( Date: Wed, 2 Oct 2019 18:23:44 -0400 Subject: [PATCH 41/59] Add KQL functionality in the find function of the saved objects (#41136) * Add KQL functionality in the find function of the saved objects wip rename variable from KQL to filter, fix unit test + add new ones miss security pluggins review I fix api changes refactor after reviewing with Rudolf fix type review III review IV for security put back allowed logic back to return empty results remove StaticIndexPattern review V fix core_api_changes fix type * validate filter to match requirement type.attributes.key or type.savedObjectKey * Fix types * fix a bug + add more api integration test * fix types in test until we create package @kbn/types * fix type issue * fix api integration test * export nodeTypes from packages @kbn/es-query instead of the function buildNodeKuery * throw 400- bad request when validation error in find * fix type issue * accept api change * renove _ to represent private * fix unit test + add doc * add comment to explain why we removed the private --- docs/api/saved-objects/find.asciidoc | 5 + ...a-plugin-public.savedobjectsclient.find.md | 2 +- ...kibana-plugin-public.savedobjectsclient.md | 2 +- ...n-public.savedobjectsfindoptions.filter.md | 11 + ...a-plugin-public.savedobjectsfindoptions.md | 1 + ...n-server.savedobjectsfindoptions.filter.md | 11 + ...a-plugin-server.savedobjectsfindoptions.md | 1 + packages/kbn-es-query/src/kuery/ast/ast.d.ts | 11 +- .../kbn-es-query/src/kuery/functions/is.js | 3 +- packages/kbn-es-query/src/kuery/index.d.ts | 10 + packages/kbn-es-query/src/kuery/index.js | 2 +- .../src/kuery/node_types/index.d.ts | 76 ++ .../notifications/notifications_service.ts | 2 +- src/core/public/public.api.md | 4 +- .../saved_objects/saved_objects_client.ts | 1 + .../server/saved_objects/service/index.ts | 1 + .../service/lib/cache_index_patterns.test.ts | 108 +++ .../service/lib/cache_index_patterns.ts | 82 ++ .../service/lib/filter_utils.test.ts | 457 ++++++++++++ .../saved_objects/service/lib/filter_utils.ts | 190 +++++ .../server/saved_objects/service/lib/index.ts | 2 + .../service/lib/repository.test.js | 18 +- .../saved_objects/service/lib/repository.ts | 40 +- .../lib/search_dsl/query_params.test.ts | 698 +++++++++++++++--- .../service/lib/search_dsl/query_params.ts | 46 +- .../service/lib/search_dsl/search_dsl.test.ts | 20 +- .../service/lib/search_dsl/search_dsl.ts | 14 +- src/core/server/saved_objects/types.ts | 1 + src/core/server/server.api.md | 3 + .../core_plugins/elasticsearch/index.d.ts | 2 +- ...create_saved_objects_stream_from_ndjson.ts | 2 +- .../server/saved_objects/routes/find.ts | 5 + .../saved_objects/saved_objects_mixin.js | 9 + .../saved_objects/saved_objects_mixin.test.js | 5 + .../data/common/field_formats/field_format.ts | 16 +- .../apis/saved_objects/find.js | 87 +++ test/tsconfig.json | 4 +- test/typings/index.d.ts | 6 + .../common/suites/find.ts | 75 ++ .../security_and_spaces/apis/find.ts | 251 +++++++ .../security_only/apis/find.ts | 276 +++++++ .../spaces_only/apis/find.ts | 51 ++ 42 files changed, 2459 insertions(+), 152 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.filter.md create mode 100644 docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.filter.md create mode 100644 packages/kbn-es-query/src/kuery/node_types/index.d.ts create mode 100644 src/core/server/saved_objects/service/lib/cache_index_patterns.test.ts create mode 100644 src/core/server/saved_objects/service/lib/cache_index_patterns.ts create mode 100644 src/core/server/saved_objects/service/lib/filter_utils.test.ts create mode 100644 src/core/server/saved_objects/service/lib/filter_utils.ts diff --git a/docs/api/saved-objects/find.asciidoc b/docs/api/saved-objects/find.asciidoc index fd80951b1c9f25..f20ded78e07434 100644 --- a/docs/api/saved-objects/find.asciidoc +++ b/docs/api/saved-objects/find.asciidoc @@ -41,6 +41,11 @@ experimental[] Retrieve a paginated set of {kib} saved objects by various condit `has_reference`:: (Optional, object) Filters to objects that have a relationship with the type and ID combination. +`filter`:: + (Optional, string) The filter is a KQL string with the caveat that if you filter with an attribute from your type saved object. + It should look like that savedObjectType.attributes.title: "myTitle". However, If you used a direct attribute of a saved object like `updatedAt`, + you will have to define your filter like that savedObjectType.updatedAt > 2018-12-22. + NOTE: As objects change in {kib}, the results on each page of the response also change. Use the find API for traditional paginated results, but avoid using it to export large amounts of data. diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md index 80ddb1aea18d15..a4fa3f17d0d94f 100644 --- a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md @@ -9,5 +9,5 @@ Search for objects Signature: ```typescript -find: (options: Pick) => Promise>; +find: (options: Pick) => Promise>; ``` diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md index 2ad9591426ab26..00a71d25cea38a 100644 --- a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md @@ -20,7 +20,7 @@ export declare class SavedObjectsClient | [bulkGet](./kibana-plugin-public.savedobjectsclient.bulkget.md) | | (objects?: {
id: string;
type: string;
}[]) => Promise<SavedObjectsBatchResponse<SavedObjectAttributes>> | Returns an array of objects by id | | [create](./kibana-plugin-public.savedobjectsclient.create.md) | | <T extends SavedObjectAttributes>(type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise<SimpleSavedObject<T>> | Persists an object | | [delete](./kibana-plugin-public.savedobjectsclient.delete.md) | | (type: string, id: string) => Promise<{}> | Deletes an object | -| [find](./kibana-plugin-public.savedobjectsclient.find.md) | | <T extends SavedObjectAttributes>(options: Pick<SavedObjectFindOptionsServer, "search" | "type" | "defaultSearchOperator" | "searchFields" | "sortField" | "hasReference" | "page" | "perPage" | "fields">) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | +| [find](./kibana-plugin-public.savedobjectsclient.find.md) | | <T extends SavedObjectAttributes>(options: Pick<SavedObjectFindOptionsServer, "search" | "filter" | "type" | "page" | "perPage" | "sortField" | "fields" | "searchFields" | "hasReference" | "defaultSearchOperator">) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | | [get](./kibana-plugin-public.savedobjectsclient.get.md) | | <T extends SavedObjectAttributes>(type: string, id: string) => Promise<SimpleSavedObject<T>> | Fetches a single object | ## Methods diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.filter.md b/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.filter.md new file mode 100644 index 00000000000000..82237134e0b22c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.filter.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [SavedObjectsFindOptions](./kibana-plugin-public.savedobjectsfindoptions.md) > [filter](./kibana-plugin-public.savedobjectsfindoptions.filter.md) + +## SavedObjectsFindOptions.filter property + +Signature: + +```typescript +filter?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.md index f90c60ebdd0dc1..4c916431d4333f 100644 --- a/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.md @@ -17,6 +17,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions | --- | --- | --- | | [defaultSearchOperator](./kibana-plugin-public.savedobjectsfindoptions.defaultsearchoperator.md) | 'AND' | 'OR' | | | [fields](./kibana-plugin-public.savedobjectsfindoptions.fields.md) | string[] | An array of fields to include in the results | +| [filter](./kibana-plugin-public.savedobjectsfindoptions.filter.md) | string | | | [hasReference](./kibana-plugin-public.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
} | | | [page](./kibana-plugin-public.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-public.savedobjectsfindoptions.perpage.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.filter.md b/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.filter.md new file mode 100644 index 00000000000000..308bebbeaf60b8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.filter.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsFindOptions](./kibana-plugin-server.savedobjectsfindoptions.md) > [filter](./kibana-plugin-server.savedobjectsfindoptions.filter.md) + +## SavedObjectsFindOptions.filter property + +Signature: + +```typescript +filter?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.md index ad81c439d902c0..dfd51d480db926 100644 --- a/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.md @@ -17,6 +17,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions | --- | --- | --- | | [defaultSearchOperator](./kibana-plugin-server.savedobjectsfindoptions.defaultsearchoperator.md) | 'AND' | 'OR' | | | [fields](./kibana-plugin-server.savedobjectsfindoptions.fields.md) | string[] | An array of fields to include in the results | +| [filter](./kibana-plugin-server.savedobjectsfindoptions.filter.md) | string | | | [hasReference](./kibana-plugin-server.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
} | | | [page](./kibana-plugin-server.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-server.savedobjectsfindoptions.perpage.md) | number | | diff --git a/packages/kbn-es-query/src/kuery/ast/ast.d.ts b/packages/kbn-es-query/src/kuery/ast/ast.d.ts index 915c024f2ab48d..448ef0e9cca750 100644 --- a/packages/kbn-es-query/src/kuery/ast/ast.d.ts +++ b/packages/kbn-es-query/src/kuery/ast/ast.d.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonObject } from '..'; + /** * WARNING: these typings are incomplete */ @@ -30,15 +32,6 @@ export interface KueryParseOptions { startRule: string; } -type JsonValue = null | boolean | number | string | JsonObject | JsonArray; - -interface JsonObject { - [key: string]: JsonValue; -} - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -interface JsonArray extends Array {} - export function fromKueryExpression( expression: string, parseOptions?: KueryParseOptions diff --git a/packages/kbn-es-query/src/kuery/functions/is.js b/packages/kbn-es-query/src/kuery/functions/is.js index 0338671e9b3fe4..690f98b08ba827 100644 --- a/packages/kbn-es-query/src/kuery/functions/is.js +++ b/packages/kbn-es-query/src/kuery/functions/is.js @@ -32,7 +32,6 @@ export function buildNodeParams(fieldName, value, isPhrase = false) { if (_.isUndefined(value)) { throw new Error('value is a required argument'); } - const fieldNode = typeof fieldName === 'string' ? ast.fromLiteralExpression(fieldName) : literal.buildNode(fieldName); const valueNode = typeof value === 'string' ? ast.fromLiteralExpression(value) : literal.buildNode(value); const isPhraseNode = literal.buildNode(isPhrase); @@ -42,7 +41,7 @@ export function buildNodeParams(fieldName, value, isPhrase = false) { } export function toElasticsearchQuery(node, indexPattern = null, config = {}) { - const { arguments: [ fieldNameArg, valueArg, isPhraseArg ] } = node; + const { arguments: [fieldNameArg, valueArg, isPhraseArg] } = node; const fieldName = ast.toElasticsearchQuery(fieldNameArg); const value = !_.isUndefined(valueArg) ? ast.toElasticsearchQuery(valueArg) : valueArg; const type = isPhraseArg.value ? 'phrase' : 'best_fields'; diff --git a/packages/kbn-es-query/src/kuery/index.d.ts b/packages/kbn-es-query/src/kuery/index.d.ts index 9d797406420d41..b01a8914f68ef3 100644 --- a/packages/kbn-es-query/src/kuery/index.d.ts +++ b/packages/kbn-es-query/src/kuery/index.d.ts @@ -18,3 +18,13 @@ */ export * from './ast'; +export { nodeTypes } from './node_types'; + +export type JsonValue = null | boolean | number | string | JsonObject | JsonArray; + +export interface JsonObject { + [key: string]: JsonValue; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface JsonArray extends Array {} diff --git a/packages/kbn-es-query/src/kuery/index.js b/packages/kbn-es-query/src/kuery/index.js index 84c6a205b42ce6..08fa9829d4a566 100644 --- a/packages/kbn-es-query/src/kuery/index.js +++ b/packages/kbn-es-query/src/kuery/index.js @@ -19,5 +19,5 @@ export * from './ast'; export * from './filter_migration'; -export * from './node_types'; +export { nodeTypes } from './node_types'; export * from './errors'; diff --git a/packages/kbn-es-query/src/kuery/node_types/index.d.ts b/packages/kbn-es-query/src/kuery/node_types/index.d.ts new file mode 100644 index 00000000000000..0d1f2c28e39f08 --- /dev/null +++ b/packages/kbn-es-query/src/kuery/node_types/index.d.ts @@ -0,0 +1,76 @@ +/* + * 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. + */ + +/** + * WARNING: these typings are incomplete + */ + +import { JsonObject, JsonValue } from '..'; + +type FunctionName = + | 'is' + | 'and' + | 'or' + | 'not' + | 'range' + | 'exists' + | 'geoBoundingBox' + | 'geoPolygon'; + +interface FunctionTypeBuildNode { + type: 'function'; + function: FunctionName; + // TODO -> Need to define a better type for DSL query + arguments: any[]; +} + +interface FunctionType { + buildNode: (functionName: FunctionName, ...args: any[]) => FunctionTypeBuildNode; + buildNodeWithArgumentNodes: (functionName: FunctionName, ...args: any[]) => FunctionTypeBuildNode; + toElasticsearchQuery: (node: any, indexPattern: any, config: JsonObject) => JsonValue; +} + +interface LiteralType { + buildNode: ( + value: null | boolean | number | string + ) => { type: 'literal'; value: null | boolean | number | string }; + toElasticsearchQuery: (node: any) => null | boolean | number | string; +} + +interface NamedArgType { + buildNode: (name: string, value: any) => { type: 'namedArg'; name: string; value: any }; + toElasticsearchQuery: (node: any) => string; +} + +interface WildcardType { + buildNode: (value: string) => { type: 'wildcard'; value: string }; + test: (node: any, string: string) => boolean; + toElasticsearchQuery: (node: any) => string; + toQueryStringQuery: (node: any) => string; + hasLeadingWildcard: (node: any) => boolean; +} + +interface NodeTypes { + function: FunctionType; + literal: LiteralType; + namedArg: NamedArgType; + wildcard: WildcardType; +} + +export const nodeTypes: NodeTypes; diff --git a/src/core/public/notifications/notifications_service.ts b/src/core/public/notifications/notifications_service.ts index 2dc2b2ef06094f..33221522fa83ca 100644 --- a/src/core/public/notifications/notifications_service.ts +++ b/src/core/public/notifications/notifications_service.ts @@ -48,7 +48,7 @@ export class NotificationsService { public setup({ uiSettings }: SetupDeps): NotificationsSetup { const notificationSetup = { toasts: this.toasts.setup({ uiSettings }) }; - this.uiSettingsErrorSubscription = uiSettings.getUpdateErrors$().subscribe(error => { + this.uiSettingsErrorSubscription = uiSettings.getUpdateErrors$().subscribe((error: Error) => { notificationSetup.toasts.addDanger({ title: i18n.translate('core.notifications.unableUpdateUISettingNotificationMessageTitle', { defaultMessage: 'Unable to update UI setting', diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index b2d730d7fa4670..102e77b564a6d6 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -752,7 +752,7 @@ export class SavedObjectsClient { }[]) => Promise>; create: (type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise>; delete: (type: string, id: string) => Promise<{}>; - find: (options: Pick) => Promise>; + find: (options: Pick) => Promise>; get: (type: string, id: string) => Promise>; update(type: string, id: string, attributes: T, { version, migrationVersion, references }?: SavedObjectsUpdateOptions): Promise>; } @@ -775,6 +775,8 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; // (undocumented) + filter?: string; + // (undocumented) hasReference?: { type: string; id: string; diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index dc13d001643a31..cf0300157aece3 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -297,6 +297,7 @@ export class SavedObjectsClient { searchFields: 'search_fields', sortField: 'sort_field', type: 'type', + filter: 'filter', }; const renamedQuery = renameKeys(renameMap, options); diff --git a/src/core/server/saved_objects/service/index.ts b/src/core/server/saved_objects/service/index.ts index 685ce51bc7d29c..dbf35ff4e134d7 100644 --- a/src/core/server/saved_objects/service/index.ts +++ b/src/core/server/saved_objects/service/index.ts @@ -56,6 +56,7 @@ export { SavedObjectsClientWrapperFactory, SavedObjectsClientWrapperOptions, SavedObjectsErrorHelpers, + SavedObjectsCacheIndexPatterns, } from './lib'; export * from './saved_objects_client'; diff --git a/src/core/server/saved_objects/service/lib/cache_index_patterns.test.ts b/src/core/server/saved_objects/service/lib/cache_index_patterns.test.ts new file mode 100644 index 00000000000000..e3aeca42d1cf07 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/cache_index_patterns.test.ts @@ -0,0 +1,108 @@ +/* + * 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 { SavedObjectsCacheIndexPatterns } from './cache_index_patterns'; + +const mockGetFieldsForWildcard = jest.fn(); +const mockIndexPatternsService: jest.Mock = jest.fn().mockImplementation(() => ({ + getFieldsForWildcard: mockGetFieldsForWildcard, + getFieldsForTimePattern: jest.fn(), +})); + +describe('SavedObjectsRepository', () => { + let cacheIndexPatterns: SavedObjectsCacheIndexPatterns; + + const fields = [ + { + aggregatable: true, + name: 'config.type', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'foo.type', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'bar.type', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'baz.type', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'dashboard.otherField', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'hiddenType.someField', + searchable: true, + type: 'string', + }, + ]; + + beforeEach(() => { + cacheIndexPatterns = new SavedObjectsCacheIndexPatterns(); + jest.clearAllMocks(); + }); + + it('setIndexPatterns should return an error object when indexPatternsService is undefined', async () => { + try { + await cacheIndexPatterns.setIndexPatterns('test-index'); + } catch (error) { + expect(error.message).toMatch('indexPatternsService is not defined'); + } + }); + + it('setIndexPatterns should return an error object if getFieldsForWildcard is not defined', async () => { + mockGetFieldsForWildcard.mockImplementation(() => { + throw new Error('something happen'); + }); + try { + cacheIndexPatterns.setIndexPatternsService(new mockIndexPatternsService()); + await cacheIndexPatterns.setIndexPatterns('test-index'); + } catch (error) { + expect(error.message).toMatch('Index Pattern Error - something happen'); + } + }); + + it('setIndexPatterns should return empty array when getFieldsForWildcard is returning null or undefined', async () => { + mockGetFieldsForWildcard.mockImplementation(() => null); + cacheIndexPatterns.setIndexPatternsService(new mockIndexPatternsService()); + await cacheIndexPatterns.setIndexPatterns('test-index'); + expect(cacheIndexPatterns.getIndexPatterns()).toEqual(undefined); + }); + + it('setIndexPatterns should return index pattern when getFieldsForWildcard is returning fields', async () => { + mockGetFieldsForWildcard.mockImplementation(() => fields); + cacheIndexPatterns.setIndexPatternsService(new mockIndexPatternsService()); + await cacheIndexPatterns.setIndexPatterns('test-index'); + expect(cacheIndexPatterns.getIndexPatterns()).toEqual({ fields, title: 'test-index' }); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/cache_index_patterns.ts b/src/core/server/saved_objects/service/lib/cache_index_patterns.ts new file mode 100644 index 00000000000000..e96cf996f504c3 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/cache_index_patterns.ts @@ -0,0 +1,82 @@ +/* + * 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 { FieldDescriptor } from 'src/legacy/server/index_patterns/service/index_patterns_service'; +import { IndexPatternsService } from 'src/legacy/server/index_patterns'; + +export interface SavedObjectsIndexPatternField { + name: string; + type: string; + aggregatable: boolean; + searchable: boolean; +} + +export interface SavedObjectsIndexPattern { + fields: SavedObjectsIndexPatternField[]; + title: string; +} + +export class SavedObjectsCacheIndexPatterns { + private _indexPatterns: SavedObjectsIndexPattern | undefined = undefined; + private _indexPatternsService: IndexPatternsService | undefined = undefined; + + public setIndexPatternsService(indexPatternsService: IndexPatternsService) { + this._indexPatternsService = indexPatternsService; + } + + public getIndexPatternsService() { + return this._indexPatternsService; + } + + public getIndexPatterns(): SavedObjectsIndexPattern | undefined { + return this._indexPatterns; + } + + public async setIndexPatterns(index: string) { + await this._getIndexPattern(index); + } + + private async _getIndexPattern(index: string) { + try { + if (this._indexPatternsService == null) { + throw new TypeError('indexPatternsService is not defined'); + } + const fieldsDescriptor: FieldDescriptor[] = await this._indexPatternsService.getFieldsForWildcard( + { + pattern: index, + } + ); + + this._indexPatterns = + fieldsDescriptor && Array.isArray(fieldsDescriptor) && fieldsDescriptor.length > 0 + ? { + fields: fieldsDescriptor.map(field => ({ + aggregatable: field.aggregatable, + name: field.name, + searchable: field.searchable, + type: field.type, + })), + title: index, + } + : undefined; + } catch (err) { + throw new Error(`Index Pattern Error - ${err.message}`); + } + } +} diff --git a/src/core/server/saved_objects/service/lib/filter_utils.test.ts b/src/core/server/saved_objects/service/lib/filter_utils.test.ts new file mode 100644 index 00000000000000..73a0804512ed10 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/filter_utils.test.ts @@ -0,0 +1,457 @@ +/* + * 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 { fromKueryExpression } from '@kbn/es-query'; + +import { + validateFilterKueryNode, + getSavedObjectTypeIndexPatterns, + validateConvertFilterToKueryNode, +} from './filter_utils'; +import { SavedObjectsIndexPattern } from './cache_index_patterns'; + +const mockIndexPatterns: SavedObjectsIndexPattern = { + fields: [ + { + name: 'updatedAt', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'foo.title', + type: 'text', + aggregatable: true, + searchable: true, + }, + { + name: 'foo.description', + type: 'text', + aggregatable: true, + searchable: true, + }, + { + name: 'foo.bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'bar.foo', + type: 'text', + aggregatable: true, + searchable: true, + }, + { + name: 'bar.description', + type: 'text', + aggregatable: true, + searchable: true, + }, + { + name: 'hiddentype.description', + type: 'text', + aggregatable: true, + searchable: true, + }, + ], + title: 'mock', +}; + +describe('Filter Utils', () => { + describe('#validateConvertFilterToKueryNode', () => { + test('Validate a simple filter', () => { + expect( + validateConvertFilterToKueryNode(['foo'], 'foo.attributes.title: "best"', mockIndexPatterns) + ).toEqual(fromKueryExpression('foo.title: "best"')); + }); + test('Assemble filter kuery node saved object attributes with one saved object type', () => { + expect( + validateConvertFilterToKueryNode( + ['foo'], + 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', + mockIndexPatterns + ) + ).toEqual( + fromKueryExpression( + '(type: foo and updatedAt: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)' + ) + ); + }); + + test('Assemble filter with one type kuery node saved object attributes with multiple saved object type', () => { + expect( + validateConvertFilterToKueryNode( + ['foo', 'bar'], + 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', + mockIndexPatterns + ) + ).toEqual( + fromKueryExpression( + '(type: foo and updatedAt: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)' + ) + ); + }); + + test('Assemble filter with two types kuery node saved object attributes with multiple saved object type', () => { + expect( + validateConvertFilterToKueryNode( + ['foo', 'bar'], + '(bar.updatedAt: 5678654567 OR foo.updatedAt: 5678654567) and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or bar.attributes.description :*)', + mockIndexPatterns + ) + ).toEqual( + fromKueryExpression( + '((type: bar and updatedAt: 5678654567) or (type: foo and updatedAt: 5678654567)) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or bar.description :*)' + ) + ); + }); + + test('Lets make sure that we are throwing an exception if we get an error', () => { + expect(() => { + validateConvertFilterToKueryNode( + ['foo', 'bar'], + 'updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', + mockIndexPatterns + ); + }).toThrowErrorMatchingInlineSnapshot( + `"This key 'updatedAt' need to be wrapped by a saved object type like foo,bar: Bad Request"` + ); + }); + + test('Lets make sure that we are throwing an exception if we are using hiddentype with types', () => { + expect(() => { + validateConvertFilterToKueryNode([], 'hiddentype.title: "title"', mockIndexPatterns); + }).toThrowErrorMatchingInlineSnapshot(`"This type hiddentype is not allowed: Bad Request"`); + }); + }); + + describe('#validateFilterKueryNode', () => { + test('Validate filter query through KueryNode - happy path', () => { + const validationObject = validateFilterKueryNode( + fromKueryExpression( + 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + ), + ['foo'], + getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns) + ); + + expect(validationObject).toEqual([ + { + astPath: 'arguments.0', + error: null, + isSavedObjectAttr: true, + key: 'foo.updatedAt', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.title', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.1', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + ]); + }); + + test('Return Error if key is not wrapper by a saved object type', () => { + const validationObject = validateFilterKueryNode( + fromKueryExpression( + 'updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + ), + ['foo'], + getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns) + ); + + expect(validationObject).toEqual([ + { + astPath: 'arguments.0', + error: "This key 'updatedAt' need to be wrapped by a saved object type like foo", + isSavedObjectAttr: true, + key: 'updatedAt', + type: null, + }, + { + astPath: 'arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.title', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.1', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + ]); + }); + + test('Return Error if key of a saved object type is not wrapped with attributes', () => { + const validationObject = validateFilterKueryNode( + fromKueryExpression( + 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.description :*)' + ), + ['foo'], + getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns) + ); + + expect(validationObject).toEqual([ + { + astPath: 'arguments.0', + error: null, + isSavedObjectAttr: true, + key: 'foo.updatedAt', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.0', + error: + "This key 'foo.bytes' does NOT match the filter proposition SavedObjectType.attributes.key", + isSavedObjectAttr: false, + key: 'foo.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.title', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.1', + error: + "This key 'foo.description' does NOT match the filter proposition SavedObjectType.attributes.key", + isSavedObjectAttr: false, + key: 'foo.description', + type: 'foo', + }, + ]); + }); + + test('Return Error if filter is not using an allowed type', () => { + const validationObject = validateFilterKueryNode( + fromKueryExpression( + 'bar.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + ), + ['foo'], + getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns) + ); + + expect(validationObject).toEqual([ + { + astPath: 'arguments.0', + error: 'This type bar is not allowed', + isSavedObjectAttr: true, + key: 'bar.updatedAt', + type: 'bar', + }, + { + astPath: 'arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.title', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.1', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + ]); + }); + + test('Return Error if filter is using an non-existing key in the index patterns of the saved object type', () => { + const validationObject = validateFilterKueryNode( + fromKueryExpression( + 'foo.updatedAt33: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.header: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + ), + ['foo'], + getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns) + ); + + expect(validationObject).toEqual([ + { + astPath: 'arguments.0', + error: "This key 'foo.updatedAt33' does NOT exist in foo saved object index patterns", + isSavedObjectAttr: false, + key: 'foo.updatedAt33', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.0', + error: + "This key 'foo.attributes.header' does NOT exist in foo saved object index patterns", + isSavedObjectAttr: false, + key: 'foo.attributes.header', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.1', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + ]); + }); + }); + + describe('#getSavedObjectTypeIndexPatterns', () => { + test('Get index patterns related to your type', () => { + const indexPatternsFilterByType = getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns); + + expect(indexPatternsFilterByType).toEqual([ + { + name: 'updatedAt', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'foo.title', + type: 'text', + aggregatable: true, + searchable: true, + }, + { + name: 'foo.description', + type: 'text', + aggregatable: true, + searchable: true, + }, + { + name: 'foo.bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + ]); + }); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/filter_utils.ts b/src/core/server/saved_objects/service/lib/filter_utils.ts new file mode 100644 index 00000000000000..2397971e66966f --- /dev/null +++ b/src/core/server/saved_objects/service/lib/filter_utils.ts @@ -0,0 +1,190 @@ +/* + * 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 { fromKueryExpression, KueryNode, nodeTypes } from '@kbn/es-query'; +import { get, set } from 'lodash'; + +import { SavedObjectsIndexPattern, SavedObjectsIndexPatternField } from './cache_index_patterns'; +import { SavedObjectsErrorHelpers } from './errors'; + +export const validateConvertFilterToKueryNode = ( + types: string[], + filter: string, + indexPattern: SavedObjectsIndexPattern | undefined +): KueryNode => { + if (filter && filter.length > 0 && indexPattern) { + const filterKueryNode = fromKueryExpression(filter); + + const typeIndexPatterns = getSavedObjectTypeIndexPatterns(types, indexPattern); + const validationFilterKuery = validateFilterKueryNode( + filterKueryNode, + types, + typeIndexPatterns, + filterKueryNode.type === 'function' && ['is', 'range'].includes(filterKueryNode.function) + ); + + if (validationFilterKuery.length === 0) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'If we have a filter options defined, we should always have validationFilterKuery defined too' + ); + } + + if (validationFilterKuery.some(obj => obj.error != null)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + validationFilterKuery + .filter(obj => obj.error != null) + .map(obj => obj.error) + .join('\n') + ); + } + + validationFilterKuery.forEach(item => { + const path: string[] = item.astPath.length === 0 ? [] : item.astPath.split('.'); + const existingKueryNode: KueryNode = + path.length === 0 ? filterKueryNode : get(filterKueryNode, path); + if (item.isSavedObjectAttr) { + existingKueryNode.arguments[0].value = existingKueryNode.arguments[0].value.split('.')[1]; + const itemType = types.filter(t => t === item.type); + if (itemType.length === 1) { + set( + filterKueryNode, + path, + nodeTypes.function.buildNode('and', [ + nodeTypes.function.buildNode('is', 'type', itemType[0]), + existingKueryNode, + ]) + ); + } + } else { + existingKueryNode.arguments[0].value = existingKueryNode.arguments[0].value.replace( + '.attributes', + '' + ); + set(filterKueryNode, path, existingKueryNode); + } + }); + return filterKueryNode; + } + return null; +}; + +export const getSavedObjectTypeIndexPatterns = ( + types: string[], + indexPattern: SavedObjectsIndexPattern | undefined +): SavedObjectsIndexPatternField[] => { + return indexPattern != null + ? indexPattern.fields.filter( + ip => + !ip.name.includes('.') || (ip.name.includes('.') && types.includes(ip.name.split('.')[0])) + ) + : []; +}; + +interface ValidateFilterKueryNode { + astPath: string; + error: string; + isSavedObjectAttr: boolean; + key: string; + type: string | null; +} + +export const validateFilterKueryNode = ( + astFilter: KueryNode, + types: string[], + typeIndexPatterns: SavedObjectsIndexPatternField[], + storeValue: boolean = false, + path: string = 'arguments' +): ValidateFilterKueryNode[] => { + return astFilter.arguments.reduce((kueryNode: string[], ast: KueryNode, index: number) => { + if (ast.arguments) { + const myPath = `${path}.${index}`; + return [ + ...kueryNode, + ...validateFilterKueryNode( + ast, + types, + typeIndexPatterns, + ast.type === 'function' && ['is', 'range'].includes(ast.function), + `${myPath}.arguments` + ), + ]; + } + if (storeValue && index === 0) { + const splitPath = path.split('.'); + return [ + ...kueryNode, + { + astPath: splitPath.slice(0, splitPath.length - 1).join('.'), + error: hasFilterKeyError(ast.value, types, typeIndexPatterns), + isSavedObjectAttr: isSavedObjectAttr(ast.value, typeIndexPatterns), + key: ast.value, + type: getType(ast.value), + }, + ]; + } + return kueryNode; + }, []); +}; + +const getType = (key: string) => (key.includes('.') ? key.split('.')[0] : null); + +export const isSavedObjectAttr = ( + key: string, + typeIndexPatterns: SavedObjectsIndexPatternField[] +) => { + const splitKey = key.split('.'); + if (splitKey.length === 1 && typeIndexPatterns.some(tip => tip.name === splitKey[0])) { + return true; + } else if (splitKey.length > 1 && typeIndexPatterns.some(tip => tip.name === splitKey[1])) { + return true; + } + return false; +}; + +export const hasFilterKeyError = ( + key: string, + types: string[], + typeIndexPatterns: SavedObjectsIndexPatternField[] +): string | null => { + if (!key.includes('.')) { + return `This key '${key}' need to be wrapped by a saved object type like ${types.join()}`; + } else if (key.includes('.')) { + const keySplit = key.split('.'); + if (keySplit.length <= 1 || !types.includes(keySplit[0])) { + return `This type ${keySplit[0]} is not allowed`; + } + if ( + (keySplit.length === 2 && typeIndexPatterns.some(tip => tip.name === key)) || + (keySplit.length > 2 && types.includes(keySplit[0]) && keySplit[1] !== 'attributes') + ) { + return `This key '${key}' does NOT match the filter proposition SavedObjectType.attributes.key`; + } + if ( + (keySplit.length === 2 && !typeIndexPatterns.some(tip => tip.name === keySplit[1])) || + (keySplit.length > 2 && + !typeIndexPatterns.some( + tip => + tip.name === [...keySplit.slice(0, 1), ...keySplit.slice(2, keySplit.length)].join('.') + )) + ) { + return `This key '${key}' does NOT exist in ${types.join()} saved object index patterns`; + } + } + return null; +}; diff --git a/src/core/server/saved_objects/service/lib/index.ts b/src/core/server/saved_objects/service/lib/index.ts index d987737c2ffa09..be78fdde762106 100644 --- a/src/core/server/saved_objects/service/lib/index.ts +++ b/src/core/server/saved_objects/service/lib/index.ts @@ -26,3 +26,5 @@ export { } from './scoped_client_provider'; export { SavedObjectsErrorHelpers } from './errors'; + +export { SavedObjectsCacheIndexPatterns } from './cache_index_patterns'; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index c35502b719d58c..bc646c8c1d2e14 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -18,6 +18,7 @@ */ import { delay } from 'bluebird'; + import { SavedObjectsRepository } from './repository'; import * as getSearchDslNS from './search_dsl/search_dsl'; import { SavedObjectsErrorHelpers } from './errors'; @@ -272,6 +273,10 @@ describe('SavedObjectsRepository', () => { savedObjectsRepository = new SavedObjectsRepository({ index: '.kibana-test', + cacheIndexPatterns: { + setIndexPatterns: jest.fn(), + getIndexPatterns: () => undefined, + }, mappings, callCluster: callAdminCluster, migrator, @@ -285,7 +290,7 @@ describe('SavedObjectsRepository', () => { getSearchDslNS.getSearchDsl.mockReset(); }); - afterEach(() => {}); + afterEach(() => { }); describe('#create', () => { beforeEach(() => { @@ -993,7 +998,7 @@ describe('SavedObjectsRepository', () => { expect(onBeforeWrite).toHaveBeenCalledTimes(1); }); - it('should return objects in the same order regardless of type', () => {}); + it('should return objects in the same order regardless of type', () => { }); }); describe('#delete', () => { @@ -1154,6 +1159,13 @@ describe('SavedObjectsRepository', () => { } }); + it('requires index pattern to be defined if filter is defined', async () => { + callAdminCluster.mockReturnValue(noNamespaceSearchResults); + expect(savedObjectsRepository.find({ type: 'foo', filter: 'foo.type: hello' })) + .rejects + .toThrowErrorMatchingInlineSnapshot('"options.filter is missing index pattern to work correctly: Bad Request"'); + }); + it('passes mappings, schema, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl', async () => { callAdminCluster.mockReturnValue(namespacedSearchResults); @@ -1169,6 +1181,8 @@ describe('SavedObjectsRepository', () => { type: 'foo', id: '1', }, + indexPattern: undefined, + kueryNode: null, }; await savedObjectsRepository.find(relevantOpts); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 3c2a644f003bda..aadb82486cccec 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -19,11 +19,13 @@ import { omit } from 'lodash'; import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; + import { getRootPropertiesObjects, IndexMapping } from '../../mappings'; import { getSearchDsl } from './search_dsl'; import { includedFields } from './included_fields'; import { decorateEsError } from './decorate_es_error'; import { SavedObjectsErrorHelpers } from './errors'; +import { SavedObjectsCacheIndexPatterns } from './cache_index_patterns'; import { decodeRequestVersion, encodeVersion, encodeHitVersion } from '../../version'; import { SavedObjectsSchema } from '../../schema'; import { KibanaMigrator } from '../../migrations'; @@ -45,6 +47,7 @@ import { SavedObjectsFindOptions, SavedObjectsMigrationVersion, } from '../../types'; +import { validateConvertFilterToKueryNode } from './filter_utils'; // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -74,6 +77,7 @@ export interface SavedObjectsRepositoryOptions { serializer: SavedObjectsSerializer; migrator: KibanaMigrator; allowedTypes: string[]; + cacheIndexPatterns: SavedObjectsCacheIndexPatterns; onBeforeWrite?: (...args: Parameters) => Promise; } @@ -91,11 +95,13 @@ export class SavedObjectsRepository { private _onBeforeWrite: (...args: Parameters) => Promise; private _unwrappedCallCluster: CallCluster; private _serializer: SavedObjectsSerializer; + private _cacheIndexPatterns: SavedObjectsCacheIndexPatterns; constructor(options: SavedObjectsRepositoryOptions) { const { index, config, + cacheIndexPatterns, mappings, callCluster, schema, @@ -106,7 +112,7 @@ export class SavedObjectsRepository { } = options; // It's important that we migrate documents / mark them as up-to-date - // prior to writing them to the index. Otherwise, we'll cause unecessary + // prior to writing them to the index. Otherwise, we'll cause unnecessary // index migrations to run at Kibana startup, and those will probably fail // due to invalidly versioned documents in the index. // @@ -117,6 +123,7 @@ export class SavedObjectsRepository { this._config = config; this._mappings = mappings; this._schema = schema; + this._cacheIndexPatterns = cacheIndexPatterns; if (allowedTypes.length === 0) { throw new Error('Empty or missing types for saved object repository!'); } @@ -126,6 +133,9 @@ export class SavedObjectsRepository { this._unwrappedCallCluster = async (...args: Parameters) => { await migrator.runMigrations(); + if (this._cacheIndexPatterns.getIndexPatterns() == null) { + await this._cacheIndexPatterns.setIndexPatterns(index); + } return callCluster(...args); }; this._schema = schema; @@ -404,9 +414,12 @@ export class SavedObjectsRepository { fields, namespace, type, + filter, }: SavedObjectsFindOptions): Promise> { if (!type) { - throw new TypeError(`options.type must be a string or an array of strings`); + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.type must be a string or an array of strings' + ); } const types = Array.isArray(type) ? type : [type]; @@ -421,13 +434,28 @@ export class SavedObjectsRepository { } if (searchFields && !Array.isArray(searchFields)) { - throw new TypeError('options.searchFields must be an array'); + throw SavedObjectsErrorHelpers.createBadRequestError('options.searchFields must be an array'); } if (fields && !Array.isArray(fields)) { - throw new TypeError('options.fields must be an array'); + throw SavedObjectsErrorHelpers.createBadRequestError('options.fields must be an array'); } + if (filter && filter !== '' && this._cacheIndexPatterns.getIndexPatterns() == null) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.filter is missing index pattern to work correctly' + ); + } + + const kueryNode = + filter && filter !== '' + ? validateConvertFilterToKueryNode( + allowedTypes, + filter, + this._cacheIndexPatterns.getIndexPatterns() + ) + : null; + const esOptions = { index: this.getIndicesForTypes(allowedTypes), size: perPage, @@ -446,6 +474,8 @@ export class SavedObjectsRepository { sortOrder, namespace, hasReference, + indexPattern: kueryNode != null ? this._cacheIndexPatterns.getIndexPatterns() : undefined, + kueryNode, }), }, }; @@ -769,7 +799,7 @@ export class SavedObjectsRepository { // The internal representation of the saved object that the serializer returns // includes the namespace, and we use this for migrating documents. However, we don't - // want the namespcae to be returned from the repository, as the repository scopes each + // want the namespace to be returned from the repository, as the repository scopes each // method transparently to the specified namespace. private _rawToSavedObject(raw: RawDoc): SavedObject { const savedObject = this._serializer.rawToSavedObject(raw); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index b13d86819716be..75b30580292279 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -18,6 +18,7 @@ */ import { schemaMock } from '../../../schema/schema.mock'; +import { SavedObjectsIndexPattern } from '../cache_index_patterns'; import { getQueryParams } from './query_params'; const SCHEMA = schemaMock.create(); @@ -61,6 +62,41 @@ const MAPPINGS = { }, }, }; +const INDEX_PATTERN: SavedObjectsIndexPattern = { + fields: [ + { + aggregatable: true, + name: 'type', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'pending.title', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'saved.title', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'saved.obj.key1', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'global.name', + searchable: true, + type: 'string', + }, + ], + title: 'test', +}; // create a type clause to be used within the "should", if a namespace is specified // the clause will ensure the namespace matches; otherwise, the clause will ensure @@ -85,7 +121,7 @@ const createTypeClause = (type: string, namespace?: string) => { describe('searchDsl/queryParams', () => { describe('no parameters', () => { it('searches for all known types without a namespace specified', () => { - expect(getQueryParams(MAPPINGS, SCHEMA)).toEqual({ + expect(getQueryParams({ mappings: MAPPINGS, schema: SCHEMA })).toEqual({ query: { bool: { filter: [ @@ -108,7 +144,9 @@ describe('searchDsl/queryParams', () => { describe('namespace', () => { it('filters namespaced types for namespace, and ensures namespace agnostic types have no namespace', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace')).toEqual({ + expect( + getQueryParams({ mappings: MAPPINGS, schema: SCHEMA, namespace: 'foo-namespace' }) + ).toEqual({ query: { bool: { filter: [ @@ -131,7 +169,9 @@ describe('searchDsl/queryParams', () => { describe('type (singular, namespaced)', () => { it('includes a terms filter for type and namespace not being specified', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, 'saved')).toEqual({ + expect( + getQueryParams({ mappings: MAPPINGS, schema: SCHEMA, namespace: undefined, type: 'saved' }) + ).toEqual({ query: { bool: { filter: [ @@ -150,7 +190,9 @@ describe('searchDsl/queryParams', () => { describe('type (singular, global)', () => { it('includes a terms filter for type and namespace not being specified', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, 'global')).toEqual({ + expect( + getQueryParams({ mappings: MAPPINGS, schema: SCHEMA, namespace: undefined, type: 'global' }) + ).toEqual({ query: { bool: { filter: [ @@ -169,7 +211,14 @@ describe('searchDsl/queryParams', () => { describe('type (plural, namespaced and global)', () => { it('includes term filters for types and namespace not being specified', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, ['saved', 'global'])).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: ['saved', 'global'], + }) + ).toEqual({ query: { bool: { filter: [ @@ -188,7 +237,14 @@ describe('searchDsl/queryParams', () => { describe('namespace, type (plural, namespaced and global)', () => { it('includes a terms filter for type and namespace not being specified', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'])).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + }) + ).toEqual({ query: { bool: { filter: [ @@ -207,7 +263,15 @@ describe('searchDsl/queryParams', () => { describe('search', () => { it('includes a sqs query and all known types without a namespace specified', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, undefined, 'us*')).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: undefined, + search: 'us*', + }) + ).toEqual({ query: { bool: { filter: [ @@ -239,7 +303,15 @@ describe('searchDsl/queryParams', () => { describe('namespace, search', () => { it('includes a sqs query and namespaced types with the namespace and global types without a namespace', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', undefined, 'us*')).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: undefined, + search: 'us*', + }) + ).toEqual({ query: { bool: { filter: [ @@ -271,7 +343,15 @@ describe('searchDsl/queryParams', () => { describe('type (plural, namespaced and global), search', () => { it('includes a sqs query and types without a namespace', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, ['saved', 'global'], 'us*')).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: ['saved', 'global'], + search: 'us*', + }) + ).toEqual({ query: { bool: { filter: [ @@ -299,40 +379,52 @@ describe('searchDsl/queryParams', () => { describe('namespace, type (plural, namespaced and global), search', () => { it('includes a sqs query and namespace type with a namespace and global type without a namespace', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], 'us*')).toEqual( - { - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + search: 'us*', + }) + ).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], + minimum_should_match: 1, }, - ], - must: [ - { - simple_query_string: { - query: 'us*', - lenient: true, - fields: ['*'], - }, + }, + ], + must: [ + { + simple_query_string: { + query: 'us*', + lenient: true, + fields: ['*'], }, - ], - }, + }, + ], }, - } - ); + }, + }); }); }); describe('search, searchFields', () => { it('includes all types for field', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, undefined, 'y*', ['title'])).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: undefined, + search: 'y*', + searchFields: ['title'], + }) + ).toEqual({ query: { bool: { filter: [ @@ -360,7 +452,16 @@ describe('searchDsl/queryParams', () => { }); }); it('supports field boosting', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, undefined, 'y*', ['title^3'])).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: undefined, + search: 'y*', + searchFields: ['title^3'], + }) + ).toEqual({ query: { bool: { filter: [ @@ -389,7 +490,14 @@ describe('searchDsl/queryParams', () => { }); it('supports field and multi-field', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, undefined, undefined, 'y*', ['title', 'title.raw']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: undefined, + search: 'y*', + searchFields: ['title', 'title.raw'], + }) ).toEqual({ query: { bool: { @@ -428,38 +536,52 @@ describe('searchDsl/queryParams', () => { describe('namespace, search, searchFields', () => { it('includes all types for field', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', undefined, 'y*', ['title'])).toEqual( - { - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending', 'foo-namespace'), - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: undefined, + search: 'y*', + searchFields: ['title'], + }) + ).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + createTypeClause('pending', 'foo-namespace'), + createTypeClause('saved', 'foo-namespace'), + createTypeClause('global'), + ], + minimum_should_match: 1, }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['pending.title', 'saved.title', 'global.title'], - }, + }, + ], + must: [ + { + simple_query_string: { + query: 'y*', + fields: ['pending.title', 'saved.title', 'global.title'], }, - ], - }, + }, + ], }, - } - ); + }, + }); }); it('supports field boosting', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', undefined, 'y*', ['title^3']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: undefined, + search: 'y*', + searchFields: ['title^3'], + }) ).toEqual({ query: { bool: { @@ -489,7 +611,14 @@ describe('searchDsl/queryParams', () => { }); it('supports field and multi-field', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', undefined, 'y*', ['title', 'title.raw']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: undefined, + search: 'y*', + searchFields: ['title', 'title.raw'], + }) ).toEqual({ query: { bool: { @@ -529,7 +658,14 @@ describe('searchDsl/queryParams', () => { describe('type (plural, namespaced and global), search, searchFields', () => { it('includes all types for field', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, undefined, ['saved', 'global'], 'y*', ['title']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: ['saved', 'global'], + search: 'y*', + searchFields: ['title'], + }) ).toEqual({ query: { bool: { @@ -555,7 +691,14 @@ describe('searchDsl/queryParams', () => { }); it('supports field boosting', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, undefined, ['saved', 'global'], 'y*', ['title^3']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: ['saved', 'global'], + search: 'y*', + searchFields: ['title^3'], + }) ).toEqual({ query: { bool: { @@ -581,10 +724,14 @@ describe('searchDsl/queryParams', () => { }); it('supports field and multi-field', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, undefined, ['saved', 'global'], 'y*', [ - 'title', - 'title.raw', - ]) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: ['saved', 'global'], + search: 'y*', + searchFields: ['title', 'title.raw'], + }) ).toEqual({ query: { bool: { @@ -613,7 +760,14 @@ describe('searchDsl/queryParams', () => { describe('namespace, type (plural, namespaced and global), search, searchFields', () => { it('includes all types for field', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], 'y*', ['title']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + search: 'y*', + searchFields: ['title'], + }) ).toEqual({ query: { bool: { @@ -639,7 +793,14 @@ describe('searchDsl/queryParams', () => { }); it('supports field boosting', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], 'y*', ['title^3']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + search: 'y*', + searchFields: ['title^3'], + }) ).toEqual({ query: { bool: { @@ -665,10 +826,14 @@ describe('searchDsl/queryParams', () => { }); it('supports field and multi-field', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], 'y*', [ - 'title', - 'title.raw', - ]) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + search: 'y*', + searchFields: ['title', 'title.raw'], + }) ).toEqual({ query: { bool: { @@ -697,15 +862,15 @@ describe('searchDsl/queryParams', () => { describe('type (plural, namespaced and global), search, defaultSearchOperator', () => { it('supports defaultSearchOperator', () => { expect( - getQueryParams( - MAPPINGS, - SCHEMA, - 'foo-namespace', - ['saved', 'global'], - 'foo', - undefined, - 'AND' - ) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + search: 'foo', + searchFields: undefined, + defaultSearchOperator: 'AND', + }) ).toEqual({ query: { bool: { @@ -771,19 +936,19 @@ describe('searchDsl/queryParams', () => { describe('type (plural, namespaced and global), hasReference', () => { it('supports hasReference', () => { expect( - getQueryParams( - MAPPINGS, - SCHEMA, - 'foo-namespace', - ['saved', 'global'], - undefined, - undefined, - 'OR', - { + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + search: undefined, + searchFields: undefined, + defaultSearchOperator: 'OR', + hasReference: { type: 'bar', id: '1', - } - ) + }, + }) ).toEqual({ query: { bool: { @@ -823,4 +988,345 @@ describe('searchDsl/queryParams', () => { }); }); }); + + describe('type filter', () => { + it(' with namespace', () => { + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + kueryNode: { + type: 'function', + function: 'is', + arguments: [ + { type: 'literal', value: 'global.name' }, + { type: 'literal', value: 'GLOBAL' }, + { type: 'literal', value: false }, + ], + }, + indexPattern: INDEX_PATTERN, + }) + ).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + match: { + 'global.name': 'GLOBAL', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + term: { + type: 'pending', + }, + }, + { + term: { + namespace: 'foo-namespace', + }, + }, + ], + }, + }, + { + bool: { + must: [ + { + term: { + type: 'saved', + }, + }, + { + term: { + namespace: 'foo-namespace', + }, + }, + ], + }, + }, + { + bool: { + must: [ + { + term: { + type: 'global', + }, + }, + ], + must_not: [ + { + exists: { + field: 'namespace', + }, + }, + ], + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }); + }); + it(' with namespace and more complex filter', () => { + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + kueryNode: { + type: 'function', + function: 'and', + arguments: [ + { + type: 'function', + function: 'is', + arguments: [ + { type: 'literal', value: 'global.name' }, + { type: 'literal', value: 'GLOBAL' }, + { type: 'literal', value: false }, + ], + }, + { + type: 'function', + function: 'not', + arguments: [ + { + type: 'function', + function: 'is', + arguments: [ + { type: 'literal', value: 'saved.obj.key1' }, + { type: 'literal', value: 'key' }, + { type: 'literal', value: true }, + ], + }, + ], + }, + ], + }, + indexPattern: INDEX_PATTERN, + }) + ).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + filter: [ + { + bool: { + should: [ + { + match: { + 'global.name': 'GLOBAL', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + match_phrase: { + 'saved.obj.key1': 'key', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + ], + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + term: { + type: 'pending', + }, + }, + { + term: { + namespace: 'foo-namespace', + }, + }, + ], + }, + }, + { + bool: { + must: [ + { + term: { + type: 'saved', + }, + }, + { + term: { + namespace: 'foo-namespace', + }, + }, + ], + }, + }, + { + bool: { + must: [ + { + term: { + type: 'global', + }, + }, + ], + must_not: [ + { + exists: { + field: 'namespace', + }, + }, + ], + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }); + }); + it(' with search and searchFields', () => { + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + search: 'y*', + searchFields: ['title'], + kueryNode: { + type: 'function', + function: 'is', + arguments: [ + { type: 'literal', value: 'global.name' }, + { type: 'literal', value: 'GLOBAL' }, + { type: 'literal', value: false }, + ], + }, + indexPattern: INDEX_PATTERN, + }) + ).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + match: { + 'global.name': 'GLOBAL', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + term: { + type: 'pending', + }, + }, + { + term: { + namespace: 'foo-namespace', + }, + }, + ], + }, + }, + { + bool: { + must: [ + { + term: { + type: 'saved', + }, + }, + { + term: { + namespace: 'foo-namespace', + }, + }, + ], + }, + }, + { + bool: { + must: [ + { + term: { + type: 'global', + }, + }, + ], + must_not: [ + { + exists: { + field: 'namespace', + }, + }, + ], + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + must: [ + { + simple_query_string: { + query: 'y*', + fields: ['pending.title', 'saved.title', 'global.title'], + }, + }, + ], + }, + }, + }); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 9c145258a755ff..125b0c40af9e41 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -16,9 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import { toElasticsearchQuery, KueryNode } from '@kbn/es-query'; import { getRootPropertiesObjects, IndexMapping } from '../../../mappings'; import { SavedObjectsSchema } from '../../../schema'; +import { SavedObjectsIndexPattern } from '../cache_index_patterns'; /** * Gets the types based on the type. Uses mappings to support @@ -76,25 +78,43 @@ function getClauseForType(schema: SavedObjectsSchema, namespace: string | undefi }; } +interface HasReferenceQueryParams { + type: string; + id: string; +} + +interface QueryParams { + mappings: IndexMapping; + schema: SavedObjectsSchema; + namespace?: string; + type?: string | string[]; + search?: string; + searchFields?: string[]; + defaultSearchOperator?: string; + hasReference?: HasReferenceQueryParams; + kueryNode?: KueryNode; + indexPattern?: SavedObjectsIndexPattern; +} + /** * Get the "query" related keys for the search body */ -export function getQueryParams( - mappings: IndexMapping, - schema: SavedObjectsSchema, - namespace?: string, - type?: string | string[], - search?: string, - searchFields?: string[], - defaultSearchOperator?: string, - hasReference?: { - type: string; - id: string; - } -) { +export function getQueryParams({ + mappings, + schema, + namespace, + type, + search, + searchFields, + defaultSearchOperator, + hasReference, + kueryNode, + indexPattern, +}: QueryParams) { const types = getTypes(mappings, type); const bool: any = { filter: [ + ...(kueryNode != null ? [toElasticsearchQuery(kueryNode, indexPattern)] : []), { bool: { must: hasReference diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index 7bd04ca8f34947..97cab3e566d5ea 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -72,16 +72,16 @@ describe('getSearchDsl', () => { getSearchDsl(MAPPINGS, SCHEMA, opts); expect(getQueryParams).toHaveBeenCalledTimes(1); - expect(getQueryParams).toHaveBeenCalledWith( - MAPPINGS, - SCHEMA, - opts.namespace, - opts.type, - opts.search, - opts.searchFields, - opts.defaultSearchOperator, - opts.hasReference - ); + expect(getQueryParams).toHaveBeenCalledWith({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: opts.namespace, + type: opts.type, + search: opts.search, + searchFields: opts.searchFields, + defaultSearchOperator: opts.defaultSearchOperator, + hasReference: opts.hasReference, + }); }); it('passes (mappings, type, sortField, sortOrder) to getSortingParams', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index 1c2c87bca6ea72..68f60607025053 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -17,12 +17,14 @@ * under the License. */ +import { KueryNode } from '@kbn/es-query'; import Boom from 'boom'; import { IndexMapping } from '../../../mappings'; import { SavedObjectsSchema } from '../../../schema'; import { getQueryParams } from './query_params'; import { getSortingParams } from './sorting_params'; +import { SavedObjectsIndexPattern } from '../cache_index_patterns'; interface GetSearchDslOptions { type: string | string[]; @@ -36,6 +38,8 @@ interface GetSearchDslOptions { type: string; id: string; }; + kueryNode?: KueryNode; + indexPattern?: SavedObjectsIndexPattern; } export function getSearchDsl( @@ -52,6 +56,8 @@ export function getSearchDsl( sortOrder, namespace, hasReference, + kueryNode, + indexPattern, } = options; if (!type) { @@ -63,7 +69,7 @@ export function getSearchDsl( } return { - ...getQueryParams( + ...getQueryParams({ mappings, schema, namespace, @@ -71,8 +77,10 @@ export function getSearchDsl( search, searchFields, defaultSearchOperator, - hasReference - ), + hasReference, + kueryNode, + indexPattern, + }), ...getSortingParams(mappings, type, sortField, sortOrder), }; } diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 1cc424199b8872..e7e7a4c64392a6 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -123,6 +123,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { searchFields?: string[]; hasReference?: { type: string; id: string }; defaultSearchOperator?: 'AND' | 'OR'; + filter?: string; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 4ae1c0c267ea94..ae839644fc2e29 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -10,6 +10,7 @@ import { ConfigOptions } from 'elasticsearch'; import { DetailedPeerCertificate } from 'tls'; import { Duration } from 'moment'; import { IncomingHttpHeaders } from 'http'; +import { IndexPatternsService } from 'src/legacy/server/index_patterns'; import { KibanaConfigType } from 'src/core/server/kibana_config'; import { Logger as Logger_2 } from 'src/core/server/logging'; import { ObjectType } from '@kbn/config-schema'; @@ -841,6 +842,8 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; // (undocumented) + filter?: string; + // (undocumented) hasReference?: { type: string; id: string; diff --git a/src/legacy/core_plugins/elasticsearch/index.d.ts b/src/legacy/core_plugins/elasticsearch/index.d.ts index eeee5f3f4c6c71..4cbb1c82cc1e40 100644 --- a/src/legacy/core_plugins/elasticsearch/index.d.ts +++ b/src/legacy/core_plugins/elasticsearch/index.d.ts @@ -482,7 +482,7 @@ export interface CallCluster { (endpoint: 'indices.upgrade', params: IndicesUpgradeParams, options?: CallClusterOptions): ReturnType; (endpoint: 'indices.validateQuery', params: IndicesValidateQueryParams, options?: CallClusterOptions): ReturnType; - // ingest namepsace + // ingest namespace (endpoint: 'ingest.deletePipeline', params: IngestDeletePipelineParams, options?: CallClusterOptions): ReturnType; (endpoint: 'ingest.getPipeline', params: IngestGetPipelineParams, options?: CallClusterOptions): ReturnType; (endpoint: 'ingest.putPipeline', params: IngestPutPipelineParams, options?: CallClusterOptions): ReturnType; diff --git a/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts b/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts index fa82e54e9fb0ac..10047284f5c96f 100644 --- a/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts +++ b/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts @@ -17,7 +17,7 @@ * under the License. */ import { Readable } from 'stream'; -import { SavedObject } from 'kibana/server'; +import { SavedObject } from 'src/core/server'; import { createSplitStream, createMapStream, createFilterStream } from '../../../utils/streams'; export function createSavedObjectsStreamFromNdJson(ndJsonStream: Readable) { diff --git a/src/legacy/server/saved_objects/routes/find.ts b/src/legacy/server/saved_objects/routes/find.ts index bb8fb21aea29c7..f8cb8c50d96843 100644 --- a/src/legacy/server/saved_objects/routes/find.ts +++ b/src/legacy/server/saved_objects/routes/find.ts @@ -39,6 +39,7 @@ interface FindRequest extends WithoutQueryAndParams { id: string; }; fields?: string[]; + filter?: string; }; } @@ -79,6 +80,9 @@ export const createFindRoute = (prereqs: Prerequisites) => ({ fields: Joi.array() .items(Joi.string()) .single(), + filter: Joi.string() + .allow('') + .optional(), }) .default(), }, @@ -94,6 +98,7 @@ export const createFindRoute = (prereqs: Prerequisites) => ({ sortField: query.sort_field, hasReference: query.has_reference, fields: query.fields, + filter: query.filter, }); }, }, diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.js b/src/legacy/server/saved_objects/saved_objects_mixin.js index edaa2850064228..156c92ef6bdc05 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.js @@ -26,6 +26,7 @@ import { SavedObjectsClient, SavedObjectsRepository, ScopedSavedObjectsClientProvider, + SavedObjectsCacheIndexPatterns, getSortedObjectsForExport, importSavedObjects, resolveImportErrors, @@ -63,6 +64,7 @@ export function savedObjectsMixin(kbnServer, server) { const schema = new SavedObjectsSchema(kbnServer.uiExports.savedObjectSchemas); const visibleTypes = allTypes.filter(type => !schema.isHiddenType(type)); const importableAndExportableTypes = getImportableAndExportableTypes({ kbnServer, visibleTypes }); + const cacheIndexPatterns = new SavedObjectsCacheIndexPatterns(); server.decorate('server', 'kibanaMigrator', migrator); server.decorate( @@ -113,11 +115,18 @@ export function savedObjectsMixin(kbnServer, server) { }); const combinedTypes = visibleTypes.concat(extraTypes); const allowedTypes = [...new Set(combinedTypes)]; + + if (cacheIndexPatterns.getIndexPatternsService() == null) { + cacheIndexPatterns.setIndexPatternsService( + server.indexPatternsServiceFactory({ callCluster }) + ); + } const config = server.config(); return new SavedObjectsRepository({ index: config.get('kibana.index'), config, + cacheIndexPatterns, migrator, mappings, schema, diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.test.js b/src/legacy/server/saved_objects/saved_objects_mixin.test.js index cdbc642485706b..d3a40583dfe23e 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.test.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.test.js @@ -84,6 +84,11 @@ describe('Saved Objects Mixin', () => { get: stubConfig, }; }, + indexPatternsServiceFactory: () => { + return { + getFieldsForWildcard: jest.fn(), + }; + }, plugins: { elasticsearch: { getCluster: () => { diff --git a/src/plugins/data/common/field_formats/field_format.ts b/src/plugins/data/common/field_formats/field_format.ts index cdf82cd9eb9d1a..962dc6b23d0985 100644 --- a/src/plugins/data/common/field_formats/field_format.ts +++ b/src/plugins/data/common/field_formats/field_format.ts @@ -56,8 +56,10 @@ export abstract class FieldFormat { /** * @property {FieldFormatConvert} * @private + * have to remove the private because of + * https://github.com/Microsoft/TypeScript/issues/17293 */ - private convertObject: FieldFormatConvert | undefined; + convertObject: FieldFormatConvert | undefined; /** * @property {Function} - ref to child class @@ -171,7 +173,11 @@ export abstract class FieldFormat { return createCustomFieldFormat(convertFn); } - private static setupContentType( + /* + * have to remove the private because of + * https://github.com/Microsoft/TypeScript/issues/17293 + */ + static setupContentType( fieldFormat: IFieldFormat, convert: Partial | FieldFormatConvertFunction = {} ): FieldFormatConvert { @@ -185,7 +191,11 @@ export abstract class FieldFormat { }; } - private static toConvertObject(convert: FieldFormatConvertFunction): Partial { + /* + * have to remove the private because of + * https://github.com/Microsoft/TypeScript/issues/17293 + */ + static toConvertObject(convert: FieldFormatConvertFunction): Partial { if (isFieldFormatConvertFn(convert)) { return { [TEXT_CONTEXT_TYPE]: convert, diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index fa03d46765e929..a41df24ea7a418 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -109,6 +109,63 @@ export default function ({ getService }) { }) )); }); + + describe('with a filter', () => { + it('should return 200 with a valid response', async () => ( + await supertest + .get('/api/saved_objects/_find?type=visualization&filter=visualization.attributes.title:"Count of requests"') + .expect(200) + .then(resp => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + attributes: { + title: 'Count of requests', + visState: resp.body.saved_objects[0].attributes.visState, + uiStateJSON: '{"spy":{"mode":{"name":null,"fill":false}}}', + description: '', + version: 1, + kibanaSavedObjectMeta: { + searchSourceJSON: resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, + }, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + } + ], + migrationVersion: { + visualization: '7.3.1', + }, + updated_at: '2017-09-21T18:51:23.794Z', + version: 'WzIsMV0=', + }, + ], + }); + }) + )); + + it('wrong type should return 400 with Bad Request', async () => ( + await supertest + .get('/api/saved_objects/_find?type=visualization&filter=dashboard.attributes.title:foo') + .expect(400) + .then(resp => { + console.log('body', JSON.stringify(resp.body)); + expect(resp.body).to.eql({ + error: 'Bad Request', + message: 'This type dashboard is not allowed: Bad Request', + statusCode: 400, + }); + }) + )); + }); }); describe('without kibana index', () => { @@ -200,6 +257,36 @@ export default function ({ getService }) { }) )); }); + + describe('with a filter', () => { + it('should return 200 with an empty response', async () => ( + await supertest + .get('/api/saved_objects/_find?type=visualization&filter=visualization.attributes.title:"Count of requests"') + .expect(200) + .then(resp => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [] + }); + }) + )); + + it('wrong type should return 400 with Bad Request', async () => ( + await supertest + .get('/api/saved_objects/_find?type=visualization&filter=dashboard.attributes.title:foo') + .expect(400) + .then(resp => { + console.log('body', JSON.stringify(resp.body)); + expect(resp.body).to.eql({ + error: 'Bad Request', + message: 'This type dashboard is not allowed: Bad Request', + statusCode: 400, + }); + }) + )); + }); }); }); } diff --git a/test/tsconfig.json b/test/tsconfig.json index 276238adf59013..71c9e375a41240 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -14,9 +14,9 @@ "**/*.ts", "**/*.tsx", "../typings/lodash.topath/*.ts", - "typings/**/*", + "typings/**/*" ], "exclude": [ "plugin_functional/plugins/**/*" ] -} +} \ No newline at end of file diff --git a/test/typings/index.d.ts b/test/typings/index.d.ts index ba43e7e7184e5a..fd2500257b315c 100644 --- a/test/typings/index.d.ts +++ b/test/typings/index.d.ts @@ -17,6 +17,12 @@ * under the License. */ +declare module '*.html' { + const template: string; + // eslint-disable-next-line import/no-default-export + export default template; +} + type MethodKeysOf = { [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never; }[keyof T]; diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index 45e4c1ed2aa4e4..6799f0ec63846e 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -23,6 +23,11 @@ interface FindTests { unknownSearchField: FindTest; hiddenType: FindTest; noType: FindTest; + filterWithNotSpaceAwareType: FindTest; + filterWithHiddenType: FindTest; + filterWithUnknownType: FindTest; + filterWithNoType: FindTest; + filterWithUnAllowedType: FindTest; } interface FindTestDefinition { @@ -73,6 +78,14 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) }); }; + const expectFilterWrongTypeError = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + error: 'Bad Request', + message: 'This type dashboard is not allowed: Bad Request', + statusCode: 400, + }); + }; + const expectTypeRequired = (resp: { [key: string]: any }) => { expect(resp.body).to.eql({ error: 'Bad Request', @@ -184,6 +197,67 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) .expect(tests.noType.statusCode) .then(tests.noType.response)); }); + + describe('filter', () => { + it(`by wrong type should return ${tests.filterWithUnAllowedType.statusCode} with ${tests.filterWithUnAllowedType.description}`, async () => + await supertest + .get( + `${getUrlPrefix( + spaceId + )}/api/saved_objects/_find?type=globaltype&filter=dashboard.title:'Requests'` + ) + .auth(user.username, user.password) + .expect(tests.filterWithUnAllowedType.statusCode) + .then(tests.filterWithUnAllowedType.response)); + + it(`not space aware type should return ${tests.filterWithNotSpaceAwareType.statusCode} with ${tests.filterWithNotSpaceAwareType.description}`, async () => + await supertest + .get( + `${getUrlPrefix( + spaceId + )}/api/saved_objects/_find?type=globaltype&filter=globaltype.attributes.name:*global*` + ) + .auth(user.username, user.password) + .expect(tests.filterWithNotSpaceAwareType.statusCode) + .then(tests.filterWithNotSpaceAwareType.response)); + + it(`finding a hiddentype should return ${tests.filterWithHiddenType.statusCode} with ${tests.filterWithHiddenType.description}`, async () => + await supertest + .get( + `${getUrlPrefix( + spaceId + )}/api/saved_objects/_find?type=hiddentype&fields=name&filter=hiddentype.attributes.name:'hello'` + ) + .auth(user.username, user.password) + .expect(tests.filterWithHiddenType.statusCode) + .then(tests.filterWithHiddenType.response)); + + describe('unknown type', () => { + it(`should return ${tests.filterWithUnknownType.statusCode} with ${tests.filterWithUnknownType.description}`, async () => + await supertest + .get( + `${getUrlPrefix( + spaceId + )}/api/saved_objects/_find?type=wigwags&filter=wigwags.attributes.title:'unknown'` + ) + .auth(user.username, user.password) + .expect(tests.filterWithUnknownType.statusCode) + .then(tests.filterWithUnknownType.response)); + }); + + describe('no type', () => { + it(`should return ${tests.filterWithNoType.statusCode} with ${tests.filterWithNoType.description}`, async () => + await supertest + .get( + `${getUrlPrefix( + spaceId + )}/api/saved_objects/_find?filter=global.attributes.name:*global*` + ) + .auth(user.username, user.password) + .expect(tests.filterWithNoType.statusCode) + .then(tests.filterWithNoType.response)); + }); + }); }); }; @@ -195,6 +269,7 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) createExpectEmpty, createExpectRbacForbidden, createExpectVisualizationResults, + expectFilterWrongTypeError, expectNotSpaceAwareResults, expectTypeRequired, findTest, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts index 92e6ec850dc0e6..366b8b44585cdb 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts @@ -18,6 +18,7 @@ export default function({ getService }: FtrProviderContext) { createExpectEmpty, createExpectRbacForbidden, createExpectVisualizationResults, + expectFilterWrongTypeError, expectNotSpaceAwareResults, expectTypeRequired, findTest, @@ -94,6 +95,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'forbidden login and find globaltype message', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -136,6 +162,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithUnknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -178,6 +229,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'forbidden login and find globaltype message', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -220,6 +296,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -262,6 +363,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -304,6 +430,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -346,6 +497,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -388,6 +564,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -430,6 +631,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -472,6 +698,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'forbidden login and find globaltype message', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); }); diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts index d17dbe6e7b1edd..64d85a199e7bca 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts @@ -17,6 +17,7 @@ export default function({ getService }: FtrProviderContext) { createExpectEmpty, createExpectRbacForbidden, createExpectVisualizationResults, + expectFilterWrongTypeError, expectNotSpaceAwareResults, expectTypeRequired, findTest, @@ -60,6 +61,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'forbidden login and find globaltype message', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden login and find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -101,6 +127,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithUnknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -142,6 +193,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'forbidden login and find globaltype message', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden login and find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -183,6 +259,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -224,6 +325,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -265,6 +391,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -306,6 +457,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -347,6 +523,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -388,6 +589,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -429,6 +655,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -470,6 +721,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); }); diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts index 69a2690c619788..a07d3edf834e9d 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts @@ -15,6 +15,7 @@ export default function({ getService }: FtrProviderContext) { const { createExpectEmpty, createExpectVisualizationResults, + expectFilterWrongTypeError, expectNotSpaceAwareResults, expectTypeRequired, findTest, @@ -59,6 +60,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithUnknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -100,6 +126,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithUnknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); }); From d4321f5aa71c7889e5a5e4e2202a073b96ee86a6 Mon Sep 17 00:00:00 2001 From: Michael Marcialis Date: Wed, 2 Oct 2019 19:47:17 -0400 Subject: [PATCH 42/59] [SIEM] Update Settings Text (#47147) * update settings text * correct ml casing --- x-pack/legacy/plugins/siem/index.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/x-pack/legacy/plugins/siem/index.ts b/x-pack/legacy/plugins/siem/index.ts index 4c0997e1d6181f..c3e2c2b0e119d4 100644 --- a/x-pack/legacy/plugins/siem/index.ts +++ b/x-pack/legacy/plugins/siem/index.ts @@ -65,14 +65,15 @@ export function siem(kibana: any) { [DEFAULT_SIEM_REFRESH_INTERVAL]: { type: 'json', name: i18n.translate('xpack.siem.uiSettings.defaultRefreshIntervalLabel', { - defaultMessage: 'Time picker refresh interval', + defaultMessage: 'Time filter refresh interval', }), value: `{ "pause": ${DEFAULT_INTERVAL_PAUSE}, "value": ${DEFAULT_INTERVAL_VALUE} }`, description: i18n.translate('xpack.siem.uiSettings.defaultRefreshIntervalDescription', { - defaultMessage: "The SIEM timefilter's default refresh interval", + defaultMessage: + '

Default refresh interval for the SIEM time filter, in milliseconds.

', }), category: ['siem'], requiresPageReload: true, @@ -80,39 +81,39 @@ export function siem(kibana: any) { [DEFAULT_SIEM_TIME_RANGE]: { type: 'json', name: i18n.translate('xpack.siem.uiSettings.defaultTimeRangeLabel', { - defaultMessage: 'Time picker defaults', + defaultMessage: 'Time filter period', }), value: `{ "from": "${DEFAULT_FROM}", "to": "${DEFAULT_TO}" }`, description: i18n.translate('xpack.siem.uiSettings.defaultTimeRangeDescription', { - defaultMessage: - 'The SIEM timefilter selection to use when Kibana is started without one', + defaultMessage: '

Default period of time in the SIEM time filter.

', }), category: ['siem'], requiresPageReload: true, }, [DEFAULT_INDEX_KEY]: { name: i18n.translate('xpack.siem.uiSettings.defaultIndexLabel', { - defaultMessage: 'Default index', + defaultMessage: 'Elasticsearch indices', }), value: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], description: i18n.translate('xpack.siem.uiSettings.defaultIndexDescription', { - defaultMessage: 'Default Elasticsearch index to search', + defaultMessage: + '

Comma-delimited list of Elasticsearch indices from which the SIEM app collects events.

', }), category: ['siem'], requiresPageReload: true, }, [DEFAULT_ANOMALY_SCORE]: { name: i18n.translate('xpack.siem.uiSettings.defaultAnomalyScoreLabel', { - defaultMessage: 'Default anomaly threshold', + defaultMessage: 'Anomaly threshold', }), value: 50, type: 'number', description: i18n.translate('xpack.siem.uiSettings.defaultAnomalyScoreDescription', { defaultMessage: - 'Default anomaly score threshold to exceed before showing anomalies. Valid values are between 0 and 100', + '

Value above which Machine Learning job anomalies are displayed in the SIEM app.

Valid values: 0 to 100.

', }), category: ['siem'], requiresPageReload: true, From f61601cc2d40c8431ce9ac2e177601e1dc716696 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 3 Oct 2019 03:13:36 +0100 Subject: [PATCH 43/59] chore(NA): fix logic behind cleaning x-pack node modules on build (#47091) --- package.json | 2 -- packages/kbn-babel-code-parser/src/can_require.js | 12 ++++++------ packages/kbn-babel-code-parser/src/code_parser.js | 2 +- packages/kbn-babel-code-parser/src/strategies.js | 8 ++++++-- .../kbn-babel-code-parser/src/strategies.test.js | 6 +++--- x-pack/package.json | 2 ++ 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index be43e242ce569c..8aff95748560db 100644 --- a/package.json +++ b/package.json @@ -160,7 +160,6 @@ "expiry-js": "0.1.7", "file-loader": "4.2.0", "font-awesome": "4.7.0", - "fp-ts": "^2.0.5", "getos": "^3.1.0", "glob": "^7.1.2", "glob-all": "^3.1.0", @@ -177,7 +176,6 @@ "https-proxy-agent": "^2.2.2", "inert": "^5.1.0", "inline-style": "^2.0.0", - "io-ts": "^2.0.1", "joi": "^13.5.2", "jquery": "^3.4.1", "js-yaml": "3.13.1", diff --git a/packages/kbn-babel-code-parser/src/can_require.js b/packages/kbn-babel-code-parser/src/can_require.js index e590c249e9806f..4d85910abe6ed6 100644 --- a/packages/kbn-babel-code-parser/src/can_require.js +++ b/packages/kbn-babel-code-parser/src/can_require.js @@ -17,18 +17,18 @@ * under the License. */ -export function canRequire(cwd, entry) { +export function canRequire(entry, cwd = require.resolve.paths(entry) || []) { try { // We will try to test if we can resolve // this entry through the require.resolve // setting as the start looking path the - // given cwd. Require.resolve will keep + // given cwd. That cwd variable could be + // a path or an array of paths + // from where Require.resolve will keep // looking recursively as normal starting - // from that location. + // from those locations. return require.resolve(entry, { - paths: [ - cwd - ] + paths: [].concat(cwd) }); } catch (e) { return false; diff --git a/packages/kbn-babel-code-parser/src/code_parser.js b/packages/kbn-babel-code-parser/src/code_parser.js index 8d76b1032561ac..0f53bd249bb5cc 100644 --- a/packages/kbn-babel-code-parser/src/code_parser.js +++ b/packages/kbn-babel-code-parser/src/code_parser.js @@ -79,7 +79,7 @@ export async function parseEntries(cwd, entries, strategy, results, wasParsed = const sanitizedCwd = cwd || process.cwd(); // Test each entry against canRequire function - const entriesQueue = entries.map(entry => canRequire(sanitizedCwd, entry)); + const entriesQueue = entries.map(entry => canRequire(entry)); while(entriesQueue.length) { // Get the first element in the queue as diff --git a/packages/kbn-babel-code-parser/src/strategies.js b/packages/kbn-babel-code-parser/src/strategies.js index 317ded014210b1..89621bc53bd534 100644 --- a/packages/kbn-babel-code-parser/src/strategies.js +++ b/packages/kbn-babel-code-parser/src/strategies.js @@ -62,8 +62,12 @@ export async function dependenciesParseStrategy(cwd, parseSingleFile, mainEntry, // new dependencies return dependencies.reduce((filteredEntries, entry) => { const absEntryPath = resolve(cwd, dirname(mainEntry), entry); - const requiredPath = canRequire(cwd, absEntryPath); - const requiredRelativePath = canRequire(cwd, entry); + + // NOTE: cwd for following canRequires is absEntryPath + // because we should start looking from there + const requiredPath = canRequire(absEntryPath, absEntryPath); + const requiredRelativePath = canRequire(entry, absEntryPath); + const isRelativeFile = !isAbsolute(entry); const isNodeModuleDep = isRelativeFile && !requiredPath && requiredRelativePath; const isNewEntry = isRelativeFile && requiredPath; diff --git a/packages/kbn-babel-code-parser/src/strategies.test.js b/packages/kbn-babel-code-parser/src/strategies.test.js index 5a84edf560af13..d7caa8b95d4a22 100644 --- a/packages/kbn-babel-code-parser/src/strategies.test.js +++ b/packages/kbn-babel-code-parser/src/strategies.test.js @@ -59,8 +59,8 @@ describe('Code Parser Strategies', () => { cb(null, `require('dep_from_node_modules')`); }); - canRequire.mockImplementation((mockCwd, entry) => { - if (entry === `${mockCwd}dep1/dep_from_node_modules`) { + canRequire.mockImplementation((entry, cwd) => { + if (entry === `${cwd}dep1/dep_from_node_modules`) { return false; } @@ -78,7 +78,7 @@ describe('Code Parser Strategies', () => { cb(null, `require('./relative_dep')`); }); - canRequire.mockImplementation((mockCwd, entry) => { + canRequire.mockImplementation((entry) => { if (entry === `${mockCwd}dep1/relative_dep`) { return `${entry}/index.js`; } diff --git a/x-pack/package.json b/x-pack/package.json index 5402e418554510..5a27764bdf9eea 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -241,6 +241,7 @@ "file-type": "^10.9.0", "font-awesome": "4.7.0", "formsy-react": "^1.1.5", + "fp-ts": "^2.0.5", "geojson-rewind": "^0.3.1", "get-port": "4.2.0", "getos": "^3.1.0", @@ -262,6 +263,7 @@ "immer": "^1.5.0", "inline-style": "^2.0.0", "intl": "^1.2.5", + "io-ts": "^2.0.1", "isbinaryfile": "4.0.2", "isomorphic-git": "0.55.5", "joi": "^13.5.2", From 27a53d166fe834cc9106807045c39e9362423e30 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Thu, 3 Oct 2019 10:15:31 +0200 Subject: [PATCH 44/59] Add TypeScript rules to STYLEGUIDE [skip ci] (#47125) * Add TypeScript rules to STYLEGUIDE * Update STYLEGUIDE.md Co-Authored-By: Court Ewing --- STYLEGUIDE.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 152a0e2c488712..5fd3ef5e8ff4bf 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -141,6 +141,39 @@ function addBar(foos, foo) { } ``` +### Avoid `any` whenever possible + +Since TypeScript 3.0 and the introduction of the +[`unknown` type](https://mariusschulz.com/blog/the-unknown-type-in-typescript) there are rarely any +reasons to use `any` as a type. Nearly all places of former `any` usage can be replace by either a +generic or `unknown` (in cases the type is really not known). + +You should always prefer using those mechanisms over using `any`, since they are stricter typed and +less likely to introduce bugs in the future due to insufficient types. + +If you’re not having `any` in your plugin or are starting a new plugin, you should enable the +[`@typescript-eslint/no-explicit-any`](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-explicit-any.md) +linting rule for your plugin via the [`.eslintrc.js`](https://github.com/elastic/kibana/blob/master/.eslintrc.js) config. + +### Avoid non-null assertions + +You should try avoiding non-null assertions (`!.`) wherever possible. By using them you tell +TypeScript, that something is not null even though by it’s type it could be. Usage of non-null +assertions is most often a side-effect of you actually checked that the variable is not `null` +but TypeScript doesn’t correctly carry on that information till the usage of the variable. + +In most cases it’s possible to replace the non-null assertion by structuring your code/checks slightly different +or using [user defined type guards](https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards) +to properly tell TypeScript what type a variable has. + +Using non-null assertion increases the risk for future bugs. In case the condition under which we assumed that the +variable can’t be null has changed (potentially even due to changes in compeltely different files), the non-null +assertion would now wrongly disable proper type checking for us. + +If you’re not using non-null assertions in your plugin or are starting a new plugin, consider enabling the +[`@typescript-eslint/no-non-null-assertion`](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-non-null-assertion.md) +linting rule for you plugin in the [`.eslintrc.js`](https://github.com/elastic/kibana/blob/master/.eslintrc.js) config. + ### Return/throw early from functions To avoid deep nesting of if-statements, always return a function's value as early From cae19e80aeafbbba5b99b593a59f8037172f2f5f Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 3 Oct 2019 10:44:21 +0200 Subject: [PATCH 45/59] =?UTF-8?q?refactor:=20=F0=9F=92=A1=20convert=20Inte?= =?UTF-8?q?rpreter=20.js=20->=20.ts=20(#44545)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 💡 convert Interpreter .js -> .ts * fix: 🐛 fix TypeScript type errors * test: 💍 remove old snapshot --- ...ibana.test.js.snap => kibana.test.ts.snap} | 0 .../functions/__tests__/{font.js => font.ts} | 10 ++--- .../public/functions/{clog.js => clog.ts} | 4 +- .../public/functions/{index.js => index.ts} | 9 ++++- .../{kibana.test.js => kibana.test.ts} | 6 +-- .../public/functions/{kibana.js => kibana.ts} | 6 +-- .../{kibana_context.js => kibana_context.ts} | 19 ++++------ .../{vis_dimension.js => vis_dimension.ts} | 32 ++++++++-------- .../{visualization.js => visualization.ts} | 27 +++++++------- ...nterpreter.test.js => interpreter.test.ts} | 15 ++++---- .../public/{interpreter.js => interpreter.ts} | 10 +++-- ...{render_function.js => render_function.ts} | 4 +- ...gistry.js => render_functions_registry.ts} | 6 +-- .../{visualization.js => visualization.ts} | 13 ++++--- ...{create_handlers.js => create_handlers.ts} | 4 +- ...{create_handlers.js => create_handlers.ts} | 4 +- .../server/routes/{index.js => index.ts} | 2 +- ...erver_functions.js => server_functions.ts} | 37 ++++++++++--------- .../{test_helpers.js => test_helpers.ts} | 5 ++- .../public/markdown_fn.test.ts | 2 +- .../public/metric_vis_fn.test.ts | 2 +- .../public/table_vis_fn.test.ts | 4 +- .../public/tag_cloud_fn.test.ts | 2 +- 23 files changed, 118 insertions(+), 105 deletions(-) rename src/legacy/core_plugins/interpreter/public/functions/__snapshots__/{kibana.test.js.snap => kibana.test.ts.snap} (100%) rename src/legacy/core_plugins/interpreter/public/functions/__tests__/{font.js => font.ts} (96%) rename src/legacy/core_plugins/interpreter/public/functions/{clog.js => clog.ts} (91%) rename src/legacy/core_plugins/interpreter/public/functions/{index.js => index.ts} (92%) rename src/legacy/core_plugins/interpreter/public/functions/{kibana.test.js => kibana.test.ts} (97%) rename src/legacy/core_plugins/interpreter/public/functions/{kibana.js => kibana.ts} (93%) rename src/legacy/core_plugins/interpreter/public/functions/{kibana_context.js => kibana_context.ts} (87%) rename src/legacy/core_plugins/interpreter/public/functions/{vis_dimension.js => vis_dimension.ts} (74%) rename src/legacy/core_plugins/interpreter/public/functions/{visualization.js => visualization.ts} (91%) rename src/legacy/core_plugins/interpreter/public/{interpreter.test.js => interpreter.test.ts} (96%) rename src/legacy/core_plugins/interpreter/public/{interpreter.js => interpreter.ts} (87%) rename src/legacy/core_plugins/interpreter/public/lib/{render_function.js => render_function.ts} (92%) rename src/legacy/core_plugins/interpreter/public/lib/{render_functions_registry.js => render_functions_registry.ts} (88%) rename src/legacy/core_plugins/interpreter/public/renderers/{visualization.js => visualization.ts} (84%) rename src/legacy/core_plugins/interpreter/server/lib/__tests__/{create_handlers.js => create_handlers.ts} (95%) rename src/legacy/core_plugins/interpreter/server/lib/{create_handlers.js => create_handlers.ts} (88%) rename src/legacy/core_plugins/interpreter/server/routes/{index.js => index.ts} (95%) rename src/legacy/core_plugins/interpreter/server/routes/{server_functions.js => server_functions.ts} (84%) rename src/legacy/core_plugins/interpreter/{test_helpers.js => test_helpers.ts} (86%) diff --git a/src/legacy/core_plugins/interpreter/public/functions/__snapshots__/kibana.test.js.snap b/src/legacy/core_plugins/interpreter/public/functions/__snapshots__/kibana.test.ts.snap similarity index 100% rename from src/legacy/core_plugins/interpreter/public/functions/__snapshots__/kibana.test.js.snap rename to src/legacy/core_plugins/interpreter/public/functions/__snapshots__/kibana.test.ts.snap diff --git a/src/legacy/core_plugins/interpreter/public/functions/__tests__/font.js b/src/legacy/core_plugins/interpreter/public/functions/__tests__/font.ts similarity index 96% rename from src/legacy/core_plugins/interpreter/public/functions/__tests__/font.js rename to src/legacy/core_plugins/interpreter/public/functions/__tests__/font.ts index 4a7ebc1522f2ab..8bf8052fee3b7b 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/__tests__/font.js +++ b/src/legacy/core_plugins/interpreter/public/functions/__tests__/font.ts @@ -23,13 +23,13 @@ import { font } from '../font'; import { functionWrapper } from '../../../test_helpers'; describe('font', () => { - const fn = functionWrapper(font); + const fn: any = functionWrapper(font); describe('default output', () => { const result = fn(null); it('returns a style', () => { - expect(result) + (expect as any)(result) .to.have.property('type', 'style') .and.to.have.property('spec') .and.to.have.property('css'); @@ -40,8 +40,8 @@ describe('font', () => { describe('size', () => { it('sets font size', () => { const result = fn(null, { size: 20 }); - expect(result.spec).to.have.property('fontSize', '20px'); - expect(result.css).to.contain('font-size:20px'); + (expect as any)(result.spec).to.have.property('fontSize', '20px'); + (expect as any)(result.css).to.contain('font-size:20px'); }); it('defaults to 14px', () => { @@ -110,7 +110,7 @@ describe('font', () => { expect(result.css).to.contain('font-weight:400'); }); - it('defaults to \'normal\'', () => { + it("defaults to 'normal'", () => { const result = fn(null); expect(result.spec).to.have.property('fontWeight', 'normal'); expect(result.css).to.contain('font-weight:normal'); diff --git a/src/legacy/core_plugins/interpreter/public/functions/clog.js b/src/legacy/core_plugins/interpreter/public/functions/clog.ts similarity index 91% rename from src/legacy/core_plugins/interpreter/public/functions/clog.js rename to src/legacy/core_plugins/interpreter/public/functions/clog.ts index 634d166f5f0bb5..4867726a42d72c 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/clog.js +++ b/src/legacy/core_plugins/interpreter/public/functions/clog.ts @@ -20,8 +20,8 @@ export const clog = () => ({ name: 'clog', help: 'Outputs the context to the console', - fn: context => { - console.log(context); //eslint-disable-line no-console + fn: (context: any) => { + console.log(context); // eslint-disable-line no-console return context; }, }); diff --git a/src/legacy/core_plugins/interpreter/public/functions/index.js b/src/legacy/core_plugins/interpreter/public/functions/index.ts similarity index 92% rename from src/legacy/core_plugins/interpreter/public/functions/index.js rename to src/legacy/core_plugins/interpreter/public/functions/index.ts index 38c3920f91bd29..d86f033acb3d13 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/index.js +++ b/src/legacy/core_plugins/interpreter/public/functions/index.ts @@ -27,5 +27,12 @@ import { visualization } from './visualization'; import { visDimension } from './vis_dimension'; export const functions = [ - clog, esaggs, font, kibana, kibanaContext, range, visualization, visDimension, + clog, + esaggs, + font, + kibana, + kibanaContext, + range, + visualization, + visDimension, ]; diff --git a/src/legacy/core_plugins/interpreter/public/functions/kibana.test.js b/src/legacy/core_plugins/interpreter/public/functions/kibana.test.ts similarity index 97% rename from src/legacy/core_plugins/interpreter/public/functions/kibana.test.js rename to src/legacy/core_plugins/interpreter/public/functions/kibana.test.ts index 4757b9b12b50da..9f80449ac36be4 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/kibana.test.js +++ b/src/legacy/core_plugins/interpreter/public/functions/kibana.test.ts @@ -22,9 +22,9 @@ import { kibana } from './kibana'; describe('interpreter/functions#kibana', () => { const fn = functionWrapper(kibana); - let context; - let initialContext; - let handlers; + let context: any; + let initialContext: any; + let handlers: any; beforeEach(() => { context = { timeRange: { from: '0', to: '1' } }; diff --git a/src/legacy/core_plugins/interpreter/public/functions/kibana.js b/src/legacy/core_plugins/interpreter/public/functions/kibana.ts similarity index 93% rename from src/legacy/core_plugins/interpreter/public/functions/kibana.js rename to src/legacy/core_plugins/interpreter/public/functions/kibana.ts index e0817d8e04b026..37ff337f58b8d4 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/kibana.js +++ b/src/legacy/core_plugins/interpreter/public/functions/kibana.ts @@ -24,10 +24,10 @@ export const kibana = () => ({ type: 'kibana_context', context: {}, help: i18n.translate('interpreter.functions.kibana.help', { - defaultMessage: 'Gets kibana global context' + defaultMessage: 'Gets kibana global context', }), args: {}, - fn(context, args, handlers) { + fn(context: any, args: any, handlers: any) { const initialContext = handlers.getInitialContext ? handlers.getInitialContext() : {}; if (context.query) { @@ -45,7 +45,7 @@ export const kibana = () => ({ type: 'kibana_context', query: initialContext.query, filters: initialContext.filters, - timeRange: timeRange, + timeRange, }; }, }); diff --git a/src/legacy/core_plugins/interpreter/public/functions/kibana_context.js b/src/legacy/core_plugins/interpreter/public/functions/kibana_context.ts similarity index 87% rename from src/legacy/core_plugins/interpreter/public/functions/kibana_context.js rename to src/legacy/core_plugins/interpreter/public/functions/kibana_context.ts index 7b7294a87831d6..2f2241a3670945 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/kibana_context.js +++ b/src/legacy/core_plugins/interpreter/public/functions/kibana_context.ts @@ -24,13 +24,10 @@ export const kibanaContext = () => ({ name: 'kibana_context', type: 'kibana_context', context: { - types: [ - 'kibana_context', - 'null', - ], + types: ['kibana_context', 'null'], }, help: i18n.translate('interpreter.functions.kibana_context.help', { - defaultMessage: 'Updates kibana global context' + defaultMessage: 'Updates kibana global context', }), args: { q: { @@ -49,11 +46,11 @@ export const kibanaContext = () => ({ savedSearchId: { types: ['string', 'null'], default: null, - } + }, }, - async fn(context, args) { + async fn(context: any, args: any) { const $injector = await chrome.dangerouslyGetActiveInjector(); - const savedSearches = $injector.get('savedSearches'); + const savedSearches = $injector.get('savedSearches') as any; const queryArg = args.q ? JSON.parse(args.q) : []; let queries = Array.isArray(queryArg) ? queryArg : [queryArg]; let filters = args.filters ? JSON.parse(args.filters) : []; @@ -71,7 +68,7 @@ export const kibanaContext = () => ({ } if (context.filters) { - filters = filters.concat(context.filters).filter(f => !f.meta.disabled); + filters = filters.concat(context.filters).filter((f: any) => !f.meta.disabled); } const timeRange = args.timeRange ? JSON.parse(args.timeRange) : context.timeRange; @@ -79,8 +76,8 @@ export const kibanaContext = () => ({ return { type: 'kibana_context', query: queries, - filters: filters, - timeRange: timeRange, + filters, + timeRange, }; }, }); diff --git a/src/legacy/core_plugins/interpreter/public/functions/vis_dimension.js b/src/legacy/core_plugins/interpreter/public/functions/vis_dimension.ts similarity index 74% rename from src/legacy/core_plugins/interpreter/public/functions/vis_dimension.js rename to src/legacy/core_plugins/interpreter/public/functions/vis_dimension.ts index e1a6c41198bad4..19503dbe03ae98 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/vis_dimension.js +++ b/src/legacy/core_plugins/interpreter/public/functions/vis_dimension.ts @@ -22,48 +22,48 @@ import { i18n } from '@kbn/i18n'; export const visDimension = () => ({ name: 'visdimension', help: i18n.translate('interpreter.function.visDimension.help', { - defaultMessage: 'Generates visConfig dimension object' + defaultMessage: 'Generates visConfig dimension object', }), type: 'vis_dimension', context: { - types: [ - 'kibana_datatable' - ], + types: ['kibana_datatable'], }, args: { accessor: { types: ['string', 'number'], aliases: ['_'], help: i18n.translate('interpreter.function.visDimension.accessor.help', { - defaultMessage: 'Column in your dataset to use (either column index or column name)' + defaultMessage: 'Column in your dataset to use (either column index or column name)', }), }, format: { types: ['string'], - default: 'string' + default: 'string', }, formatParams: { types: ['string'], default: '"{}"', - } + }, }, - fn: (context, args) => { - const accessor = Number.isInteger(args.accessor) ? - args.accessor : - context.columns.find(c => c.id === args.accessor); + fn: (context: any, args: any) => { + const accessor = Number.isInteger(args.accessor) + ? args.accessor + : context.columns.find((c: any) => c.id === args.accessor); if (accessor === undefined) { - throw new Error(i18n.translate('interpreter.function.visDimension.error.accessor', { - defaultMessage: 'Column name provided is invalid' - })); + throw new Error( + i18n.translate('interpreter.function.visDimension.error.accessor', { + defaultMessage: 'Column name provided is invalid', + }) + ); } return { type: 'vis_dimension', - accessor: accessor, + accessor, format: { id: args.format, params: JSON.parse(args.formatParams), - } + }, }; }, }); diff --git a/src/legacy/core_plugins/interpreter/public/functions/visualization.js b/src/legacy/core_plugins/interpreter/public/functions/visualization.ts similarity index 91% rename from src/legacy/core_plugins/interpreter/public/functions/visualization.js rename to src/legacy/core_plugins/interpreter/public/functions/visualization.ts index 7dceeaf6843540..94be78befd3d09 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/visualization.js +++ b/src/legacy/core_plugins/interpreter/public/functions/visualization.ts @@ -20,17 +20,16 @@ import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; import chrome from 'ui/chrome'; -import { setup as data } from '../../../data/public/legacy'; -import { start as visualizations } from '../../../visualizations/public/legacy'; - import { FilterBarQueryFilterProvider } from 'ui/filter_manager/query_filter'; import { PersistedState } from 'ui/persisted_state'; +import { setup as data } from '../../../data/public/legacy'; +import { start as visualizations } from '../../../visualizations/public/legacy'; export const visualization = () => ({ name: 'visualization', type: 'render', help: i18n.translate('interpreter.functions.visualization.help', { - defaultMessage: 'A simple visualization' + defaultMessage: 'A simple visualization', }), args: { index: { @@ -60,17 +59,17 @@ export const visualization = () => ({ uiState: { types: ['string'], default: '"{}"', - } + }, }, - async fn(context, args, handlers) { + async fn(context: any, args: any, handlers: any) { const $injector = await chrome.dangerouslyGetActiveInjector(); - const Private = $injector.get('Private'); + const Private = $injector.get('Private') as any; const { indexPatterns } = data.indexPatterns; const queryFilter = Private(FilterBarQueryFilterProvider); const visConfigParams = JSON.parse(args.visConfig); const schemas = JSON.parse(args.schemas); - const visType = visualizations.types.get(args.type || 'histogram'); + const visType = visualizations.types.get(args.type || 'histogram') as any; const indexPattern = args.index ? await indexPatterns.get(args.index) : null; const uiStateParams = JSON.parse(args.uiState); @@ -85,7 +84,7 @@ export const visualization = () => ({ timeRange: get(context, 'timeRange', null), query: get(context, 'query', null), filters: get(context, 'filters', null), - uiState: uiState, + uiState, inspectorAdapters: handlers.inspectorAdapters, queryFilter, forceFetch: true, @@ -95,14 +94,14 @@ export const visualization = () => ({ if (typeof visType.responseHandler === 'function') { if (context.columns) { // assign schemas to aggConfigs - context.columns.forEach(column => { + context.columns.forEach((column: any) => { if (column.aggConfig) { column.aggConfig.aggConfigs.schemas = visType.schemas.all; } }); Object.keys(schemas).forEach(key => { - schemas[key].forEach(i => { + schemas[key].forEach((i: any) => { if (context.columns[i] && context.columns[i].aggConfig) { context.columns[i].aggConfig.schema = key; } @@ -119,8 +118,8 @@ export const visualization = () => ({ value: { visData: context, visType: args.type, - visConfig: visConfigParams - } + visConfig: visConfigParams, + }, }; - } + }, }); diff --git a/src/legacy/core_plugins/interpreter/public/interpreter.test.js b/src/legacy/core_plugins/interpreter/public/interpreter.test.ts similarity index 96% rename from src/legacy/core_plugins/interpreter/public/interpreter.test.js rename to src/legacy/core_plugins/interpreter/public/interpreter.test.ts index bd7dc0a47c1240..1de1e8c0cc0598 100644 --- a/src/legacy/core_plugins/interpreter/public/interpreter.test.js +++ b/src/legacy/core_plugins/interpreter/public/interpreter.test.ts @@ -24,9 +24,9 @@ jest.mock('ui/new_platform', () => ({ injectedMetadata: { getKibanaVersion: () => '8.0.0', getBasePath: () => '/lol', - } - } - } + }, + }, + }, })); jest.mock('uiExports/interpreter'); @@ -38,7 +38,7 @@ jest.mock('@kbn/interpreter/common', () => ({ const mockInterpreter = { interpreter: { interpretAst: jest.fn(), - } + }, }; jest.mock('./lib/interpreter', () => ({ initializeInterpreter: jest.fn().mockReturnValue(Promise.resolve(mockInterpreter)), @@ -57,9 +57,9 @@ jest.mock('./functions', () => ({ functions: [{}, {}, {}] })); jest.mock('./renderers/visualization', () => ({ visualization: {} })); describe('interpreter/interpreter', () => { - let getInterpreter; - let interpretAst; - let initializeInterpreter; + let getInterpreter: any; + let interpretAst: any; + let initializeInterpreter: any; beforeEach(() => { jest.clearAllMocks(); @@ -117,5 +117,4 @@ describe('interpreter/interpreter', () => { expect(mockInterpreter.interpreter.interpretAst).toHaveBeenCalledTimes(2); }); }); - }); diff --git a/src/legacy/core_plugins/interpreter/public/interpreter.js b/src/legacy/core_plugins/interpreter/public/interpreter.ts similarity index 87% rename from src/legacy/core_plugins/interpreter/public/interpreter.js rename to src/legacy/core_plugins/interpreter/public/interpreter.ts index 84e05bb10d9fa6..8ba82d5daf759a 100644 --- a/src/legacy/core_plugins/interpreter/public/interpreter.js +++ b/src/legacy/core_plugins/interpreter/public/interpreter.ts @@ -18,6 +18,7 @@ */ import 'uiExports/interpreter'; +// @ts-ignore import { register, registryFactory } from '@kbn/interpreter/common'; import { initializeInterpreter } from './lib/interpreter'; import { registries } from './registries'; @@ -27,7 +28,10 @@ import { typeSpecs } from '../../../../plugins/expressions/common'; // Expose kbnInterpreter.register(specs) and kbnInterpreter.registries() globally so that plugins // can register without a transpile step. -global.kbnInterpreter = Object.assign(global.kbnInterpreter || {}, registryFactory(registries)); +(global as any).kbnInterpreter = Object.assign( + (global as any).kbnInterpreter || {}, + registryFactory(registries) +); register(registries, { types: typeSpecs, @@ -35,7 +39,7 @@ register(registries, { renderers: [visualization], }); -let interpreterPromise; +let interpreterPromise: Promise | undefined; export const getInterpreter = async () => { if (!interpreterPromise) { @@ -44,7 +48,7 @@ export const getInterpreter = async () => { return await interpreterPromise; }; -export const interpretAst = async (...params) => { +export const interpretAst = async (...params: any) => { const { interpreter } = await getInterpreter(); return await interpreter.interpretAst(...params); }; diff --git a/src/legacy/core_plugins/interpreter/public/lib/render_function.js b/src/legacy/core_plugins/interpreter/public/lib/render_function.ts similarity index 92% rename from src/legacy/core_plugins/interpreter/public/lib/render_function.js rename to src/legacy/core_plugins/interpreter/public/lib/render_function.ts index 04aa05951be70f..76d1f58b661950 100644 --- a/src/legacy/core_plugins/interpreter/public/lib/render_function.js +++ b/src/legacy/core_plugins/interpreter/public/lib/render_function.ts @@ -17,7 +17,7 @@ * under the License. */ -export function RenderFunction(config) { +export function RenderFunction(this: any, config: any) { // This must match the name of the function that is used to create the `type: render` object this.name = config.name; @@ -36,7 +36,7 @@ export function RenderFunction(config) { // the function called to render the data this.render = config.render || - function render(domNode, data, done) { + function render(domNode: any, data: any, done: any) { done(); }; } diff --git a/src/legacy/core_plugins/interpreter/public/lib/render_functions_registry.js b/src/legacy/core_plugins/interpreter/public/lib/render_functions_registry.ts similarity index 88% rename from src/legacy/core_plugins/interpreter/public/lib/render_functions_registry.js rename to src/legacy/core_plugins/interpreter/public/lib/render_functions_registry.ts index 60e823baf0fa78..427e7f7454c24e 100644 --- a/src/legacy/core_plugins/interpreter/public/lib/render_functions_registry.js +++ b/src/legacy/core_plugins/interpreter/public/lib/render_functions_registry.ts @@ -20,9 +20,9 @@ import { Registry } from '@kbn/interpreter/common'; import { RenderFunction } from './render_function'; -class RenderFunctionsRegistry extends Registry { - wrapper(obj) { - return new RenderFunction(obj); +class RenderFunctionsRegistry extends Registry { + wrapper(obj: any) { + return new (RenderFunction as any)(obj); } } diff --git a/src/legacy/core_plugins/interpreter/public/renderers/visualization.js b/src/legacy/core_plugins/interpreter/public/renderers/visualization.ts similarity index 84% rename from src/legacy/core_plugins/interpreter/public/renderers/visualization.js rename to src/legacy/core_plugins/interpreter/public/renderers/visualization.ts index 38fe02436380c7..960e925b132213 100644 --- a/src/legacy/core_plugins/interpreter/public/renderers/visualization.js +++ b/src/legacy/core_plugins/interpreter/public/renderers/visualization.ts @@ -19,17 +19,18 @@ import chrome from 'ui/chrome'; import { visualizationLoader } from 'ui/visualize/loader/visualization_loader'; +// @ts-ignore import { VisProvider } from 'ui/visualize/loader/vis'; export const visualization = () => ({ name: 'visualization', displayName: 'visualization', reuseDomNode: true, - render: async (domNode, config, handlers) => { + render: async (domNode: HTMLElement, config: any, handlers: any) => { const { visData, visConfig, params } = config; const visType = config.visType || visConfig.type; const $injector = await chrome.dangerouslyGetActiveInjector(); - const Private = $injector.get('Private'); + const Private = $injector.get('Private') as any; const Vis = Private(VisProvider); if (handlers.vis) { @@ -49,8 +50,10 @@ export const visualization = () => ({ handlers.onDestroy(() => visualizationLoader.destroy()); - await visualizationLoader.render(domNode, handlers.vis, visData, handlers.vis.params, uiState, params).then(() => { - if (handlers.done) handlers.done(); - }); + await visualizationLoader + .render(domNode, handlers.vis, visData, handlers.vis.params, uiState, params) + .then(() => { + if (handlers.done) handlers.done(); + }); }, }); diff --git a/src/legacy/core_plugins/interpreter/server/lib/__tests__/create_handlers.js b/src/legacy/core_plugins/interpreter/server/lib/__tests__/create_handlers.ts similarity index 95% rename from src/legacy/core_plugins/interpreter/server/lib/__tests__/create_handlers.js rename to src/legacy/core_plugins/interpreter/server/lib/__tests__/create_handlers.ts index a6e0e13049e1c9..00886630807748 100644 --- a/src/legacy/core_plugins/interpreter/server/lib/__tests__/create_handlers.js +++ b/src/legacy/core_plugins/interpreter/server/lib/__tests__/create_handlers.ts @@ -28,13 +28,13 @@ const mockServer = { plugins: { elasticsearch: { getCluster: () => ({ - callWithRequest: (...args) => Promise.resolve(args), + callWithRequest: (...args: any) => Promise.resolve(args), }), }, }, config: () => ({ has: () => false, - get: val => val, + get: (val: any) => val, }), info: { uri: 'serveruri', diff --git a/src/legacy/core_plugins/interpreter/server/lib/create_handlers.js b/src/legacy/core_plugins/interpreter/server/lib/create_handlers.ts similarity index 88% rename from src/legacy/core_plugins/interpreter/server/lib/create_handlers.js rename to src/legacy/core_plugins/interpreter/server/lib/create_handlers.ts index d4ea9b3dc6180f..6e295d0aecaa59 100644 --- a/src/legacy/core_plugins/interpreter/server/lib/create_handlers.js +++ b/src/legacy/core_plugins/interpreter/server/lib/create_handlers.ts @@ -17,7 +17,7 @@ * under the License. */ -export const createHandlers = (request, server) => { +export const createHandlers = (request: any, server: any) => { const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); const config = server.config(); @@ -27,6 +27,6 @@ export const createHandlers = (request, server) => { config.has('server.rewriteBasePath') && config.get('server.rewriteBasePath') ? `${server.info.uri}${config.get('server.basePath')}` : server.info.uri, - elasticsearchClient: async (...args) => callWithRequest(request, ...args), + elasticsearchClient: async (...args: any) => callWithRequest(request, ...args), }; }; diff --git a/src/legacy/core_plugins/interpreter/server/routes/index.js b/src/legacy/core_plugins/interpreter/server/routes/index.ts similarity index 95% rename from src/legacy/core_plugins/interpreter/server/routes/index.js rename to src/legacy/core_plugins/interpreter/server/routes/index.ts index 9140f93a9bde64..50385147dd38e6 100644 --- a/src/legacy/core_plugins/interpreter/server/routes/index.js +++ b/src/legacy/core_plugins/interpreter/server/routes/index.ts @@ -19,6 +19,6 @@ import { registerServerFunctions } from './server_functions'; -export function routes(server) { +export function routes(server: any) { registerServerFunctions(server); } diff --git a/src/legacy/core_plugins/interpreter/server/routes/server_functions.js b/src/legacy/core_plugins/interpreter/server/routes/server_functions.ts similarity index 84% rename from src/legacy/core_plugins/interpreter/server/routes/server_functions.js rename to src/legacy/core_plugins/interpreter/server/routes/server_functions.ts index b64a9af006e412..740b046610d9e9 100644 --- a/src/legacy/core_plugins/interpreter/server/routes/server_functions.js +++ b/src/legacy/core_plugins/interpreter/server/routes/server_functions.ts @@ -18,16 +18,16 @@ */ import Boom from 'boom'; +import Joi from 'joi'; import { serializeProvider, API_ROUTE } from '../../common'; import { createHandlers } from '../lib/create_handlers'; -import Joi from 'joi'; /** * Register the Canvas function endopints. * * @param {*} server - The Kibana server */ -export function registerServerFunctions(server) { +export function registerServerFunctions(server: any) { getServerFunctions(server); runServerFunctions(server); } @@ -37,7 +37,7 @@ export function registerServerFunctions(server) { * * @param {*} server - The Kibana server */ -function runServerFunctions(server) { +function runServerFunctions(server: any) { server.route({ method: 'POST', path: `${API_ROUTE}/fns`, @@ -48,19 +48,20 @@ function runServerFunctions(server) { }, validate: { payload: Joi.object({ - functions: Joi.array().items( - Joi.object() - .keys({ + functions: Joi.array() + .items( + Joi.object().keys({ id: Joi.number().required(), functionName: Joi.string().required(), args: Joi.object().default({}), context: Joi.any().default(null), - }), - ).required(), + }) + ) + .required(), }).required(), }, }, - async handler(req) { + async handler(req: any) { const handlers = await createHandlers(req, server); const { functions } = req.payload; @@ -73,19 +74,19 @@ function runServerFunctions(server) { // Send the initial headers. res.writeHead(200, { 'Content-Type': 'text/plain', - 'Connection': 'keep-alive', + Connection: 'keep-alive', 'Transfer-Encoding': 'chunked', 'Cache-Control': 'no-cache', }); // Write a length-delimited response - const streamResult = (result) => { + const streamResult = (result: any) => { const payload = JSON.stringify(result) + '\n'; res.write(`${payload.length}:${payload}`); }; // Tries to run an interpreter function, and ensures a consistent error payload on failure. - const tryFunction = async (id, fnCall) => { + const tryFunction = async (id: any, fnCall: any) => { try { const result = await runFunction(server, handlers, fnCall); @@ -96,7 +97,7 @@ function runServerFunctions(server) { return { id, statusCode: 200, result }; } catch (err) { if (Boom.isBoom(err)) { - return batchError(id, err.output.payload, err.statusCode); + return batchError(id, err.output.payload, (err as any).statusCode); } else if (err instanceof Error) { return batchError(id, err.message); } @@ -107,7 +108,9 @@ function runServerFunctions(server) { }; // Process each function individually, and stream the responses back to the client - await Promise.all(functions.map(({ id, ...fnCall }) => tryFunction(id, fnCall).then(streamResult))); + await Promise.all( + functions.map(({ id, ...fnCall }: any) => tryFunction(id, fnCall).then(streamResult)) + ); // All of the responses have been written, so we can close the response. res.end(); @@ -118,7 +121,7 @@ function runServerFunctions(server) { /** * A helper function for bundling up errors. */ -function batchError(id, message, statusCode = 500) { +function batchError(id: any, message: any, statusCode = 500) { return { id, statusCode, @@ -130,7 +133,7 @@ function batchError(id, message, statusCode = 500) { * Register the endpoint that returns the list of server-only functions. * @param {*} server - The Kibana server */ -function getServerFunctions(server) { +function getServerFunctions(server: any) { server.route({ method: 'GET', path: `${API_ROUTE}/fns`, @@ -147,7 +150,7 @@ function getServerFunctions(server) { * @param {*} handlers - The Canvas handlers * @param {*} fnCall - Describes the function being run `{ functionName, args, context }` */ -async function runFunction(server, handlers, fnCall) { +async function runFunction(server: any, handlers: any, fnCall: any) { const registries = server.plugins.interpreter.registries(); const { functionName, args, context } = fnCall; const types = registries.types.toJS(); diff --git a/src/legacy/core_plugins/interpreter/test_helpers.js b/src/legacy/core_plugins/interpreter/test_helpers.ts similarity index 86% rename from src/legacy/core_plugins/interpreter/test_helpers.js rename to src/legacy/core_plugins/interpreter/test_helpers.ts index e743b8a09280e2..741cd83bb47fed 100644 --- a/src/legacy/core_plugins/interpreter/test_helpers.js +++ b/src/legacy/core_plugins/interpreter/test_helpers.ts @@ -21,8 +21,9 @@ import { mapValues } from 'lodash'; // Takes a function spec and passes in default args, // overriding with any provided args. -export const functionWrapper = fnSpec => { +export const functionWrapper = (fnSpec: any) => { const spec = fnSpec(); const defaultArgs = mapValues(spec.args, argSpec => argSpec.default); - return (context, args, handlers) => spec.fn(context, { ...defaultArgs, ...args }, handlers); + return (context: any, args: any, handlers: any) => + spec.fn(context, { ...defaultArgs, ...args }, handlers); }; diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.test.ts b/src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.test.ts index 28021a763b2878..009797905701c5 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.test.ts +++ b/src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.test.ts @@ -30,7 +30,7 @@ describe('interpreter/functions#markdown', () => { }; it('returns an object with the correct structure', async () => { - const actual = await fn(undefined, args); + const actual = await fn(undefined, args, undefined); expect(actual).toMatchSnapshot(); }); }); diff --git a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.test.ts b/src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.test.ts index 5fe2ac7b7fdf00..fee6dec641842b 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.test.ts +++ b/src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.test.ts @@ -67,7 +67,7 @@ describe('interpreter/functions#metric', () => { }; it('returns an object with the correct structure', () => { - const actual = fn(context, args); + const actual = fn(context, args, undefined); expect(actual).toMatchSnapshot(); }); diff --git a/src/legacy/core_plugins/vis_type_table/public/table_vis_fn.test.ts b/src/legacy/core_plugins/vis_type_table/public/table_vis_fn.test.ts index 2bdebc8a9d19ef..1c1b808ffb0143 100644 --- a/src/legacy/core_plugins/vis_type_table/public/table_vis_fn.test.ts +++ b/src/legacy/core_plugins/vis_type_table/public/table_vis_fn.test.ts @@ -76,12 +76,12 @@ describe('interpreter/functions#table', () => { }); it('returns an object with the correct structure', async () => { - const actual = await fn(context, { visConfig: JSON.stringify(visConfig) }); + const actual = await fn(context, { visConfig: JSON.stringify(visConfig) }, undefined); expect(actual).toMatchSnapshot(); }); it('calls response handler with correct values', async () => { - await fn(context, { visConfig: JSON.stringify(visConfig) }); + await fn(context, { visConfig: JSON.stringify(visConfig) }, undefined); expect(mockResponseHandler).toHaveBeenCalledTimes(1); expect(mockResponseHandler).toHaveBeenCalledWith(context, visConfig.dimensions); }); diff --git a/src/legacy/core_plugins/vis_type_tagcloud/public/tag_cloud_fn.test.ts b/src/legacy/core_plugins/vis_type_tagcloud/public/tag_cloud_fn.test.ts index d14871c6bd3375..0365f7840cac42 100644 --- a/src/legacy/core_plugins/vis_type_tagcloud/public/tag_cloud_fn.test.ts +++ b/src/legacy/core_plugins/vis_type_tagcloud/public/tag_cloud_fn.test.ts @@ -39,7 +39,7 @@ describe('interpreter/functions#tagcloud', () => { }; it('returns an object with the correct structure', () => { - const actual = fn(context, visConfig); + const actual = fn(context, visConfig, undefined); expect(actual).toMatchSnapshot(); }); }); From e322acceab83c444843447f0e55dc3c798d155e6 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 3 Oct 2019 13:57:31 +0500 Subject: [PATCH 46/59] [Uptime] Change default status filter in ping list to all on monitor page (#47108) * change default status filter in ping list to all * update snaps --- .../plugins/uptime/common/constants/client_defaults.ts | 2 +- .../__tests__/__snapshots__/use_url_params.test.tsx.snap | 2 +- .../__snapshots__/get_supported_url_params.test.ts.snap | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/legacy/plugins/uptime/common/constants/client_defaults.ts b/x-pack/legacy/plugins/uptime/common/constants/client_defaults.ts index 21e866e9919444..66ac571e2b7a5e 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/client_defaults.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/client_defaults.ts @@ -31,6 +31,6 @@ export const CLIENT_DEFAULTS = { MONITOR_LIST_SORT_DIRECTION: 'asc', MONITOR_LIST_SORT_FIELD: 'monitor_id', SEARCH: '', - SELECTED_PING_LIST_STATUS: 'down', + SELECTED_PING_LIST_STATUS: '', STATUS_FILTER: '', }; diff --git a/x-pack/legacy/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap index 4b45ed20d5d838..5794169d755975 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap @@ -5,7 +5,7 @@ exports[`useUrlParams gets the expected values using the context 1`] = ` hook={[Function]} >
- {"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-19d","dateRangeEnd":"now-1m","filters":"","search":"","selectedPingStatus":"down","statusFilter":""} + {"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-19d","dateRangeEnd":"now-1m","filters":"","search":"","selectedPingStatus":"","statusFilter":""}
- - diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/index_or_search/paginated_selectable_list/paginated_selectable_list.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/index_or_search/paginated_selectable_list/paginated_selectable_list.js deleted file mode 100644 index 784bf84322dd37..00000000000000 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/index_or_search/paginated_selectable_list/paginated_selectable_list.js +++ /dev/null @@ -1,87 +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 _ from 'lodash'; -import { uiModules } from 'ui/modules'; -import 'ui/directives/paginate'; -import 'ui/directives/kbn_href'; -import paginatedSelectableListTemplate from './paginated_selectable_list.html'; - -const module = uiModules.get('kibana'); - -function throwError(message) { - throw new Error(message); -} - -module.directive('paginatedSelectableList', function () { - - return { - restrict: 'E', - scope: { - perPage: '=?', - list: '=', - listProperty: '@', - userMakeUrl: '=?', - userOnSelect: '=?', - disableAutoFocus: '=' - }, - template: paginatedSelectableListTemplate, - controller: function ($scope, $filter) { - function calculateHitsByQuery() { - $scope.hitsByQuery = $filter('filter')($scope.hits, $scope.query); - } - - // Should specify either user-make-url or user-on-select - if (!$scope.userMakeUrl && !$scope.userOnSelect) { - throwError('paginatedSelectableList directive expects a makeUrl or onSelect function'); - } - - // Should specify either user-make-url or user-on-select, but not both. - if ($scope.userMakeUrl && $scope.userOnSelect) { - throwError('paginatedSelectableList directive expects a makeUrl or onSelect attribute but not both'); - } - - $scope.perPage = $scope.perPage || 10; - $scope.hits = $scope.list = _.sortBy($scope.list, $scope.accessor); - $scope.$watchGroup(['hits', 'query'], calculateHitsByQuery); - calculateHitsByQuery(); - $scope.hitCount = $scope.hits.length; - - /** - * Boolean that keeps track of whether hits are sorted ascending (true) - * or descending (false) - * * @type {Boolean} - */ - $scope.isAscending = true; - - /** - * Sorts saved object finder hits either ascending or descending - * @param {Array} hits Array of saved finder object hits - * @return {Array} Array sorted either ascending or descending - */ - $scope.sortHits = function (hits) { - const sortedList = _.sortBy(hits, $scope.accessor); - - $scope.isAscending = !$scope.isAscending; - $scope.hits = $scope.isAscending ? sortedList : sortedList.reverse(); - }; - - $scope.makeUrl = function (hit) { - return $scope.userMakeUrl(hit); - }; - - $scope.onSelect = function (hit, $event) { - return $scope.userOnSelect(hit, $event); - }; - - $scope.accessor = function (val) { - const prop = $scope.listProperty; - return prop ? _.get(val, prop) : val; - }; - } - }; -}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts index 2366f2c655000d..f0061b1b9847eb 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts @@ -8,3 +8,5 @@ import './pages/new_job/route'; import './pages/new_job/directive'; import './pages/job_type/route'; import './pages/job_type/directive'; +import './pages/index_or_search/route'; +import './pages/index_or_search/directive'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/__test__/directive.js b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/__test__/directive.js new file mode 100644 index 00000000000000..bd63a16abfacda --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/__test__/directive.js @@ -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 ngMock from 'ng_mock'; +import expect from '@kbn/expect'; +import sinon from 'sinon'; + +// Import this way to be able to stub/mock functions later on in the tests using sinon. +import * as indexUtils from 'plugins/ml/util/index_utils'; + +describe('ML - Index or Saved Search selection directive', () => { + let $scope; + let $compile; + let $element; + + beforeEach(ngMock.module('kibana')); + beforeEach(() => { + ngMock.inject(function ($injector) { + $compile = $injector.get('$compile'); + const $rootScope = $injector.get('$rootScope'); + $scope = $rootScope.$new(); + }); + }); + + afterEach(() => { + $scope.$destroy(); + }); + + it('Initialize Index or Saved Search selection directive', done => { + sinon.stub(indexUtils, 'timeBasedIndexCheck').callsFake(() => false); + ngMock.inject(function () { + expect(() => { + $element = $compile('')($scope); + }).to.not.throwError(); + + // directive has scope: false + const scope = $element.isolateScope(); + expect(scope).to.eql(undefined); + done(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/directive.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/directive.tsx new file mode 100644 index 00000000000000..7f3edf0896840e --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/directive.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +// @ts-ignore +import { uiModules } from 'ui/modules'; +const module = uiModules.get('apps/ml', ['react']); +import { timefilter } from 'ui/timefilter'; + +import { I18nContext } from 'ui/i18n'; +import { InjectorService } from '../../../../../common/types/angular'; +import { Page } from './page'; + +module.directive('mlIndexOrSearch', ($injector: InjectorService) => { + return { + scope: {}, + restrict: 'E', + link: async (scope: ng.IScope, element: ng.IAugmentedJQuery) => { + // remove time picker from top of page + timefilter.disableTimeRangeSelector(); + timefilter.disableAutoRefreshSelector(); + + const $route = $injector.get('$route'); + const { nextStepPath } = $route.current.locals; + + ReactDOM.render( + {React.createElement(Page, { nextStepPath })}, + element[0] + ); + + element.on('$destroy', () => { + ReactDOM.unmountComponentAtNode(element[0]); + scope.$destroy(); + }); + }, + }; +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/page.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/page.tsx new file mode 100644 index 00000000000000..68013bd243a91f --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/page.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { + EuiPage, + EuiPageBody, + EuiTitle, + EuiPageHeader, + EuiPageHeaderSection, + EuiPageContent, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { SavedObjectFinder } from 'ui/saved_objects/components/saved_object_finder'; + +export interface PageProps { + nextStepPath: string; +} + +export const Page: FC = ({ nextStepPath }) => { + const RESULTS_PER_PAGE = 20; + + const onObjectSelection = (id: string, type: string) => { + window.location.href = `${nextStepPath}?${ + type === 'index-pattern' ? 'index' : 'savedSearchId' + }=${encodeURIComponent(id)}`; + }; + + return ( + + + + + +

+ +

+
+
+
+ + 'search', + name: i18n.translate( + 'xpack.ml.newJob.wizard.searchSelection.savedObjectType.search', + { + defaultMessage: 'Saved search', + } + ), + }, + { + type: 'index-pattern', + getIconForSavedObject: () => 'indexPatternApp', + name: i18n.translate( + 'xpack.ml.newJob.wizard.searchSelection.savedObjectType.indexPattern', + { + defaultMessage: 'Index pattern', + } + ), + }, + ]} + fixedPageSize={RESULTS_PER_PAGE} + /> + +
+
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/route.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/route.ts new file mode 100644 index 00000000000000..1ed12577960c3d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/route.ts @@ -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. + */ + +// @ts-ignore +import { checkMlNodesAvailable } from 'plugins/ml/ml_nodes_check/check_ml_nodes'; +// @ts-ignore +import { preConfiguredJobRedirect } from 'plugins/ml/jobs/new_job/wizard/preconfigured_job_redirect'; +import uiRoutes from 'ui/routes'; +// @ts-ignore +import { checkLicenseExpired, checkBasicLicense } from '../../../../license/check_license'; +import { loadIndexPatterns } from '../../../../util/index_utils'; +import { + checkCreateJobsPrivilege, + checkFindFileStructurePrivilege, +} from '../../../../privilege/check_privilege'; +import { + getCreateJobBreadcrumbs, + getDataVisualizerIndexOrSearchBreadcrumbs, +} from '../../../breadcrumbs'; + +uiRoutes.when('/jobs/new_job', { + redirectTo: '/jobs/new_job/step/index_or_search', +}); + +uiRoutes.when('/jobs/new_job/step/index_or_search', { + template: '', + k7Breadcrumbs: getCreateJobBreadcrumbs, + resolve: { + CheckLicense: checkLicenseExpired, + privileges: checkCreateJobsPrivilege, + indexPatterns: loadIndexPatterns, + preConfiguredJobRedirect, + checkMlNodesAvailable, + nextStepPath: () => '#/jobs/new_job/step/job_type', + }, +}); + +uiRoutes.when('/datavisualizer_index_select', { + template: '', + k7Breadcrumbs: getDataVisualizerIndexOrSearchBreadcrumbs, + resolve: { + CheckLicense: checkBasicLicense, + privileges: checkFindFileStructurePrivilege, + indexPatterns: loadIndexPatterns, + nextStepPath: () => '#jobs/new_job/datavisualizer', + }, +}); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 97b509f0e3ffe3..d055e776065877 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7175,8 +7175,6 @@ "xpack.ml.newJob.simple.watcher.email.timeLabel": "時間", "xpack.ml.newJob.simple.watcher.email.topInfluencersLabel": "トップ影響因子:", "xpack.ml.newJob.simple.watcher.email.topRecordsLabel": "トップの記録:", - "xpack.ml.newJob.wizard.createFromNewSearchTitle": "新規検索からインデックスを選択", - "xpack.ml.newJob.wizard.createFromSavedSearchTitle": "または保存された検索から", "xpack.ml.newJob.wizard.jobType.advancedAriaLabel": "高度なジョブ", "xpack.ml.newJob.wizard.jobType.advancedDescription": "より高度なユースケースでは、ジョブの作成にすべてのオプションを使用します。", "xpack.ml.newJob.wizard.jobType.advancedTitle": "高度な設定", @@ -7205,7 +7203,6 @@ "xpack.ml.newJob.wizard.jobType.useSuppliedConfigurationTitle": "提供された構成を使用", "xpack.ml.newJob.wizard.jobType.useWizardDescription": "ウィザードの 1 つを使用し、データの異常を検知する機械学習ジョブを作成します。", "xpack.ml.newJob.wizard.jobType.useWizardTitle": "ウィザードを使用", - "xpack.ml.newJob.wizard.savedSearchesTooltip": "保存された検索", "xpack.ml.privilege.licenseHasExpiredTooltip": "ご使用のライセンスは期限切れです。", "xpack.ml.privilege.noPermission.createCalendarsTooltip": "カレンダーを作成するパーミッションがありません。", "xpack.ml.privilege.noPermission.createDataFrameTransformTooltip": "データフレーム変換を作成するパーミッションがありません。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index aaced82cb5b14f..6ca82b053ba664 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7177,8 +7177,6 @@ "xpack.ml.newJob.simple.watcher.email.timeLabel": "时间", "xpack.ml.newJob.simple.watcher.email.topInfluencersLabel": "排在前面的影响因素:", "xpack.ml.newJob.simple.watcher.email.topRecordsLabel": "排在前面的记录:", - "xpack.ml.newJob.wizard.createFromNewSearchTitle": "基于“新搜索”,选择“索引”", - "xpack.ml.newJob.wizard.createFromSavedSearchTitle": "或者,基于“已保存的搜索”", "xpack.ml.newJob.wizard.jobType.advancedAriaLabel": "高级作业", "xpack.ml.newJob.wizard.jobType.advancedDescription": "使用全部选项为更高级的用例创建作业。", "xpack.ml.newJob.wizard.jobType.advancedTitle": "高级", @@ -7207,7 +7205,6 @@ "xpack.ml.newJob.wizard.jobType.useSuppliedConfigurationTitle": "使用提供的配置", "xpack.ml.newJob.wizard.jobType.useWizardDescription": "使用其中一个向导创建 Machine Learning 作业,以查找数据中的异常。", "xpack.ml.newJob.wizard.jobType.useWizardTitle": "使用向导", - "xpack.ml.newJob.wizard.savedSearchesTooltip": "已保存的搜索", "xpack.ml.privilege.licenseHasExpiredTooltip": "您的许可证已过期。", "xpack.ml.privilege.noPermission.createCalendarsTooltip": "您没有权限创建日历。", "xpack.ml.privilege.noPermission.createDataFrameTransformTooltip": "您无权创建数据帧转换。", diff --git a/x-pack/test/functional/services/machine_learning/job_source_selection.ts b/x-pack/test/functional/services/machine_learning/job_source_selection.ts index 89fae06fd33ce3..81f2de3a47e799 100644 --- a/x-pack/test/functional/services/machine_learning/job_source_selection.ts +++ b/x-pack/test/functional/services/machine_learning/job_source_selection.ts @@ -11,8 +11,7 @@ export function MachineLearningJobSourceSelectionProvider({ getService }: FtrPro return { async selectSourceIndexPattern(indexPattern: string) { - const subj = 'paginatedListItem-' + indexPattern; - await testSubjects.clickWhenNotDisabled(subj); + await testSubjects.clickWhenNotDisabled(`savedObjectTitle${indexPattern}`); await testSubjects.existOrFail('mlPageJobTypeSelection'); }, }; From 127eab03201f5805c789ab9976560afb9b316737 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 3 Oct 2019 11:13:42 +0200 Subject: [PATCH 48/59] [Graph] Empty workspace overlay (#45547) --- .../public/angular/graph_client_workspace.js | 9 +- .../graph/public/angular/templates/index.html | 4 +- x-pack/legacy/plugins/graph/public/app.js | 30 +++- .../graph/public/components/_index.scss | 2 + .../public/components/_source_modal.scss | 4 + .../plugins/graph/public/components/app.tsx | 63 +++++--- .../components/field_manager/field_icon.tsx | 2 +- .../field_manager/field_manager.test.tsx | 27 +++- .../field_manager/field_manager.tsx | 30 ++-- .../components/field_manager/field_picker.tsx | 12 +- .../guidance_panel/_guidance_panel.scss | 47 ++++++ .../components/guidance_panel/_index.scss | 1 + .../guidance_panel/guidance_panel.tsx | 143 ++++++++++++++++++ .../public/components/guidance_panel/index.ts | 7 + .../public/components/search_bar.test.tsx | 3 + .../graph/public/components/search_bar.tsx | 117 +++++++------- .../graph/public/components/source_modal.tsx | 4 +- .../public/services/fetch_top_nodes.test.ts | 97 ++++++++++++ .../graph/public/services/fetch_top_nodes.ts | 112 ++++++++++++++ .../graph/public/types/workspace_state.ts | 42 ++--- 20 files changed, 622 insertions(+), 134 deletions(-) create mode 100644 x-pack/legacy/plugins/graph/public/components/_source_modal.scss create mode 100644 x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss create mode 100644 x-pack/legacy/plugins/graph/public/components/guidance_panel/_index.scss create mode 100644 x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx create mode 100644 x-pack/legacy/plugins/graph/public/components/guidance_panel/index.ts create mode 100644 x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts create mode 100644 x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.ts diff --git a/x-pack/legacy/plugins/graph/public/angular/graph_client_workspace.js b/x-pack/legacy/plugins/graph/public/angular/graph_client_workspace.js index db427fd11c7651..96be9eed2b4679 100644 --- a/x-pack/legacy/plugins/graph/public/angular/graph_client_workspace.js +++ b/x-pack/legacy/plugins/graph/public/angular/graph_client_workspace.js @@ -1200,11 +1200,14 @@ module.exports = (function () { } - //Add missing links between existing nodes - this.fillInGraph = function () { + /** + * Add missing links between existing nodes + * @param maxNewEdges Max number of new edges added. Avoid adding too many new edges + * at once into the graph otherwise disorientating + */ + this.fillInGraph = function (maxNewEdges = 10) { let nodesForLinking = self.getSelectedOrAllTopNodes(); - const maxNewEdges = 10; // Avoid adding too many new edges at once into the graph otherwise disorientating const maxNumVerticesSearchable = 100; diff --git a/x-pack/legacy/plugins/graph/public/angular/templates/index.html b/x-pack/legacy/plugins/graph/public/angular/templates/index.html index 3ed9b390c6a787..07b57ee3225482 100644 --- a/x-pack/legacy/plugins/graph/public/angular/templates/index.html +++ b/x-pack/legacy/plugins/graph/public/angular/templates/index.html @@ -12,15 +12,17 @@ on-index-pattern-selected="uiSelectIndex" on-query-submit="submit" is-loading="loading" + is-initialized="!!workspace || savedWorkspace.id" initial-query="initialQuery" state="reduxState" dispatch="reduxDispatch" + on-fill-workspace="fillWorkspace" autocomplete-start="autocompleteStart" core-start="coreStart" store="store" > -
+
{ + try { + const fields = selectedFieldsSelector(store.getState()); + const topTermNodes = await fetchTopNodes( + npStart.core.http.post, + $scope.selectedIndex.title, + fields + ); + initWorkspaceIfRequired(); + $scope.workspace.mergeGraph({ + nodes: topTermNodes, + edges: [] + }); + $scope.workspace.fillInGraph(fields.length * 10); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.graph.fillWorkspaceError', + { defaultMessage: 'Fetching top terms failed: {message}', values: { message: e.message } } + ), + }); + } + }; + $scope.submit = function (searchTerm) { initWorkspaceIfRequired(); const numHops = 2; @@ -846,9 +873,6 @@ app.controller('graphuiPlugin', function ( } else { $route.current.locals.SavedWorkspacesProvider.get().then(function (newWorkspace) { $scope.savedWorkspace = newWorkspace; - openSourceModal(npStart.core, indexPattern => { - $scope.indexSelected(indexPattern); - }); }); } diff --git a/x-pack/legacy/plugins/graph/public/components/_index.scss b/x-pack/legacy/plugins/graph/public/components/_index.scss index 85bbf4fcc3adef..a06209e7e4d344 100644 --- a/x-pack/legacy/plugins/graph/public/components/_index.scss +++ b/x-pack/legacy/plugins/graph/public/components/_index.scss @@ -1,5 +1,7 @@ @import './app'; @import './search_bar'; +@import './source_modal'; +@import './guidance_panel/index'; @import './graph_visualization/index'; @import './venn_diagram/index'; @import './settings/index'; diff --git a/x-pack/legacy/plugins/graph/public/components/_source_modal.scss b/x-pack/legacy/plugins/graph/public/components/_source_modal.scss new file mode 100644 index 00000000000000..fbc293442f3316 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/components/_source_modal.scss @@ -0,0 +1,4 @@ +.gphSourceModal { + width: 720px; + min-height: 530px; +} \ No newline at end of file diff --git a/x-pack/legacy/plugins/graph/public/components/app.tsx b/x-pack/legacy/plugins/graph/public/components/app.tsx index 907e7e4cecdcd5..894c6b9ef45ac6 100644 --- a/x-pack/legacy/plugins/graph/public/components/app.tsx +++ b/x-pack/legacy/plugins/graph/public/components/app.tsx @@ -5,12 +5,16 @@ */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React from 'react'; +import React, { useState } from 'react'; +import { I18nProvider } from '@kbn/i18n/react'; import { Storage } from 'ui/storage'; import { CoreStart } from 'kibana/public'; import { AutocompletePublicPluginStart } from 'src/plugins/data/public'; import { FieldManagerProps, FieldManager } from './field_manager'; import { SearchBarProps, SearchBar } from './search_bar'; +import { GuidancePanel } from './guidance_panel'; +import { selectedFieldsSelector } from '../state_management'; +import { openSourceModal } from '../services/source_modal'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; @@ -18,28 +22,47 @@ export interface GraphAppProps extends FieldManagerProps, SearchBarProps { coreStart: CoreStart; autocompleteStart: AutocompletePublicPluginStart; store: Storage; + onFillWorkspace: () => void; + isInitialized: boolean; } export function GraphApp(props: GraphAppProps) { + const [pickerOpen, setPickerOpen] = useState(false); + return ( - -
- - - - - - - - -
-
+ + +
+ + + + + + + + +
+ {!props.isInitialized && ( + 0} + onFillWorkspace={props.onFillWorkspace} + onOpenFieldPicker={() => { + setPickerOpen(true); + }} + onOpenDatasourcePicker={() => { + openSourceModal(props.coreStart, props.onIndexPatternSelected); + }} + /> + )} +
+
); } diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_icon.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_icon.tsx index 93561e17229360..429eec19a47fa7 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_icon.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_icon.tsx @@ -16,7 +16,7 @@ function getIconForDataType(dataType: string) { boolean: 'invert', date: 'calendar', geo_point: 'globe', - ip: 'link', + ip: 'storage', }; return icons[dataType] || ICON_TYPES.find(t => t === dataType) || 'document'; } diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx index 32cc546a3ad0ce..fb715e759c62db 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx @@ -18,6 +18,7 @@ describe('field_manager', () => { let store: GraphStore; let instance: ShallowWrapper; let dispatchSpy: jest.Mock; + let openSpy: jest.Mock; beforeEach(() => { store = createGraphStore(); @@ -52,8 +53,16 @@ describe('field_manager', () => { ); dispatchSpy = jest.fn(store.dispatch); - - instance = shallow(); + openSpy = jest.fn(); + + instance = shallow( + + ); }); function update() { @@ -80,13 +89,19 @@ describe('field_manager', () => { }); it('should select fields from picker', () => { - const fieldPicker = instance.find(FieldPicker).dive(); - act(() => { - (fieldPicker.find(EuiPopover).prop('button')! as ReactElement).props.onClick(); + (instance + .find(FieldPicker) + .dive() + .find(EuiPopover) + .prop('button')! as ReactElement).props.onClick(); }); - fieldPicker.update(); + expect(openSpy).toHaveBeenCalled(); + + instance.setProps({ pickerOpen: true }); + + const fieldPicker = instance.find(FieldPicker).dive(); expect( fieldPicker diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.tsx index 7f89b555c9f7a0..e44ad248e279de 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { I18nProvider } from '@kbn/i18n/react'; import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { bindActionCreators } from 'redux'; @@ -24,9 +23,11 @@ import { export interface FieldManagerProps { state: GraphState; dispatch: GraphDispatch; + pickerOpen: boolean; + setPickerOpen: (open: boolean) => void; } -export function FieldManager({ state, dispatch }: FieldManagerProps) { +export function FieldManager({ state, dispatch, pickerOpen, setPickerOpen }: FieldManagerProps) { const fieldMap = fieldMapSelector(state); const allFields = fieldsSelector(state); const selectedFields = selectedFieldsSelector(state); @@ -41,17 +42,20 @@ export function FieldManager({ state, dispatch }: FieldManagerProps) { ); return ( - - - {selectedFields.map(field => ( - - - - ))} - - + + {selectedFields.map(field => ( + + - - + ))} + + + + ); } diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx index b1ddce4fa1744e..8ef566e881989f 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx @@ -16,11 +16,17 @@ export interface FieldPickerProps { fieldMap: Record; selectField: (fieldName: string) => void; deselectField: (fieldName: string) => void; + open: boolean; + setOpen: (open: boolean) => void; } -export function FieldPicker({ fieldMap, selectField, deselectField }: FieldPickerProps) { - const [open, setOpen] = useState(false); - +export function FieldPicker({ + fieldMap, + selectField, + deselectField, + open, + setOpen, +}: FieldPickerProps) { const allFields = Object.values(fieldMap); const unselectedFields = allFields.filter(field => !field.selected); const hasSelectedFields = unselectedFields.length < allFields.length; diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss new file mode 100644 index 00000000000000..f1c332eba1aa8a --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss @@ -0,0 +1,47 @@ +.gphGuidancePanel { + max-width: 580px; + margin: $euiSizeL 0; +} + +.gphGuidancePanel__list { + list-style: none; + margin: 0; + padding: 0; +} + +.gphGuidancePanel__item { + display: block; + max-width: 420px; + position: relative; + padding-left: $euiSizeXL; + margin-bottom: $euiSizeL; + + button { + // make buttons wrap lines like regular text + display: contents; + } +} + +.gphGuidancePanel__item--disabled { + color: $euiColorDarkShade; + pointer-events: none; + + button { + color: $euiColorDarkShade !important; + } +} + +.gphGuidancePanel__itemIcon { + position: absolute; + left: 0; + top: -($euiSizeXS / 2); + width: $euiSizeL; + height: $euiSizeL; + padding: $euiSizeXS; + + &--done { + background-color: $euiColorSecondary; + color: $euiColorEmptyShade; + border-radius: 50%; + } +} diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/_index.scss b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_index.scss new file mode 100644 index 00000000000000..65c71cc17ba354 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_index.scss @@ -0,0 +1 @@ +@import './_guidance_panel'; \ No newline at end of file diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx new file mode 100644 index 00000000000000..62d8bbb03bc3ff --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx @@ -0,0 +1,143 @@ +/* + * 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, { ReactNode } from 'react'; +import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import classNames from 'classnames'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export interface GuidancePanelProps { + onFillWorkspace: () => void; + onOpenFieldPicker: () => void; + onOpenDatasourcePicker: () => void; + hasDatasource: boolean; + hasFields: boolean; +} + +function ListItem({ + children, + state, +}: { + state: 'done' | 'active' | 'disabled'; + children: ReactNode; +}) { + return ( +
  • + {state !== 'disabled' && ( + + + + )} + {children} +
  • + ); +} + +export function GuidancePanel(props: GuidancePanelProps) { + const { + onFillWorkspace, + onOpenFieldPicker, + onOpenDatasourcePicker, + hasDatasource, + hasFields, + } = props; + + return ( + + + + + + + + + +

    + {i18n.translate('xpack.graph.guidancePanel.title', { + defaultMessage: "Let's get started!", + })} +

    +
    +
    + +
      + + + {i18n.translate( + 'xpack.graph.guidancePanel.datasourceItem.indexPatternButtonLabel', + { + defaultMessage: 'index pattern', + } + )} + + ), + }} + /> + + + + {i18n.translate( + 'xpack.graph.guidancePanel.fieldsItem.fieldsButtonLabel', + { + defaultMessage: 'Select fields', + } + )} + + ), + }} + /> + + + + {i18n.translate( + 'xpack.graph.guidancePanel.nodesItem.topTermsButtonLabel', + { + defaultMessage: 'show correlations of the top terms', + } + )} + + ), + }} + /> + +
    +
    +
    +
    +
    +
    + ); +} diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/index.ts b/x-pack/legacy/plugins/graph/public/components/guidance_panel/index.ts new file mode 100644 index 00000000000000..8704eb2eb6761c --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/index.ts @@ -0,0 +1,7 @@ +/* + * 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 * from './guidance_panel'; diff --git a/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx b/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx index 80b1c3c3439427..dbad0e01078fd5 100644 --- a/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx @@ -51,6 +51,7 @@ describe('search_bar', () => { onIndexPatternSelected: () => {}, onQuerySubmit: querySubmit, currentIndexPattern: { title: 'Testpattern' } as IndexPattern, + coreStart: {} as CoreStart, }) ); act(() => { @@ -72,6 +73,7 @@ describe('search_bar', () => { onIndexPatternSelected: () => {}, onQuerySubmit: querySubmit, currentIndexPattern: { title: 'Testpattern', fields: [{ name: 'test' }] } as IndexPattern, + coreStart: {} as CoreStart, }) ); act(() => { @@ -97,6 +99,7 @@ describe('search_bar', () => { onIndexPatternSelected: indexPatternSelected, onQuerySubmit: () => {}, currentIndexPattern: { title: 'Testpattern' } as IndexPattern, + coreStart: {} as CoreStart, }) ); diff --git a/x-pack/legacy/plugins/graph/public/components/search_bar.tsx b/x-pack/legacy/plugins/graph/public/components/search_bar.tsx index 226f6f829d8a44..18eca326776f5e 100644 --- a/x-pack/legacy/plugins/graph/public/components/search_bar.tsx +++ b/x-pack/legacy/plugins/graph/public/components/search_bar.tsx @@ -8,9 +8,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty, EuiToolTip } from import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { I18nProvider } from '@kbn/i18n/react'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { IDataPluginServices } from 'src/legacy/core_plugins/data/public/types'; +import { CoreStart } from 'kibana/public'; import { QueryBarInput, Query, @@ -21,6 +21,7 @@ import { openSourceModal } from '../services/source_modal'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; export interface SearchBarProps { + coreStart: CoreStart; isLoading: boolean; currentIndexPattern?: IndexPattern; initialQuery?: string; @@ -54,71 +55,61 @@ export function SearchBar(props: SearchBarProps) { } = props; const [query, setQuery] = useState({ language: 'kuery', query: initialQuery || '' }); const kibana = useKibana(); - const { overlays, uiSettings, savedObjects } = kibana.services; + const { overlays } = kibana.services; if (!overlays) return null; return ( - -
    { - e.preventDefault(); - if (!isLoading && currentIndexPattern) { - onQuerySubmit(queryToString(query, currentIndexPattern)); - } - }} - > - - - { + e.preventDefault(); + if (!isLoading && currentIndexPattern) { + onQuerySubmit(queryToString(query, currentIndexPattern)); + } + }} + > + + + + { + openSourceModal(props.coreStart, onIndexPatternSelected); + }} > - { - openSourceModal( - { - overlays, - savedObjects, - uiSettings, - }, - onIndexPatternSelected - ); - }} - > - {currentIndexPattern - ? currentIndexPattern.title - : // This branch will be shown if the user exits the - // initial picker modal - i18n.translate('xpack.graph.bar.pickSourceLabel', { - defaultMessage: 'Click here to pick a data source', - })} - - - } - onChange={setQuery} - /> - - - - {i18n.translate('xpack.graph.bar.exploreLabel', { defaultMessage: 'Explore' })} - - - - -
    + {currentIndexPattern + ? currentIndexPattern.title + : // This branch will be shown if the user exits the + // initial picker modal + i18n.translate('xpack.graph.bar.pickSourceLabel', { + defaultMessage: 'Click here to pick a data source', + })} + + + } + onChange={setQuery} + /> + + + + {i18n.translate('xpack.graph.bar.exploreLabel', { defaultMessage: 'Explore' })} + + + + ); } diff --git a/x-pack/legacy/plugins/graph/public/components/source_modal.tsx b/x-pack/legacy/plugins/graph/public/components/source_modal.tsx index 4c3b3c8be9110b..5829370b030e65 100644 --- a/x-pack/legacy/plugins/graph/public/components/source_modal.tsx +++ b/x-pack/legacy/plugins/graph/public/components/source_modal.tsx @@ -12,7 +12,7 @@ import { SourcePicker, SourcePickerProps } from './source_picker'; export function SourceModal(props: SourcePickerProps) { return ( - <> +
    - +
    ); } diff --git a/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts new file mode 100644 index 00000000000000..0a0fc8cae5d269 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { getSuitableIcon } from '../helpers/style_choices'; +import { fetchTopNodes } from './fetch_top_nodes'; + +const icon = getSuitableIcon(''); + +describe('fetch_top_nodes', () => { + it('should build terms agg', async () => { + const postMock = jest.fn(() => Promise.resolve({ resp: {} })); + await fetchTopNodes(postMock, 'test', [ + { color: '', hopSize: 5, icon, name: 'field1', selected: false, type: 'string' }, + { color: '', hopSize: 5, icon, name: 'field2', selected: false, type: 'string' }, + ]); + expect(postMock).toHaveBeenCalledWith('../api/graph/searchProxy', { + body: JSON.stringify({ + index: 'test', + body: { + size: 0, + aggs: { + sample: { + sampler: { + shard_size: 5000, + }, + aggs: { + top_values_field1: { + terms: { + field: 'field1', + size: 10, + }, + }, + top_values_field2: { + terms: { + field: 'field2', + size: 10, + }, + }, + }, + }, + }, + }, + }), + }); + }); + + it('should map result to nodes', async () => { + const postMock = jest.fn(() => + Promise.resolve({ + resp: { + aggregations: { + sample: { + top_values_field1: { + buckets: [{ key: 'A' }, { key: 'B' }], + }, + top_values_field2: { + buckets: [{ key: 'C' }, { key: 'D' }], + }, + }, + }, + }, + }) + ); + const result = await fetchTopNodes(postMock, 'test', [ + { color: 'red', hopSize: 5, icon, name: 'field1', selected: false, type: 'string' }, + { color: 'blue', hopSize: 5, icon, name: 'field2', selected: false, type: 'string' }, + ]); + expect(result.length).toEqual(4); + expect(result[0]).toEqual({ + color: 'red', + data: { + field: 'field1', + term: 'A', + }, + field: 'field1', + icon, + id: '', + label: 'A', + term: 'A', + }); + expect(result[2]).toEqual({ + color: 'blue', + data: { + field: 'field2', + term: 'C', + }, + field: 'field2', + icon, + id: '', + label: 'C', + term: 'C', + }); + }); +}); diff --git a/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.ts b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.ts new file mode 100644 index 00000000000000..87b33cbe35f82c --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.ts @@ -0,0 +1,112 @@ +/* + * 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 { CoreStart } from 'src/core/public'; +import { WorkspaceField, ServerResultNode } from '../types'; + +const DEFAULT_SHARD_SIZE = 5000; + +function createSamplerSearchBody(aggs: object, shardSize: number = DEFAULT_SHARD_SIZE) { + return { + size: 0, + aggs: { + sample: { + sampler: { + shard_size: shardSize, + }, + aggs, + }, + }, + }; +} + +function createTopTermsAggName(fieldName: string) { + return `top_values_${fieldName}`; +} + +function createTopTermsSubAgg(field: string, size: number = 10) { + return { + [createTopTermsAggName(field)]: { + terms: { + field, + size, + }, + }, + }; +} + +// TODO use elasticsearch types here +interface TopTermsAggResponse { + aggregations?: { + sample: Record< + string, + { + buckets: Array<{ key: string; doc_count: number }>; + } + >; + }; +} + +function getTopTermsResult(response: TopTermsAggResponse, fieldName: string) { + if (!response.aggregations) { + return []; + } + return response.aggregations.sample[createTopTermsAggName(fieldName)].buckets.map( + bucket => bucket.key + ); +} + +export function createServerResultNode( + fieldName: string, + term: string, + allFields: WorkspaceField[] +): ServerResultNode { + const field = allFields.find(({ name }) => name === fieldName); + + if (!field) { + throw new Error('Invariant error: field not found'); + } + + return { + field: fieldName, + term, + id: '', + color: field.color, + icon: field.icon, + data: { + field: fieldName, + term, + }, + label: term, + }; +} + +export async function fetchTopNodes( + post: CoreStart['http']['post'], + index: string, + fields: WorkspaceField[] +) { + const aggs = fields + .map(({ name }) => name) + .map(fieldName => createTopTermsSubAgg(fieldName)) + .reduce((allAggs, subAgg) => ({ ...allAggs, ...subAgg })); + const body = createSamplerSearchBody(aggs); + + const response: TopTermsAggResponse = (await post('../api/graph/searchProxy', { + body: JSON.stringify({ index, body }), + })).resp; + + const nodes: ServerResultNode[] = []; + + fields.forEach(({ name }) => { + const topTerms = getTopTermsResult(response, name); + const fieldNodes = topTerms.map(term => createServerResultNode(name, term, fields)); + + nodes.push(...fieldNodes); + }); + + return nodes; +} diff --git a/x-pack/legacy/plugins/graph/public/types/workspace_state.ts b/x-pack/legacy/plugins/graph/public/types/workspace_state.ts index 54666c48161e68..fab093535cb63c 100644 --- a/x-pack/legacy/plugins/graph/public/types/workspace_state.ts +++ b/x-pack/legacy/plugins/graph/public/types/workspace_state.ts @@ -41,27 +41,31 @@ export interface WorkspaceEdge { isSelected?: boolean; } -export interface GraphData { - nodes: Array<{ +export interface ServerResultNode { + field: string; + term: string; + id: string; + label: string; + color: string; + icon: FontawesomeIcon; + data: { field: string; term: string; - id: string; - label: string; - color: string; - icon: FontawesomeIcon; - data: { - field: string; - term: string; - }; - }>; - edges: Array<{ - source: number; - target: number; - weight: number; - width: number; - doc_count?: number; - inferred: boolean; - }>; + }; +} + +export interface ServerResultEdge { + source: number; + target: number; + weight: number; + width: number; + doc_count?: number; + inferred: boolean; +} + +export interface GraphData { + nodes: ServerResultNode[]; + edges: ServerResultEdge[]; } export interface Workspace { From ea18949ce72713b8406df72f492a5fe14081817c Mon Sep 17 00:00:00 2001 From: Michail Yasonik Date: Thu, 3 Oct 2019 15:00:12 +0530 Subject: [PATCH 49/59] Variety of quick a11y fixes (#46569) Focusing on heading structure and page layout for Home, Discover, Dashboard, and Visualize. This is progress on #37539 --- src/core/public/chrome/ui/header/header.tsx | 14 +-- .../query_bar_input.test.tsx.snap | 33 ++----- .../query_bar/components/query_bar_input.tsx | 43 ++++---- .../components/options/gauge/labels_panel.tsx | 4 +- .../components/options/gauge/ranges_panel.tsx | 4 +- .../components/options/gauge/style_panel.tsx | 4 +- .../category_axis_panel.test.tsx.snap | 4 +- .../value_axes_panel.test.tsx.snap | 4 +- .../metrics_axes/category_axis_panel.tsx | 4 +- .../options/metrics_axes/series_panel.tsx | 4 +- .../options/metrics_axes/value_axes_panel.tsx | 4 +- .../public/components/options/pie.tsx | 8 +- .../options/point_series/grid_panel.tsx | 4 +- .../options/point_series/point_series.tsx | 4 +- .../options/point_series/threshold_panel.tsx | 4 +- .../public/dashboard/dashboard_app.html | 1 + .../field_chooser/field_chooser.html | 2 +- .../__snapshots__/no_results.test.js.snap | 4 +- .../public/discover/directives/no_results.js | 4 +- .../kibana/public/discover/index.html | 10 +- .../public/visualize/editor/editor.html | 10 ++ .../public/markdown_options.tsx | 4 +- .../public/chrome/directives/kbn_chrome.html | 4 +- .../__snapshots__/agg_group.test.tsx.snap | 4 +- .../editors/default/components/agg_group.tsx | 2 +- .../public/vis/editors/default/sidebar.html | 4 +- .../lib/containers/embeddable_child_panel.tsx | 2 +- .../public/lib/panel/embeddable_panel.tsx | 14 ++- .../lib/panel/panel_header/panel_header.tsx | 97 +++++++++++-------- .../panel/panel_header/panel_options_menu.tsx | 22 ++++- .../public/context_menu/open_context_menu.tsx | 1 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 33 files changed, 186 insertions(+), 145 deletions(-) diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index afd9f8e4a38209..f24b0ed1681aab 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -19,7 +19,7 @@ import Url from 'url'; -import React, { Component, createRef, Fragment } from 'react'; +import React, { Component, createRef } from 'react'; import * as Rx from 'rxjs'; import { @@ -376,7 +376,7 @@ class HeaderUI extends Component { ]; return ( - +
    @@ -407,11 +407,13 @@ class HeaderUI extends Component { isLocked={isLocked} onIsLockedUpdate={onIsLockedUpdate} > - - - + - +
    ); } diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap b/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap index da756275a83e9a..6fdbf4fce45537 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap @@ -583,10 +583,9 @@ exports[`QueryBarInput Should disable autoFocus on EuiFieldText when disableAuto onOutsideClick={[Function]} >
    } - aria-activedescendant="" aria-autocomplete="list" - aria-controls="kbnTypeahead__items" - aria-label="You are on search box of Another Screen page. Start typing to search and filter the test" + aria-label="Start typing to search and filter the test page" autoComplete="off" autoFocus={false} compressed={false} @@ -651,10 +648,8 @@ exports[`QueryBarInput Should disable autoFocus on EuiFieldText when disableAuto >
    } - aria-activedescendant="" aria-autocomplete="list" - aria-controls="kbnTypeahead__items" - aria-label="You are on search box of Another Screen page. Start typing to search and filter the test" + aria-label="Start typing to search and filter the test page" autoComplete="off" autoFocus={true} compressed={false} @@ -1425,10 +1417,8 @@ exports[`QueryBarInput Should pass the query language to the language switcher 1 >
    } - aria-activedescendant="" aria-autocomplete="list" - aria-controls="kbnTypeahead__items" - aria-label="You are on search box of Another Screen page. Start typing to search and filter the test" + aria-label="Start typing to search and filter the test page" autoComplete="off" autoFocus={true} compressed={false} @@ -2199,10 +2186,8 @@ exports[`QueryBarInput Should render the given query 1`] = ` > { } public render() { + const isSuggestionsVisible = this.state.isSuggestionsVisible && { + 'aria-controls': 'kbnTypeahead__items', + 'aria-owns': 'kbnTypeahead__items', + }; + const ariaCombobox = { ...isSuggestionsVisible, role: 'combobox' }; + return (
    { }} autoComplete="off" spellCheck={false} - aria-label={ - this.props.screenTitle - ? this.props.intl.formatMessage( - { - id: 'data.query.queryBar.searchInputAriaLabel', - defaultMessage: - 'You are on search box of {previouslyTranslatedPageTitle} page. Start typing to search and filter the {pageType}', - }, - { - previouslyTranslatedPageTitle: this.props.screenTitle, - pageType: this.services.appName, - } - ) - : undefined - } + aria-label={i18n.translate('data.query.queryBar.searchInputAriaLabel', { + defaultMessage: 'Start typing to search and filter the {pageType} page', + values: { pageType: this.services.appName }, + })} type="text" aria-autocomplete="list" - aria-controls="kbnTypeahead__items" + aria-controls={this.state.isSuggestionsVisible ? 'kbnTypeahead__items' : undefined} aria-activedescendant={ - this.state.isSuggestionsVisible ? 'suggestion-' + this.state.index : '' + this.state.isSuggestionsVisible && typeof this.state.index === 'number' + ? `suggestion-${this.state.index}` + : undefined } role="textbox" prepend={this.props.prepend} diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/labels_panel.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/labels_panel.tsx index a24a37ca971d58..b96132fa29380f 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/labels_panel.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/labels_panel.tsx @@ -29,12 +29,12 @@ function LabelsPanel({ stateParams, setValue, setGaugeValue }: GaugeOptionsInter return ( -

    +

    -

    +

    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/ranges_panel.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/ranges_panel.tsx index 4abfb2e604b1dc..4e3b511782c9e7 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/ranges_panel.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/ranges_panel.tsx @@ -38,12 +38,12 @@ function RangesPanel({ return ( -

    +

    -

    +

    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/style_panel.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/style_panel.tsx index f606080afbdb2f..a76171673d9a82 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/style_panel.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/style_panel.tsx @@ -34,12 +34,12 @@ function StylePanel({ aggs, setGaugeValue, stateParams, vis }: GaugeOptionsInter return ( -

    +

    -

    +

    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/__snapshots__/category_axis_panel.test.tsx.snap b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/__snapshots__/category_axis_panel.test.tsx.snap index 6eef5047634f4f..d88654cfdc0c43 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/__snapshots__/category_axis_panel.test.tsx.snap +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/__snapshots__/category_axis_panel.test.tsx.snap @@ -7,13 +7,13 @@ exports[`CategoryAxisPanel component should init with the default set of props 1 -

    +

    -

    +

    -

    +

    -

    +

    -

    +

    -

    +

    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/series_panel.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/series_panel.tsx index 434202d64d6c30..5a455f4adde31f 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/series_panel.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/series_panel.tsx @@ -38,12 +38,12 @@ function SeriesPanel(props: SeriesPanelProps) { return ( -

    +

    -

    +
    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/value_axes_panel.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/value_axes_panel.tsx index 34a0d2cd981c5f..eb0ab4333af599 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/value_axes_panel.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/value_axes_panel.tsx @@ -109,12 +109,12 @@ function ValueAxesPanel(props: ValueAxesPanelProps) { -

    +

    -

    +
    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/pie.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/pie.tsx index 982c7265d5494c..53dde185ec09fe 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/pie.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/pie.tsx @@ -36,12 +36,12 @@ function PieOptions(props: VisOptionsProps) { <> -

    +

    -

    +
    ) { -
    +

    -

    +
    -

    +

    -

    +
    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/point_series.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/point_series.tsx index 11034f7f7335e4..8e3f66d12b9bdf 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/point_series.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/point_series.tsx @@ -34,12 +34,12 @@ function PointSeriesOptions(props: VisOptionsProps) { <> -

    +

    -

    +
    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/threshold_panel.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/threshold_panel.tsx index 9877b84345a1fe..49e56e377a8d56 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/threshold_panel.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/threshold_panel.tsx @@ -42,12 +42,12 @@ function ThresholdPanel({ stateParams, setValue, vis }: VisOptionsProps -

    +

    -

    +
    diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html index 5ceb28e6b225b8..39db357a69321f 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html @@ -120,6 +120,7 @@
    +

    {{screenTitle}}

    diff --git a/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/field_chooser.html b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/field_chooser.html index 2043dc44c147e7..d1a75adac5b82f 100644 --- a/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/field_chooser.html +++ b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/field_chooser.html @@ -84,7 +84,7 @@ i18n-default-message="Selected fields" > -
      +
        -

        Expand your time range -

        +

        One or more of the indices you’re looking at contains a date field. Your query may not match anything in the current time range, or there may not be any data at all in the currently selected time range. You can try changing the time range to one which contains data.

        diff --git a/src/legacy/core_plugins/kibana/public/discover/directives/no_results.js b/src/legacy/core_plugins/kibana/public/discover/directives/no_results.js index 9f57c49977f5a5..5f6d32681b50e4 100644 --- a/src/legacy/core_plugins/kibana/public/discover/directives/no_results.js +++ b/src/legacy/core_plugins/kibana/public/discover/directives/no_results.js @@ -119,12 +119,12 @@ export class DiscoverNoResults extends Component { -

        +

        -

        +

        - +

        {{screenTitle}}

        + +

        +

        +

        -

        Markdown

        +

        + +

        diff --git a/src/legacy/ui/public/chrome/directives/kbn_chrome.html b/src/legacy/ui/public/chrome/directives/kbn_chrome.html index 541082e68de58d..ced89287d310f2 100644 --- a/src/legacy/ui/public/chrome/directives/kbn_chrome.html +++ b/src/legacy/ui/public/chrome/directives/kbn_chrome.html @@ -1,9 +1,9 @@
        -
        + >
    diff --git a/src/legacy/ui/public/vis/editors/default/components/__snapshots__/agg_group.test.tsx.snap b/src/legacy/ui/public/vis/editors/default/components/__snapshots__/agg_group.test.tsx.snap index 813b7978d26671..29af0887db2b8c 100644 --- a/src/legacy/ui/public/vis/editors/default/components/__snapshots__/agg_group.test.tsx.snap +++ b/src/legacy/ui/public/vis/editors/default/components/__snapshots__/agg_group.test.tsx.snap @@ -10,9 +10,9 @@ exports[`DefaultEditorAgg component should init with the default set of props 1` -
    +

    Metrics -

    +
    -
    {groupNameLabel}
    +

    {groupNameLabel}

    diff --git a/src/legacy/ui/public/vis/editors/default/sidebar.html b/src/legacy/ui/public/vis/editors/default/sidebar.html index 0434534bddbfdb..b0a03e461fc1ce 100644 --- a/src/legacy/ui/public/vis/editors/default/sidebar.html +++ b/src/legacy/ui/public/vis/editors/default/sidebar.html @@ -7,7 +7,7 @@ ng-keydown="submitEditorWithKeyboard($event)" > -
    {{ vis.indexPattern.title }} -
    +