diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 1895046e885b5..a7487b48cb45e 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -143,7 +143,14 @@ This setting does not have an effect when loading a saved search. Highlighting slows requests when working on big documents. +[float] +[[kibana-ml-settings]] +==== Machine learning +[horizontal] +`ml:fileDataVisualizerMaxFileSize`:: Sets the file size limit when importing +data in the {data-viz}. The default value is `100MB`. The highest supported +value for this setting is `1GB`. [float] diff --git a/docs/settings/ml-settings.asciidoc b/docs/settings/ml-settings.asciidoc index b71e1c672756a..36578c909f513 100644 --- a/docs/settings/ml-settings.asciidoc +++ b/docs/settings/ml-settings.asciidoc @@ -19,10 +19,4 @@ instance. If `xpack.ml.enabled` is set to `true` in `elasticsearch.yml`, however you can still use the {ml} APIs. To disable {ml} entirely, see the {ref}/ml-settings.html[{es} {ml} settings]. -[[data-visualizer-settings]] -==== {data-viz} settings - -`xpack.ml.file_data_visualizer.max_file_size`:: -Sets the file size limit when importing data in the {data-viz}. The default -value is `100MB`. The highest supported value for this setting is `1GB`. diff --git a/docs/user/ml/index.asciidoc b/docs/user/ml/index.asciidoc index e9ef4a55b2b3a..6483ddde07335 100644 --- a/docs/user/ml/index.asciidoc +++ b/docs/user/ml/index.asciidoc @@ -20,7 +20,7 @@ image::user/ml/images/ml-data-visualizer-sample.jpg[{data-viz} for sample flight experimental[] You can also upload a CSV, NDJSON, or log file. The *{data-viz}* identifies the file format and field mappings. You can then optionally import that data into an {es} index. To change the default file size limit, see -<>. +<>. You need the following permissions to use the {data-viz} with file upload: diff --git a/package.json b/package.json index 76d0dda02648b..5c1d1e542c8b4 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "data/optimize", "built_assets", ".eslintcache", - ".node_binaries" + ".node_binaries", + "src/plugins/*/target" ] } }, @@ -437,7 +438,7 @@ "gulp-babel": "^8.0.0", "gulp-sourcemaps": "2.6.5", "has-ansi": "^3.0.0", - "iedriver": "^3.14.1", + "iedriver": "^3.14.2", "intl-messageformat-parser": "^1.4.0", "is-path-inside": "^2.1.0", "istanbul-instrumenter-loader": "3.0.1", diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 73d1252821bb3..6553442de27c8 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -57107,7 +57107,7 @@ async function getChangesForProjects(projects, kbn, log) { log.verbose('getting changed files'); const { stdout - } = await execa__WEBPACK_IMPORTED_MODULE_3___default()('git', ['ls-files', '-dmt', '--', ...Array.from(projects.values()).filter(p => kbn.isPartOfRepo(p)).map(p => p.path)], { + } = await execa__WEBPACK_IMPORTED_MODULE_3___default()('git', ['ls-files', '-dmto', '--exclude-standard', '--', ...Array.from(projects.values()).filter(p => kbn.isPartOfRepo(p)).map(p => p.path)], { cwd: kbn.getAbsolute() }); const output = stdout.trim(); @@ -57134,10 +57134,13 @@ async function getChangesForProjects(projects, kbn, log) { unassignedChanges.set(path, 'deleted'); break; + case '?': + unassignedChanges.set(path, 'untracked'); + break; + case 'H': case 'S': case 'K': - case '?': default: log.warning(`unexpected modification status "${tag}" for ${path}, please report this!`); unassignedChanges.set(path, 'invalid'); diff --git a/packages/kbn-pm/src/utils/project_checksums.ts b/packages/kbn-pm/src/utils/project_checksums.ts index 572f2adb19bd9..7d939e715d411 100644 --- a/packages/kbn-pm/src/utils/project_checksums.ts +++ b/packages/kbn-pm/src/utils/project_checksums.ts @@ -32,7 +32,7 @@ import { Kibana } from '../utils/kibana'; export type ChecksumMap = Map; /** map of [repo relative path to changed file, type of change] */ -type Changes = Map; +type Changes = Map; const statAsync = promisify(Fs.stat); const projectBySpecificitySorter = (a: Project, b: Project) => b.path.length - a.path.length; @@ -45,7 +45,8 @@ async function getChangesForProjects(projects: ProjectMap, kbn: Kibana, log: Too 'git', [ 'ls-files', - '-dmt', + '-dmto', + '--exclude-standard', '--', ...Array.from(projects.values()) .filter(p => kbn.isPartOfRepo(p)) @@ -78,10 +79,13 @@ async function getChangesForProjects(projects: ProjectMap, kbn: Kibana, log: Too unassignedChanges.set(path, 'deleted'); break; + case '?': + unassignedChanges.set(path, 'untracked'); + break; + case 'H': case 'S': case 'K': - case '?': default: log.warning(`unexpected modification status "${tag}" for ${path}, please report this!`); unassignedChanges.set(path, 'invalid'); diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 2451b98ffdf29..c707fa2b479e4 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -18,7 +18,7 @@ */ import { of } from 'rxjs'; import { duration } from 'moment'; -import { PluginInitializerContext, CoreSetup, CoreStart } from '.'; +import { PluginInitializerContext, CoreSetup, CoreStart, StartServicesAccessor } from '.'; import { CspConfig } from './csp'; import { loggingServiceMock } from './logging/logging_service.mock'; import { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service.mock'; @@ -100,7 +100,9 @@ function pluginInitializerContextMock(config: T = {} as T) { return mock; } -type CoreSetupMockType = MockedKeys & jest.Mocked>; +type CoreSetupMockType = MockedKeys & { + getStartServices: jest.MockedFunction>; +}; function createCoreSetupMock({ pluginStartDeps = {}, diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index fcdfa14a9462f..13c13621ba071 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -236,6 +236,7 @@ kibana_vars=( xpack.security.session.idleTimeout xpack.security.session.lifespan xpack.security.loginAssistanceMessage + xpack.security.loginHelp xpack.security.public.protocol xpack.security.public.hostname xpack.security.public.port diff --git a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx index fb33649093c8d..f8632011002d0 100644 --- a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx +++ b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx @@ -275,6 +275,7 @@ class DashboardGridUi extends React.Component { getEmbeddableFactory={this.props.kibana.services.embeddable.getEmbeddableFactory} getAllEmbeddableFactories={this.props.kibana.services.embeddable.getEmbeddableFactories} overlays={this.props.kibana.services.overlays} + application={this.props.kibana.services.application} notifications={this.props.kibana.services.notifications} inspector={this.props.kibana.services.inspector} SavedObjectFinder={this.props.kibana.services.SavedObjectFinder} diff --git a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx index 836cea298f035..5dab21ff671b4 100644 --- a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx @@ -84,6 +84,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { getAllEmbeddableFactories={(() => []) as any} getEmbeddableFactory={(() => null) as any} notifications={{} as any} + application={{} as any} overlays={{} as any} inspector={inspector} SavedObjectFinder={() => null} diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx index d07bf915845e9..fc5438b8c8dcb 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx @@ -41,7 +41,7 @@ class EditableEmbeddable extends Embeddable { } test('is compatible when edit url is available, in edit mode and editable', async () => { - const action = new EditPanelAction(getFactory); + const action = new EditPanelAction(getFactory, {} as any); expect( await action.isCompatible({ embeddable: new EditableEmbeddable({ id: '123', viewMode: ViewMode.EDIT }, true), @@ -50,7 +50,7 @@ test('is compatible when edit url is available, in edit mode and editable', asyn }); test('getHref returns the edit urls', async () => { - const action = new EditPanelAction(getFactory); + const action = new EditPanelAction(getFactory, {} as any); expect(action.getHref).toBeDefined(); if (action.getHref) { @@ -64,7 +64,7 @@ test('getHref returns the edit urls', async () => { }); test('is not compatible when edit url is not available', async () => { - const action = new EditPanelAction(getFactory); + const action = new EditPanelAction(getFactory, {} as any); const embeddable = new ContactCardEmbeddable( { id: '123', @@ -83,7 +83,7 @@ test('is not compatible when edit url is not available', async () => { }); test('is not visible when edit url is available but in view mode', async () => { - const action = new EditPanelAction(getFactory); + const action = new EditPanelAction(getFactory, {} as any); expect( await action.isCompatible({ embeddable: new EditableEmbeddable( @@ -98,7 +98,7 @@ test('is not visible when edit url is available but in view mode', async () => { }); test('is not compatible when edit url is available, in edit mode, but not editable', async () => { - const action = new EditPanelAction(getFactory); + const action = new EditPanelAction(getFactory, {} as any); expect( await action.isCompatible({ embeddable: new EditableEmbeddable( diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index 044e7b5d35ad8..0abbc25ff49a6 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -18,6 +18,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ApplicationStart } from 'kibana/public'; import { Action } from 'src/plugins/ui_actions/public'; import { ViewMode } from '../types'; import { EmbeddableFactoryNotFoundError } from '../errors'; @@ -35,7 +36,10 @@ export class EditPanelAction implements Action { public readonly id = ACTION_EDIT_PANEL; public order = 15; - constructor(private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']) {} + constructor( + private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'], + private readonly application: ApplicationStart + ) {} public getDisplayName({ embeddable }: ActionContext) { const factory = this.getEmbeddableFactory(embeddable.type); @@ -56,18 +60,35 @@ export class EditPanelAction implements Action { public async isCompatible({ embeddable }: ActionContext) { const canEditEmbeddable = Boolean( - embeddable && embeddable.getOutput().editable && embeddable.getOutput().editUrl + embeddable && + embeddable.getOutput().editable && + (embeddable.getOutput().editUrl || + (embeddable.getOutput().editApp && embeddable.getOutput().editPath)) ); const inDashboardEditMode = embeddable.getInput().viewMode === ViewMode.EDIT; return Boolean(canEditEmbeddable && inDashboardEditMode); } public async execute(context: ActionContext) { + const appTarget = this.getAppTarget(context); + + if (appTarget) { + await this.application.navigateToApp(appTarget.app, { path: appTarget.path }); + return; + } + const href = await this.getHref(context); if (href) { - // TODO: when apps start using browser router instead of hash router this has to be fixed - // https://github.com/elastic/kibana/issues/58217 window.location.href = href; + return; + } + } + + public getAppTarget({ embeddable }: ActionContext): { app: string; path: string } | undefined { + const app = embeddable ? embeddable.getOutput().editApp : undefined; + const path = embeddable ? embeddable.getOutput().editPath : undefined; + if (app && path) { + return { app, path }; } } diff --git a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx index 2a0ffd723850b..b046376a304ae 100644 --- a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx @@ -66,6 +66,7 @@ test('EmbeddableChildPanel renders an embeddable when it is done loading', async getAllEmbeddableFactories={start.getEmbeddableFactories} getEmbeddableFactory={getEmbeddableFactory} notifications={{} as any} + application={{} as any} overlays={{} as any} inspector={inspector} SavedObjectFinder={() => null} @@ -105,6 +106,7 @@ test(`EmbeddableChildPanel renders an error message if the factory doesn't exist getEmbeddableFactory={(() => undefined) as any} notifications={{} as any} overlays={{} as any} + application={{} as any} inspector={inspector} SavedObjectFinder={() => null} /> diff --git a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.tsx b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.tsx index 4c08a80a356bf..70628665e6e8c 100644 --- a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.tsx +++ b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.tsx @@ -40,6 +40,7 @@ export interface EmbeddableChildPanelProps { getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories']; overlays: CoreStart['overlays']; notifications: CoreStart['notifications']; + application: CoreStart['application']; inspector: InspectorStartContract; SavedObjectFinder: React.ComponentType; } @@ -101,6 +102,7 @@ export class EmbeddableChildPanel extends React.Component { getAllEmbeddableFactories={start.getEmbeddableFactories} getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} + application={{} as any} overlays={{} as any} inspector={inspector} SavedObjectFinder={() => null} @@ -198,6 +199,7 @@ const renderInEditModeAndOpenContextMenu = async ( getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} + application={{} as any} inspector={inspector} SavedObjectFinder={() => null} /> @@ -296,6 +298,7 @@ test('HelloWorldContainer in edit mode shows edit mode actions', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} + application={{} as any} inspector={inspector} SavedObjectFinder={() => null} /> @@ -358,6 +361,7 @@ test('Updates when hidePanelTitles is toggled', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} + application={{} as any} inspector={inspector} SavedObjectFinder={() => null} /> @@ -410,6 +414,7 @@ test('Check when hide header option is false', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} + application={{} as any} inspector={inspector} SavedObjectFinder={() => null} hideHeader={false} @@ -447,6 +452,7 @@ test('Check when hide header option is true', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} + application={{} as any} inspector={inspector} SavedObjectFinder={() => null} hideHeader={true} diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index b95060a73252f..c43359382a33d 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -45,6 +45,7 @@ interface Props { getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories']; overlays: CoreStart['overlays']; notifications: CoreStart['notifications']; + application: CoreStart['application']; inspector: InspectorStartContract; SavedObjectFinder: React.ComponentType; hideHeader?: boolean; @@ -243,7 +244,7 @@ export class EmbeddablePanel extends React.Component { ), new InspectPanelAction(this.props.inspector), new RemovePanelAction(), - new EditPanelAction(this.props.getEmbeddableFactory), + new EditPanelAction(this.props.getEmbeddableFactory, this.props.application), ]; const sorted = actions diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx index a88c3ba086325..31e14a0af59d7 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx @@ -49,6 +49,7 @@ interface HelloWorldContainerOptions { getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']; getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories']; overlays: CoreStart['overlays']; + application: CoreStart['application']; notifications: CoreStart['notifications']; inspector: InspectorStartContract; SavedObjectFinder: React.ComponentType; @@ -81,6 +82,7 @@ export class HelloWorldContainer extends Container; @@ -112,6 +113,7 @@ export class HelloWorldContainerComponent extends Component { getAllEmbeddableFactories={this.props.getAllEmbeddableFactories} overlays={this.props.overlays} notifications={this.props.notifications} + application={this.props.application} inspector={this.props.inspector} SavedObjectFinder={this.props.SavedObjectFinder} /> diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index 01fbf52c80182..36f49f2508e80 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -118,6 +118,7 @@ export class EmbeddablePublicPlugin implements Plugin diff --git a/src/plugins/embeddable/public/tests/apply_filter_action.test.ts b/src/plugins/embeddable/public/tests/apply_filter_action.test.ts index 3bd414ecf0d4a..ebb76c743393b 100644 --- a/src/plugins/embeddable/public/tests/apply_filter_action.test.ts +++ b/src/plugins/embeddable/public/tests/apply_filter_action.test.ts @@ -110,6 +110,7 @@ test('ApplyFilterAction is incompatible if the root container does not accept a getAllEmbeddableFactories: api.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector, SavedObjectFinder: () => null, } @@ -145,6 +146,7 @@ test('trying to execute on incompatible context throws an error ', async () => { getAllEmbeddableFactories: api.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector, SavedObjectFinder: () => null, } diff --git a/src/plugins/embeddable/public/tests/container.test.ts b/src/plugins/embeddable/public/tests/container.test.ts index 1aae43550ec6f..d2769e208ba42 100644 --- a/src/plugins/embeddable/public/tests/container.test.ts +++ b/src/plugins/embeddable/public/tests/container.test.ts @@ -74,6 +74,7 @@ async function creatHelloWorldContainerAndEmbeddable( getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, }); @@ -147,6 +148,7 @@ test('Container.removeEmbeddable removes and cleans up', async done => { getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } @@ -327,6 +329,7 @@ test(`Container updates its state when a child's input is updated`, async done = getEmbeddableFactory: start.getEmbeddableFactory, notifications: coreStart.notifications, overlays: coreStart.overlays, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, }); @@ -584,6 +587,7 @@ test('Container changes made directly after adding a new embeddable are propagat getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } @@ -708,6 +712,7 @@ test('untilEmbeddableLoaded() throws an error if there is no such child panel in getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } @@ -742,6 +747,7 @@ test('untilEmbeddableLoaded() resolves if child is loaded in the container', asy getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } @@ -781,6 +787,7 @@ test('untilEmbeddableLoaded resolves with undefined if child is subsequently rem getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } @@ -821,6 +828,7 @@ test('adding a panel then subsequently removing it before its loaded removes the getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } diff --git a/src/plugins/embeddable/public/tests/customize_panel_modal.test.tsx b/src/plugins/embeddable/public/tests/customize_panel_modal.test.tsx index 19e461b8bde7e..a9cb83504d958 100644 --- a/src/plugins/embeddable/public/tests/customize_panel_modal.test.tsx +++ b/src/plugins/embeddable/public/tests/customize_panel_modal.test.tsx @@ -63,6 +63,7 @@ beforeEach(async () => { getAllEmbeddableFactories: api.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } diff --git a/src/plugins/embeddable/public/tests/explicit_input.test.ts b/src/plugins/embeddable/public/tests/explicit_input.test.ts index 0e03db3ec8358..ef3c4b6f17e7f 100644 --- a/src/plugins/embeddable/public/tests/explicit_input.test.ts +++ b/src/plugins/embeddable/public/tests/explicit_input.test.ts @@ -88,6 +88,7 @@ test('Explicit embeddable input mapped to undefined with no inherited value will getEmbeddableFactory: start.getEmbeddableFactory, notifications: coreStart.notifications, overlays: coreStart.overlays, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } @@ -136,6 +137,7 @@ test('Explicit input tests in async situations', (done: () => void) => { getEmbeddableFactory: start.getEmbeddableFactory, notifications: coreStart.notifications, overlays: coreStart.overlays, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } diff --git a/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts b/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts index 3127e03ada0ef..fa4427fbb8c12 100644 --- a/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts +++ b/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts @@ -36,6 +36,7 @@ const queryObject = Joi.object({ language: Joi.string().allow(''), query: Joi.string().allow(''), }); +const stringOrNumberOptionalNullable = Joi.alternatives([stringOptionalNullable, numberOptional]); const numberOptionalOrEmptyString = Joi.alternatives(numberOptional, Joi.string().valid('')); const annotationsItems = Joi.object({ @@ -78,7 +79,7 @@ const metricsItems = Joi.object({ unit: stringOptionalNullable, model_type: stringOptionalNullable, mode: stringOptionalNullable, - lag: numberOptional, + lag: numberOptionalOrEmptyString, alpha: numberOptional, beta: numberOptional, gamma: numberOptional, @@ -130,8 +131,8 @@ const seriesItems = Joi.object({ aggregate_by: stringOptionalNullable, aggregate_function: stringOptionalNullable, axis_position: stringRequired, - axis_max: stringOptionalNullable, - axis_min: stringOptionalNullable, + axis_max: stringOrNumberOptionalNullable, + axis_min: stringOrNumberOptionalNullable, chart_type: stringRequired, color: stringRequired, color_rules: Joi.array() @@ -198,8 +199,8 @@ export const visPayloadSchema = Joi.object({ axis_formatter: stringRequired, axis_position: stringRequired, axis_scale: stringRequired, - axis_min: stringOptionalNullable, - axis_max: stringOptionalNullable, + axis_min: stringOrNumberOptionalNullable, + axis_max: stringOrNumberOptionalNullable, bar_color_rules: arrayNullable.optional(), background_color: stringOptionalNullable, background_color_rules: Joi.array() @@ -221,9 +222,9 @@ export const visPayloadSchema = Joi.object({ .optional(), gauge_width: [stringOptionalNullable, numberOptional], gauge_inner_color: stringOptionalNullable, - gauge_inner_width: Joi.alternatives(stringOptionalNullable, numberIntegerOptional), + gauge_inner_width: stringOrNumberOptionalNullable, gauge_style: stringOptionalNullable, - gauge_max: stringOptionalNullable, + gauge_max: stringOrNumberOptionalNullable, id: stringRequired, ignore_global_filters: numberOptional, ignore_global_filter: numberOptional, diff --git a/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts b/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts index f18fa1e4cc2fa..34922976f22ff 100644 --- a/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts +++ b/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts @@ -41,5 +41,6 @@ export const tsvbTelemetrySavedObjectType: SavedObjectsType = { }, migrations: { '7.7.0': flow(resetCount), + '7.8.0': flow(resetCount), }, }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index eb71994bd2e47..e9942a327b69e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -60,7 +60,10 @@ const style: cytoscape.Stylesheet[] = [ ? theme.euiColorPrimary : theme.euiColorMediumShade, 'border-width': 2, - color: theme.textColors.text, + color: (el: cytoscape.NodeSingular) => + el.hasClass('primary') || el.selected() + ? theme.euiColorPrimaryText + : theme.textColors.text, // theme.euiFontFamily doesn't work here for some reason, so we're just // specifying a subset of the fonts for the label text. 'font-family': 'Inter UI, Segoe UI, Helvetica, Arial, sans-serif', @@ -78,8 +81,9 @@ const style: cytoscape.Stylesheet[] = [ 'overlay-opacity': 0, shape: (el: cytoscape.NodeSingular) => isService(el) ? (isIE11 ? 'rectangle' : 'ellipse') : 'diamond', - 'text-background-color': theme.euiColorLightestShade, - 'text-background-opacity': 0, + 'text-background-color': theme.euiColorPrimary, + 'text-background-opacity': (el: cytoscape.NodeSingular) => + el.hasClass('primary') || el.selected() ? 0.1 : 0, 'text-background-padding': theme.paddingSizes.xs, 'text-background-shape': 'roundrectangle', 'text-margin-y': parseInt(theme.paddingSizes.s, 10), diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index ee08dfb87e1c1..1e8983a0ca5e5 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -45,6 +45,7 @@ const renderEmbeddableFactory = (core: CoreStart, plugins: StartDeps) => { getAllEmbeddableFactories={plugins.embeddable.getEmbeddableFactories} notifications={core.notifications} overlays={core.overlays} + application={core.application} inspector={plugins.inspector} SavedObjectFinder={getSavedObjectFinder(core.savedObjects, core.uiSettings)} /> diff --git a/x-pack/package.json b/x-pack/package.json index d8fe019d7ac5b..d6b777f57346c 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -20,6 +20,11 @@ "build": { "intermediateBuildDirectory": "build/plugin/kibana/x-pack", "oss": false + }, + "clean": { + "extraPatterns": [ + "plugins/*/target" + ] } }, "resolutions": { diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts index 8cfd736a336c2..6268f5899d7ff 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts @@ -143,11 +143,15 @@ export const agentConfigurationSearchRoute = createRoute(core => ({ params: { body: t.intersection([ t.type({ service: serviceRt }), - t.partial({ etag: t.string }) + t.partial({ etag: t.string, mark_as_applied_by_agent: t.boolean }) ]) }, handler: async ({ context, request }) => { - const { service, etag } = context.params.body; + const { + service, + etag, + mark_as_applied_by_agent: markAsAppliedByAgent + } = context.params.body; const setup = await setupRequest(context, request); const config = await searchConfigurations({ @@ -166,9 +170,14 @@ export const agentConfigurationSearchRoute = createRoute(core => ({ `Config was found for ${service.name}/${service.environment}` ); - // update `applied_by_agent` field if etags match + // update `applied_by_agent` field + // when `markAsAppliedByAgent` is true (Jaeger agent doesn't have etags) + // or if etags match. // this happens in the background and doesn't block the response - if (etag === config._source.etag && !config._source.applied_by_agent) { + if ( + (markAsAppliedByAgent || etag === config._source.etag) && + !config._source.applied_by_agent + ) { markAppliedByAgent({ id: config._id, body: config._source, setup }); } diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/index.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/index.test.ts index 515c54eab3280..863ffc50d0155 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/index.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/index.test.ts @@ -57,7 +57,7 @@ describe('HostList store concerns', () => { }); const currentState = store.getState(); - expect(currentState.hosts).toEqual(payload.hosts.map(hostInfo => hostInfo.metadata)); + expect(currentState.hosts).toEqual(payload.hosts); expect(currentState.pageSize).toEqual(payload.request_page_size); expect(currentState.pageIndex).toEqual(payload.request_page_index); expect(currentState.total).toEqual(payload.total); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.test.ts index 1af83a975d1d8..2064c76f7dfb5 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.test.ts @@ -62,6 +62,6 @@ describe('host list middleware', () => { paging_properties: [{ page_index: '0' }, { page_size: '10' }], }), }); - expect(listData(getState())).toEqual(apiResponse.hosts.map(hostInfo => hostInfo.metadata)); + expect(listData(getState())).toEqual(apiResponse.hosts); }); }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts index eb74c40ff3687..93e995194353b 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts @@ -38,7 +38,7 @@ export const hostListReducer: ImmutableReducer = ( } = action.payload; return { ...state, - hosts: hosts.map(hostInfo => hostInfo.metadata), + hosts, total, pageSize, pageIndex, diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts index 0598ce5f38efa..82b812b79670f 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts @@ -23,6 +23,7 @@ import { UIPolicyConfig, PolicyData, HostPolicyResponse, + HostInfo, } from '../../../common/types'; import { EndpointPluginStartDependencies } from '../../plugin'; import { AppAction } from './store/action'; @@ -91,7 +92,7 @@ export type SubstateMiddlewareFactory = ( export interface HostState { /** list of host **/ - hosts: HostMetadata[]; + hosts: HostInfo[]; /** number of items per page */ pageSize: number; /** which page to show */ diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx index 499efb4f4b8ed..5a8765110c909 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx @@ -14,7 +14,7 @@ import { mockHostResultList, } from '../../store/hosts/mock_host_result_list'; import { AppContextTestRender, createAppRootMockRenderer } from '../../mocks'; -import { HostInfo, HostPolicyResponseActionStatus } from '../../../../../common/types'; +import { HostInfo, HostStatus, HostPolicyResponseActionStatus } from '../../../../../common/types'; import { EndpointDocGenerator } from '../../../../../common/generate_data'; describe('when on the hosts page', () => { @@ -48,21 +48,50 @@ describe('when on the hosts page', () => { describe('when list data loads', () => { beforeEach(() => { reactTestingLibrary.act(() => { + const hostListData = mockHostResultList({ total: 3 }); + [HostStatus.ERROR, HostStatus.ONLINE, HostStatus.OFFLINE].forEach((status, index) => { + hostListData.hosts[index] = { + metadata: hostListData.hosts[index].metadata, + host_status: status, + }; + }); const action: AppAction = { type: 'serverReturnedHostList', - payload: mockHostResultList(), + payload: hostListData, }; store.dispatch(action); }); }); - it('should render the host summary row in the table', async () => { + it('should display rows in the table', async () => { const renderResult = render(); const rows = await renderResult.findAllByRole('row'); - expect(rows).toHaveLength(2); + expect(rows).toHaveLength(4); + }); + it('should show total', async () => { + const renderResult = render(); + const total = await renderResult.findByTestId('hostListTableTotal'); + expect(total.textContent).toEqual('3 Hosts'); + }); + it('should display correct status', async () => { + const renderResult = render(); + const hostStatuses = await renderResult.findAllByTestId('rowHostStatus'); + + expect(hostStatuses[0].textContent).toEqual('Error'); + expect(hostStatuses[0].querySelector('[data-euiicon-type][color="danger"]')).not.toBeNull(); + + expect(hostStatuses[1].textContent).toEqual('Online'); + expect( + hostStatuses[1].querySelector('[data-euiicon-type][color="success"]') + ).not.toBeNull(); + + expect(hostStatuses[2].textContent).toEqual('Offline'); + expect( + hostStatuses[2].querySelector('[data-euiicon-type][color="subdued"]') + ).not.toBeNull(); }); - describe('when the user clicks the hostname in the table', () => { + describe('when the user clicks the first hostname in the table', () => { let renderResult: reactTestingLibrary.RenderResult; beforeEach(async () => { const hostDetailsApiResponse = mockHostDetailsApiResult(); @@ -76,9 +105,9 @@ describe('when on the hosts page', () => { }); renderResult = render(); - const detailsLink = await renderResult.findByTestId('hostnameCellLink'); - if (detailsLink) { - reactTestingLibrary.fireEvent.click(detailsLink); + const hostNameLinks = await renderResult.findAllByTestId('hostnameCellLink'); + if (hostNameLinks.length) { + reactTestingLibrary.fireEvent.click(hostNameLinks[0]); } }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.tsx index 5c2922162ce0c..026ba2ff15126 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.tsx @@ -16,10 +16,20 @@ import * as selectors from '../../store/hosts/selectors'; import { useHostSelector } from './hooks'; import { CreateStructuredSelector } from '../../types'; import { urlFromQueryParams } from './url_from_query_params'; -import { HostMetadata, Immutable } from '../../../../../common/types'; +import { HostInfo, HostStatus, Immutable } from '../../../../../common/types'; import { PageView } from '../components/page_view'; import { useNavigateByRouterEventHandler } from '../hooks/use_navigate_by_router_event_handler'; +const HOST_STATUS_TO_HEALTH_COLOR = Object.freeze< + { + [key in HostStatus]: string; + } +>({ + [HostStatus.ERROR]: 'danger', + [HostStatus.ONLINE]: 'success', + [HostStatus.OFFLINE]: 'subdued', +}); + const HostLink = memo<{ name: string; href: string; @@ -73,20 +83,40 @@ export const HostList = () => { [history, queryParams] ); - const columns: Array>> = useMemo(() => { + const columns: Array>> = useMemo(() => { return [ { - field: '', + field: 'metadata.host', name: i18n.translate('xpack.endpoint.host.list.hostname', { defaultMessage: 'Hostname', }), - render: ({ host: { hostname, id } }: { host: { hostname: string; id: string } }) => { + render: ({ hostname, id }: HostInfo['metadata']['host']) => { const newQueryParams = urlFromQueryParams({ ...queryParams, selected_host: id }); return ( ); }, }, + { + field: 'host_status', + name: i18n.translate('xpack.endpoint.host.list.hostStatus', { + defaultMessage: 'Host Status', + }), + render: (hostStatus: HostInfo['host_status']) => { + return ( + + + + ); + }, + }, { field: '', name: i18n.translate('xpack.endpoint.host.list.policy', { @@ -117,26 +147,23 @@ export const HostList = () => { }, }, { - field: 'host.os.name', + field: 'metadata.host.os.name', name: i18n.translate('xpack.endpoint.host.list.os', { defaultMessage: 'Operating System', }), }, { - field: 'host.ip', + field: 'metadata.host.ip', name: i18n.translate('xpack.endpoint.host.list.ip', { defaultMessage: 'IP Address', }), truncateText: true, }, { - field: '', - name: i18n.translate('xpack.endpoint.host.list.sensorVersion', { - defaultMessage: 'Sensor Version', + field: 'metadata.agent.version', + name: i18n.translate('xpack.endpoint.host.list.endpointVersion', { + defaultMessage: 'Version', }), - render: () => { - return 'version'; - }, }, { field: '', @@ -158,7 +185,7 @@ export const HostList = () => { headerLeft={i18n.translate('xpack.endpoint.host.hosts', { defaultMessage: 'Hosts' })} > {hasSelectedHost && } - + { + const [popoverOpen, setPopoverOpen] = useState(false); + const [flyoutVisible, setFlyoutVisible] = useState(false); + const manageAlertsLinkProps = useLinkProps( + { + app: 'kibana', + hash: 'management/kibana/triggersActions/alerts', + }, + { + hrefOnly: true, + } + ); + + const closePopover = useCallback(() => { + setPopoverOpen(false); + }, [setPopoverOpen]); + + const openPopover = useCallback(() => { + setPopoverOpen(true); + }, [setPopoverOpen]); + + const menuItems = useMemo(() => { + return [ + setFlyoutVisible(true)}> + + , + + + , + ]; + }, [manageAlertsLinkProps]); + + return ( + <> + + + + } + isOpen={popoverOpen} + closePopover={closePopover} + > + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.tsx b/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.tsx new file mode 100644 index 0000000000000..b18c2e5b8d69c --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { AlertsContextProvider, AlertAdd } from '../../../../../triggers_actions_ui/public'; +import { TriggerActionsContext } from '../../../utils/triggers_actions_context'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { LOG_DOCUMENT_COUNT_ALERT_TYPE_ID } from '../../../../common/alerting/logs/types'; + +interface Props { + visible?: boolean; + setVisible: React.Dispatch>; +} + +export const AlertFlyout = (props: Props) => { + const { triggersActionsUI } = useContext(TriggerActionsContext); + const { services } = useKibana(); + + return ( + <> + {triggersActionsUI && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criteria.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criteria.tsx new file mode 100644 index 0000000000000..a9b45a117c281 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criteria.tsx @@ -0,0 +1,54 @@ +/* + * 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 { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { IFieldType } from 'src/plugins/data/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../../triggers_actions_ui/public/types'; +import { Criterion } from './criterion'; +import { + LogDocumentCountAlertParams, + Criterion as CriterionType, +} from '../../../../../common/alerting/logs/types'; + +interface Props { + fields: IFieldType[]; + criteria?: LogDocumentCountAlertParams['criteria']; + updateCriterion: (idx: number, params: Partial) => void; + removeCriterion: (idx: number) => void; + errors: IErrorObject; +} + +export const Criteria: React.FC = ({ + fields, + criteria, + updateCriterion, + removeCriterion, + errors, +}) => { + if (!criteria) return null; + return ( + + + {criteria.map((criterion, idx) => { + return ( + 1} + errors={errors[idx.toString()] as IErrorObject} + /> + ); + })} + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criterion.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criterion.tsx new file mode 100644 index 0000000000000..e8cafecd94db1 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criterion.tsx @@ -0,0 +1,269 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useMemo, useCallback } from 'react'; +import { + EuiPopoverTitle, + EuiFlexItem, + EuiFlexGroup, + EuiPopover, + EuiSelect, + EuiFieldNumber, + EuiExpression, + EuiFieldText, + EuiButtonIcon, + EuiFormRow, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { IFieldType } from 'src/plugins/data/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../../triggers_actions_ui/public/types'; +import { + Comparator, + Criterion as CriterionType, + ComparatorToi18nMap, +} from '../../../../../common/alerting/logs/types'; + +const firstCriterionFieldPrefix = i18n.translate( + 'xpack.infra.logs.alertFlyout.firstCriterionFieldPrefix', + { + defaultMessage: 'with', + } +); + +const successiveCriterionFieldPrefix = i18n.translate( + 'xpack.infra.logs.alertFlyout.successiveCriterionFieldPrefix', + { + defaultMessage: 'and', + } +); + +const criterionFieldTitle = i18n.translate('xpack.infra.logs.alertFlyout.criterionFieldTitle', { + defaultMessage: 'Field', +}); + +const criterionComparatorValueTitle = i18n.translate( + 'xpack.infra.logs.alertFlyout.criterionComparatorValueTitle', + { + defaultMessage: 'Comparison : Value', + } +); + +const getCompatibleComparatorsForField = (fieldInfo: IFieldType | undefined) => { + if (fieldInfo?.type === 'number') { + return [ + { value: Comparator.GT, text: ComparatorToi18nMap[Comparator.GT] }, + { value: Comparator.GT_OR_EQ, text: ComparatorToi18nMap[Comparator.GT_OR_EQ] }, + { value: Comparator.LT, text: ComparatorToi18nMap[Comparator.LT] }, + { value: Comparator.LT_OR_EQ, text: ComparatorToi18nMap[Comparator.LT_OR_EQ] }, + { value: Comparator.EQ, text: ComparatorToi18nMap[`${Comparator.EQ}:number`] }, + { value: Comparator.NOT_EQ, text: ComparatorToi18nMap[`${Comparator.NOT_EQ}:number`] }, + ]; + } else if (fieldInfo?.aggregatable) { + return [ + { value: Comparator.EQ, text: ComparatorToi18nMap[Comparator.EQ] }, + { value: Comparator.NOT_EQ, text: ComparatorToi18nMap[Comparator.NOT_EQ] }, + ]; + } else { + return [ + { value: Comparator.MATCH, text: ComparatorToi18nMap[Comparator.MATCH] }, + { value: Comparator.NOT_MATCH, text: ComparatorToi18nMap[Comparator.NOT_MATCH] }, + { value: Comparator.MATCH_PHRASE, text: ComparatorToi18nMap[Comparator.MATCH_PHRASE] }, + { + value: Comparator.NOT_MATCH_PHRASE, + text: ComparatorToi18nMap[Comparator.NOT_MATCH_PHRASE], + }, + ]; + } +}; + +const getFieldInfo = (fields: IFieldType[], fieldName: string): IFieldType | undefined => { + return fields.find(field => { + return field.name === fieldName; + }); +}; + +interface Props { + idx: number; + fields: IFieldType[]; + criterion: CriterionType; + updateCriterion: (idx: number, params: Partial) => void; + removeCriterion: (idx: number) => void; + canDelete: boolean; + errors: IErrorObject; +} + +export const Criterion: React.FC = ({ + idx, + fields, + criterion, + updateCriterion, + removeCriterion, + canDelete, + errors, +}) => { + const [isFieldPopoverOpen, setIsFieldPopoverOpen] = useState(false); + const [isComparatorPopoverOpen, setIsComparatorPopoverOpen] = useState(false); + + const fieldOptions = useMemo(() => { + return fields.map(field => { + return { value: field.name, text: field.name }; + }); + }, [fields]); + + const fieldInfo: IFieldType | undefined = useMemo(() => { + return getFieldInfo(fields, criterion.field); + }, [fields, criterion]); + + const compatibleComparatorOptions = useMemo(() => { + return getCompatibleComparatorsForField(fieldInfo); + }, [fieldInfo]); + + const handleFieldChange = useCallback( + e => { + const fieldName = e.target.value; + const nextFieldInfo = getFieldInfo(fields, fieldName); + // If the field information we're dealing with has changed, reset the comparator and value. + if ( + fieldInfo && + nextFieldInfo && + (fieldInfo.type !== nextFieldInfo.type || + fieldInfo.aggregatable !== nextFieldInfo.aggregatable) + ) { + const compatibleComparators = getCompatibleComparatorsForField(nextFieldInfo); + updateCriterion(idx, { + field: fieldName, + comparator: compatibleComparators[0].value, + value: undefined, + }); + } else { + updateCriterion(idx, { field: fieldName }); + } + }, + [fieldInfo, fields, idx, updateCriterion] + ); + + return ( + + + + + setIsFieldPopoverOpen(true)} + /> + } + isOpen={isFieldPopoverOpen} + closePopover={() => setIsFieldPopoverOpen(false)} + ownFocus + panelPaddingSize="s" + anchorPosition="downLeft" + > +
+ {criterionFieldTitle} + 0} error={errors.field}> + + +
+
+
+ + setIsComparatorPopoverOpen(true)} + /> + } + isOpen={isComparatorPopoverOpen} + closePopover={() => setIsComparatorPopoverOpen(false)} + ownFocus + panelPaddingSize="s" + anchorPosition="downLeft" + > +
+ {criterionComparatorValueTitle} + + + 0} error={errors.comparator}> + + updateCriterion(idx, { comparator: e.target.value as Comparator }) + } + options={compatibleComparatorOptions} + /> + + + + 0} error={errors.value}> + {fieldInfo?.type === 'number' ? ( + { + const number = parseInt(e.target.value, 10); + updateCriterion(idx, { value: number ? number : undefined }); + }} + /> + ) : ( + updateCriterion(idx, { value: e.target.value })} + /> + )} + + + +
+
+
+
+
+ {canDelete && ( + + removeCriterion(idx)} + /> + + )} +
+ ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/document_count.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/document_count.tsx new file mode 100644 index 0000000000000..308165ce08a9b --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/document_count.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiPopoverTitle, + EuiFlexItem, + EuiFlexGroup, + EuiPopover, + EuiSelect, + EuiFieldNumber, + EuiExpression, + EuiFormRow, +} from '@elastic/eui'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../../triggers_actions_ui/public/types'; +import { + Comparator, + ComparatorToi18nMap, + LogDocumentCountAlertParams, +} from '../../../../../common/alerting/logs/types'; + +const documentCountPrefix = i18n.translate('xpack.infra.logs.alertFlyout.documentCountPrefix', { + defaultMessage: 'when', +}); + +const getComparatorOptions = (): Array<{ + value: Comparator; + text: string; +}> => { + return [ + { value: Comparator.LT, text: ComparatorToi18nMap[Comparator.LT] }, + { value: Comparator.LT_OR_EQ, text: ComparatorToi18nMap[Comparator.LT_OR_EQ] }, + { value: Comparator.GT, text: ComparatorToi18nMap[Comparator.GT] }, + { value: Comparator.GT_OR_EQ, text: ComparatorToi18nMap[Comparator.GT_OR_EQ] }, + ]; +}; + +interface Props { + comparator?: Comparator; + value?: number; + updateCount: (params: Partial) => void; + errors: IErrorObject; +} + +export const DocumentCount: React.FC = ({ comparator, value, updateCount, errors }) => { + const [isComparatorPopoverOpen, setComparatorPopoverOpenState] = useState(false); + const [isValuePopoverOpen, setIsValuePopoverOpen] = useState(false); + + const documentCountValue = i18n.translate('xpack.infra.logs.alertFlyout.documentCountValue', { + defaultMessage: '{value, plural, one {log entry} other {log entries}}', + values: { value }, + }); + + return ( + + + setComparatorPopoverOpenState(true)} + /> + } + isOpen={isComparatorPopoverOpen} + closePopover={() => setComparatorPopoverOpenState(false)} + ownFocus + panelPaddingSize="s" + anchorPosition="downLeft" + > +
+ {documentCountPrefix} + updateCount({ comparator: e.target.value as Comparator })} + options={getComparatorOptions()} + /> +
+
+
+ + + setIsValuePopoverOpen(true)} + color={errors.value.length === 0 ? 'secondary' : 'danger'} + /> + } + isOpen={isValuePopoverOpen} + closePopover={() => setIsValuePopoverOpen(false)} + ownFocus + panelPaddingSize="s" + anchorPosition="downLeft" + > +
+ {documentCountValue} + 0} error={errors.value}> + { + const number = parseInt(e.target.value, 10); + updateCount({ value: number ? number : undefined }); + }} + /> + +
+
+
+
+ ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx new file mode 100644 index 0000000000000..3aed0db53bf2c --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useMemo, useEffect, useState } from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + ForLastExpression, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../triggers_actions_ui/public/common'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../../triggers_actions_ui/public/types'; +import { useSource } from '../../../../containers/source'; +import { + LogDocumentCountAlertParams, + Comparator, + TimeUnit, +} from '../../../../../common/alerting/logs/types'; +import { DocumentCount } from './document_count'; +import { Criteria } from './criteria'; + +export interface ExpressionCriteria { + field?: string; + comparator?: Comparator; + value?: string | number; +} + +interface Props { + errors: IErrorObject; + alertParams: Partial; + setAlertParams(key: string, value: any): void; + setAlertProperty(key: string, value: any): void; +} + +const DEFAULT_CRITERIA = { field: 'log.level', comparator: Comparator.EQ, value: 'error' }; + +const DEFAULT_EXPRESSION = { + count: { + value: 75, + comparator: Comparator.GT, + }, + criteria: [DEFAULT_CRITERIA], + timeSize: 5, + timeUnit: 'm', +}; + +export const ExpressionEditor: React.FC = props => { + const { setAlertParams, alertParams, errors } = props; + const { createDerivedIndexPattern } = useSource({ sourceId: 'default' }); + const [timeSize, setTimeSize] = useState(1); + const [timeUnit, setTimeUnit] = useState('m'); + const [hasSetDefaults, setHasSetDefaults] = useState(false); + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('logs'), [ + createDerivedIndexPattern, + ]); + + const supportedFields = useMemo(() => { + if (derivedIndexPattern?.fields) { + return derivedIndexPattern.fields.filter(field => { + return (field.type === 'string' || field.type === 'number') && field.searchable; + }); + } else { + return []; + } + }, [derivedIndexPattern]); + + // Set the default expression (disables exhaustive-deps as we only want to run this once on mount) + useEffect(() => { + for (const [key, value] of Object.entries(DEFAULT_EXPRESSION)) { + setAlertParams(key, value); + setHasSetDefaults(true); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const updateCount = useCallback( + countParams => { + const nextCountParams = { ...alertParams.count, ...countParams }; + setAlertParams('count', nextCountParams); + }, + [alertParams.count, setAlertParams] + ); + + const updateCriterion = useCallback( + (idx, criterionParams) => { + const nextCriteria = alertParams.criteria?.map((criterion, index) => { + return idx === index ? { ...criterion, ...criterionParams } : criterion; + }); + setAlertParams('criteria', nextCriteria ? nextCriteria : []); + }, + [alertParams, setAlertParams] + ); + + const updateTimeSize = useCallback( + (ts: number | undefined) => { + setTimeSize(ts || undefined); + setAlertParams('timeSize', ts); + }, + [setTimeSize, setAlertParams] + ); + + const updateTimeUnit = useCallback( + (tu: string) => { + setTimeUnit(tu as TimeUnit); + setAlertParams('timeUnit', tu); + }, + [setAlertParams] + ); + + const addCriterion = useCallback(() => { + const nextCriteria = alertParams?.criteria + ? [...alertParams.criteria, DEFAULT_CRITERIA] + : [DEFAULT_CRITERIA]; + setAlertParams('criteria', nextCriteria); + }, [alertParams, setAlertParams]); + + const removeCriterion = useCallback( + idx => { + const nextCriteria = alertParams?.criteria?.filter((criterion, index) => { + return index !== idx; + }); + setAlertParams('criteria', nextCriteria); + }, + [alertParams, setAlertParams] + ); + + // Wait until field info has loaded + if (supportedFields.length === 0) return null; + // Wait until the alert param defaults have been set + if (!hasSetDefaults) return null; + + return ( + <> + + + + + + +
+ + + +
+ + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/index.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/index.tsx new file mode 100644 index 0000000000000..8b0fd5eb721b3 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/index.tsx @@ -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 './editor'; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/log_threshold_alert_type.ts b/x-pack/plugins/infra/public/components/alerting/logs/log_threshold_alert_type.ts new file mode 100644 index 0000000000000..18126ec583696 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/log_threshold_alert_type.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 { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../../triggers_actions_ui/public/types'; +import { LOG_DOCUMENT_COUNT_ALERT_TYPE_ID } from '../../../../common/alerting/logs/types'; +import { ExpressionEditor } from './expression_editor'; +import { validateExpression } from './validation'; + +export function getAlertType(): AlertTypeModel { + return { + id: LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, + name: i18n.translate('xpack.infra.logs.alertFlyout.alertName', { + defaultMessage: 'Log threshold', + }), + iconClass: 'bell', + alertParamsExpression: ExpressionEditor, + validate: validateExpression, + defaultActionMessage: i18n.translate( + 'xpack.infra.logs.alerting.threshold.defaultActionMessage', + { + defaultMessage: `\\{\\{context.matchingDocuments\\}\\} log entries have matched the following conditions: \\{\\{context.conditions\\}\\}`, + } + ), + }; +} diff --git a/x-pack/plugins/infra/public/components/alerting/logs/validation.ts b/x-pack/plugins/infra/public/components/alerting/logs/validation.ts new file mode 100644 index 0000000000000..c8c513f57a9d7 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/validation.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ValidationResult } from '../../../../../triggers_actions_ui/public/types'; +import { LogDocumentCountAlertParams } from '../../../../common/alerting/logs/types'; + +export function validateExpression({ + count, + criteria, + timeSize, + timeUnit, +}: Partial): ValidationResult { + const validationResult = { errors: {} }; + + // NOTE: In the case of components provided by the Alerting framework the error property names + // must match what they expect. + const errors: { + count: { + value: string[]; + }; + criteria: { + [id: string]: { + field: string[]; + comparator: string[]; + value: string[]; + }; + }; + timeWindowSize: string[]; + timeSizeUnit: string[]; + } = { + count: { + value: [], + }, + criteria: {}, + timeSizeUnit: [], + timeWindowSize: [], + }; + + validationResult.errors = errors; + + // Document count validation + if (typeof count?.value !== 'number') { + errors.count.value.push( + i18n.translate('xpack.infra.logs.alertFlyout.error.documentCountRequired', { + defaultMessage: 'Document count is Required.', + }) + ); + } + + // Time validation + if (!timeSize) { + errors.timeWindowSize.push( + i18n.translate('xpack.infra.logs.alertFlyout.error.timeSizeRequired', { + defaultMessage: 'Time size is Required.', + }) + ); + } + + if (criteria && criteria.length > 0) { + // Criteria validation + criteria.forEach((criterion, idx: number) => { + const id = idx.toString(); + + errors.criteria[id] = { + field: [], + comparator: [], + value: [], + }; + + if (!criterion.field) { + errors.criteria[id].field.push( + i18n.translate('xpack.infra.logs.alertFlyout.error.criterionFieldRequired', { + defaultMessage: 'Field is required.', + }) + ); + } + + if (!criterion.comparator) { + errors.criteria[id].comparator.push( + i18n.translate('xpack.infra.logs.alertFlyout.error.criterionComparatorRequired', { + defaultMessage: 'Comparator is required.', + }) + ); + } + + if (!criterion.value) { + errors.criteria[id].value.push( + i18n.translate('xpack.infra.logs.alertFlyout.error.criterionValueRequired', { + defaultMessage: 'Value is required.', + }) + ); + } + }); + } + + return validationResult; +} diff --git a/x-pack/plugins/infra/public/hooks/use_link_props.tsx b/x-pack/plugins/infra/public/hooks/use_link_props.tsx index 8c522bb7fa764..dec8eaae56f41 100644 --- a/x-pack/plugins/infra/public/hooks/use_link_props.tsx +++ b/x-pack/plugins/infra/public/hooks/use_link_props.tsx @@ -26,12 +26,20 @@ interface LinkProps { onClick?: (e: React.MouseEvent | React.MouseEvent) => void; } -export const useLinkProps = ({ app, pathname, hash, search }: LinkDescriptor): LinkProps => { +interface Options { + hrefOnly?: boolean; +} + +export const useLinkProps = ( + { app, pathname, hash, search }: LinkDescriptor, + options: Options = {} +): LinkProps => { validateParams({ app, pathname, hash, search }); const { prompt } = useNavigationWarningPrompt(); const prefixer = usePrefixPathWithBasepath(); const navigateToApp = useKibana().services.application?.navigateToApp; + const { hrefOnly } = options; const encodedSearch = useMemo(() => { return search ? encodeSearch(search) : undefined; @@ -86,7 +94,10 @@ export const useLinkProps = ({ app, pathname, hash, search }: LinkDescriptor): L return { href, - onClick, + // Sometimes it may not be desirable to have onClick call "navigateToApp". + // E.g. the management section of Kibana cannot be successfully deeplinked to via + // "navigateToApp". In those cases we can choose to defer to legacy behaviour. + onClick: hrefOnly ? undefined : onClick, }; }; diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx index ed6f06deeef64..dc210406275d8 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { Route, Switch } from 'react-router-dom'; @@ -21,6 +22,7 @@ import { LogEntryCategoriesPage } from './log_entry_categories'; import { LogEntryRatePage } from './log_entry_rate'; import { LogsSettingsPage } from './settings'; import { StreamPage } from './stream'; +import { AlertDropdown } from '../../components/alerting/logs/alert_dropdown'; export const LogsPageContent: React.FunctionComponent = () => { const uiCapabilities = useKibana().services.application?.capabilities; @@ -65,13 +67,20 @@ export const LogsPageContent: React.FunctionComponent = () => { readOnlyBadge={!uiCapabilities?.logs?.save} /> - + + + + + + + + diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index 3b6647b9bfbbe..40366b2a54f24 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -21,7 +21,8 @@ import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/p import { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public'; -import { getAlertType } from './components/alerting/metrics/metric_threshold_alert_type'; +import { getAlertType as getMetricsAlertType } from './components/alerting/metrics/metric_threshold_alert_type'; +import { getAlertType as getLogsAlertType } from './components/alerting/logs/log_threshold_alert_type'; export type ClientSetup = void; export type ClientStart = void; @@ -52,7 +53,8 @@ export class Plugin setup(core: CoreSetup, pluginsSetup: ClientPluginsSetup) { registerFeatures(pluginsSetup.home); - pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getAlertType()); + pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getMetricsAlertType()); + pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getLogsAlertType()); core.application.register({ id: 'logs', diff --git a/x-pack/plugins/infra/server/graphql/sources/resolvers.ts b/x-pack/plugins/infra/server/graphql/sources/resolvers.ts index 1fe1431392a38..f880eca933241 100644 --- a/x-pack/plugins/infra/server/graphql/sources/resolvers.ts +++ b/x-pack/plugins/infra/server/graphql/sources/resolvers.ts @@ -93,7 +93,10 @@ export const createSourcesResolvers = ( } => ({ Query: { async source(root, args, { req }) { - const requestedSourceConfiguration = await libs.sources.getSourceConfiguration(req, args.id); + const requestedSourceConfiguration = await libs.sources.getSourceConfiguration( + req.core.savedObjects.client, + args.id + ); return requestedSourceConfiguration; }, diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts new file mode 100644 index 0000000000000..cdec04ab81a8e --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -0,0 +1,250 @@ +/* + * 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'; +import { AlertExecutorOptions, AlertServices } from '../../../../../alerting/server'; +import { + AlertStates, + Comparator, + LogDocumentCountAlertParams, + Criterion, +} from '../../../../common/alerting/logs/types'; +import { InfraBackendLibs } from '../../infra_types'; +import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; +import { InfraSource } from '../../../../common/http_api/source_api'; + +const checkValueAgainstComparatorMap: { + [key: string]: (a: number, b: number) => boolean; +} = { + [Comparator.GT]: (a: number, b: number) => a > b, + [Comparator.GT_OR_EQ]: (a: number, b: number) => a >= b, + [Comparator.LT]: (a: number, b: number) => a < b, + [Comparator.LT_OR_EQ]: (a: number, b: number) => a <= b, +}; + +export const createLogThresholdExecutor = (alertUUID: string, libs: InfraBackendLibs) => + async function({ services, params }: AlertExecutorOptions) { + const { count, criteria } = params as LogDocumentCountAlertParams; + const { alertInstanceFactory, savedObjectsClient, callCluster } = services; + const { sources } = libs; + + const sourceConfiguration = await sources.getSourceConfiguration(savedObjectsClient, 'default'); + const indexPattern = sourceConfiguration.configuration.logAlias; + + const alertInstance = alertInstanceFactory(alertUUID); + + try { + const query = getESQuery( + params as LogDocumentCountAlertParams, + sourceConfiguration.configuration + ); + const result = await getResults(query, indexPattern, callCluster); + + if (checkValueAgainstComparatorMap[count.comparator](result.count, count.value)) { + alertInstance.scheduleActions(FIRED_ACTIONS.id, { + matchingDocuments: result.count, + conditions: createConditionsMessage(criteria), + }); + + alertInstance.replaceState({ + alertState: AlertStates.ALERT, + }); + } else { + alertInstance.replaceState({ + alertState: AlertStates.OK, + }); + } + } catch (e) { + alertInstance.replaceState({ + alertState: AlertStates.ERROR, + }); + + throw new Error(e); + } + }; + +const getESQuery = ( + params: LogDocumentCountAlertParams, + sourceConfiguration: InfraSource['configuration'] +): object => { + const { timeSize, timeUnit, criteria } = params; + const interval = `${timeSize}${timeUnit}`; + const intervalAsSeconds = getIntervalInSeconds(interval); + const to = Date.now(); + const from = to - intervalAsSeconds * 1000; + + const rangeFilters = [ + { + range: { + [sourceConfiguration.fields.timestamp]: { + gte: from, + lte: to, + format: 'epoch_millis', + }, + }, + }, + ]; + + const positiveComparators = getPositiveComparators(); + const negativeComparators = getNegativeComparators(); + const positiveCriteria = criteria.filter(criterion => + positiveComparators.includes(criterion.comparator) + ); + const negativeCriteria = criteria.filter(criterion => + negativeComparators.includes(criterion.comparator) + ); + // Positive assertions (things that "must" match) + const mustFilters = buildFiltersForCriteria(positiveCriteria); + // Negative assertions (things that "must not" match) + const mustNotFilters = buildFiltersForCriteria(negativeCriteria); + + const query = { + query: { + bool: { + filter: [...rangeFilters], + ...(mustFilters.length > 0 && { must: mustFilters }), + ...(mustNotFilters.length > 0 && { must_not: mustNotFilters }), + }, + }, + }; + + return query; +}; + +type SupportedESQueryTypes = 'term' | 'match' | 'match_phrase' | 'range'; +type Filter = { + [key in SupportedESQueryTypes]?: object; +}; + +const buildFiltersForCriteria = (criteria: LogDocumentCountAlertParams['criteria']) => { + let filters: Filter[] = []; + + criteria.forEach(criterion => { + const criterionQuery = buildCriterionQuery(criterion); + if (criterionQuery) { + filters = [...filters, criterionQuery]; + } + }); + return filters; +}; + +const buildCriterionQuery = (criterion: Criterion): Filter | undefined => { + const { field, value, comparator } = criterion; + + const queryType = getQueryMappingForComparator(comparator); + + switch (queryType) { + case 'term': + return { + term: { + [field]: { + value, + }, + }, + }; + break; + case 'match': { + return { + match: { + [field]: value, + }, + }; + } + case 'match_phrase': { + return { + match_phrase: { + [field]: value, + }, + }; + } + case 'range': { + const comparatorToRangePropertyMapping: { + [key: string]: string; + } = { + [Comparator.LT]: 'lt', + [Comparator.LT_OR_EQ]: 'lte', + [Comparator.GT]: 'gt', + [Comparator.GT_OR_EQ]: 'gte', + }; + + const rangeProperty = comparatorToRangePropertyMapping[comparator]; + + return { + range: { + [field]: { + [rangeProperty]: value, + }, + }, + }; + } + default: { + return undefined; + } + } +}; + +const getPositiveComparators = () => { + return [ + Comparator.GT, + Comparator.GT_OR_EQ, + Comparator.LT, + Comparator.LT_OR_EQ, + Comparator.EQ, + Comparator.MATCH, + Comparator.MATCH_PHRASE, + ]; +}; + +const getNegativeComparators = () => { + return [Comparator.NOT_EQ, Comparator.NOT_MATCH, Comparator.NOT_MATCH_PHRASE]; +}; + +const queryMappings: { + [key: string]: string; +} = { + [Comparator.GT]: 'range', + [Comparator.GT_OR_EQ]: 'range', + [Comparator.LT]: 'range', + [Comparator.LT_OR_EQ]: 'range', + [Comparator.EQ]: 'term', + [Comparator.MATCH]: 'match', + [Comparator.MATCH_PHRASE]: 'match_phrase', + [Comparator.NOT_EQ]: 'term', + [Comparator.NOT_MATCH]: 'match', + [Comparator.NOT_MATCH_PHRASE]: 'match_phrase', +}; + +const getQueryMappingForComparator = (comparator: Comparator) => { + return queryMappings[comparator]; +}; + +const getResults = async ( + query: object, + index: string, + callCluster: AlertServices['callCluster'] +) => { + return await callCluster('count', { + body: query, + index, + }); +}; + +const createConditionsMessage = (criteria: LogDocumentCountAlertParams['criteria']) => { + const parts = criteria.map((criterion, index) => { + const { field, comparator, value } = criterion; + return `${index === 0 ? '' : 'and'} ${field} ${comparator} ${value}`; + }); + return parts.join(' '); +}; + +// When the Alerting plugin implements support for multiple action groups, add additional +// action groups here to send different messages, e.g. a recovery notification +export const FIRED_ACTIONS = { + id: 'logs.threshold.fired', + name: i18n.translate('xpack.infra.logs.alerting.threshold.fired', { + defaultMessage: 'Fired', + }), +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts new file mode 100644 index 0000000000000..04207a4233dfd --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import uuid from 'uuid'; +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import { PluginSetupContract } from '../../../../../alerting/server'; +import { createLogThresholdExecutor, FIRED_ACTIONS } from './log_threshold_executor'; +import { + LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, + Comparator, +} from '../../../../common/alerting/logs/types'; +import { InfraBackendLibs } from '../../infra_types'; + +const documentCountActionVariableDescription = i18n.translate( + 'xpack.infra.logs.alerting.threshold.documentCountActionVariableDescription', + { + defaultMessage: 'The number of log entries that matched the conditions provided', + } +); + +const conditionsActionVariableDescription = i18n.translate( + 'xpack.infra.logs.alerting.threshold.conditionsActionVariableDescription', + { + defaultMessage: 'The conditions that log entries needed to fulfill', + } +); + +const countSchema = schema.object({ + value: schema.number(), + comparator: schema.oneOf([ + schema.literal(Comparator.GT), + schema.literal(Comparator.LT), + schema.literal(Comparator.GT_OR_EQ), + schema.literal(Comparator.LT_OR_EQ), + schema.literal(Comparator.EQ), + ]), +}); + +const criteriaSchema = schema.object({ + field: schema.string(), + comparator: schema.oneOf([ + schema.literal(Comparator.GT), + schema.literal(Comparator.LT), + schema.literal(Comparator.GT_OR_EQ), + schema.literal(Comparator.LT_OR_EQ), + schema.literal(Comparator.EQ), + schema.literal(Comparator.NOT_EQ), + schema.literal(Comparator.MATCH), + schema.literal(Comparator.NOT_MATCH), + ]), + value: schema.oneOf([schema.number(), schema.string()]), +}); + +export async function registerLogThresholdAlertType( + alertingPlugin: PluginSetupContract, + libs: InfraBackendLibs +) { + if (!alertingPlugin) { + throw new Error( + 'Cannot register log threshold alert type. Both the actions and alerting plugins need to be enabled.' + ); + } + + const alertUUID = uuid.v4(); + + alertingPlugin.registerType({ + id: LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, + name: 'Log threshold', + validate: { + params: schema.object({ + count: countSchema, + criteria: schema.arrayOf(criteriaSchema), + timeUnit: schema.string(), + timeSize: schema.number(), + }), + }, + defaultActionGroupId: FIRED_ACTIONS.id, + actionGroups: [FIRED_ACTIONS], + executor: createLogThresholdExecutor(alertUUID, libs), + actionVariables: { + context: [ + { name: 'matchingDocuments', description: documentCountActionVariableDescription }, + { name: 'conditions', description: conditionsActionVariableDescription }, + ], + }, + }); +} diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index b697af4fa4c3b..3415ae9873bfb 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { PluginSetupContract } from '../../../../../alerting/server'; import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api/metrics_explorer'; import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; +import { InfraBackendLibs } from '../../infra_types'; import { METRIC_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types'; const oneOfLiterals = (arrayOfLiterals: Readonly) => @@ -17,7 +18,10 @@ const oneOfLiterals = (arrayOfLiterals: Readonly) => arrayOfLiterals.includes(value) ? undefined : `must be one of ${arrayOfLiterals.join(' | ')}`, }); -export async function registerMetricThresholdAlertType(alertingPlugin: PluginSetupContract) { +export async function registerMetricThresholdAlertType( + alertingPlugin: PluginSetupContract, + libs: InfraBackendLibs +) { if (!alertingPlugin) { throw new Error( 'Cannot register metric threshold alert type. Both the actions and alerting plugins need to be enabled.' diff --git a/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts b/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts index 6ec6f31256b78..9760873ff7478 100644 --- a/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts @@ -6,13 +6,15 @@ import { PluginSetupContract } from '../../../../alerting/server'; import { registerMetricThresholdAlertType } from './metric_threshold/register_metric_threshold_alert_type'; +import { registerLogThresholdAlertType } from './log_threshold/register_log_threshold_alert_type'; +import { InfraBackendLibs } from '../infra_types'; -const registerAlertTypes = (alertingPlugin: PluginSetupContract) => { +const registerAlertTypes = (alertingPlugin: PluginSetupContract, libs: InfraBackendLibs) => { if (alertingPlugin) { - const registerFns = [registerMetricThresholdAlertType]; + const registerFns = [registerMetricThresholdAlertType, registerLogThresholdAlertType]; registerFns.forEach(fn => { - fn(alertingPlugin); + fn(alertingPlugin, libs); }); } }; diff --git a/x-pack/plugins/infra/server/lib/domains/fields_domain.ts b/x-pack/plugins/infra/server/lib/domains/fields_domain.ts index d2e151ca2c3f5..b6837e5b769a6 100644 --- a/x-pack/plugins/infra/server/lib/domains/fields_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/fields_domain.ts @@ -21,7 +21,7 @@ export class InfraFieldsDomain { indexType: InfraIndexType ): Promise { const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const includeMetricIndices = [InfraIndexType.ANY, InfraIndexType.METRICS].includes(indexType); diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index 1e84a8c016c87..07bc965dda77a 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -113,7 +113,7 @@ export class InfraLogEntriesDomain { params: LogEntriesParams ): Promise { const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); @@ -172,7 +172,7 @@ export class InfraLogEntriesDomain { filterQuery?: LogEntryQuery ): Promise { const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const dateRangeBuckets = await this.adapter.getContainedLogSummaryBuckets( @@ -196,7 +196,7 @@ export class InfraLogEntriesDomain { filterQuery?: LogEntryQuery ): Promise { const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const messageFormattingRules = compileFormattingRules( diff --git a/x-pack/plugins/infra/server/lib/source_status.ts b/x-pack/plugins/infra/server/lib/source_status.ts index 1f0845b6b223f..9bb953845e5a1 100644 --- a/x-pack/plugins/infra/server/lib/source_status.ts +++ b/x-pack/plugins/infra/server/lib/source_status.ts @@ -18,7 +18,7 @@ export class InfraSourceStatus { sourceId: string ): Promise { const sourceConfiguration = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const indexNames = await this.adapter.getIndexNames( @@ -32,7 +32,7 @@ export class InfraSourceStatus { sourceId: string ): Promise { const sourceConfiguration = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const indexNames = await this.adapter.getIndexNames( @@ -46,7 +46,7 @@ export class InfraSourceStatus { sourceId: string ): Promise { const sourceConfiguration = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const hasAlias = await this.adapter.hasAlias( @@ -60,7 +60,7 @@ export class InfraSourceStatus { sourceId: string ): Promise { const sourceConfiguration = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const hasAlias = await this.adapter.hasAlias( @@ -74,7 +74,7 @@ export class InfraSourceStatus { sourceId: string ): Promise { const sourceConfiguration = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const hasIndices = await this.adapter.hasIndices( @@ -88,7 +88,7 @@ export class InfraSourceStatus { sourceId: string ): Promise { const sourceConfiguration = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const hasIndices = await this.adapter.hasIndices( diff --git a/x-pack/plugins/infra/server/lib/sources/sources.test.ts b/x-pack/plugins/infra/server/lib/sources/sources.test.ts index 4a83ca730ff83..57efb0f676b2f 100644 --- a/x-pack/plugins/infra/server/lib/sources/sources.test.ts +++ b/x-pack/plugins/infra/server/lib/sources/sources.test.ts @@ -29,7 +29,9 @@ describe('the InfraSources lib', () => { }, }); - expect(await sourcesLib.getSourceConfiguration(request, 'TEST_ID')).toMatchObject({ + expect( + await sourcesLib.getSourceConfiguration(request.core.savedObjects.client, 'TEST_ID') + ).toMatchObject({ id: 'TEST_ID', version: 'foo', updatedAt: 946684800000, @@ -74,7 +76,9 @@ describe('the InfraSources lib', () => { }, }); - expect(await sourcesLib.getSourceConfiguration(request, 'TEST_ID')).toMatchObject({ + expect( + await sourcesLib.getSourceConfiguration(request.core.savedObjects.client, 'TEST_ID') + ).toMatchObject({ id: 'TEST_ID', version: 'foo', updatedAt: 946684800000, @@ -104,7 +108,9 @@ describe('the InfraSources lib', () => { attributes: {}, }); - expect(await sourcesLib.getSourceConfiguration(request, 'TEST_ID')).toMatchObject({ + expect( + await sourcesLib.getSourceConfiguration(request.core.savedObjects.client, 'TEST_ID') + ).toMatchObject({ id: 'TEST_ID', version: 'foo', updatedAt: 946684800000, diff --git a/x-pack/plugins/infra/server/lib/sources/sources.ts b/x-pack/plugins/infra/server/lib/sources/sources.ts index 99e062aa49ccf..0368c7bfd6db8 100644 --- a/x-pack/plugins/infra/server/lib/sources/sources.ts +++ b/x-pack/plugins/infra/server/lib/sources/sources.ts @@ -9,7 +9,7 @@ import { failure } from 'io-ts/lib/PathReporter'; import { identity, constant } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; import { map, fold } from 'fp-ts/lib/Either'; -import { RequestHandlerContext } from 'src/core/server'; +import { RequestHandlerContext, SavedObjectsClientContract } from 'src/core/server'; import { defaultSourceConfiguration } from './defaults'; import { NotFoundError } from './errors'; import { infraSourceConfigurationSavedObjectType } from './saved_object_mappings'; @@ -37,7 +37,7 @@ export class InfraSources { } public async getSourceConfiguration( - requestContext: RequestHandlerContext, + savedObjectsClient: SavedObjectsClientContract, sourceId: string ): Promise { const staticDefaultSourceConfiguration = await this.getStaticDefaultSourceConfiguration(); @@ -55,7 +55,7 @@ export class InfraSources { })) .catch(err => err instanceof NotFoundError - ? this.getSavedSourceConfiguration(requestContext, sourceId).then(result => ({ + ? this.getSavedSourceConfiguration(savedObjectsClient, sourceId).then(result => ({ ...result, configuration: mergeSourceConfiguration( staticDefaultSourceConfiguration, @@ -65,7 +65,7 @@ export class InfraSources { : Promise.reject(err) ) .catch(err => - requestContext.core.savedObjects.client.errors.isNotFoundError(err) + savedObjectsClient.errors.isNotFoundError(err) ? Promise.resolve({ id: sourceId, version: undefined, @@ -136,7 +136,10 @@ export class InfraSources { ) { const staticDefaultSourceConfiguration = await this.getStaticDefaultSourceConfiguration(); - const { configuration, version } = await this.getSourceConfiguration(requestContext, sourceId); + const { configuration, version } = await this.getSourceConfiguration( + requestContext.core.savedObjects.client, + sourceId + ); const updatedSourceConfigurationAttributes = mergeSourceConfiguration( configuration, @@ -199,10 +202,10 @@ export class InfraSources { } private async getSavedSourceConfiguration( - requestContext: RequestHandlerContext, + savedObjectsClient: SavedObjectsClientContract, sourceId: string ) { - const savedObject = await requestContext.core.savedObjects.client.get( + const savedObject = await savedObjectsClient.get( infraSourceConfigurationSavedObjectType, sourceId ); diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index e3804078604cc..d4dfa60ac67a0 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -147,7 +147,7 @@ export class InfraServerPlugin { ]); initInfraServer(this.libs); - registerAlertTypes(plugins.alerting); + registerAlertTypes(plugins.alerting, this.libs); // Telemetry UsageCollector.registerUsageCollector(plugins.usageCollection); diff --git a/x-pack/plugins/infra/server/routes/inventory_metadata/index.ts b/x-pack/plugins/infra/server/routes/inventory_metadata/index.ts index 7e9b7ada28c8e..687e368736a41 100644 --- a/x-pack/plugins/infra/server/routes/inventory_metadata/index.ts +++ b/x-pack/plugins/infra/server/routes/inventory_metadata/index.ts @@ -39,7 +39,7 @@ export const initInventoryMetaRoute = (libs: InfraBackendLibs) => { ); const { configuration } = await libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const awsMetadata = await getCloudMetadata( diff --git a/x-pack/plugins/infra/server/routes/log_entries/item.ts b/x-pack/plugins/infra/server/routes/log_entries/item.ts index 3a6bdaf3804e3..85dba8f598a89 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/item.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/item.ts @@ -37,8 +37,9 @@ export const initLogEntriesItemRoute = ({ framework, sources, logEntries }: Infr ); const { id, sourceId } = payload; - const sourceConfiguration = (await sources.getSourceConfiguration(requestContext, sourceId)) - .configuration; + const sourceConfiguration = ( + await sources.getSourceConfiguration(requestContext.core.savedObjects.client, sourceId) + ).configuration; const logEntry = await logEntries.getLogItem(requestContext, id, sourceConfiguration); diff --git a/x-pack/plugins/infra/server/routes/metadata/index.ts b/x-pack/plugins/infra/server/routes/metadata/index.ts index c45f191b1130d..fe142aa93dcda 100644 --- a/x-pack/plugins/infra/server/routes/metadata/index.ts +++ b/x-pack/plugins/infra/server/routes/metadata/index.ts @@ -44,7 +44,7 @@ export const initMetadataRoute = (libs: InfraBackendLibs) => { ); const { configuration } = await libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const metricsMetadata = await getMetricMetadata( diff --git a/x-pack/plugins/infra/server/routes/node_details/index.ts b/x-pack/plugins/infra/server/routes/node_details/index.ts index 36906f6f4125b..a457ccac2416c 100644 --- a/x-pack/plugins/infra/server/routes/node_details/index.ts +++ b/x-pack/plugins/infra/server/routes/node_details/index.ts @@ -37,7 +37,10 @@ export const initNodeDetailsRoute = (libs: InfraBackendLibs) => { NodeDetailsRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const source = await libs.sources.getSourceConfiguration(requestContext, sourceId); + const source = await libs.sources.getSourceConfiguration( + requestContext.core.savedObjects.client, + sourceId + ); UsageCollector.countNode(nodeType); diff --git a/x-pack/plugins/infra/server/routes/snapshot/index.ts b/x-pack/plugins/infra/server/routes/snapshot/index.ts index e45b9884967d0..d1dc03893a0d9 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/index.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/index.ts @@ -42,7 +42,10 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => { SnapshotRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const source = await libs.sources.getSourceConfiguration(requestContext, sourceId); + const source = await libs.sources.getSourceConfiguration( + requestContext.core.savedObjects.client, + sourceId + ); UsageCollector.countNode(nodeType); const options = { filterQuery: parseFilterQuery(filterQuery), diff --git a/x-pack/plugins/infra/server/routes/source/index.ts b/x-pack/plugins/infra/server/routes/source/index.ts index 2f29320d7bb81..62b7fd7ba902f 100644 --- a/x-pack/plugins/infra/server/routes/source/index.ts +++ b/x-pack/plugins/infra/server/routes/source/index.ts @@ -37,7 +37,10 @@ export const initSourceRoute = (libs: InfraBackendLibs) => { try { const { type, sourceId } = request.params; - const source = await libs.sources.getSourceConfiguration(requestContext, sourceId); + const source = await libs.sources.getSourceConfiguration( + requestContext.core.savedObjects.client, + sourceId + ); if (!source) { return response.notFound(); } diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts index 17509571f1985..a7d4e36d16f2a 100644 --- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts +++ b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts @@ -26,7 +26,7 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { const mockInput: DatasourceInput = { type: 'test-logs', enabled: true, - config: { + vars: { inputVar: { value: 'input-value' }, inputVar2: { value: undefined }, inputVar3: { @@ -40,11 +40,11 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { id: 'test-logs-foo', enabled: true, dataset: 'foo', - config: { + vars: { fooVar: { value: 'foo-value' }, fooVar2: { value: [1, 2] }, }, - pkg_stream: { + agent_stream: { fooKey: 'fooValue1', fooKey2: ['fooValue2'], }, @@ -53,7 +53,7 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { id: 'test-logs-bar', enabled: true, dataset: 'bar', - config: { + vars: { barVar: { value: 'bar-value' }, barVar2: { value: [1, 2] }, barVar3: { diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts index 9e09d3fa3153a..5deb33ccf10f1 100644 --- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts +++ b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts @@ -22,18 +22,28 @@ export const storedDatasourceToAgentDatasource = ( .map(input => { const fullInput = { ...input, + ...Object.entries(input.config || {}).reduce((acc, [key, { value }]) => { + acc[key] = value; + return acc; + }, {} as { [k: string]: any }), streams: input.streams .filter(stream => stream.enabled) .map(stream => { const fullStream = { ...stream, - ...stream.pkg_stream, + ...stream.agent_stream, + ...Object.entries(stream.config || {}).reduce((acc, [key, { value }]) => { + acc[key] = value; + return acc; + }, {} as { [k: string]: any }), }; - delete fullStream.pkg_stream; + delete fullStream.agent_stream; + delete fullStream.vars; delete fullStream.config; return fullStream; }), }; + delete fullInput.vars; delete fullInput.config; return fullInput; }), diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts index 5fa7af2dda79a..cb290e61b17e5 100644 --- a/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts +++ b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts @@ -132,7 +132,7 @@ describe('Ingest Manager - packageToConfig', () => { id: 'foo-foo', enabled: true, dataset: 'foo', - config: { 'var-name': { value: 'foo-var-value' } }, + vars: { 'var-name': { value: 'foo-var-value' } }, }, ], }, @@ -144,13 +144,13 @@ describe('Ingest Manager - packageToConfig', () => { id: 'bar-bar', enabled: true, dataset: 'bar', - config: { 'var-name': { type: 'text', value: 'bar-var-value' } }, + vars: { 'var-name': { type: 'text', value: 'bar-var-value' } }, }, { id: 'bar-bar2', enabled: true, dataset: 'bar2', - config: { 'var-name': { type: 'yaml', value: 'bar2-var-value' } }, + vars: { 'var-name': { type: 'yaml', value: 'bar2-var-value' } }, }, ], }, @@ -205,7 +205,7 @@ describe('Ingest Manager - packageToConfig', () => { { type: 'foo', enabled: true, - config: { + vars: { 'foo-input-var-name': { value: 'foo-input-var-value' }, 'foo-input2-var-name': { value: 'foo-input2-var-value' }, 'foo-input3-var-name': { value: undefined }, @@ -215,7 +215,7 @@ describe('Ingest Manager - packageToConfig', () => { id: 'foo-foo', enabled: true, dataset: 'foo', - config: { + vars: { 'var-name': { value: 'foo-var-value' }, }, }, @@ -224,7 +224,7 @@ describe('Ingest Manager - packageToConfig', () => { { type: 'bar', enabled: true, - config: { + vars: { 'bar-input-var-name': { value: ['value1', 'value2'] }, 'bar-input2-var-name': { value: 123456 }, }, @@ -233,7 +233,7 @@ describe('Ingest Manager - packageToConfig', () => { id: 'bar-bar', enabled: true, dataset: 'bar', - config: { + vars: { 'var-name': { value: 'bar-var-value' }, }, }, @@ -241,7 +241,7 @@ describe('Ingest Manager - packageToConfig', () => { id: 'bar-bar2', enabled: true, dataset: 'bar2', - config: { + vars: { 'var-name': { value: 'bar2-var-value' }, }, }, @@ -255,7 +255,7 @@ describe('Ingest Manager - packageToConfig', () => { id: 'with-disabled-streams-disabled', enabled: false, dataset: 'disabled', - config: { + vars: { 'var-name': { value: [] }, }, }, diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_config.ts b/x-pack/plugins/ingest_manager/common/services/package_to_config.ts index fa3479a69e39d..e7a912ddf1741 100644 --- a/x-pack/plugins/ingest_manager/common/services/package_to_config.ts +++ b/x-pack/plugins/ingest_manager/common/services/package_to_config.ts @@ -53,7 +53,7 @@ export const packageToConfigDatasourceInputs = (packageInfo: PackageInfo): Datas dataset: packageStream.dataset, }; if (packageStream.vars && packageStream.vars.length) { - stream.config = packageStream.vars.reduce(varsReducer, {}); + stream.vars = packageStream.vars.reduce(varsReducer, {}); } return stream; }) @@ -66,7 +66,7 @@ export const packageToConfigDatasourceInputs = (packageInfo: PackageInfo): Datas }; if (packageInput.vars && packageInput.vars.length) { - input.config = packageInput.vars.reduce(varsReducer, {}); + input.vars = packageInput.vars.reduce(varsReducer, {}); } inputs.push(input); diff --git a/x-pack/plugins/ingest_manager/common/types/models/datasource.ts b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts index 48243a12120f9..885e0a9316d79 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/datasource.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts @@ -23,7 +23,8 @@ export interface DatasourceInputStream { dataset: string; processors?: string[]; config?: DatasourceConfigRecord; - pkg_stream?: any; + vars?: DatasourceConfigRecord; + agent_stream?: any; } export interface DatasourceInput { @@ -31,6 +32,7 @@ export interface DatasourceInput { enabled: boolean; processors?: string[]; config?: DatasourceConfigRecord; + vars?: DatasourceConfigRecord; streams: DatasourceInputStream[]; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_config.tsx index 0e8763cb2d4c0..36e987d007679 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_config.tsx @@ -97,7 +97,7 @@ export const DatasourceInputConfig: React.FunctionComponent<{ {requiredVars.map(varDef => { const { name: varName, type: varType } = varDef; - const value = datasourceInput.config![varName].value; + const value = datasourceInput.vars![varName].value; return ( { updateDatasourceInput({ - config: { - ...datasourceInput.config, + vars: { + ...datasourceInput.vars, [varName]: { type: varType, value: newValue, @@ -114,7 +114,7 @@ export const DatasourceInputConfig: React.FunctionComponent<{ }, }); }} - errors={inputVarsValidationResults.config![varName]} + errors={inputVarsValidationResults.vars![varName]} forceShowErrors={forceShowErrors} /> @@ -141,7 +141,7 @@ export const DatasourceInputConfig: React.FunctionComponent<{ {isShowingAdvanced ? advancedVars.map(varDef => { const { name: varName, type: varType } = varDef; - const value = datasourceInput.config![varName].value; + const value = datasourceInput.vars![varName].value; return ( { updateDatasourceInput({ - config: { - ...datasourceInput.config, + vars: { + ...datasourceInput.vars, [varName]: { type: varType, value: newValue, @@ -158,7 +158,7 @@ export const DatasourceInputConfig: React.FunctionComponent<{ }, }); }} - errors={inputVarsValidationResults.config![varName]} + errors={inputVarsValidationResults.vars![varName]} forceShowErrors={forceShowErrors} /> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_panel.tsx index 6b0c68ccb7d3f..586fc6b1d4138 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_panel.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_panel.tsx @@ -158,7 +158,7 @@ export const DatasourceInputPanel: React.FunctionComponent<{ packageInputVars={packageInput.vars} datasourceInput={datasourceInput} updateDatasourceInput={updateDatasourceInput} - inputVarsValidationResults={{ config: inputValidationResults.config }} + inputVarsValidationResults={{ vars: inputValidationResults.vars }} forceShowErrors={forceShowErrors} /> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_stream_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_stream_config.tsx index 43e8f5a2c060d..7e32936a6fffa 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_stream_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_stream_config.tsx @@ -101,7 +101,7 @@ export const DatasourceInputStreamConfig: React.FunctionComponent<{ {requiredVars.map(varDef => { const { name: varName, type: varType } = varDef; - const value = datasourceInputStream.config![varName].value; + const value = datasourceInputStream.vars![varName].value; return ( { updateDatasourceInputStream({ - config: { - ...datasourceInputStream.config, + vars: { + ...datasourceInputStream.vars, [varName]: { type: varType, value: newValue, @@ -118,7 +118,7 @@ export const DatasourceInputStreamConfig: React.FunctionComponent<{ }, }); }} - errors={inputStreamValidationResults.config![varName]} + errors={inputStreamValidationResults.vars![varName]} forceShowErrors={forceShowErrors} /> @@ -145,7 +145,7 @@ export const DatasourceInputStreamConfig: React.FunctionComponent<{ {isShowingAdvanced ? advancedVars.map(varDef => { const { name: varName, type: varType } = varDef; - const value = datasourceInputStream.config![varName].value; + const value = datasourceInputStream.vars![varName].value; return ( { updateDatasourceInputStream({ - config: { - ...datasourceInputStream.config, + vars: { + ...datasourceInputStream.vars, [varName]: { type: varType, value: newValue, @@ -162,7 +162,7 @@ export const DatasourceInputStreamConfig: React.FunctionComponent<{ }, }); }} - errors={inputStreamValidationResults.config![varName]} + errors={inputStreamValidationResults.vars![varName]} forceShowErrors={forceShowErrors} /> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/services/validate_datasource.test.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/services/validate_datasource.test.ts index a45fabeb5ed6a..b970a7d222001 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/services/validate_datasource.test.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/services/validate_datasource.test.ts @@ -120,7 +120,7 @@ describe('Ingest Manager - validateDatasource()', () => { { type: 'foo', enabled: true, - config: { + vars: { 'foo-input-var-name': { value: 'foo-input-var-value', type: 'text' }, 'foo-input2-var-name': { value: 'foo-input2-var-value', type: 'text' }, 'foo-input3-var-name': { value: ['test'], type: 'text' }, @@ -130,14 +130,14 @@ describe('Ingest Manager - validateDatasource()', () => { id: 'foo-foo', dataset: 'foo', enabled: true, - config: { 'var-name': { value: 'test_yaml: value', type: 'yaml' } }, + vars: { 'var-name': { value: 'test_yaml: value', type: 'yaml' } }, }, ], }, { type: 'bar', enabled: true, - config: { + vars: { 'bar-input-var-name': { value: ['value1', 'value2'], type: 'text' }, 'bar-input2-var-name': { value: 'test', type: 'text' }, }, @@ -146,13 +146,13 @@ describe('Ingest Manager - validateDatasource()', () => { id: 'bar-bar', dataset: 'bar', enabled: true, - config: { 'var-name': { value: 'test_yaml: value', type: 'yaml' } }, + vars: { 'var-name': { value: 'test_yaml: value', type: 'yaml' } }, }, { id: 'bar-bar2', dataset: 'bar2', enabled: true, - config: { 'var-name': { value: undefined, type: 'text' } }, + vars: { 'var-name': { value: undefined, type: 'text' } }, }, ], }, @@ -169,7 +169,7 @@ describe('Ingest Manager - validateDatasource()', () => { id: 'with-disabled-streams-disabled', dataset: 'disabled', enabled: false, - config: { 'var-name': { value: undefined, type: 'text' } }, + vars: { 'var-name': { value: undefined, type: 'text' } }, }, { id: 'with-disabled-streams-disabled2', @@ -188,7 +188,7 @@ describe('Ingest Manager - validateDatasource()', () => { { type: 'foo', enabled: true, - config: { + vars: { 'foo-input-var-name': { value: undefined, type: 'text' }, 'foo-input2-var-name': { value: '', type: 'text' }, 'foo-input3-var-name': { value: [], type: 'text' }, @@ -198,14 +198,14 @@ describe('Ingest Manager - validateDatasource()', () => { id: 'foo-foo', dataset: 'foo', enabled: true, - config: { 'var-name': { value: 'invalidyaml: test\n foo bar:', type: 'yaml' } }, + vars: { 'var-name': { value: 'invalidyaml: test\n foo bar:', type: 'yaml' } }, }, ], }, { type: 'bar', enabled: true, - config: { + vars: { 'bar-input-var-name': { value: 'invalid value for multi', type: 'text' }, 'bar-input2-var-name': { value: undefined, type: 'text' }, }, @@ -214,13 +214,13 @@ describe('Ingest Manager - validateDatasource()', () => { id: 'bar-bar', dataset: 'bar', enabled: true, - config: { 'var-name': { value: ' \n\n', type: 'yaml' } }, + vars: { 'var-name': { value: ' \n\n', type: 'yaml' } }, }, { id: 'bar-bar2', dataset: 'bar2', enabled: true, - config: { 'var-name': { value: undefined, type: 'text' } }, + vars: { 'var-name': { value: undefined, type: 'text' } }, }, ], }, @@ -237,7 +237,7 @@ describe('Ingest Manager - validateDatasource()', () => { id: 'with-disabled-streams-disabled', dataset: 'disabled', enabled: false, - config: { + vars: { 'var-name': { value: 'invalid value but not checked due to not enabled', type: 'text', @@ -259,22 +259,22 @@ describe('Ingest Manager - validateDatasource()', () => { description: null, inputs: { foo: { - config: { + vars: { 'foo-input-var-name': null, 'foo-input2-var-name': null, 'foo-input3-var-name': null, }, - streams: { 'foo-foo': { config: { 'var-name': null } } }, + streams: { 'foo-foo': { vars: { 'var-name': null } } }, }, bar: { - config: { 'bar-input-var-name': null, 'bar-input2-var-name': null }, + vars: { 'bar-input-var-name': null, 'bar-input2-var-name': null }, streams: { - 'bar-bar': { config: { 'var-name': null } }, - 'bar-bar2': { config: { 'var-name': null } }, + 'bar-bar': { vars: { 'var-name': null } }, + 'bar-bar2': { vars: { 'var-name': null } }, }, }, 'with-disabled-streams': { - streams: { 'with-disabled-streams-disabled': { config: { 'var-name': null } } }, + streams: { 'with-disabled-streams-disabled': { vars: { 'var-name': null } } }, }, }, }; @@ -289,25 +289,25 @@ describe('Ingest Manager - validateDatasource()', () => { description: null, inputs: { foo: { - config: { + vars: { 'foo-input-var-name': null, 'foo-input2-var-name': ['foo-input2-var-name is required'], 'foo-input3-var-name': ['foo-input3-var-name is required'], }, - streams: { 'foo-foo': { config: { 'var-name': ['Invalid YAML format'] } } }, + streams: { 'foo-foo': { vars: { 'var-name': ['Invalid YAML format'] } } }, }, bar: { - config: { + vars: { 'bar-input-var-name': ['Invalid format'], 'bar-input2-var-name': ['bar-input2-var-name is required'], }, streams: { - 'bar-bar': { config: { 'var-name': ['var-name is required'] } }, - 'bar-bar2': { config: { 'var-name': null } }, + 'bar-bar': { vars: { 'var-name': ['var-name is required'] } }, + 'bar-bar2': { vars: { 'var-name': null } }, }, }, 'with-disabled-streams': { - streams: { 'with-disabled-streams-disabled': { config: { 'var-name': null } } }, + streams: { 'with-disabled-streams-disabled': { vars: { 'var-name': null } } }, }, }, }); @@ -336,25 +336,25 @@ describe('Ingest Manager - validateDatasource()', () => { description: null, inputs: { foo: { - config: { + vars: { 'foo-input-var-name': null, 'foo-input2-var-name': ['foo-input2-var-name is required'], 'foo-input3-var-name': ['foo-input3-var-name is required'], }, - streams: { 'foo-foo': { config: { 'var-name': null } } }, + streams: { 'foo-foo': { vars: { 'var-name': null } } }, }, bar: { - config: { + vars: { 'bar-input-var-name': ['Invalid format'], 'bar-input2-var-name': ['bar-input2-var-name is required'], }, streams: { - 'bar-bar': { config: { 'var-name': null } }, - 'bar-bar2': { config: { 'var-name': null } }, + 'bar-bar': { vars: { 'var-name': null } }, + 'bar-bar2': { vars: { 'var-name': null } }, }, }, 'with-disabled-streams': { - streams: { 'with-disabled-streams-disabled': { config: { 'var-name': null } } }, + streams: { 'with-disabled-streams-disabled': { vars: { 'var-name': null } } }, }, }, }); @@ -411,7 +411,7 @@ describe('Ingest Manager - validationHasErrors()', () => { it('returns true for stream validation results with errors', () => { expect( validationHasErrors({ - config: { foo: ['foo error'], bar: null }, + vars: { foo: ['foo error'], bar: null }, }) ).toBe(true); }); @@ -419,7 +419,7 @@ describe('Ingest Manager - validationHasErrors()', () => { it('returns false for stream validation results with no errors', () => { expect( validationHasErrors({ - config: { foo: null, bar: null }, + vars: { foo: null, bar: null }, }) ).toBe(false); }); @@ -427,14 +427,14 @@ describe('Ingest Manager - validationHasErrors()', () => { it('returns true for input validation results with errors', () => { expect( validationHasErrors({ - config: { foo: ['foo error'], bar: null }, - streams: { stream1: { config: { foo: null, bar: null } } }, + vars: { foo: ['foo error'], bar: null }, + streams: { stream1: { vars: { foo: null, bar: null } } }, }) ).toBe(true); expect( validationHasErrors({ - config: { foo: null, bar: null }, - streams: { stream1: { config: { foo: ['foo error'], bar: null } } }, + vars: { foo: null, bar: null }, + streams: { stream1: { vars: { foo: ['foo error'], bar: null } } }, }) ).toBe(true); }); @@ -442,8 +442,8 @@ describe('Ingest Manager - validationHasErrors()', () => { it('returns false for input validation results with no errors', () => { expect( validationHasErrors({ - config: { foo: null, bar: null }, - streams: { stream1: { config: { foo: null, bar: null } } }, + vars: { foo: null, bar: null }, + streams: { stream1: { vars: { foo: null, bar: null } } }, }) ).toBe(false); }); @@ -455,8 +455,8 @@ describe('Ingest Manager - validationHasErrors()', () => { description: null, inputs: { input1: { - config: { foo: null, bar: null }, - streams: { stream1: { config: { foo: null, bar: null } } }, + vars: { foo: null, bar: null }, + streams: { stream1: { vars: { foo: null, bar: null } } }, }, }, }) @@ -467,8 +467,8 @@ describe('Ingest Manager - validationHasErrors()', () => { description: null, inputs: { input1: { - config: { foo: ['foo error'], bar: null }, - streams: { stream1: { config: { foo: null, bar: null } } }, + vars: { foo: ['foo error'], bar: null }, + streams: { stream1: { vars: { foo: null, bar: null } } }, }, }, }) @@ -479,8 +479,8 @@ describe('Ingest Manager - validationHasErrors()', () => { description: null, inputs: { input1: { - config: { foo: null, bar: null }, - streams: { stream1: { config: { foo: ['foo error'], bar: null } } }, + vars: { foo: null, bar: null }, + streams: { stream1: { vars: { foo: ['foo error'], bar: null } } }, }, }, }) @@ -494,8 +494,8 @@ describe('Ingest Manager - validationHasErrors()', () => { description: null, inputs: { input1: { - config: { foo: null, bar: null }, - streams: { stream1: { config: { foo: null, bar: null } } }, + vars: { foo: null, bar: null }, + streams: { stream1: { vars: { foo: null, bar: null } } }, }, }, }) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/services/validate_datasource.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/services/validate_datasource.ts index 518e2bfc1af07..3a712b072dac1 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/services/validate_datasource.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/services/validate_datasource.ts @@ -21,7 +21,7 @@ type Errors = string[] | null; type ValidationEntry = Record; export interface DatasourceConfigValidationResults { - config?: ValidationEntry; + vars?: ValidationEntry; } export type DatasourceInputValidationResults = DatasourceConfigValidationResults & { @@ -77,12 +77,12 @@ export const validateDatasource = ( // Validate each datasource input with either its own config fields or streams datasource.inputs.forEach(input => { - if (!input.config && !input.streams) { + if (!input.vars && !input.streams) { return; } const inputValidationResults: DatasourceInputValidationResults = { - config: undefined, + vars: undefined, streams: {}, }; @@ -95,27 +95,27 @@ export const validateDatasource = ( ); // Validate input-level config fields - const inputConfigs = Object.entries(input.config || {}); + const inputConfigs = Object.entries(input.vars || {}); if (inputConfigs.length) { - inputValidationResults.config = inputConfigs.reduce((results, [name, configEntry]) => { + inputValidationResults.vars = inputConfigs.reduce((results, [name, configEntry]) => { results[name] = input.enabled ? validateDatasourceConfig(configEntry, inputVarsByName[name]) : null; return results; }, {} as ValidationEntry); } else { - delete inputValidationResults.config; + delete inputValidationResults.vars; } // Validate each input stream with config fields if (input.streams.length) { input.streams.forEach(stream => { - if (!stream.config) { + if (!stream.vars) { return; } const streamValidationResults: DatasourceConfigValidationResults = { - config: undefined, + vars: undefined, }; const streamVarsByName = ( @@ -130,7 +130,7 @@ export const validateDatasource = ( }, {} as Record); // Validate stream-level config fields - streamValidationResults.config = Object.entries(stream.config).reduce( + streamValidationResults.vars = Object.entries(stream.vars).reduce( (results, [name, configEntry]) => { results[name] = input.enabled && stream.enabled @@ -147,7 +147,7 @@ export const validateDatasource = ( delete inputValidationResults.streams; } - if (inputValidationResults.config || inputValidationResults.streams) { + if (inputValidationResults.vars || inputValidationResults.streams) { validationResults.inputs![input.type] = inputValidationResults; } }); diff --git a/x-pack/plugins/ingest_manager/server/saved_objects.ts b/x-pack/plugins/ingest_manager/server/saved_objects.ts index 0b130e7b70101..882258e859555 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects.ts @@ -201,6 +201,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { enabled: { type: 'boolean' }, processors: { type: 'keyword' }, config: { type: 'flattened' }, + vars: { type: 'flattened' }, streams: { type: 'nested', properties: { @@ -209,7 +210,8 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { dataset: { type: 'keyword' }, processors: { type: 'keyword' }, config: { type: 'flattened' }, - pkg_stream: { type: 'flattened' }, + agent_stream: { type: 'flattened' }, + vars: { type: 'flattened' }, }, }, }, diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.test.ts b/x-pack/plugins/ingest_manager/server/services/datasource.test.ts index 09c59998388d1..4cbbadce7f5bb 100644 --- a/x-pack/plugins/ingest_manager/server/services/datasource.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/datasource.test.ts @@ -47,7 +47,7 @@ describe('Datasource service', () => { id: 'dataset01', dataset: 'package.dataset1', enabled: true, - config: { + vars: { paths: { value: ['/var/log/set.log'], }, @@ -67,12 +67,12 @@ describe('Datasource service', () => { id: 'dataset01', dataset: 'package.dataset1', enabled: true, - config: { + vars: { paths: { value: ['/var/log/set.log'], }, }, - pkg_stream: { + agent_stream: { metricset: ['dataset1'], paths: ['/var/log/set.log'], type: 'log', @@ -93,7 +93,7 @@ describe('Datasource service', () => { { type: 'log', enabled: true, - config: { + vars: { paths: { value: ['/var/log/set.log'], }, @@ -113,7 +113,7 @@ describe('Datasource service', () => { { type: 'log', enabled: true, - config: { + vars: { paths: { value: ['/var/log/set.log'], }, @@ -123,7 +123,7 @@ describe('Datasource service', () => { id: 'dataset01', dataset: 'package.dataset1', enabled: true, - pkg_stream: { + agent_stream: { metricset: ['dataset1'], paths: ['/var/log/set.log'], type: 'log', diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.ts b/x-pack/plugins/ingest_manager/server/services/datasource.ts index f27252aaa9a84..804039cf508ba 100644 --- a/x-pack/plugins/ingest_manager/server/services/datasource.ts +++ b/x-pack/plugins/ingest_manager/server/services/datasource.ts @@ -229,7 +229,7 @@ async function _assignPackageStreamToStream( stream: DatasourceInputStream ) { if (!stream.enabled) { - return { ...stream, pkg_stream: undefined }; + return { ...stream, agent_stream: undefined }; } const dataset = getDataset(stream.dataset); const assetsData = await getAssetsDataForPackageKey(pkgInfo, _isAgentStream, dataset); @@ -241,18 +241,18 @@ async function _assignPackageStreamToStream( // Populate template variables from input config and stream config const data: { [k: string]: string | string[] } = {}; - if (input.config) { - for (const key of Object.keys(input.config)) { - data[key] = input.config[key].value; + if (input.vars) { + for (const key of Object.keys(input.vars)) { + data[key] = input.vars[key].value; } } - if (stream.config) { - for (const key of Object.keys(stream.config)) { - data[key] = stream.config[key].value; + if (stream.vars) { + for (const key of Object.keys(stream.vars)) { + data[key] = stream.vars[key].value; } } const yaml = safeLoad(createStream(data, pkgStream.buffer.toString())); - stream.pkg_stream = yaml; + stream.agent_stream = yaml; return { ...stream }; } diff --git a/x-pack/plugins/ingest_manager/server/types/models/datasource.ts b/x-pack/plugins/ingest_manager/server/types/models/datasource.ts index c0cfee8f231c9..e71016560f60c 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/datasource.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/datasource.ts @@ -6,6 +6,14 @@ import { schema } from '@kbn/config-schema'; export { Datasource, NewDatasource } from '../../../common'; +const ConfigRecordSchema = schema.recordOf( + schema.string(), + schema.object({ + type: schema.maybe(schema.string()), + value: schema.maybe(schema.any()), + }) +); + const DatasourceBaseSchema = { name: schema.string(), description: schema.maybe(schema.string()), @@ -25,6 +33,7 @@ const DatasourceBaseSchema = { type: schema.string(), enabled: schema.boolean(), processors: schema.maybe(schema.arrayOf(schema.string())), + vars: schema.maybe(ConfigRecordSchema), config: schema.maybe( schema.recordOf( schema.string(), @@ -40,6 +49,7 @@ const DatasourceBaseSchema = { enabled: schema.boolean(), dataset: schema.string(), processors: schema.maybe(schema.arrayOf(schema.string())), + vars: schema.maybe(ConfigRecordSchema), config: schema.maybe( schema.recordOf( schema.string(), diff --git a/x-pack/plugins/licensing/server/licensing.mocks.ts b/x-pack/plugins/licensing/server/licensing.mocks.ts index bed930819d6e4..c74c636187332 100644 --- a/x-pack/plugins/licensing/server/licensing.mocks.ts +++ b/x-pack/plugins/licensing/server/licensing.mocks.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { BehaviorSubject } from 'rxjs'; -import { LicensingPluginSetup } from './types'; +import { LicensingPluginSetup, LicensingPluginStart } from './types'; import { licenseMock } from '../common/licensing.mock'; +import { featureUsageMock } from './services/feature_usage_service.mock'; const createSetupMock = () => { const license = licenseMock.createLicense(); @@ -13,6 +14,7 @@ const createSetupMock = () => { license$: new BehaviorSubject(license), refresh: jest.fn(), createLicensePoller: jest.fn(), + featureUsage: featureUsageMock.createSetup(), }; mock.refresh.mockResolvedValue(license); mock.createLicensePoller.mockReturnValue({ @@ -23,7 +25,16 @@ const createSetupMock = () => { return mock; }; +const createStartMock = (): jest.Mocked => { + const mock = { + featureUsage: featureUsageMock.createStart(), + }; + + return mock; +}; + export const licensingMock = { createSetup: createSetupMock, + createStart: createStartMock, createLicense: licenseMock.createLicense, }; diff --git a/x-pack/plugins/licensing/server/mocks.ts b/x-pack/plugins/licensing/server/mocks.ts index d622e3f71eff5..154692a2fd197 100644 --- a/x-pack/plugins/licensing/server/mocks.ts +++ b/x-pack/plugins/licensing/server/mocks.ts @@ -4,15 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ import { BehaviorSubject } from 'rxjs'; -import { LicensingPluginSetup } from './types'; +import { LicensingPluginSetup, LicensingPluginStart } from './types'; import { licenseMock } from '../common/licensing.mock'; +import { featureUsageMock } from './services/feature_usage_service.mock'; -const createSetupMock = () => { +const createSetupMock = (): jest.Mocked => { const license = licenseMock.createLicense(); - const mock: jest.Mocked = { + const mock = { license$: new BehaviorSubject(license), refresh: jest.fn(), createLicensePoller: jest.fn(), + featureUsage: featureUsageMock.createSetup(), }; mock.refresh.mockResolvedValue(license); mock.createLicensePoller.mockReturnValue({ @@ -23,7 +25,16 @@ const createSetupMock = () => { return mock; }; +const createStartMock = (): jest.Mocked => { + const mock = { + featureUsage: featureUsageMock.createStart(), + }; + + return mock; +}; + export const licensingMock = { createSetup: createSetupMock, + createStart: createStartMock, ...licenseMock, }; diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts index 383245e6f4ee8..ee43ac0ce233c 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -20,12 +20,13 @@ import { } from 'src/core/server'; import { ILicense, PublicLicense, PublicFeatures } from '../common/types'; -import { LicensingPluginSetup } from './types'; +import { LicensingPluginSetup, LicensingPluginStart } from './types'; import { License } from '../common/license'; import { createLicenseUpdate } from '../common/license_update'; import { ElasticsearchError, RawLicense, RawFeatures } from './types'; import { registerRoutes } from './routes'; +import { FeatureUsageService } from './services'; import { LicenseConfigType } from './licensing_config'; import { createRouteHandlerContext } from './licensing_route_handler_context'; @@ -77,18 +78,19 @@ function sign({ * A plugin for fetching, refreshing, and receiving information about the license for the * current Kibana instance. */ -export class LicensingPlugin implements Plugin { +export class LicensingPlugin implements Plugin { private stop$ = new Subject(); private readonly logger: Logger; private readonly config$: Observable; private loggingSubscription?: Subscription; + private featureUsage = new FeatureUsageService(); constructor(private readonly context: PluginInitializerContext) { this.logger = this.context.logger.get(); this.config$ = this.context.config.create(); } - public async setup(core: CoreSetup) { + public async setup(core: CoreSetup<{}, LicensingPluginStart>) { this.logger.debug('Setting up Licensing plugin'); const config = await this.config$.pipe(take(1)).toPromise(); const pollingFrequency = config.api_polling_frequency; @@ -101,13 +103,14 @@ export class LicensingPlugin implements Plugin { core.http.registerRouteHandlerContext('licensing', createRouteHandlerContext(license$)); - registerRoutes(core.http.createRouter()); + registerRoutes(core.http.createRouter(), core.getStartServices); core.http.registerOnPreResponse(createOnPreResponseHandler(refresh, license$)); return { refresh, license$, createLicensePoller: this.createLicensePoller.bind(this), + featureUsage: this.featureUsage.setup(), }; } @@ -186,7 +189,11 @@ export class LicensingPlugin implements Plugin { return error.message; } - public async start(core: CoreStart) {} + public async start(core: CoreStart) { + return { + featureUsage: this.featureUsage.start(), + }; + } public stop() { this.stop$.next(); diff --git a/x-pack/plugins/licensing/server/routes/feature_usage.ts b/x-pack/plugins/licensing/server/routes/feature_usage.ts new file mode 100644 index 0000000000000..5fbfbc3f577b8 --- /dev/null +++ b/x-pack/plugins/licensing/server/routes/feature_usage.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IRouter, StartServicesAccessor } from 'src/core/server'; +import { LicensingPluginStart } from '../types'; + +export function registerFeatureUsageRoute( + router: IRouter, + getStartServices: StartServicesAccessor<{}, LicensingPluginStart> +) { + router.get( + { path: '/api/licensing/feature_usage', validate: false }, + async (context, request, response) => { + const [, , { featureUsage }] = await getStartServices(); + return response.ok({ + body: [...featureUsage.getLastUsages().entries()].reduce( + (res, [featureName, lastUsage]) => { + return { + ...res, + [featureName]: new Date(lastUsage).toISOString(), + }; + }, + {} + ), + }); + } + ); +} diff --git a/x-pack/plugins/licensing/server/routes/index.ts b/x-pack/plugins/licensing/server/routes/index.ts index 26b3bc6292dd6..2d073a92e507e 100644 --- a/x-pack/plugins/licensing/server/routes/index.ts +++ b/x-pack/plugins/licensing/server/routes/index.ts @@ -3,9 +3,16 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { IRouter } from 'src/core/server'; + +import { IRouter, StartServicesAccessor } from 'src/core/server'; +import { LicensingPluginStart } from '../types'; import { registerInfoRoute } from './info'; +import { registerFeatureUsageRoute } from './feature_usage'; -export function registerRoutes(router: IRouter) { +export function registerRoutes( + router: IRouter, + getStartServices: StartServicesAccessor<{}, LicensingPluginStart> +) { registerInfoRoute(router); + registerFeatureUsageRoute(router, getStartServices); } diff --git a/x-pack/plugins/licensing/server/services/feature_usage_service.mock.ts b/x-pack/plugins/licensing/server/services/feature_usage_service.mock.ts new file mode 100644 index 0000000000000..f247c6ffcb526 --- /dev/null +++ b/x-pack/plugins/licensing/server/services/feature_usage_service.mock.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + FeatureUsageService, + FeatureUsageServiceSetup, + FeatureUsageServiceStart, +} from './feature_usage_service'; + +const createSetupMock = (): jest.Mocked => { + const mock = { + register: jest.fn(), + }; + + return mock; +}; + +const createStartMock = (): jest.Mocked => { + const mock = { + notifyUsage: jest.fn(), + getLastUsages: jest.fn(), + }; + + return mock; +}; + +const createServiceMock = (): jest.Mocked> => { + const mock = { + setup: jest.fn(() => createSetupMock()), + start: jest.fn(() => createStartMock()), + }; + + return mock; +}; + +export const featureUsageMock = { + create: createServiceMock, + createSetup: createSetupMock, + createStart: createStartMock, +}; diff --git a/x-pack/plugins/licensing/server/services/feature_usage_service.test.ts b/x-pack/plugins/licensing/server/services/feature_usage_service.test.ts new file mode 100644 index 0000000000000..f0ef0dbec0b22 --- /dev/null +++ b/x-pack/plugins/licensing/server/services/feature_usage_service.test.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FeatureUsageService } from './feature_usage_service'; + +describe('FeatureUsageService', () => { + let service: FeatureUsageService; + + beforeEach(() => { + service = new FeatureUsageService(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const toObj = (map: ReadonlyMap): Record => + Object.fromEntries(map.entries()); + + describe('#setup', () => { + describe('#register', () => { + it('throws when registering the same feature twice', () => { + const setup = service.setup(); + setup.register('foo'); + expect(() => { + setup.register('foo'); + }).toThrowErrorMatchingInlineSnapshot(`"Feature 'foo' has already been registered."`); + }); + }); + }); + + describe('#start', () => { + describe('#notifyUsage', () => { + it('allows to notify a feature usage', () => { + const setup = service.setup(); + setup.register('feature'); + const start = service.start(); + start.notifyUsage('feature', 127001); + + expect(start.getLastUsages().get('feature')).toBe(127001); + }); + + it('can receive a Date object', () => { + const setup = service.setup(); + setup.register('feature'); + const start = service.start(); + + const usageTime = new Date(2015, 9, 21, 17, 54, 12); + start.notifyUsage('feature', usageTime); + expect(start.getLastUsages().get('feature')).toBe(usageTime.getTime()); + }); + + it('uses the current time when `usedAt` is unspecified', () => { + jest.spyOn(Date, 'now').mockReturnValue(42); + + const setup = service.setup(); + setup.register('feature'); + const start = service.start(); + start.notifyUsage('feature'); + + expect(start.getLastUsages().get('feature')).toBe(42); + }); + + it('throws when notifying for an unregistered feature', () => { + service.setup(); + const start = service.start(); + expect(() => { + start.notifyUsage('unregistered'); + }).toThrowErrorMatchingInlineSnapshot(`"Feature 'unregistered' is not registered."`); + }); + }); + + describe('#getLastUsages', () => { + it('returns the last usage for all used features', () => { + const setup = service.setup(); + setup.register('featureA'); + setup.register('featureB'); + const start = service.start(); + start.notifyUsage('featureA', 127001); + start.notifyUsage('featureB', 6666); + + expect(toObj(start.getLastUsages())).toEqual({ + featureA: 127001, + featureB: 6666, + }); + }); + + it('returns the last usage even after notifying for an older usage', () => { + const setup = service.setup(); + setup.register('featureA'); + const start = service.start(); + start.notifyUsage('featureA', 1000); + start.notifyUsage('featureA', 500); + + expect(toObj(start.getLastUsages())).toEqual({ + featureA: 1000, + }); + }); + + it('does not return entries for unused registered features', () => { + const setup = service.setup(); + setup.register('featureA'); + setup.register('featureB'); + const start = service.start(); + start.notifyUsage('featureA', 127001); + + expect(toObj(start.getLastUsages())).toEqual({ + featureA: 127001, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/licensing/server/services/feature_usage_service.ts b/x-pack/plugins/licensing/server/services/feature_usage_service.ts new file mode 100644 index 0000000000000..47ffe3a3d9f54 --- /dev/null +++ b/x-pack/plugins/licensing/server/services/feature_usage_service.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isDate } from 'lodash'; + +/** @public */ +export interface FeatureUsageServiceSetup { + /** + * Register a feature to be able to notify of it's usages using the {@link FeatureUsageServiceStart | service start contract}. + */ + register(featureName: string): void; +} + +/** @public */ +export interface FeatureUsageServiceStart { + /** + * Notify of a registered feature usage at given time. + * + * @param featureName - the name of the feature to notify usage of + * @param usedAt - Either a `Date` or an unix timestamp with ms. If not specified, it will be set to the current time. + */ + notifyUsage(featureName: string, usedAt?: Date | number): void; + /** + * Return a map containing last usage timestamp for all features. + * Features that were not used yet do not appear in the map. + */ + getLastUsages(): ReadonlyMap; +} + +export class FeatureUsageService { + private readonly features: string[] = []; + private readonly lastUsages = new Map(); + + public setup(): FeatureUsageServiceSetup { + return { + register: featureName => { + if (this.features.includes(featureName)) { + throw new Error(`Feature '${featureName}' has already been registered.`); + } + this.features.push(featureName); + }, + }; + } + + public start(): FeatureUsageServiceStart { + return { + notifyUsage: (featureName, usedAt = Date.now()) => { + if (!this.features.includes(featureName)) { + throw new Error(`Feature '${featureName}' is not registered.`); + } + if (isDate(usedAt)) { + usedAt = usedAt.getTime(); + } + const currentValue = this.lastUsages.get(featureName) ?? 0; + this.lastUsages.set(featureName, Math.max(usedAt, currentValue)); + }, + getLastUsages: () => new Map(this.lastUsages.entries()), + }; + } +} diff --git a/x-pack/plugins/licensing/server/services/index.ts b/x-pack/plugins/licensing/server/services/index.ts new file mode 100644 index 0000000000000..fc890dd3c927d --- /dev/null +++ b/x-pack/plugins/licensing/server/services/index.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 { + FeatureUsageService, + FeatureUsageServiceSetup, + FeatureUsageServiceStart, +} from './feature_usage_service'; diff --git a/x-pack/plugins/licensing/server/types.ts b/x-pack/plugins/licensing/server/types.ts index f46167a0d0a42..f11d9d5e69a58 100644 --- a/x-pack/plugins/licensing/server/types.ts +++ b/x-pack/plugins/licensing/server/types.ts @@ -6,6 +6,7 @@ import { Observable } from 'rxjs'; import { IClusterClient } from 'src/core/server'; import { ILicense, LicenseStatus, LicenseType } from '../common/types'; +import { FeatureUsageServiceSetup, FeatureUsageServiceStart } from './services'; export interface ElasticsearchError extends Error { status?: number; @@ -57,7 +58,6 @@ export interface LicensingPluginSetup { * Triggers licensing information re-fetch. */ refresh(): Promise; - /** * Creates a license poller to retrieve a license data with. * Allows a plugin to configure a cluster to retrieve data from at @@ -67,4 +67,16 @@ export interface LicensingPluginSetup { clusterClient: IClusterClient, pollingFrequency: number ) => { license$: Observable; refresh(): Promise }; + /** + * APIs to register licensed feature usage. + */ + featureUsage: FeatureUsageServiceSetup; +} + +/** @public */ +export interface LicensingPluginStart { + /** + * APIs to manage licensed feature usage. + */ + featureUsage: FeatureUsageServiceStart; } diff --git a/x-pack/plugins/ml/common/constants/settings.ts b/x-pack/plugins/ml/common/constants/settings.ts new file mode 100644 index 0000000000000..2df2ecd22e078 --- /dev/null +++ b/x-pack/plugins/ml/common/constants/settings.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 const FILE_DATA_VISUALIZER_MAX_FILE_SIZE = 'ml:fileDataVisualizerMaxFileSize'; diff --git a/x-pack/plugins/ml/common/types/ml_config.ts b/x-pack/plugins/ml/common/types/ml_config.ts deleted file mode 100644 index f2ddadccb2170..0000000000000 --- a/x-pack/plugins/ml/common/types/ml_config.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 { schema, TypeOf } from '@kbn/config-schema'; -import { MAX_FILE_SIZE } from '../constants/file_datavisualizer'; - -export const configSchema = schema.object({ - file_data_visualizer: schema.object({ - max_file_size: schema.string({ defaultValue: MAX_FILE_SIZE }), - }), -}); - -export type MlConfigType = TypeOf; diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index f1facd18b9da5..e9796fcbb0fe4 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -15,14 +15,10 @@ import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/p import { setDependencyCache, clearCache } from './util/dependency_cache'; import { setLicenseCache } from './license'; import { MlSetupDependencies, MlStartDependencies } from '../plugin'; -import { MlConfigType } from '../../common/types/ml_config'; import { MlRouter } from './routing'; -type MlDependencies = MlSetupDependencies & - MlStartDependencies & { - mlConfig: MlConfigType; - }; +type MlDependencies = MlSetupDependencies & MlStartDependencies; interface AppProps { coreStart: CoreStart; @@ -78,7 +74,6 @@ export const renderApp = ( http: coreStart.http, security: deps.security, urlGenerators: deps.share.urlGenerators, - mlConfig: deps.mlConfig, }); const mlLicense = setLicenseCache(deps.licensing); diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index 475e44af3669c..c65d872212ad6 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -15,7 +15,7 @@ import { LicenseManagementUIPluginSetup } from '../../../../../license_managemen interface StartPlugins { data: DataPublicPluginStart; - security: SecurityPluginSetup; + security?: SecurityPluginSetup; licenseManagement?: LicenseManagementUIPluginSetup; } export type StartServices = CoreStart & StartPlugins; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config_flyout.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config_flyout.tsx index 30fc74acbabf4..32b51c8b7d4ee 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config_flyout.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config_flyout.tsx @@ -55,9 +55,11 @@ export const FilebeatConfigFlyout: FC = ({ } = useMlKibana(); useEffect(() => { - security.authc.getCurrentUser().then(user => { - setUsername(user.username === undefined ? null : user.username); - }); + if (security !== undefined) { + security.authc.getCurrentUser().then(user => { + setUsername(user.username === undefined ? null : user.username); + }); + } }, []); useEffect(() => { diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts index 7b6464570e55c..7d966949624c1 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts @@ -9,11 +9,13 @@ import numeral from '@elastic/numeral'; import { ml } from '../../../../services/ml_api_service'; import { AnalysisResult, InputOverrides } from '../../../../../../common/types/file_datavisualizer'; import { + MAX_FILE_SIZE, MAX_FILE_SIZE_BYTES, ABSOLUTE_MAX_FILE_SIZE_BYTES, FILE_SIZE_DISPLAY_FORMAT, } from '../../../../../../common/constants/file_datavisualizer'; -import { getMlConfig } from '../../../../util/dependency_cache'; +import { getUiSettings } from '../../../../util/dependency_cache'; +import { FILE_DATA_VISUALIZER_MAX_FILE_SIZE } from '../../../../../../common/constants/settings'; const DEFAULT_LINES_TO_SAMPLE = 1000; const UPLOAD_SIZE_MB = 5; @@ -62,13 +64,13 @@ export function readFile(file: File) { } export function getMaxBytes() { - const maxFileSize = getMlConfig().file_data_visualizer.max_file_size; + const maxFileSize = getUiSettings().get(FILE_DATA_VISUALIZER_MAX_FILE_SIZE, MAX_FILE_SIZE); // @ts-ignore const maxBytes = numeral(maxFileSize.toUpperCase()).value(); if (maxBytes < MAX_FILE_SIZE_BYTES) { return MAX_FILE_SIZE_BYTES; } - return maxBytes < ABSOLUTE_MAX_FILE_SIZE_BYTES ? maxBytes : ABSOLUTE_MAX_FILE_SIZE_BYTES; + return maxBytes <= ABSOLUTE_MAX_FILE_SIZE_BYTES ? maxBytes : ABSOLUTE_MAX_FILE_SIZE_BYTES; } export function getMaxBytesFormatted() { diff --git a/x-pack/plugins/ml/public/application/management/index.ts b/x-pack/plugins/ml/public/application/management/index.ts index a6fe9e1d11953..6bc5c9b15074f 100644 --- a/x-pack/plugins/ml/public/application/management/index.ts +++ b/x-pack/plugins/ml/public/application/management/index.ts @@ -25,8 +25,11 @@ export function initManagementSection( ) { const licensing = pluginsSetup.licensing.license$.pipe(take(1)); licensing.subscribe(license => { - if (license.check(PLUGIN_ID, MINIMUM_FULL_LICENSE).state === 'valid') { - const management = pluginsSetup.management; + const management = pluginsSetup.management; + if ( + management !== undefined && + license.check(PLUGIN_ID, MINIMUM_FULL_LICENSE).state === 'valid' + ) { const mlSection = management.sections.register({ id: PLUGIN_ID, title: i18n.translate('xpack.ml.management.mlTitle', { diff --git a/x-pack/plugins/ml/public/application/util/dependency_cache.ts b/x-pack/plugins/ml/public/application/util/dependency_cache.ts index 934a0a5e9ae3a..356da38d5ad08 100644 --- a/x-pack/plugins/ml/public/application/util/dependency_cache.ts +++ b/x-pack/plugins/ml/public/application/util/dependency_cache.ts @@ -23,7 +23,6 @@ import { } from 'kibana/public'; import { SharePluginStart } from 'src/plugins/share/public'; import { SecurityPluginSetup } from '../../../../security/public'; -import { MlConfigType } from '../../../common/types/ml_config'; export interface DependencyCache { timefilter: DataPublicPluginSetup['query']['timefilter'] | null; @@ -40,10 +39,9 @@ export interface DependencyCache { savedObjectsClient: SavedObjectsClientContract | null; application: ApplicationStart | null; http: HttpStart | null; - security: SecurityPluginSetup | null; + security: SecurityPluginSetup | undefined | null; i18n: I18nStart | null; urlGenerators: SharePluginStart['urlGenerators'] | null; - mlConfig: MlConfigType | null; } const cache: DependencyCache = { @@ -64,7 +62,6 @@ const cache: DependencyCache = { security: null, i18n: null, urlGenerators: null, - mlConfig: null, }; export function setDependencyCache(deps: Partial) { @@ -85,7 +82,6 @@ export function setDependencyCache(deps: Partial) { cache.security = deps.security || null; cache.i18n = deps.i18n || null; cache.urlGenerators = deps.urlGenerators || null; - cache.mlConfig = deps.mlConfig || null; } export function getTimefilter() { @@ -206,13 +202,6 @@ export function getGetUrlGenerator() { return cache.urlGenerators.getUrlGenerator; } -export function getMlConfig() { - if (cache.mlConfig === null) { - throw new Error("mlConfig hasn't been initialized"); - } - return cache.mlConfig; -} - export function clearCache() { console.log('clearing dependency cache'); // eslint-disable-line no-console Object.keys(cache).forEach(k => { diff --git a/x-pack/plugins/ml/public/index.ts b/x-pack/plugins/ml/public/index.ts index 4697496270edf..8070f94a1264d 100755 --- a/x-pack/plugins/ml/public/index.ts +++ b/x-pack/plugins/ml/public/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializer, PluginInitializerContext } from 'kibana/public'; +import { PluginInitializer } from 'kibana/public'; import './index.scss'; import { MlPlugin, @@ -19,6 +19,6 @@ export const plugin: PluginInitializer< MlPluginStart, MlSetupDependencies, MlStartDependencies -> = (context: PluginInitializerContext) => new MlPlugin(context); +> = () => new MlPlugin(); export { MlPluginSetup, MlPluginStart }; diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index b51be4d248683..d37d1fd815eb5 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -5,13 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import { - Plugin, - CoreStart, - CoreSetup, - AppMountParameters, - PluginInitializerContext, -} from 'kibana/public'; +import { Plugin, CoreStart, CoreSetup, AppMountParameters } from 'kibana/public'; import { ManagementSetup } from 'src/plugins/management/public'; import { SharePluginStart } from 'src/plugins/share/public'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; @@ -25,26 +19,22 @@ import { LicenseManagementUIPluginSetup } from '../../license_management/public' import { setDependencyCache } from './application/util/dependency_cache'; import { PLUGIN_ID, PLUGIN_ICON } from '../common/constants/app'; import { registerFeature } from './register_feature'; -import { MlConfigType } from '../common/types/ml_config'; export interface MlStartDependencies { data: DataPublicPluginStart; share: SharePluginStart; } export interface MlSetupDependencies { - security: SecurityPluginSetup; + security?: SecurityPluginSetup; licensing: LicensingPluginSetup; - management: ManagementSetup; + management?: ManagementSetup; usageCollection: UsageCollectionSetup; licenseManagement?: LicenseManagementUIPluginSetup; home: HomePublicPluginSetup; } export class MlPlugin implements Plugin { - constructor(private readonly initializerContext: PluginInitializerContext) {} - setup(core: CoreSetup, pluginsSetup: MlSetupDependencies) { - const mlConfig = this.initializerContext.config.get(); core.application.register({ id: PLUGIN_ID, title: i18n.translate('xpack.ml.plugin.title', { @@ -67,7 +57,6 @@ export class MlPlugin implements Plugin { usageCollection: pluginsSetup.usageCollection, licenseManagement: pluginsSetup.licenseManagement, home: pluginsSetup.home, - mlConfig, }, { element: params.element, diff --git a/x-pack/plugins/ml/server/config.ts b/x-pack/plugins/ml/server/config.ts deleted file mode 100644 index 7cef6f17bbefb..0000000000000 --- a/x-pack/plugins/ml/server/config.ts +++ /dev/null @@ -1,15 +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 { PluginConfigDescriptor } from 'kibana/server'; -import { MlConfigType, configSchema } from '../common/types/ml_config'; - -export const config: PluginConfigDescriptor = { - exposeToBrowser: { - file_data_visualizer: true, - }, - schema: configSchema, -}; diff --git a/x-pack/plugins/ml/server/index.ts b/x-pack/plugins/ml/server/index.ts index 6e638d647a387..175c20bf49c94 100644 --- a/x-pack/plugins/ml/server/index.ts +++ b/x-pack/plugins/ml/server/index.ts @@ -9,5 +9,3 @@ import { MlServerPlugin } from './plugin'; export { MlPluginSetup, MlPluginStart } from './plugin'; export const plugin = (ctx: PluginInitializerContext) => new MlServerPlugin(ctx); - -export { config } from './config'; diff --git a/x-pack/plugins/ml/server/lib/register_settings.ts b/x-pack/plugins/ml/server/lib/register_settings.ts new file mode 100644 index 0000000000000..38b1f5e3fc083 --- /dev/null +++ b/x-pack/plugins/ml/server/lib/register_settings.ts @@ -0,0 +1,34 @@ +/* + * 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 { CoreSetup } from 'kibana/server'; +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import { FILE_DATA_VISUALIZER_MAX_FILE_SIZE } from '../../common/constants/settings'; +import { MAX_FILE_SIZE } from '../../common/constants/file_datavisualizer'; + +export function registerKibanaSettings(coreSetup: CoreSetup) { + coreSetup.uiSettings.register({ + [FILE_DATA_VISUALIZER_MAX_FILE_SIZE]: { + name: i18n.translate('xpack.ml.maxFileSizeSettingsName', { + defaultMessage: 'File Data Visualizer maximum file upload size', + }), + value: MAX_FILE_SIZE, + description: i18n.translate('xpack.ml.maxFileSizeSettingsDescription', { + defaultMessage: + 'Sets the file size limit when importing data in the File Data Visualizer. The highest supported value for this setting is 1GB.', + }), + category: ['Machine Learning'], + schema: schema.string(), + validation: { + regexString: '\\d+[mMgG][bB]', + message: i18n.translate('xpack.ml.maxFileSizeSettingsError', { + defaultMessage: 'Should be a valid data size. e.g. 200MB, 1GB', + }), + }, + }, + }); +} diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 67e80a3bc44c0..64f8eb4b0acd3 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -47,6 +47,7 @@ import { MlServerLicense } from './lib/license'; import { createSharedServices, SharedServices } from './shared_services'; import { userMlCapabilities, adminMlCapabilities } from '../common/types/capabilities'; import { setupCapabilitiesSwitcher } from './lib/capabilities'; +import { registerKibanaSettings } from './lib/register_settings'; declare module 'kibana/server' { interface RequestHandlerContext { @@ -122,6 +123,8 @@ export class MlServerPlugin implements Plugin initSampleDataSets(mlLicense, plugins), ]); diff --git a/x-pack/plugins/security/common/login_state.ts b/x-pack/plugins/security/common/login_state.ts index 4342e82d2f90b..fd2b1cb8d1cf7 100644 --- a/x-pack/plugins/security/common/login_state.ts +++ b/x-pack/plugins/security/common/login_state.ts @@ -6,15 +6,24 @@ import { LoginLayout } from './licensing'; +export interface LoginSelectorProvider { + type: string; + name: string; + usesLoginForm: boolean; + description?: string; + hint?: string; + icon?: string; +} + export interface LoginSelector { enabled: boolean; - providers: Array<{ type: string; name: string; description?: string }>; + providers: LoginSelectorProvider[]; } export interface LoginState { layout: LoginLayout; allowLogin: boolean; - showLoginForm: boolean; requiresSecureConnection: boolean; + loginHelp?: string; selector: LoginSelector; } diff --git a/x-pack/plugins/security/public/authentication/_index.scss b/x-pack/plugins/security/public/authentication/_index.scss deleted file mode 100644 index 0a423c00f0218..0000000000000 --- a/x-pack/plugins/security/public/authentication/_index.scss +++ /dev/null @@ -1,5 +0,0 @@ -// Component styles -@import './components/index'; - -// Login styles -@import './login/index'; diff --git a/x-pack/plugins/security/public/authentication/components/_index.scss b/x-pack/plugins/security/public/authentication/components/_index.scss deleted file mode 100644 index dfa258d523c5a..0000000000000 --- a/x-pack/plugins/security/public/authentication/components/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './authentication_state_page/index'; diff --git a/x-pack/plugins/security/public/authentication/components/authentication_state_page/_index.scss b/x-pack/plugins/security/public/authentication/components/authentication_state_page/_index.scss deleted file mode 100644 index f7cdd75143791..0000000000000 --- a/x-pack/plugins/security/public/authentication/components/authentication_state_page/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './authentication_state_page'; diff --git a/x-pack/plugins/security/public/authentication/components/authentication_state_page/authentication_state_page.tsx b/x-pack/plugins/security/public/authentication/components/authentication_state_page/authentication_state_page.tsx index aa30661129978..66176129407cd 100644 --- a/x-pack/plugins/security/public/authentication/components/authentication_state_page/authentication_state_page.tsx +++ b/x-pack/plugins/security/public/authentication/components/authentication_state_page/authentication_state_page.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import './_authentication_state_page.scss'; + import { EuiIcon, EuiSpacer, EuiTitle } from '@elastic/eui'; import React from 'react'; diff --git a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap index ecbdfedac1dd3..bbc6bfa1faddc 100644 --- a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap +++ b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap @@ -121,10 +121,15 @@ exports[`LoginPage enabled form state renders as expected 1`] = ` selector={ Object { "enabled": false, - "providers": Array [], + "providers": Array [ + Object { + "name": "basic1", + "type": "basic", + "usesLoginForm": true, + }, + ], } } - showLoginForm={true} /> `; @@ -155,10 +160,15 @@ exports[`LoginPage enabled form state renders as expected when info message is s selector={ Object { "enabled": false, - "providers": Array [], + "providers": Array [ + Object { + "name": "basic1", + "type": "basic", + "usesLoginForm": true, + }, + ], } } - showLoginForm={true} /> `; @@ -189,10 +199,55 @@ exports[`LoginPage enabled form state renders as expected when loginAssistanceMe selector={ Object { "enabled": false, - "providers": Array [], + "providers": Array [ + Object { + "name": "basic1", + "type": "basic", + "usesLoginForm": true, + }, + ], + } + } +/> +`; + +exports[`LoginPage enabled form state renders as expected when loginHelp is set 1`] = ` + `; @@ -279,10 +334,15 @@ exports[`LoginPage page renders as expected 1`] = ` selector={ Object { "enabled": false, - "providers": Array [], + "providers": Array [ + Object { + "name": "basic1", + "type": "basic", + "usesLoginForm": true, + }, + ], } } - showLoginForm={true} /> diff --git a/x-pack/plugins/security/public/authentication/login/_index.scss b/x-pack/plugins/security/public/authentication/login/_index.scss deleted file mode 100644 index 4dd2c0cabfb5e..0000000000000 --- a/x-pack/plugins/security/public/authentication/login/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './login_page'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap index 7b8283b7bec0e..072a025aa06a0 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap @@ -1,170 +1,91 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`LoginForm login selector renders as expected with login form 1`] = ` - - - Login w/SAML - - - - Login w/PKI - - - login help and back: Login Help 1`] = ` + +
- ―――   - -   ――― - - - -
- - } - labelType="label" + - - - - } - labelType="label" - > - - - - - - -
- + some help + +

+
+
`; -exports[`LoginForm login selector renders as expected without login form for providers with and without description 1`] = ` - - +
- Login w/SAML - - - + + some help + +

+
+ +`; + +exports[`LoginForm properly switches to login help: Login Help 1`] = ` + +
- - - - +

+ + some help + +

+
+
`; exports[`LoginForm renders as expected 1`] = ` - +
@@ -227,21 +148,32 @@ exports[`LoginForm renders as expected 1`] = ` value="" /> - + - - + + + + + +
diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.scss b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.scss new file mode 100644 index 0000000000000..6784052ef4337 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.scss @@ -0,0 +1,55 @@ +.secLoginCard { + display: block; + box-shadow: none; + padding: $euiSize; + text-align: left; + width: 100%; + + &:hover { + .secLoginCard__title { + text-decoration: underline; + } + } + + &:disabled { + pointer-events: none; + } + + &:not(.secLoginCard-isLoading):disabled { + .secLoginCard__title, + .secLoginCard__hint { + color: $euiColorMediumShade; + } + } + + &:focus { + border-color: transparent; + border-radius: $euiBorderRadius; + @include euiFocusRing; + + .secLoginCard__title { + text-decoration: underline; + } + + // Make the focus ring clean and without borders + + .secLoginCard { + border-color: transparent; + } + } + + + .secLoginCard { + border-top: $euiBorderThin; + } +} + +.secLoginCard__hint { + @include euiFontSizeXS; + color: $euiColorDarkShade; + margin-top: $euiSizeXS; +} + +.secLoginAssistanceMessage { + // This tightens up the layout if message is present + margin-top: -($euiSizeXXL + $euiSizeS); + padding: 0 $euiSize; +} diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx index c17c10a2c5148..4e172cdde0eed 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -5,12 +5,39 @@ */ import React from 'react'; +import ReactMarkdown from 'react-markdown'; import { act } from '@testing-library/react'; -import { EuiButton, EuiCallOut } from '@elastic/eui'; +import { EuiButton, EuiCallOut, EuiIcon } from '@elastic/eui'; import { mountWithIntl, nextTick, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { LoginForm } from './login_form'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { LoginForm, PageMode } from './login_form'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { ReactWrapper } from 'enzyme'; + +function expectPageMode(wrapper: ReactWrapper, mode: PageMode) { + const assertions: Array<[string, boolean]> = + mode === PageMode.Form + ? [ + ['loginForm', true], + ['loginSelector', false], + ['loginHelp', false], + ] + : mode === PageMode.Selector + ? [ + ['loginForm', false], + ['loginSelector', true], + ['loginHelp', false], + ] + : [ + ['loginForm', false], + ['loginSelector', false], + ['loginHelp', true], + ]; + for (const [selector, exists] of assertions) { + expect(findTestSubject(wrapper, selector).exists()).toBe(exists); + } +} describe('LoginForm', () => { beforeAll(() => { @@ -32,8 +59,10 @@ describe('LoginForm', () => { http={coreStartMock.http} notifications={coreStartMock.notifications} loginAssistanceMessage="" - showLoginForm={true} - selector={{ enabled: false, providers: [] }} + selector={{ + enabled: false, + providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }], + }} /> ) ).toMatchSnapshot(); @@ -41,20 +70,44 @@ describe('LoginForm', () => { it('renders an info message when provided.', () => { const coreStartMock = coreMock.createStart(); - const wrapper = shallowWithIntl( + const wrapper = mountWithIntl( ); + expectPageMode(wrapper, PageMode.Form); + expect(wrapper.find(EuiCallOut).props().title).toEqual('Hey this is an info message'); }); + it('renders `Need help?` link if login help text is provided.', () => { + const coreStartMock = coreMock.createStart(); + const wrapper = mountWithIntl( + + ); + + expectPageMode(wrapper, PageMode.Form); + + expect(findTestSubject(wrapper, 'loginHelpLink').text()).toEqual('Need help?'); + }); + it('renders an invalid credentials message', async () => { const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); coreStartMock.http.post.mockRejectedValue({ response: { status: 401 } }); @@ -64,11 +117,15 @@ describe('LoginForm', () => { http={coreStartMock.http} notifications={coreStartMock.notifications} loginAssistanceMessage="" - showLoginForm={true} - selector={{ enabled: false, providers: [] }} + selector={{ + enabled: false, + providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }], + }} /> ); + expectPageMode(wrapper, PageMode.Form); + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); wrapper.find(EuiButton).simulate('click'); @@ -92,11 +149,15 @@ describe('LoginForm', () => { http={coreStartMock.http} notifications={coreStartMock.notifications} loginAssistanceMessage="" - showLoginForm={true} - selector={{ enabled: false, providers: [] }} + selector={{ + enabled: false, + providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }], + }} /> ); + expectPageMode(wrapper, PageMode.Form); + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); wrapper.find(EuiButton).simulate('click'); @@ -121,11 +182,15 @@ describe('LoginForm', () => { http={coreStartMock.http} notifications={coreStartMock.notifications} loginAssistanceMessage="" - showLoginForm={true} - selector={{ enabled: false, providers: [] }} + selector={{ + enabled: false, + providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }], + }} /> ); + expectPageMode(wrapper, PageMode.Form); + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username1' } }); wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password1' } }); wrapper.find(EuiButton).simulate('click'); @@ -144,47 +209,125 @@ describe('LoginForm', () => { expect(wrapper.find(EuiCallOut).exists()).toBe(false); }); + it('properly switches to login help', async () => { + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + const wrapper = mountWithIntl( + + ); + + expectPageMode(wrapper, PageMode.Form); + expect(findTestSubject(wrapper, 'loginBackToSelector').exists()).toBe(false); + + // Going to login help. + findTestSubject(wrapper, 'loginHelpLink').simulate('click'); + wrapper.update(); + expectPageMode(wrapper, PageMode.LoginHelp); + + expect(findTestSubject(wrapper, 'loginHelp').find(ReactMarkdown)).toMatchSnapshot('Login Help'); + + // Going back to login form. + findTestSubject(wrapper, 'loginBackToLoginLink').simulate('click'); + wrapper.update(); + expectPageMode(wrapper, PageMode.Form); + expect(findTestSubject(wrapper, 'loginBackToSelector').exists()).toBe(false); + }); + describe('login selector', () => { - it('renders as expected with login form', async () => { + it('renders as expected with providers that use login form', async () => { const coreStartMock = coreMock.createStart(); + const wrapper = mountWithIntl( + + ); + + expectPageMode(wrapper, PageMode.Selector); + expect( - shallowWithIntl( - - ) - ).toMatchSnapshot(); + wrapper.find('.secLoginCard').map(card => { + const hint = card.find('.secLoginCard__hint'); + return { + title: card.find('p.secLoginCard__title').text(), + hint: hint.exists() ? hint.text() : '', + icon: card.find(EuiIcon).props().type, + }; + }) + ).toEqual([ + { title: 'Log in with basic/basic', hint: 'Basic hint', icon: 'logoElastic' }, + { title: 'Log in w/SAML', hint: '', icon: 'empty' }, + { title: 'Log in w/PKI', hint: 'PKI hint', icon: 'empty' }, + ]); }); - it('renders as expected without login form for providers with and without description', async () => { + it('renders as expected without providers that use login form', async () => { const coreStartMock = coreMock.createStart(); + const wrapper = mountWithIntl( + + ); + + expectPageMode(wrapper, PageMode.Selector); + expect( - shallowWithIntl( - - ) - ).toMatchSnapshot(); + wrapper.find('.secLoginCard').map(card => { + const hint = card.find('.secLoginCard__hint'); + return { + title: card.find('p.secLoginCard__title').text(), + hint: hint.exists() ? hint.text() : '', + icon: card.find(EuiIcon).props().type, + }; + }) + ).toEqual([ + { title: 'Login w/SAML', hint: 'SAML hint', icon: 'empty' }, + { title: 'Log in with pki/pki1', hint: '', icon: 'some-icon' }, + ]); }); it('properly redirects after successful login', async () => { @@ -203,17 +346,19 @@ describe('LoginForm', () => { http={coreStartMock.http} notifications={coreStartMock.notifications} loginAssistanceMessage="" - showLoginForm={true} selector={{ enabled: true, providers: [ - { type: 'saml', name: 'saml1', description: 'Login w/SAML' }, - { type: 'pki', name: 'pki1', description: 'Login w/PKI' }, + { type: 'basic', name: 'basic', usesLoginForm: true }, + { type: 'saml', name: 'saml1', description: 'Login w/SAML', usesLoginForm: false }, + { type: 'pki', name: 'pki1', description: 'Login w/PKI', usesLoginForm: false }, ], }} /> ); + expectPageMode(wrapper, PageMode.Selector); + wrapper.findWhere(node => node.key() === 'saml1').simulate('click'); await act(async () => { @@ -246,11 +391,18 @@ describe('LoginForm', () => { http={coreStartMock.http} notifications={coreStartMock.notifications} loginAssistanceMessage="" - showLoginForm={true} - selector={{ enabled: true, providers: [{ type: 'saml', name: 'saml1' }] }} + selector={{ + enabled: true, + providers: [ + { type: 'basic', name: 'basic', usesLoginForm: true }, + { type: 'saml', name: 'saml1', usesLoginForm: false }, + ], + }} /> ); + expectPageMode(wrapper, PageMode.Selector); + wrapper.findWhere(node => node.key() === 'saml1').simulate('click'); await act(async () => { @@ -268,5 +420,123 @@ describe('LoginForm', () => { title: 'Could not perform login.', }); }); + + it('properly switches to login form', async () => { + const currentURL = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + window.location.href = currentURL; + const wrapper = mountWithIntl( + + ); + + expectPageMode(wrapper, PageMode.Selector); + + wrapper.findWhere(node => node.key() === 'basic').simulate('click'); + wrapper.update(); + expectPageMode(wrapper, PageMode.Form); + + expect(coreStartMock.http.post).not.toHaveBeenCalled(); + expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled(); + expect(window.location.href).toBe(currentURL); + }); + + it('properly switches to login help', async () => { + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + const wrapper = mountWithIntl( + + ); + + expectPageMode(wrapper, PageMode.Selector); + + findTestSubject(wrapper, 'loginHelpLink').simulate('click'); + wrapper.update(); + expectPageMode(wrapper, PageMode.LoginHelp); + + expect(findTestSubject(wrapper, 'loginHelp').find(ReactMarkdown)).toMatchSnapshot( + 'Login Help' + ); + + // Going back to login selector. + findTestSubject(wrapper, 'loginBackToLoginLink').simulate('click'); + wrapper.update(); + expectPageMode(wrapper, PageMode.Selector); + + expect(coreStartMock.http.post).not.toHaveBeenCalled(); + expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled(); + }); + + it('properly switches to login form -> login help and back', async () => { + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + const wrapper = mountWithIntl( + + ); + + expectPageMode(wrapper, PageMode.Selector); + + // Going to login form. + wrapper.findWhere(node => node.key() === 'basic').simulate('click'); + wrapper.update(); + expectPageMode(wrapper, PageMode.Form); + + // Going to login help. + findTestSubject(wrapper, 'loginHelpLink').simulate('click'); + wrapper.update(); + expectPageMode(wrapper, PageMode.LoginHelp); + + expect(findTestSubject(wrapper, 'loginHelp').find(ReactMarkdown)).toMatchSnapshot( + 'Login Help' + ); + + // Going back to login form. + findTestSubject(wrapper, 'loginBackToLoginLink').simulate('click'); + wrapper.update(); + expectPageMode(wrapper, PageMode.Form); + + // Going back to login selector. + findTestSubject(wrapper, 'loginBackToSelector').simulate('click'); + wrapper.update(); + expectPageMode(wrapper, PageMode.Selector); + + expect(coreStartMock.http.post).not.toHaveBeenCalled(); + expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx index 01f5c40a69aeb..460c6550085a4 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx @@ -4,10 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import './login_form.scss'; + import React, { ChangeEvent, Component, FormEvent, Fragment, MouseEvent } from 'react'; import ReactMarkdown from 'react-markdown'; import { EuiButton, + EuiIcon, EuiCallOut, EuiFieldPassword, EuiFieldText, @@ -15,21 +18,28 @@ import { EuiPanel, EuiSpacer, EuiText, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiLoadingSpinner, + EuiLink, + EuiHorizontalRule, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public'; -import { LoginValidator, LoginValidationResult } from './validate_login'; import { parseNext } from '../../../../../common/parse_next'; import { LoginSelector } from '../../../../../common/login_state'; +import { LoginValidator } from './validate_login'; interface Props { http: HttpStart; notifications: NotificationsStart; selector: LoginSelector; - showLoginForm: boolean; infoMessage?: string; loginAssistanceMessage: string; + loginHelp?: string; } interface State { @@ -42,7 +52,8 @@ interface State { message: | { type: MessageType.None } | { type: MessageType.Danger | MessageType.Info; content: string }; - formError: LoginValidationResult | null; + mode: PageMode; + previousMode: PageMode; } enum LoadingStateType { @@ -57,12 +68,21 @@ enum MessageType { Danger, } +export enum PageMode { + Selector, + Form, + LoginHelp, +} + export class LoginForm extends Component { private readonly validator: LoginValidator; constructor(props: Props) { super(props); this.validator = new LoginValidator({ shouldValidate: false }); + + const mode = this.showLoginSelector() ? PageMode.Selector : PageMode.Form; + this.state = { loadingState: { type: LoadingStateType.None }, username: '', @@ -70,7 +90,8 @@ export class LoginForm extends Component { message: this.props.infoMessage ? { type: MessageType.Info, content: this.props.infoMessage } : { type: MessageType.None }, - formError: null, + mode, + previousMode: mode, }; } @@ -79,19 +100,91 @@ export class LoginForm extends Component { {this.renderLoginAssistanceMessage()} {this.renderMessage()} - {this.renderSelector()} - {this.renderLoginForm()} + {this.renderContent()} + {this.renderPageModeSwitchLink()} ); } - private renderLoginForm = () => { - if (!this.props.showLoginForm) { + private renderLoginAssistanceMessage = () => { + if (!this.props.loginAssistanceMessage) { return null; } return ( - +
+ + + {this.props.loginAssistanceMessage} + +
+ ); + }; + + private renderMessage = () => { + const { message } = this.state; + if (message.type === MessageType.Danger) { + return ( + + + + + ); + } + + if (message.type === MessageType.Info) { + return ( + + + + + ); + } + + return null; + }; + + public renderContent() { + switch (this.state.mode) { + case PageMode.Form: + return this.renderLoginForm(); + case PageMode.Selector: + return this.renderSelector(); + case PageMode.LoginHelp: + return this.renderLoginHelp(); + } + } + + private renderLoginForm = () => { + const loginSelectorLink = this.showLoginSelector() ? ( + + this.onPageModeChange(PageMode.Selector)} + > + + + + ) : null; + + return ( +
{ /> - - - + + + + + + + + + {loginSelectorLink} +
); }; - private renderLoginAssistanceMessage = () => { - if (!this.props.loginAssistanceMessage) { - return null; - } + private renderSelector = () => { + return ( + + {this.props.selector.providers.map(provider => ( + + ))} + + ); + }; + private renderLoginHelp = () => { return ( - - - {this.props.loginAssistanceMessage} + + + {this.props.loginHelp || ''} - +
); }; - private renderMessage = () => { - const { message } = this.state; - if (message.type === MessageType.Danger) { + private renderPageModeSwitchLink = () => { + if (this.state.mode === PageMode.LoginHelp) { return ( - - + + + this.onPageModeChange(this.state.previousMode)} + > + + + ); } - if (message.type === MessageType.Info) { + if (this.props.loginHelp) { return ( - - + + + this.onPageModeChange(PageMode.LoginHelp)} + > + + + ); } @@ -205,60 +359,16 @@ export class LoginForm extends Component { return null; }; - private renderSelector = () => { - const showLoginSelector = - this.props.selector.enabled && this.props.selector.providers.length > 0; - if (!showLoginSelector) { - return null; - } - - const loginSelectorAndLoginFormSeparator = showLoginSelector && this.props.showLoginForm && ( - <> - - ―――   - -   ――― - - - - ); - - return ( - <> - {this.props.selector.providers.map((provider, index) => ( - - this.loginWithSelector(provider.type, provider.name)} - > - {provider.description ?? ( - - )} - - - - ))} - {loginSelectorAndLoginFormSeparator} - - ); - }; - private setUsernameInputRef(ref: HTMLInputElement) { if (ref) { ref.focus(); } } + private onPageModeChange = (mode: PageMode) => { + this.setState({ message: { type: MessageType.None }, mode, previousMode: this.state.mode }); + }; + private onUsernameChange = (e: ChangeEvent) => { this.setState({ username: e.target.value, @@ -279,12 +389,10 @@ export class LoginForm extends Component { this.validator.enableValidation(); const { username, password } = this.state; - const result = this.validator.validateForLogin(username, password); - if (result.isInvalid) { - this.setState({ formError: result }); - return; - } else { - this.setState({ formError: null }); + if (this.validator.validateForLogin(username, password).isInvalid) { + // Since validation is enabled now, we should ask React to re-render form and display + // validation error messages if any. + return this.forceUpdate(); } this.setState({ @@ -351,4 +459,11 @@ export class LoginForm extends Component { loadingState.type !== LoadingStateType.Selector || loadingState.providerName === providerName ); } + + private showLoginSelector() { + return ( + this.props.selector.enabled && + this.props.selector.providers.some(provider => !provider.usesLoginForm) + ); + } } diff --git a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx index c4be57d8d7db7..ab107e46dfff6 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx @@ -18,8 +18,10 @@ const createLoginState = (options?: Partial) => { allowLogin: true, layout: 'form', requiresSecureConnection: false, - showLoginForm: true, - selector: { enabled: false, providers: [] }, + selector: { + enabled: false, + providers: [{ type: 'basic', name: 'basic1', usesLoginForm: true }], + }, ...options, } as LoginState; }; @@ -163,7 +165,9 @@ describe('LoginPage', () => { it('renders as expected when login is not enabled', async () => { const coreStartMock = coreMock.createStart(); - httpMock.get.mockResolvedValue(createLoginState({ showLoginForm: false })); + httpMock.get.mockResolvedValue( + createLoginState({ selector: { enabled: false, providers: [] } }) + ); const wrapper = shallow( { expect(wrapper.find(LoginForm)).toMatchSnapshot(); }); + + it('renders as expected when loginHelp is set', async () => { + const coreStartMock = coreMock.createStart(); + httpMock.get.mockResolvedValue(createLoginState({ loginHelp: '**some-help**' })); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot + }); + + expect(wrapper.find(LoginForm)).toMatchSnapshot(); + }); }); describe('API calls', () => { diff --git a/x-pack/plugins/security/public/authentication/login/login_page.tsx b/x-pack/plugins/security/public/authentication/login/login_page.tsx index 70f8f76ee0a9c..b7ac70f2aaf89 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import './_login_page.scss'; + import React, { Component } from 'react'; import ReactDOM from 'react-dom'; import classNames from 'classnames'; @@ -120,10 +122,9 @@ export class LoginPage extends Component { requiresSecureConnection, isSecureConnection, selector, - showLoginForm, + loginHelp, }: LoginState & { isSecureConnection: boolean }) => { - const isLoginExplicitlyDisabled = - !showLoginForm && (!selector.enabled || selector.providers.length === 0); + const isLoginExplicitlyDisabled = selector.providers.length === 0; if (isLoginExplicitlyDisabled) { return ( { ); }; diff --git a/x-pack/plugins/security/public/index.scss b/x-pack/plugins/security/public/index.scss index 999639ba22eb7..1bdb8cc178fdf 100644 --- a/x-pack/plugins/security/public/index.scss +++ b/x-pack/plugins/security/public/index.scss @@ -1,7 +1,4 @@ $secFormWidth: 460px; -// Authentication styles -@import './authentication/index'; - // Management styles @import './management/index'; diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index d0f497b2c924b..8798f577a2455 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -29,6 +29,8 @@ describe('config schema', () => { "basic": Object { "description": undefined, "enabled": true, + "hint": undefined, + "icon": undefined, "order": 0, "showInSelector": true, }, @@ -72,6 +74,8 @@ describe('config schema', () => { "basic": Object { "description": undefined, "enabled": true, + "hint": undefined, + "icon": undefined, "order": 0, "showInSelector": true, }, @@ -115,6 +119,8 @@ describe('config schema', () => { "basic": Object { "description": undefined, "enabled": true, + "hint": undefined, + "icon": undefined, "order": 0, "showInSelector": true, }, @@ -499,20 +505,6 @@ describe('config schema', () => { `); }); - it('does not allow custom description', () => { - expect(() => - ConfigSchema.validate({ - authc: { - providers: { basic: { basic1: { order: 0, description: 'Some description' } } }, - }, - }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.basic.basic1.description]: \`basic\` provider does not support custom description." -`); - }); - it('cannot be hidden from selector', () => { expect(() => ConfigSchema.validate({ @@ -548,7 +540,9 @@ describe('config schema', () => { Object { "basic": Object { "basic1": Object { + "description": "Log in with Elasticsearch", "enabled": true, + "icon": "logoElastic", "order": 0, "showInSelector": true, }, @@ -571,20 +565,6 @@ describe('config schema', () => { `); }); - it('does not allow custom description', () => { - expect(() => - ConfigSchema.validate({ - authc: { - providers: { token: { token1: { order: 0, description: 'Some description' } } }, - }, - }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.token.token1.description]: \`token\` provider does not support custom description." -`); - }); - it('cannot be hidden from selector', () => { expect(() => ConfigSchema.validate({ @@ -620,7 +600,9 @@ describe('config schema', () => { Object { "token": Object { "token1": Object { + "description": "Log in with Elasticsearch", "enabled": true, + "icon": "logoElastic", "order": 0, "showInSelector": true, }, @@ -897,12 +879,16 @@ describe('config schema', () => { Object { "basic": Object { "basic1": Object { + "description": "Log in with Elasticsearch", "enabled": true, + "icon": "logoElastic", "order": 0, "showInSelector": true, }, "basic2": Object { + "description": "Log in with Elasticsearch", "enabled": false, + "icon": "logoElastic", "order": 1, "showInSelector": true, }, @@ -1181,7 +1167,7 @@ describe('createConfig()', () => { Object { "name": "basic1", "options": Object { - "description": undefined, + "description": "Log in with Elasticsearch", "order": 3, "showInSelector": true, }, diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 2d6feee6bc9d7..e46ae8ac4b4e6 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -6,6 +6,7 @@ import crypto from 'crypto'; import { schema, Type, TypeOf } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; import { Logger } from '../../../../src/core/server'; export type ConfigType = ReturnType; @@ -21,7 +22,7 @@ const providerOptionsSchema = (providerType: string, optionsSchema: Type) = ); type ProvidersCommonConfigType = Record< - 'enabled' | 'showInSelector' | 'order' | 'description', + 'enabled' | 'showInSelector' | 'order' | 'description' | 'hint' | 'icon', Type >; function getCommonProviderSchemaProperties(overrides: Partial = {}) { @@ -30,6 +31,8 @@ function getCommonProviderSchemaProperties(overrides: Partial; const providersConfigSchema = schema.object( { basic: getUniqueProviderSchema('basic', { - description: schema.maybe( - schema.any({ - validate: () => '`basic` provider does not support custom description.', - }) - ), + description: schema.string({ + defaultValue: i18n.translate('xpack.security.loginWithElasticsearchLabel', { + defaultMessage: 'Log in with Elasticsearch', + }), + }), + icon: schema.string({ defaultValue: 'logoElastic' }), showInSelector: schema.boolean({ defaultValue: true, validate: value => { @@ -68,11 +72,12 @@ const providersConfigSchema = schema.object( }), }), token: getUniqueProviderSchema('token', { - description: schema.maybe( - schema.any({ - validate: () => '`token` provider does not support custom description.', - }) - ), + description: schema.string({ + defaultValue: i18n.translate('xpack.security.loginWithElasticsearchLabel', { + defaultMessage: 'Log in with Elasticsearch', + }), + }), + icon: schema.string({ defaultValue: 'logoElastic' }), showInSelector: schema.boolean({ defaultValue: true, validate: value => { @@ -131,6 +136,7 @@ const providersConfigSchema = schema.object( export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), loginAssistanceMessage: schema.string({ defaultValue: '' }), + loginHelp: schema.maybe(schema.string()), cookieName: schema.string({ defaultValue: 'sid' }), encryptionKey: schema.conditional( schema.contextRef('dist'), @@ -152,7 +158,16 @@ export const ConfigSchema = schema.object({ selector: schema.object({ enabled: schema.maybe(schema.boolean()) }), providers: schema.oneOf([schema.arrayOf(schema.string()), providersConfigSchema], { defaultValue: { - basic: { basic: { enabled: true, showInSelector: true, order: 0, description: undefined } }, + basic: { + basic: { + enabled: true, + showInSelector: true, + order: 0, + description: undefined, + hint: undefined, + icon: undefined, + }, + }, token: undefined, saml: undefined, oidc: undefined, diff --git a/x-pack/plugins/security/server/routes/views/login.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts index d43319efbdfb9..8bc2bb32325fc 100644 --- a/x-pack/plugins/security/server/routes/views/login.test.ts +++ b/x-pack/plugins/security/server/routes/views/login.test.ts @@ -15,7 +15,7 @@ import { RouteConfig, } from '../../../../../../src/core/server'; import { SecurityLicense } from '../../../common/licensing'; -import { LoginState } from '../../../common/login_state'; +import { LoginSelectorProvider } from '../../../common/login_state'; import { ConfigType } from '../../config'; import { defineLoginRoutes } from './login'; @@ -141,6 +141,10 @@ describe('Login view routes', () => { }); describe('Login state route', () => { + function getAuthcConfig(authcConfig: Record = {}) { + return routeDefinitionParamsMock.create({ authc: { ...authcConfig } }).config.authc; + } + let routeHandler: RequestHandler; let routeConfig: RouteConfig; beforeEach(() => { @@ -176,9 +180,11 @@ describe('Login view routes', () => { const expectedPayload = { allowLogin: true, layout: 'error-es-unavailable', - showLoginForm: true, requiresSecureConnection: false, - selector: { enabled: false, providers: [] }, + selector: { + enabled: false, + providers: [{ name: 'basic', type: 'basic', usesLoginForm: true }], + }, }; await expect( routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) @@ -198,9 +204,11 @@ describe('Login view routes', () => { const expectedPayload = { allowLogin: true, layout: 'form', - showLoginForm: true, requiresSecureConnection: false, - selector: { enabled: false, providers: [] }, + selector: { + enabled: false, + providers: [{ name: 'basic', type: 'basic', usesLoginForm: true }], + }, }; await expect( routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) @@ -229,22 +237,46 @@ describe('Login view routes', () => { }); }); - it('returns `showLoginForm: true` only if either `basic` or `token` provider is enabled.', async () => { + it('returns `useLoginForm: true` for `basic` and `token` providers.', async () => { license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); const request = httpServerMock.createKibanaRequest(); const contextMock = coreMock.createRequestHandlerContext(); - const cases: Array<[boolean, ConfigType['authc']['sortedProviders']]> = [ - [false, []], - [true, [{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }]], - [true, [{ type: 'token', name: 'token1', options: { order: 0, showInSelector: true } }]], + const cases: Array<[LoginSelectorProvider[], ConfigType['authc']]> = [ + [[], getAuthcConfig({ providers: { basic: { basic1: { order: 0, enabled: false } } } })], + [ + [ + { + name: 'basic1', + type: 'basic', + usesLoginForm: true, + icon: 'logoElastic', + description: 'Log in with Elasticsearch', + }, + ], + getAuthcConfig({ providers: { basic: { basic1: { order: 0 } } } }), + ], + [ + [ + { + name: 'token1', + type: 'token', + usesLoginForm: true, + icon: 'logoElastic', + description: 'Log in with Elasticsearch', + }, + ], + getAuthcConfig({ providers: { token: { token1: { order: 0 } } } }), + ], ]; - for (const [showLoginForm, sortedProviders] of cases) { - config.authc.sortedProviders = sortedProviders; + for (const [providers, authcConfig] of cases) { + config.authc = authcConfig; - const expectedPayload = expect.objectContaining({ showLoginForm }); + const expectedPayload = expect.objectContaining({ + selector: { enabled: false, providers }, + }); await expect( routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) ).resolves.toEqual({ @@ -261,81 +293,142 @@ describe('Login view routes', () => { const request = httpServerMock.createKibanaRequest(); const contextMock = coreMock.createRequestHandlerContext(); - const cases: Array<[ - boolean, - ConfigType['authc']['sortedProviders'], - LoginState['selector']['providers'] - ]> = [ - // selector is disabled, providers shouldn't be returned. + const cases: Array<[ConfigType['authc'], LoginSelectorProvider[]]> = [ + // selector is disabled, multiple providers, but only basic provider should be returned. [ - false, + getAuthcConfig({ + selector: { enabled: false }, + providers: { + basic: { basic1: { order: 0 } }, + saml: { saml1: { order: 1, realm: 'realm1' } }, + }, + }), [ - { type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }, - { type: 'saml', name: 'saml1', options: { order: 1, showInSelector: true } }, + { + name: 'basic1', + type: 'basic', + usesLoginForm: true, + icon: 'logoElastic', + description: 'Log in with Elasticsearch', + }, ], - [], ], - // selector is enabled, but only basic/token is available, providers shouldn't be returned. + // selector is enabled, but only basic/token is available and should be returned. [ - true, - [{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }], - [], + getAuthcConfig({ + selector: { enabled: true }, + providers: { basic: { basic1: { order: 0 } } }, + }), + [ + { + name: 'basic1', + type: 'basic', + usesLoginForm: true, + icon: 'logoElastic', + description: 'Log in with Elasticsearch', + }, + ], ], - // selector is enabled, non-basic/token providers should be returned + // selector is enabled, all providers should be returned [ - true, + getAuthcConfig({ + selector: { enabled: true }, + providers: { + basic: { + basic1: { + order: 0, + description: 'some-desc1', + hint: 'some-hint1', + icon: 'logoElastic', + }, + }, + saml: { + saml1: { order: 1, description: 'some-desc2', realm: 'realm1', icon: 'some-icon2' }, + saml2: { order: 2, description: 'some-desc3', hint: 'some-hint3', realm: 'realm2' }, + }, + }, + }), [ { type: 'basic', name: 'basic1', - options: { order: 0, showInSelector: true, description: 'some-desc1' }, + description: 'some-desc1', + hint: 'some-hint1', + icon: 'logoElastic', + usesLoginForm: true, }, { type: 'saml', name: 'saml1', - options: { order: 1, showInSelector: true, description: 'some-desc2' }, + description: 'some-desc2', + icon: 'some-icon2', + usesLoginForm: false, }, { type: 'saml', name: 'saml2', - options: { order: 2, showInSelector: true, description: 'some-desc3' }, + description: 'some-desc3', + hint: 'some-hint3', + usesLoginForm: false, }, ], - [ - { type: 'saml', name: 'saml1', description: 'some-desc2' }, - { type: 'saml', name: 'saml2', description: 'some-desc3' }, - ], ], - // selector is enabled, only non-basic/token providers that are enabled in selector should be returned. + // selector is enabled, only providers that are enabled should be returned. [ - true, + getAuthcConfig({ + selector: { enabled: true }, + providers: { + basic: { + basic1: { + order: 0, + description: 'some-desc1', + hint: 'some-hint1', + icon: 'some-icon1', + }, + }, + saml: { + saml1: { + order: 1, + description: 'some-desc2', + realm: 'realm1', + showInSelector: false, + }, + saml2: { + order: 2, + description: 'some-desc3', + hint: 'some-hint3', + icon: 'some-icon3', + realm: 'realm2', + }, + }, + }, + }), [ { type: 'basic', name: 'basic1', - options: { order: 0, showInSelector: true, description: 'some-desc1' }, - }, - { - type: 'saml', - name: 'saml1', - options: { order: 1, showInSelector: false, description: 'some-desc2' }, + description: 'some-desc1', + hint: 'some-hint1', + icon: 'some-icon1', + usesLoginForm: true, }, { type: 'saml', name: 'saml2', - options: { order: 2, showInSelector: true, description: 'some-desc3' }, + description: 'some-desc3', + hint: 'some-hint3', + icon: 'some-icon3', + usesLoginForm: false, }, ], - [{ type: 'saml', name: 'saml2', description: 'some-desc3' }], ], ]; - for (const [selectorEnabled, sortedProviders, expectedProviders] of cases) { - config.authc.selector.enabled = selectorEnabled; - config.authc.sortedProviders = sortedProviders; + for (const [authcConfig, expectedProviders] of cases) { + config.authc = authcConfig; const expectedPayload = expect.objectContaining({ - selector: { enabled: selectorEnabled, providers: expectedProviders }, + selector: { enabled: authcConfig.selector.enabled, providers: expectedProviders }, }); await expect( routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) diff --git a/x-pack/plugins/security/server/routes/views/login.ts b/x-pack/plugins/security/server/routes/views/login.ts index 4d6747de713f7..f72facb2e24cc 100644 --- a/x-pack/plugins/security/server/routes/views/login.ts +++ b/x-pack/plugins/security/server/routes/views/login.ts @@ -55,15 +55,16 @@ export function defineLoginRoutes({ const { allowLogin, layout = 'form' } = license.getFeatures(); const { sortedProviders, selector } = config.authc; - let showLoginForm = false; const providers = []; - for (const { type, name, options } of sortedProviders) { - if (options.showInSelector) { - if (type === 'basic' || type === 'token') { - showLoginForm = true; - } else if (selector.enabled) { - providers.push({ type, name, description: options.description }); - } + for (const { type, name } of sortedProviders) { + // Since `config.authc.sortedProviders` is based on `config.authc.providers` config we can + // be sure that config is present for every provider in `config.authc.sortedProviders`. + const { showInSelector, description, hint, icon } = config.authc.providers[type]?.[name]!; + + // Include provider into the list if either selector is enabled or provider uses login form. + const usesLoginForm = type === 'basic' || type === 'token'; + if (showInSelector && (usesLoginForm || selector.enabled)) { + providers.push({ type, name, usesLoginForm, description, hint, icon }); } } @@ -71,7 +72,7 @@ export function defineLoginRoutes({ allowLogin, layout, requiresSecureConnection: config.secureCookies, - showLoginForm, + loginHelp: config.loginHelp, selector: { enabled: selector.enabled, providers }, }; diff --git a/x-pack/plugins/siem/public/components/embeddables/embedded_map.tsx b/x-pack/plugins/siem/public/components/embeddables/embedded_map.tsx index 404ba6bb6611c..d2dd3e5429341 100644 --- a/x-pack/plugins/siem/public/components/embeddables/embedded_map.tsx +++ b/x-pack/plugins/siem/public/components/embeddables/embedded_map.tsx @@ -204,6 +204,7 @@ export const EmbeddedMapComponent = ({ notifications={services.notifications} overlays={services.overlays} inspector={services.inspector} + application={services.application} SavedObjectFinder={getSavedObjectFinder(services.savedObjects, services.uiSettings)} /> ) : !isLoading && isIndexError ? ( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e2e419017324d..ad043b942ae2a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6375,7 +6375,6 @@ "xpack.endpoint.host.list.os": "オペレーティングシステム", "xpack.endpoint.host.list.policy": "ポリシー", "xpack.endpoint.host.list.policyStatus": "ポリシーステータス", - "xpack.endpoint.host.list.sensorVersion": "サーバーバージョン", "xpack.endpoint.host.list.totalCount": "表示中: {totalItemCount, plural, one {# ホスト} other {# ホスト}}", "xpack.endpoint.notFound": "ページが見つかりません", "xpack.endpoint.pluginTitle": "エンドポイント", @@ -12661,7 +12660,6 @@ "xpack.security.loginPage.esUnavailableTitle": "Elasticsearch クラスターに接続できません", "xpack.security.loginPage.loginProviderDescription": "{providerType}/{providerName} でログイン", "xpack.security.loginPage.loginSelectorErrorMessage": "ログインを実行できませんでした。", - "xpack.security.loginPage.loginSelectorOR": "OR", "xpack.security.loginPage.noLoginMethodsAvailableMessage": "システム管理者にお問い合わせください。", "xpack.security.loginPage.noLoginMethodsAvailableTitle": "ログインが無効です。", "xpack.security.loginPage.requiresSecureConnectionMessage": "システム管理者にお問い合わせください。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 1a10295e4f13f..b603075d35994 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6377,7 +6377,6 @@ "xpack.endpoint.host.list.os": "操作系统", "xpack.endpoint.host.list.policy": "政策", "xpack.endpoint.host.list.policyStatus": "策略状态", - "xpack.endpoint.host.list.sensorVersion": "感应器版本", "xpack.endpoint.host.list.totalCount": "正在显示:{totalItemCount, plural, one {# 个主机} other {# 个主机}}", "xpack.endpoint.notFound": "未找到页面", "xpack.endpoint.pluginTitle": "终端", @@ -12665,7 +12664,6 @@ "xpack.security.loginPage.esUnavailableTitle": "无法连接到 Elasticsearch 集群", "xpack.security.loginPage.loginProviderDescription": "使用 {providerType}/{providerName} 登录", "xpack.security.loginPage.loginSelectorErrorMessage": "无法执行登录。", - "xpack.security.loginPage.loginSelectorOR": "或", "xpack.security.loginPage.noLoginMethodsAvailableMessage": "请联系您的管理员。", "xpack.security.loginPage.noLoginMethodsAvailableTitle": "登录已禁用。", "xpack.security.loginPage.requiresSecureConnectionMessage": "请联系您的管理员。", diff --git a/x-pack/test/api_integration/apis/apm/agent_configuration.ts b/x-pack/test/api_integration/apis/apm/agent_configuration.ts index 41d78995711f2..8af648e062cf4 100644 --- a/x-pack/test/api_integration/apis/apm/agent_configuration.ts +++ b/x-pack/test/api_integration/apis/apm/agent_configuration.ts @@ -182,15 +182,21 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte service: { name: 'myservice', environment: 'development' }, settings: { transaction_sample_rate: '0.9' }, }; + const configProduction = { + service: { name: 'myservice', environment: 'production' }, + settings: { transaction_sample_rate: '0.9' }, + }; let etag: string; before(async () => { log.debug('creating agent configuration'); await createConfiguration(config); + await createConfiguration(configProduction); }); after(async () => { await deleteConfiguration(config); + await deleteConfiguration(configProduction); }); it(`should have 'applied_by_agent=false' before supplying etag`, async () => { @@ -210,17 +216,45 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte }); it(`should have 'applied_by_agent=true' after supplying etag`, async () => { - async function getAppliedByAgent() { + await searchConfigurations({ + service: { name: 'myservice', environment: 'development' }, + etag, + }); + + async function hasBeenAppliedByAgent() { const { body } = await searchConfigurations({ service: { name: 'myservice', environment: 'development' }, - etag, }); return body._source.applied_by_agent; } // wait until `applied_by_agent` has been updated in elasticsearch - expect(await waitFor(getAppliedByAgent)).to.be(true); + expect(await waitFor(hasBeenAppliedByAgent)).to.be(true); + }); + it(`should have 'applied_by_agent=false' before marking as applied`, async () => { + const res1 = await searchConfigurations({ + service: { name: 'myservice', environment: 'production' }, + }); + + expect(res1.body._source.applied_by_agent).to.be(false); + }); + it(`should have 'applied_by_agent=true' when 'mark_as_applied_by_agent' attribute is true`, async () => { + await searchConfigurations({ + service: { name: 'myservice', environment: 'production' }, + mark_as_applied_by_agent: true, + }); + + async function hasBeenAppliedByAgent() { + const { body } = await searchConfigurations({ + service: { name: 'myservice', environment: 'production' }, + }); + + return body._source.applied_by_agent; + } + + // wait until `applied_by_agent` has been updated in elasticsearch + expect(await waitFor(hasBeenAppliedByAgent)).to.be(true); }); }); }); diff --git a/x-pack/test/plugin_api_integration/config.ts b/x-pack/test/plugin_api_integration/config.ts index c581e0c246e13..adb31f3562a6f 100644 --- a/x-pack/test/plugin_api_integration/config.ts +++ b/x-pack/test/plugin_api_integration/config.ts @@ -22,6 +22,7 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { testFiles: [ require.resolve('./test_suites/task_manager'), require.resolve('./test_suites/event_log'), + require.resolve('./test_suites/licensed_feature_usage'), ], services, servers: integrationConfig.get('servers'), diff --git a/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json new file mode 100644 index 0000000000000..b11b7ada24a57 --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "feature_usage_test", + "version": "1.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack", "feature_usage_test"], + "requiredPlugins": ["licensing"], + "server": true, + "ui": false +} diff --git a/x-pack/test/plugin_api_integration/plugins/feature_usage_test/server/index.ts b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/server/index.ts new file mode 100644 index 0000000000000..e07915ab5f46b --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/server/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { PluginInitializer } from 'kibana/server'; +import { + FeatureUsageTestPlugin, + FeatureUsageTestPluginSetup, + FeatureUsageTestPluginStart, +} from './plugin'; + +export const plugin: PluginInitializer< + FeatureUsageTestPluginSetup, + FeatureUsageTestPluginStart +> = () => new FeatureUsageTestPlugin(); diff --git a/x-pack/test/plugin_api_integration/plugins/feature_usage_test/server/plugin.ts b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/server/plugin.ts new file mode 100644 index 0000000000000..b36d6dca077f7 --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/server/plugin.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Plugin, CoreSetup } from 'kibana/server'; +import { + LicensingPluginSetup, + LicensingPluginStart, +} from '../../../../../plugins/licensing/server'; +import { registerRoutes } from './routes'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface FeatureUsageTestPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface FeatureUsageTestPluginStart {} + +export interface FeatureUsageTestSetupDependencies { + licensing: LicensingPluginSetup; +} +export interface FeatureUsageTestStartDependencies { + licensing: LicensingPluginStart; +} + +export class FeatureUsageTestPlugin + implements + Plugin< + FeatureUsageTestPluginSetup, + FeatureUsageTestPluginStart, + FeatureUsageTestSetupDependencies, + FeatureUsageTestStartDependencies + > { + public setup( + { + http, + getStartServices, + }: CoreSetup, + { licensing }: FeatureUsageTestSetupDependencies + ) { + licensing.featureUsage.register('test_feature_a'); + licensing.featureUsage.register('test_feature_b'); + licensing.featureUsage.register('test_feature_c'); + + registerRoutes(http.createRouter(), getStartServices); + + return {}; + } + + public start() { + return {}; + } +} diff --git a/x-pack/test/plugin_api_integration/plugins/feature_usage_test/server/routes/hit.ts b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/server/routes/hit.ts new file mode 100644 index 0000000000000..494bcdbf5f61e --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/server/routes/hit.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter, StartServicesAccessor } from 'src/core/server'; +import { FeatureUsageTestStartDependencies, FeatureUsageTestPluginStart } from '../plugin'; + +export function registerFeatureHitRoute( + router: IRouter, + getStartServices: StartServicesAccessor< + FeatureUsageTestStartDependencies, + FeatureUsageTestPluginStart + > +) { + router.get( + { + path: '/api/feature_usage_test/hit', + validate: { + query: schema.object({ + featureName: schema.string(), + usedAt: schema.maybe(schema.number()), + }), + }, + }, + async (context, request, response) => { + const [, { licensing }] = await getStartServices(); + try { + const { featureName, usedAt } = request.query; + licensing.featureUsage.notifyUsage(featureName, usedAt); + return response.ok(); + } catch (e) { + return response.badRequest(); + } + } + ); +} diff --git a/x-pack/test/plugin_api_integration/plugins/feature_usage_test/server/routes/index.ts b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/server/routes/index.ts new file mode 100644 index 0000000000000..a8225838fd9bf --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/server/routes/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter, StartServicesAccessor } from 'src/core/server'; +import { FeatureUsageTestStartDependencies, FeatureUsageTestPluginStart } from '../plugin'; + +import { registerFeatureHitRoute } from './hit'; + +export function registerRoutes( + router: IRouter, + getStartServices: StartServicesAccessor< + FeatureUsageTestStartDependencies, + FeatureUsageTestPluginStart + > +) { + registerFeatureHitRoute(router, getStartServices); +} diff --git a/x-pack/test/plugin_api_integration/test_suites/licensed_feature_usage/feature_usage.ts b/x-pack/test/plugin_api_integration/test_suites/licensed_feature_usage/feature_usage.ts new file mode 100644 index 0000000000000..41f2cfc7983ef --- /dev/null +++ b/x-pack/test/plugin_api_integration/test_suites/licensed_feature_usage/feature_usage.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + const notifyUsage = async (featureName: string, usedAt: number) => { + await supertest.get(`/api/feature_usage_test/hit?featureName=${featureName}&usedAt=${usedAt}`); + }; + + const toISO = (time: number) => new Date(time).toISOString(); + + describe('/api/licensing/feature_usage', () => { + it('returns a map of last feature usages', async () => { + const timeA = Date.now(); + await notifyUsage('test_feature_a', timeA); + + const timeB = Date.now() - 4567; + await notifyUsage('test_feature_b', timeB); + + const response = await supertest.get('/api/licensing/feature_usage').expect(200); + + expect(response.body.test_feature_a).to.eql(toISO(timeA)); + expect(response.body.test_feature_b).to.eql(toISO(timeB)); + }); + }); +} diff --git a/x-pack/test/plugin_api_integration/test_suites/licensed_feature_usage/index.ts b/x-pack/test/plugin_api_integration/test_suites/licensed_feature_usage/index.ts new file mode 100644 index 0000000000000..6cafb60bf8167 --- /dev/null +++ b/x-pack/test/plugin_api_integration/test_suites/licensed_feature_usage/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('Licensed feature usage APIs', function() { + this.tags('ciGroup2'); + loadTestFile(require.resolve('./feature_usage')); + }); +} diff --git a/yarn.lock b/yarn.lock index 9252849d7e9cd..8c327412898be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5531,11 +5531,6 @@ adm-zip@0.4.11: resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.11.tgz#2aa54c84c4b01a9d0fb89bb11982a51f13e3d62a" integrity sha512-L8vcjDTCOIJk7wFvmlEUN7AsSb8T+2JrdP7KINBjzr24TJ5Mwj590sLu3BC7zNZowvJWa/JtPmD8eJCzdtDWjA== -adm-zip@^0.4.13: - version "0.4.13" - resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.13.tgz#597e2f8cc3672151e1307d3e95cddbc75672314a" - integrity sha512-fERNJX8sOXfel6qCBCMPvZLzENBEhZTzKqg6vrOW5pvoEaQuJhRU4ndTAh6lHOxn1I6jnz2NHra56ZODM751uw== - affine-hull@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/affine-hull/-/affine-hull-1.0.0.tgz#763ff1d38d063ceb7e272f17ee4d7bbcaf905c5d" @@ -9474,15 +9469,6 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= -concat-stream@1.6.0, concat-stream@^1.4.7, concat-stream@~1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.0.tgz#0aac662fd52be78964d5532f694784e70110acf7" - integrity sha1-CqxmL9Ur54lk1VMvaUeE5wEQrPc= - dependencies: - inherits "^2.0.3" - readable-stream "^2.2.2" - typedarray "^0.0.6" - concat-stream@1.6.2, concat-stream@^1.4.6, concat-stream@^1.5.0, concat-stream@^1.6.0, concat-stream@^1.6.1, concat-stream@^1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" @@ -9493,6 +9479,15 @@ concat-stream@1.6.2, concat-stream@^1.4.6, concat-stream@^1.5.0, concat-stream@^ readable-stream "^2.2.2" typedarray "^0.0.6" +concat-stream@^1.4.7, concat-stream@~1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.0.tgz#0aac662fd52be78964d5532f694784e70110acf7" + integrity sha1-CqxmL9Ur54lk1VMvaUeE5wEQrPc= + dependencies: + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + concat-stream@~1.5.0: version "1.5.2" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.5.2.tgz#708978624d856af41a5a741defdd261da752c266" @@ -13264,16 +13259,6 @@ extract-stack@^1.0.0: resolved "https://registry.yarnpkg.com/extract-stack/-/extract-stack-1.0.0.tgz#b97acaf9441eea2332529624b732fc5a1c8165fa" integrity sha1-uXrK+UQe6iMyUpYktzL8WhyBZfo= -extract-zip@1.6.6: - version "1.6.6" - resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.6.6.tgz#1290ede8d20d0872b429fd3f351ca128ec5ef85c" - integrity sha1-EpDt6NINCHK0Kf0/NRyhKOxe+Fw= - dependencies: - concat-stream "1.6.0" - debug "2.6.9" - mkdirp "0.5.0" - yauzl "2.4.1" - extract-zip@1.7.0, extract-zip@^1.6.6, extract-zip@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927" @@ -16377,16 +16362,14 @@ idx@^2.5.6: resolved "https://registry.yarnpkg.com/idx/-/idx-2.5.6.tgz#1f824595070100ae9ad585c86db08dc74f83a59d" integrity sha512-WFXLF7JgPytbMgelpRY46nHz5tyDcedJ76pLV+RJWdb8h33bxFq4bdZau38DhNSzk5eVniBf1K3jwfK+Lb5nYA== -iedriver@^3.14.1: - version "3.14.1" - resolved "https://registry.yarnpkg.com/iedriver/-/iedriver-3.14.1.tgz#447c49be83c62d3f2f158283d58ccf7b35002be8" - integrity sha512-YyCi703BGK7R37A8QlSe2B87xgwDGGoPqBrlXe4Q68o/MNLJrR53/IpTs6J1+KKk51MLiTbWa57N7P3KZ11tow== +iedriver@^3.14.2: + version "3.14.2" + resolved "https://registry.yarnpkg.com/iedriver/-/iedriver-3.14.2.tgz#a19391ff123e21823ce0afe300e38b58a7dc79c4" + integrity sha512-vvFwfpOOZXmpXT/3Oa9SOFrr4uZNNUtBKPLRz7z8oZigvvIOokDiBlbImrd80q+rgjkmqUGi6a2NnpyCOAXnOw== dependencies: - adm-zip "^0.4.13" - extract-zip "1.6.6" + extract-zip "1.7.0" kew "~0.1.7" - md5-file "^1.1.4" - mkdirp "0.3.5" + mkdirp "0.5.4" npmconf "^2.1.3" request "^2.88.0" rimraf "~2.0.2" @@ -20136,11 +20119,6 @@ material-colors@^1.2.1: resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.5.tgz#5292593e6754cb1bcc2b98030e4e0d6a3afc9ea1" integrity sha1-UpJZPmdUyxvMK5gDDk4Najr8nqE= -md5-file@^1.1.4: - version "1.1.10" - resolved "https://registry.yarnpkg.com/md5-file/-/md5-file-1.1.10.tgz#d8f4fce76c92cb20b7d143a59f58ca49b4cf3174" - integrity sha1-2PT852ySyyC30UOln1jKSbTPMXQ= - md5.js@^1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.4.tgz#e9bdbde94a20a5ac18b04340fc5764d5b09d901d" @@ -20727,18 +20705,6 @@ mixin-object@^2.0.1: for-in "^0.1.3" is-extendable "^0.1.1" -mkdirp@0.3.5, mkdirp@^0.3.5: - version "0.3.5" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.5.tgz#de3e5f8961c88c787ee1368df849ac4413eca8d7" - integrity sha1-3j5fiWHIjHh+4TaN+EmsRBPsqNc= - -mkdirp@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.0.tgz#1d73076a6df986cd9344e15e71fcc05a4c9abf12" - integrity sha1-HXMHam35hs2TROFecfzAWkyavxI= - dependencies: - minimist "0.0.8" - mkdirp@0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" @@ -20753,13 +20719,18 @@ mkdirp@0.5.3: dependencies: minimist "^1.2.5" -mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.4, mkdirp@~0.5.0, mkdirp@~0.5.1: +mkdirp@0.5.4, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.4, mkdirp@~0.5.0, mkdirp@~0.5.1: version "0.5.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.4.tgz#fd01504a6797ec5c9be81ff43d204961ed64a512" integrity sha512-iG9AK/dJLtJ0XNgTuDbSyNS3zECqDlAhnQW4CsNxBG3LQJBbHmRX1egw39DmtOdCAqY+dKXV+sgPgilNWUKMVw== dependencies: minimist "^1.2.5" +mkdirp@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.5.tgz#de3e5f8961c88c787ee1368df849ac4413eca8d7" + integrity sha1-3j5fiWHIjHh+4TaN+EmsRBPsqNc= + mkdirp@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" @@ -32480,13 +32451,6 @@ yauzl@2.10.0, yauzl@^2.10.0: buffer-crc32 "~0.2.3" fd-slicer "~1.1.0" -yauzl@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.4.1.tgz#9528f442dab1b2284e58b4379bb194e22e0c4005" - integrity sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU= - dependencies: - fd-slicer "~1.0.1" - yauzl@^2.4.2: version "2.9.1" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.9.1.tgz#a81981ea70a57946133883f029c5821a89359a7f"