From 524401973f1d0ac5403cd0fbb3ea82a63962a45a Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Wed, 23 Jun 2021 19:58:10 +0200 Subject: [PATCH 01/95] Add timeouts and setup enforcement for custom plugins statuses (#77965) --- ...ugin-core-server.statusservicesetup.set.md | 2 + packages/kbn-pm/dist/index.js | 1 + packages/kbn-pm/src/config.ts | 1 + src/core/server/server.test.ts | 2 + src/core/server/server.ts | 2 +- src/core/server/status/plugins_status.test.ts | 93 ++++++++++++++++++- src/core/server/status/plugins_status.ts | 46 +++++++-- src/core/server/status/status_service.ts | 6 +- src/core/server/status/types.ts | 3 + src/dev/typescript/projects.ts | 3 + .../test_suites/core_plugins/status.ts | 71 ++++++++++++++ test/scripts/test/server_integration.sh | 7 ++ .../plugins/status_plugin_a/kibana.json | 7 ++ .../plugins/status_plugin_a/package.json | 14 +++ .../plugins/status_plugin_a/server/index.ts | 11 +++ .../plugins/status_plugin_a/server/plugin.ts | 56 +++++++++++ .../plugins/status_plugin_a/tsconfig.json | 17 ++++ .../plugins/status_plugin_b/kibana.json | 8 ++ .../plugins/status_plugin_b/package.json | 14 +++ .../plugins/status_plugin_b/server/index.ts | 11 +++ .../plugins/status_plugin_b/server/plugin.ts | 15 +++ .../plugins/status_plugin_b/tsconfig.json | 17 ++++ .../http/platform/config.status.ts | 58 ++++++++++++ .../http/platform/status.ts | 69 ++++++++++++++ test/tsconfig.json | 9 +- 25 files changed, 529 insertions(+), 14 deletions(-) create mode 100644 test/plugin_functional/test_suites/core_plugins/status.ts create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_a/kibana.json create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_a/package.json create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_a/server/index.ts create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_a/server/plugin.ts create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_a/tsconfig.json create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_b/kibana.json create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_b/package.json create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_b/server/index.ts create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_b/server/plugin.ts create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_b/tsconfig.json create mode 100644 test/server_integration/http/platform/config.status.ts create mode 100644 test/server_integration/http/platform/status.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md index 143cd397c40ae..bf08ca1682f3b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md +++ b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md @@ -24,5 +24,7 @@ set(status$: Observable): void; ## Remarks +The first emission from this Observable should occur within 30s, else this plugin's status will fallback to `unavailable` until the first emission. + See the [StatusServiceSetup.derivedStatus$](./kibana-plugin-core-server.statusservicesetup.derivedstatus_.md) API for leveraging the default status calculation that is provided by Core. diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index e455f487d1384..5be9dff630ed5 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -63827,6 +63827,7 @@ function getProjectPaths({ projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'test/plugin_functional/plugins/*')); projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'test/interpreter_functional/plugins/*')); + projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'test/server_integration/__fixtures__/plugins/*')); projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'examples/*')); if (!ossOnly) { diff --git a/packages/kbn-pm/src/config.ts b/packages/kbn-pm/src/config.ts index a11b2ad9c72c3..666a2fed7a33c 100644 --- a/packages/kbn-pm/src/config.ts +++ b/packages/kbn-pm/src/config.ts @@ -31,6 +31,7 @@ export function getProjectPaths({ rootPath, ossOnly, skipKibanaPlugins }: Option // correct and the expect behavior. projectPaths.push(resolve(rootPath, 'test/plugin_functional/plugins/*')); projectPaths.push(resolve(rootPath, 'test/interpreter_functional/plugins/*')); + projectPaths.push(resolve(rootPath, 'test/server_integration/__fixtures__/plugins/*')); projectPaths.push(resolve(rootPath, 'examples/*')); if (!ossOnly) { diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index 534d7df9d9466..e1986c5bf1d92 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -114,6 +114,7 @@ test('runs services on "start"', async () => { expect(mockSavedObjectsService.start).not.toHaveBeenCalled(); expect(mockUiSettingsService.start).not.toHaveBeenCalled(); expect(mockMetricsService.start).not.toHaveBeenCalled(); + expect(mockStatusService.start).not.toHaveBeenCalled(); await server.start(); @@ -121,6 +122,7 @@ test('runs services on "start"', async () => { expect(mockSavedObjectsService.start).toHaveBeenCalledTimes(1); expect(mockUiSettingsService.start).toHaveBeenCalledTimes(1); expect(mockMetricsService.start).toHaveBeenCalledTimes(1); + expect(mockStatusService.start).toHaveBeenCalledTimes(1); }); test('does not fail on "setup" if there are unused paths detected', async () => { diff --git a/src/core/server/server.ts b/src/core/server/server.ts index adf794c390338..3f553dd90678e 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -248,6 +248,7 @@ export class Server { savedObjects: savedObjectsStart, exposedConfigsToUsage: this.plugins.getExposedPluginConfigsToUsage(), }); + this.status.start(); this.coreStart = { capabilities: capabilitiesStart, @@ -261,7 +262,6 @@ export class Server { await this.plugins.start(this.coreStart); - this.status.start(); await this.http.start(); startTransaction?.end(); diff --git a/src/core/server/status/plugins_status.test.ts b/src/core/server/status/plugins_status.test.ts index b0d9e47876940..9dc1ddcddca3e 100644 --- a/src/core/server/status/plugins_status.test.ts +++ b/src/core/server/status/plugins_status.test.ts @@ -8,7 +8,7 @@ import { PluginName } from '../plugins'; import { PluginsStatusService } from './plugins_status'; -import { of, Observable, BehaviorSubject } from 'rxjs'; +import { of, Observable, BehaviorSubject, ReplaySubject } from 'rxjs'; import { ServiceStatusLevels, CoreStatus, ServiceStatus } from './types'; import { first } from 'rxjs/operators'; import { ServiceStatusLevelSnapshotSerializer } from './test_utils'; @@ -34,6 +34,28 @@ describe('PluginStatusService', () => { ['c', ['a', 'b']], ]); + describe('set', () => { + it('throws an exception if called after registrations are blocked', () => { + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies, + }); + + service.blockNewRegistrations(); + expect(() => { + service.set( + 'a', + of({ + level: ServiceStatusLevels.available, + summary: 'fail!', + }) + ); + }).toThrowErrorMatchingInlineSnapshot( + `"Custom statuses cannot be registered after setup, plugin [a] attempted"` + ); + }); + }); + describe('getDerivedStatus$', () => { it(`defaults to core's most severe status`, async () => { const serviceAvailable = new PluginsStatusService({ @@ -231,6 +253,75 @@ describe('PluginStatusService', () => { { a: { level: ServiceStatusLevels.available, summary: 'a available' } }, ]); }); + + it('updates when a plugin status observable emits', async () => { + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies: new Map([['a', []]]), + }); + const statusUpdates: Array> = []; + const subscription = service + .getAll$() + .subscribe((pluginStatuses) => statusUpdates.push(pluginStatuses)); + + const aStatus$ = new BehaviorSubject({ + level: ServiceStatusLevels.degraded, + summary: 'a degraded', + }); + service.set('a', aStatus$); + aStatus$.next({ level: ServiceStatusLevels.unavailable, summary: 'a unavailable' }); + aStatus$.next({ level: ServiceStatusLevels.available, summary: 'a available' }); + subscription.unsubscribe(); + + expect(statusUpdates).toEqual([ + { a: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' } }, + { a: { level: ServiceStatusLevels.degraded, summary: 'a degraded' } }, + { a: { level: ServiceStatusLevels.unavailable, summary: 'a unavailable' } }, + { a: { level: ServiceStatusLevels.available, summary: 'a available' } }, + ]); + }); + + it('emits an unavailable status if first emission times out, then continues future emissions', async () => { + jest.useFakeTimers(); + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies: new Map([ + ['a', []], + ['b', ['a']], + ]), + }); + + const pluginA$ = new ReplaySubject(1); + service.set('a', pluginA$); + const firstEmission = service.getAll$().pipe(first()).toPromise(); + jest.runAllTimers(); + + expect(await firstEmission).toEqual({ + a: { level: ServiceStatusLevels.unavailable, summary: 'Status check timed out after 30s' }, + b: { + level: ServiceStatusLevels.unavailable, + summary: '[a]: Status check timed out after 30s', + detail: 'See the status page for more information', + meta: { + affectedServices: { + a: { + level: ServiceStatusLevels.unavailable, + summary: 'Status check timed out after 30s', + }, + }, + }, + }, + }); + + pluginA$.next({ level: ServiceStatusLevels.available, summary: 'a available' }); + const secondEmission = service.getAll$().pipe(first()).toPromise(); + jest.runAllTimers(); + expect(await secondEmission).toEqual({ + a: { level: ServiceStatusLevels.available, summary: 'a available' }, + b: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' }, + }); + jest.useRealTimers(); + }); }); describe('getDependenciesStatus$', () => { diff --git a/src/core/server/status/plugins_status.ts b/src/core/server/status/plugins_status.ts index 1aacbf3be56db..6a8ef1081e165 100644 --- a/src/core/server/status/plugins_status.ts +++ b/src/core/server/status/plugins_status.ts @@ -7,13 +7,22 @@ */ import { BehaviorSubject, Observable, combineLatest, of } from 'rxjs'; -import { map, distinctUntilChanged, switchMap, debounceTime } from 'rxjs/operators'; +import { + map, + distinctUntilChanged, + switchMap, + debounceTime, + timeoutWith, + startWith, +} from 'rxjs/operators'; import { isDeepStrictEqual } from 'util'; import { PluginName } from '../plugins'; -import { ServiceStatus, CoreStatus } from './types'; +import { ServiceStatus, CoreStatus, ServiceStatusLevels } from './types'; import { getSummaryStatus } from './get_summary_status'; +const STATUS_TIMEOUT_MS = 30 * 1000; // 30 seconds + interface Deps { core$: Observable; pluginDependencies: ReadonlyMap; @@ -23,6 +32,7 @@ export class PluginsStatusService { private readonly pluginStatuses = new Map>(); private readonly update$ = new BehaviorSubject(true); private readonly defaultInheritedStatus$: Observable; + private newRegistrationsAllowed = true; constructor(private readonly deps: Deps) { this.defaultInheritedStatus$ = this.deps.core$.pipe( @@ -35,10 +45,19 @@ export class PluginsStatusService { } public set(plugin: PluginName, status$: Observable) { + if (!this.newRegistrationsAllowed) { + throw new Error( + `Custom statuses cannot be registered after setup, plugin [${plugin}] attempted` + ); + } this.pluginStatuses.set(plugin, status$); this.update$.next(true); // trigger all existing Observables to update from the new source Observable } + public blockNewRegistrations() { + this.newRegistrationsAllowed = false; + } + public getAll$(): Observable> { return this.getPluginStatuses$([...this.deps.pluginDependencies.keys()]); } @@ -86,13 +105,22 @@ export class PluginsStatusService { return this.update$.pipe( switchMap(() => { const pluginStatuses = plugins - .map( - (depName) => - [depName, this.pluginStatuses.get(depName) ?? this.getDerivedStatus$(depName)] as [ - PluginName, - Observable - ] - ) + .map((depName) => { + const pluginStatus = this.pluginStatuses.get(depName) + ? this.pluginStatuses.get(depName)!.pipe( + timeoutWith( + STATUS_TIMEOUT_MS, + this.pluginStatuses.get(depName)!.pipe( + startWith({ + level: ServiceStatusLevels.unavailable, + summary: `Status check timed out after ${STATUS_TIMEOUT_MS / 1000}s`, + }) + ) + ) + ) + : this.getDerivedStatus$(depName); + return [depName, pluginStatus] as [PluginName, Observable]; + }) .map(([pName, status$]) => status$.pipe(map((status) => [pName, status] as [PluginName, ServiceStatus])) ); diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index b8c19508a5d61..d4dc8ed3d4d72 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -135,9 +135,11 @@ export class StatusService implements CoreService { } public start() { - if (!this.overall$) { - throw new Error('cannot call `start` before `setup`'); + if (!this.pluginsStatus || !this.overall$) { + throw new Error(`StatusService#setup must be called before #start`); } + this.pluginsStatus.blockNewRegistrations(); + getOverallStatusChanges(this.overall$, this.stop$).subscribe((message) => { this.logger.info(message); }); diff --git a/src/core/server/status/types.ts b/src/core/server/status/types.ts index 411b942c8eb33..bfca4c74d9365 100644 --- a/src/core/server/status/types.ts +++ b/src/core/server/status/types.ts @@ -196,6 +196,9 @@ export interface StatusServiceSetup { * Completely overrides the default inherited status. * * @remarks + * The first emission from this Observable should occur within 30s, else this plugin's status will fallback to + * `unavailable` until the first emission. + * * See the {@link StatusServiceSetup.derivedStatus$} API for leveraging the default status * calculation that is provided by Core. */ diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index f372cf052d368..2c54bb8dba179 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -58,6 +58,9 @@ export const PROJECTS = [ ...glob .sync('test/interpreter_functional/plugins/*/tsconfig.json', { cwd: REPO_ROOT }) .map((path) => new Project(resolve(REPO_ROOT, path))), + ...glob + .sync('test/server_integration/__fixtures__/plugins/*/tsconfig.json', { cwd: REPO_ROOT }) + .map((path) => new Project(resolve(REPO_ROOT, path))), ]; export function filterProjectsByFlag(projectFlag?: string) { diff --git a/test/plugin_functional/test_suites/core_plugins/status.ts b/test/plugin_functional/test_suites/core_plugins/status.ts new file mode 100644 index 0000000000000..2b0f15cb39273 --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/status.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { ServiceStatusLevels } from '../../../../src/core/server'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const log = getService('log'); + + const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const getStatus = async (pluginName?: string) => { + const resp = await supertest.get('/api/status?v8format=true'); + + if (pluginName) { + return resp.body.status.plugins[pluginName]; + } else { + return resp.body.status.overall; + } + }; + + const setStatus = async (level: T) => + supertest + .post(`/internal/core_plugin_a/status/set?level=${level}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + describe('status service', () => { + // This test must comes first because the timeout only applies to the initial emission + it("returns a timeout for status check that doesn't emit after 30s", async () => { + let aStatus = await getStatus('corePluginA'); + expect(aStatus.level).to.eql('unavailable'); + + // Status will remain in unavailable due to core services until custom status timesout + // Keep polling until that condition ends, up to a timeout + const start = Date.now(); + while ('elasticsearch' in (aStatus.meta?.affectedServices ?? {})) { + aStatus = await getStatus('corePluginA'); + expect(aStatus.level).to.eql('unavailable'); + + // If it's been more than 40s, break out of this loop + if (Date.now() - start >= 40_000) { + throw new Error(`Timed out waiting for status timeout after 40s`); + } + + log.info('Waiting for status check to timeout...'); + await delay(2000); + } + + expect(aStatus.summary).to.eql('Status check timed out after 30s'); + }); + + it('propagates status issues to dependencies', async () => { + await setStatus('degraded'); + await delay(1000); + expect((await getStatus('corePluginA')).level).to.eql('degraded'); + expect((await getStatus('corePluginB')).level).to.eql('degraded'); + + await setStatus('available'); + await delay(1000); + expect((await getStatus('corePluginA')).level).to.eql('available'); + expect((await getStatus('corePluginB')).level).to.eql('available'); + }); + }); +} diff --git a/test/scripts/test/server_integration.sh b/test/scripts/test/server_integration.sh index 1ff4a772bb6e0..6ec08c7727e20 100755 --- a/test/scripts/test/server_integration.sh +++ b/test/scripts/test/server_integration.sh @@ -12,3 +12,10 @@ checks-reporter-with-killswitch "Server Integration Tests" \ --bail \ --debug \ --kibana-install-dir $KIBANA_INSTALL_DIR + +# Tests that must be run against source in order to build test plugins +checks-reporter-with-killswitch "Status Integration Tests" \ + node scripts/functional_tests \ + --config test/server_integration/http/platform/config.status.ts \ + --bail \ + --debug \ diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_a/kibana.json b/test/server_integration/__fixtures__/plugins/status_plugin_a/kibana.json new file mode 100644 index 0000000000000..36981d446c9f9 --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_a/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "statusPluginA", + "version": "0.0.1", + "kibanaVersion": "kibana", + "server": true, + "ui": false +} diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_a/package.json b/test/server_integration/__fixtures__/plugins/status_plugin_a/package.json new file mode 100644 index 0000000000000..5c73bca024f4e --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_a/package.json @@ -0,0 +1,14 @@ +{ + "name": "status_plugin_a", + "version": "1.0.0", + "main": "target/test/server_integration/__fixtures__/plugins/status_plugin_a", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "SSPL-1.0 OR Elastic License 2.0", + "scripts": { + "kbn": "node ../../../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../../../node_modules/.bin/tsc" + } +} \ No newline at end of file diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_a/server/index.ts b/test/server_integration/__fixtures__/plugins/status_plugin_a/server/index.ts new file mode 100644 index 0000000000000..cf221c00e32b0 --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_a/server/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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { StatusPluginAPlugin } from './plugin'; + +export const plugin = () => new StatusPluginAPlugin(); diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_a/server/plugin.ts b/test/server_integration/__fixtures__/plugins/status_plugin_a/server/plugin.ts new file mode 100644 index 0000000000000..b2e4f0dd322c4 --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_a/server/plugin.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import { Subject } from 'rxjs'; +import { + Plugin, + CoreSetup, + ServiceStatus, + ServiceStatusLevels, +} from '../../../../../../src/core/server'; + +export class StatusPluginAPlugin implements Plugin { + private status$ = new Subject(); + + public setup(core: CoreSetup, deps: {}) { + // Set a custom status that will not emit immediately to force a timeout + core.status.set(this.status$); + + const router = core.http.createRouter(); + + router.post( + { + path: '/internal/status_plugin_a/status/set', + validate: { + query: schema.object({ + level: schema.oneOf([ + schema.literal('available'), + schema.literal('degraded'), + schema.literal('unavailable'), + schema.literal('critical'), + ]), + }), + }, + }, + (context, req, res) => { + const { level } = req.query; + + this.status$.next({ + level: ServiceStatusLevels[level], + summary: `statusPluginA is ${level}`, + }); + + return res.ok(); + } + ); + } + + public start() {} + public stop() {} +} diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_a/tsconfig.json b/test/server_integration/__fixtures__/plugins/status_plugin_a/tsconfig.json new file mode 100644 index 0000000000000..5069db62589c7 --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_a/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true, + "composite": true + }, + "include": [ + "index.ts", + "server/**/*.ts", + "../../../../../../typings/**/*", + ], + "exclude": [], + "references": [ + { "path": "../../../../../src/core/tsconfig.json" } + ] +} diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_b/kibana.json b/test/server_integration/__fixtures__/plugins/status_plugin_b/kibana.json new file mode 100644 index 0000000000000..fa02f42d500af --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_b/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "statusPluginB", + "version": "0.0.1", + "kibanaVersion": "kibana", + "server": true, + "ui": false, + "requiredPlugins": ["statusPluginA"] +} diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_b/package.json b/test/server_integration/__fixtures__/plugins/status_plugin_b/package.json new file mode 100644 index 0000000000000..3799d5d470754 --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_b/package.json @@ -0,0 +1,14 @@ +{ + "name": "status_plugin_b", + "version": "1.0.0", + "main": "target/test/server_integration/__fixtures__/plugins/status_plugin_b", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "SSPL-1.0 OR Elastic License 2.0", + "scripts": { + "kbn": "node ../../../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../../../node_modules/.bin/tsc" + } +} \ No newline at end of file diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_b/server/index.ts b/test/server_integration/__fixtures__/plugins/status_plugin_b/server/index.ts new file mode 100644 index 0000000000000..2002d234827b9 --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_b/server/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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { StatusPluginBPlugin } from './plugin'; + +export const plugin = () => new StatusPluginBPlugin(); diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_b/server/plugin.ts b/test/server_integration/__fixtures__/plugins/status_plugin_b/server/plugin.ts new file mode 100644 index 0000000000000..191e8135f69a9 --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_b/server/plugin.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Plugin } from 'kibana/server'; + +export class StatusPluginBPlugin implements Plugin { + public setup() {} + public start() {} + public stop() {} +} diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_b/tsconfig.json b/test/server_integration/__fixtures__/plugins/status_plugin_b/tsconfig.json new file mode 100644 index 0000000000000..224aa42ef68d2 --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true, + "composite": true + }, + "include": [ + "index.ts", + "server/**/*.ts", + "../../../../../typings/**/*", + ], + "exclude": [], + "references": [ + { "path": "../../../../../src/core/tsconfig.json" } + ] +} diff --git a/test/server_integration/http/platform/config.status.ts b/test/server_integration/http/platform/config.status.ts new file mode 100644 index 0000000000000..8cc76c901f47c --- /dev/null +++ b/test/server_integration/http/platform/config.status.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import fs from 'fs'; +import path from 'path'; +import { FtrConfigProviderContext } from '@kbn/test'; + +/* + * These tests exist in a separate configuration because: + * 1) It must run as the first test after Kibana launches to clear the unavailable status. A separate config makes this + * easier to manage and prevent from breaking. + * 2) The other server_integration tests run against a built distributable, however the FTR does not support building + * and installing plugins against built Kibana. This test must be run against source only in order to build the + * fixture plugins + */ +// eslint-disable-next-line import/no-default-export +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const httpConfig = await readConfigFile(require.resolve('../../config')); + + // Find all folders in __fixtures__/plugins since we treat all them as plugin folder + const allFiles = fs.readdirSync(path.resolve(__dirname, '../../__fixtures__/plugins')); + const plugins = allFiles.filter((file) => + fs.statSync(path.resolve(__dirname, '../../__fixtures__/plugins', file)).isDirectory() + ); + + return { + testFiles: [ + // Status test should be first to resolve manually created "unavailable" plugin + require.resolve('./status'), + ], + services: httpConfig.get('services'), + servers: httpConfig.get('servers'), + junit: { + reportName: 'Kibana Platform Status Integration Tests', + }, + esTestCluster: httpConfig.get('esTestCluster'), + kbnTestServer: { + ...httpConfig.get('kbnTestServer'), + serverArgs: [ + ...httpConfig.get('kbnTestServer.serverArgs'), + ...plugins.map( + (pluginDir) => + `--plugin-path=${path.resolve(__dirname, '../../__fixtures__/plugins', pluginDir)}` + ), + ], + runOptions: { + ...httpConfig.get('kbnTestServer.runOptions'), + // Don't wait for Kibana to be completely ready so that we can test the status timeouts + wait: /\[Kibana\]\[http\] http server running/, + }, + }, + }; +} diff --git a/test/server_integration/http/platform/status.ts b/test/server_integration/http/platform/status.ts new file mode 100644 index 0000000000000..0dcf82c9bea9e --- /dev/null +++ b/test/server_integration/http/platform/status.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import type { ServiceStatus, ServiceStatusLevels } from '../../../../src/core/server'; +import { FtrProviderContext } from '../../services/types'; + +type ServiceStatusSerialized = Omit & { level: string }; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + + const getStatus = async (pluginName: string): Promise => { + const resp = await supertest.get('/api/status?v8format=true'); + + return resp.body.status.plugins[pluginName]; + }; + + const setStatus = async (level: T) => + supertest + .post(`/internal/status_plugin_a/status/set?level=${level}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + describe('status service', () => { + // This test must comes first because the timeout only applies to the initial emission + it("returns a timeout for status check that doesn't emit after 30s", async () => { + let aStatus = await getStatus('statusPluginA'); + expect(aStatus.level).to.eql('unavailable'); + + // Status will remain in unavailable until the custom status check times out + // Keep polling until that condition ends, up to a timeout + await retry.waitForWithTimeout(`Status check to timeout`, 40_000, async () => { + aStatus = await getStatus('statusPluginA'); + return aStatus.summary === 'Status check timed out after 30s'; + }); + + expect(aStatus.level).to.eql('unavailable'); + expect(aStatus.summary).to.eql('Status check timed out after 30s'); + }); + + it('propagates status issues to dependencies', async () => { + await setStatus('degraded'); + await retry.waitForWithTimeout( + `statusPluginA status to update`, + 5_000, + async () => (await getStatus('statusPluginA')).level === 'degraded' + ); + expect((await getStatus('statusPluginA')).level).to.eql('degraded'); + expect((await getStatus('statusPluginB')).level).to.eql('degraded'); + + await setStatus('available'); + await retry.waitForWithTimeout( + `statusPluginA status to update`, + 5_000, + async () => (await getStatus('statusPluginA')).level === 'available' + ); + expect((await getStatus('statusPluginA')).level).to.eql('available'); + expect((await getStatus('statusPluginB')).level).to.eql('available'); + }); + }); +} diff --git a/test/tsconfig.json b/test/tsconfig.json index 3e02283946080..8cf33d93a4067 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -17,7 +17,12 @@ "api_integration/apis/telemetry/fixtures/*.json", "api_integration/apis/telemetry/fixtures/*.json", ], - "exclude": ["target/**/*", "plugin_functional/plugins/**/*", "interpreter_functional/plugins/**/*"], + "exclude": [ + "target/**/*", + "interpreter_functional/plugins/**/*", + "plugin_functional/plugins/**/*", + "server_integration/__fixtures__/plugins/**/*", + ], "references": [ { "path": "../src/core/tsconfig.json" }, { "path": "../src/plugins/telemetry_management_section/tsconfig.json" }, @@ -52,5 +57,7 @@ { "path": "../src/plugins/visualize/tsconfig.json" }, { "path": "plugin_functional/plugins/core_app_status/tsconfig.json" }, { "path": "plugin_functional/plugins/core_provider_plugin/tsconfig.json" }, + { "path": "server_integration/__fixtures__/plugins/status_plugin_a/tsconfig.json" }, + { "path": "server_integration/__fixtures__/plugins/status_plugin_b/tsconfig.json" }, ] } From 4d514c6db61dfd7d84d2792362f24ec906506700 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 23 Jun 2021 14:20:50 -0400 Subject: [PATCH 02/95] [Lens] Escape field names in formula (#102588) * [Lens] Escape field names in formula * Fix handling of partially typed fields with invalid chars Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-tinymath/grammar/grammar.peggy | 4 +-- packages/kbn-tinymath/test/library.test.js | 3 ++ .../formula/editor/formula_editor.tsx | 12 +++++-- .../formula/editor/math_completion.test.ts | 31 ++++++++++++++++ .../formula/editor/math_completion.ts | 30 ++++++++++++---- .../definitions/formula/generate.ts | 4 +++ .../operations/definitions/formula/util.ts | 2 ++ x-pack/test/functional/apps/lens/formula.ts | 36 +++++++++++++++++++ 8 files changed, 111 insertions(+), 11 deletions(-) diff --git a/packages/kbn-tinymath/grammar/grammar.peggy b/packages/kbn-tinymath/grammar/grammar.peggy index 1c6f8c3334c23..414bc2fa11cb7 100644 --- a/packages/kbn-tinymath/grammar/grammar.peggy +++ b/packages/kbn-tinymath/grammar/grammar.peggy @@ -43,7 +43,7 @@ Literal "literal" // Quoted variables are interpreted as strings // but unquoted variables are more restrictive Variable - = _ [\'] chars:(ValidChar / Space / [\"])* [\'] _ { + = _ '"' chars:("\\\"" { return "\""; } / [^"])* '"' _ { return { type: 'variable', value: chars.join(''), @@ -51,7 +51,7 @@ Variable text: text() }; } - / _ [\"] chars:(ValidChar / Space / [\'])* [\"] _ { + / _ "'" chars:("\\\'" { return "\'"; } / [^'])* "'" _ { return { type: 'variable', value: chars.join(''), diff --git a/packages/kbn-tinymath/test/library.test.js b/packages/kbn-tinymath/test/library.test.js index bbc8503684fd4..9d87919c4f1ac 100644 --- a/packages/kbn-tinymath/test/library.test.js +++ b/packages/kbn-tinymath/test/library.test.js @@ -92,6 +92,7 @@ describe('Parser', () => { expect(parse('@foo0')).toEqual(variableEqual('@foo0')); expect(parse('.foo0')).toEqual(variableEqual('.foo0')); expect(parse('-foo0')).toEqual(variableEqual('-foo0')); + expect(() => parse(`foo😀\t')`)).toThrow('Failed to parse'); }); }); @@ -103,6 +104,7 @@ describe('Parser', () => { expect(parse('"foo bar fizz buzz"')).toEqual(variableEqual('foo bar fizz buzz')); expect(parse('"foo bar baby"')).toEqual(variableEqual('foo bar baby')); expect(parse(`"f'oo"`)).toEqual(variableEqual(`f'oo`)); + expect(parse(`"foo😀\t"`)).toEqual(variableEqual(`foo😀\t`)); }); it('strings with single quotes', () => { @@ -119,6 +121,7 @@ describe('Parser', () => { expect(parse("'foo bar '")).toEqual(variableEqual("foo bar ")); expect(parse("'0foo'")).toEqual(variableEqual("0foo")); expect(parse(`'f"oo'`)).toEqual(variableEqual(`f"oo`)); + expect(parse(`'foo😀\t'`)).toEqual(variableEqual(`foo😀\t`)); /* eslint-enable prettier/prettier */ }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx index 654a93374703d..d1b0ec8876feb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx @@ -29,7 +29,7 @@ import { ParamEditorProps } from '../../index'; import { getManagedColumnsFrom } from '../../../layer_helpers'; import { ErrorWrapper, runASTValidation, tryToParse } from '../validation'; import { - LensMathSuggestion, + LensMathSuggestions, SUGGESTION_TYPE, suggest, getSuggestion, @@ -329,7 +329,7 @@ export function FormulaEditor({ context: monaco.languages.CompletionContext ) => { const innerText = model.getValue(); - let aSuggestions: { list: LensMathSuggestion[]; type: SUGGESTION_TYPE } = { + let aSuggestions: LensMathSuggestions = { list: [], type: SUGGESTION_TYPE.FIELD, }; @@ -367,7 +367,13 @@ export function FormulaEditor({ return { suggestions: aSuggestions.list.map((s) => - getSuggestion(s, aSuggestions.type, visibleOperationsMap, context.triggerCharacter) + getSuggestion( + s, + aSuggestions.type, + visibleOperationsMap, + context.triggerCharacter, + aSuggestions.range + ) ), }; }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.test.ts index 9cd748f5759c9..c55f22dd682d0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.test.ts @@ -18,6 +18,7 @@ import { getHover, suggest, monacoPositionToOffset, + offsetToRowColumn, getInfoAtZeroIndexedPosition, } from './math_completion'; @@ -363,6 +364,36 @@ describe('math completion', () => { }); }); + describe('offsetToRowColumn', () => { + it('should work with single-line strings', () => { + const input = `0123456`; + expect(offsetToRowColumn(input, 5)).toEqual( + expect.objectContaining({ + lineNumber: 1, + column: 6, + }) + ); + }); + + it('should work with multi-line strings accounting for newline characters', () => { + const input = `012 +456 +89')`; + expect(offsetToRowColumn(input, 0)).toEqual( + expect.objectContaining({ + lineNumber: 1, + column: 1, + }) + ); + expect(offsetToRowColumn(input, 9)).toEqual( + expect.objectContaining({ + lineNumber: 3, + column: 2, + }) + ); + }); + }); + describe('monacoPositionToOffset', () => { it('should work with multi-line strings accounting for newline characters', () => { const input = `012 diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.ts index 815df943cdba3..28e762e7dff0f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.ts @@ -13,6 +13,7 @@ import { TinymathLocation, TinymathAST, TinymathFunction, + TinymathVariable, TinymathNamedArgument, } from '@kbn/tinymath'; import type { @@ -21,7 +22,7 @@ import type { } from '../../../../../../../../../src/plugins/data/public'; import { IndexPattern } from '../../../../types'; import { memoizedGetAvailableOperationsByMetadata } from '../../../operations'; -import { tinymathFunctions, groupArgsByType } from '../util'; +import { tinymathFunctions, groupArgsByType, unquotedStringRegex } from '../util'; import type { GenericOperationDefinition } from '../..'; import { getFunctionSignatureLabel, getHelpTextContent } from './formula_help'; import { hasFunctionFieldArgument } from '../validation'; @@ -47,6 +48,7 @@ export type LensMathSuggestion = export interface LensMathSuggestions { list: LensMathSuggestion[]; type: SUGGESTION_TYPE; + range?: monaco.IRange; } function inLocation(cursorPosition: number, location: TinymathLocation) { @@ -92,7 +94,7 @@ export function offsetToRowColumn(expression: string, offset: number): monaco.Po let lineNumber = 1; for (const line of lines) { if (line.length >= remainingChars) { - return new monaco.Position(lineNumber, remainingChars); + return new monaco.Position(lineNumber, remainingChars + 1); } remainingChars -= line.length + 1; lineNumber++; @@ -128,7 +130,7 @@ export async function suggest({ operationDefinitionMap: Record; data: DataPublicPluginStart; dateHistogramInterval?: number; -}): Promise<{ list: LensMathSuggestion[]; type: SUGGESTION_TYPE }> { +}): Promise { const text = expression.substr(0, zeroIndexedOffset) + MARKER + expression.substr(zeroIndexedOffset); try { @@ -154,6 +156,7 @@ export async function suggest({ return getArgumentSuggestions( tokenInfo.parent, tokenInfo.parent.args.findIndex((a) => a === tokenAst), + text, indexPattern, operationDefinitionMap ); @@ -210,6 +213,7 @@ function getFunctionSuggestions( function getArgumentSuggestions( ast: TinymathFunction, position: number, + expression: string, indexPattern: IndexPattern, operationDefinitionMap: Record ) { @@ -280,7 +284,16 @@ function getArgumentSuggestions( .filter((op) => op.operationType === operation.type) .map((op) => ('field' in op ? op.field : undefined)) .filter((field) => field); - return { list: fields as string[], type: SUGGESTION_TYPE.FIELD }; + const fieldArg = ast.args[0]; + const location = typeof fieldArg !== 'string' && (fieldArg as TinymathVariable).location; + let range: monaco.IRange | undefined; + if (location) { + const start = offsetToRowColumn(expression, location.min); + // This accounts for any characters that the user has already typed + const end = offsetToRowColumn(expression, location.max - MARKER.length); + range = monaco.Range.fromPositions(start, end); + } + return { list: fields as string[], type: SUGGESTION_TYPE.FIELD, range }; } else { return { list: [], type: SUGGESTION_TYPE.FIELD }; } @@ -375,7 +388,8 @@ export function getSuggestion( suggestion: LensMathSuggestion, type: SUGGESTION_TYPE, operationDefinitionMap: Record, - triggerChar: string | undefined + triggerChar: string | undefined, + range?: monaco.IRange ): monaco.languages.CompletionItem { let kind: monaco.languages.CompletionItemKind = monaco.languages.CompletionItemKind.Method; let label: string = @@ -397,6 +411,10 @@ export function getSuggestion( break; case SUGGESTION_TYPE.FIELD: kind = monaco.languages.CompletionItemKind.Value; + // Look for unsafe characters + if (unquotedStringRegex.test(label)) { + insertText = `'${label.replaceAll(`'`, "\\'")}'`; + } break; case SUGGESTION_TYPE.FUNCTIONS: insertText = `${label}($0)`; @@ -450,7 +468,7 @@ export function getSuggestion( command, additionalTextEdits: [], // @ts-expect-error Monaco says this type is required, but provides a default value - range: undefined, + range, sortText, filterText, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts index a5c19c537acee..589f547434b91 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts @@ -13,6 +13,7 @@ import { } from '../index'; import { ReferenceBasedIndexPatternColumn } from '../column_types'; import { IndexPatternLayer } from '../../../types'; +import { unquotedStringRegex } from './util'; // Just handle two levels for now type OperationParams = Record>; @@ -25,6 +26,9 @@ export function getSafeFieldName({ if (!fieldName || operationType === 'count') { return ''; } + if (unquotedStringRegex.test(fieldName)) { + return `'${fieldName.replaceAll(`'`, "\\'")}'`; + } return fieldName; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts index d29682eafa329..9806cdaad637e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts @@ -16,6 +16,8 @@ import type { import type { OperationDefinition, IndexPatternColumn, GenericOperationDefinition } from '../index'; import type { GroupedNodes } from './types'; +export const unquotedStringRegex = /[^0-9A-Za-z._@\[\]/]/; + export function groupArgsByType(args: TinymathAST[]) { const { namedArgument, variable, function: functions } = groupBy( args, diff --git a/x-pack/test/functional/apps/lens/formula.ts b/x-pack/test/functional/apps/lens/formula.ts index e9e5051c006f0..38d1f63e946d4 100644 --- a/x-pack/test/functional/apps/lens/formula.ts +++ b/x-pack/test/functional/apps/lens/formula.ts @@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const listingTable = getService('listingTable'); const browser = getService('browser'); const testSubjects = getService('testSubjects'); + const fieldEditor = getService('fieldEditor'); describe('lens formula', () => { it('should transition from count to formula', async () => { @@ -88,6 +89,41 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await element.getVisibleText()).to.equal(`count(kql='Men\\'s Clothing')`); }); + it('should insert single quotes and escape when needed to create valid field name', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + await PageObjects.lens.clickAddField(); + await fieldEditor.setName(`*' "'`); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit('abc')"); + await fieldEditor.save(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsDatatable_metrics > lns-empty-dimension', + operation: 'unique_count', + field: `*`, + keepOpen: true, + }); + + await PageObjects.lens.switchToFormula(); + let element = await find.byCssSelector('.monaco-editor'); + expect(await element.getVisibleText()).to.equal(`unique_count('*\\' "\\'')`); + + const input = await find.activeElement(); + await input.clearValueWithKeyboard({ charByChar: true }); + await input.type('unique_count('); + await PageObjects.common.sleep(100); + await input.type('*'); + await input.pressKeys(browser.keys.ENTER); + + await PageObjects.common.sleep(100); + + element = await find.byCssSelector('.monaco-editor'); + expect(await element.getVisibleText()).to.equal(`unique_count('*\\' "\\'')`); + }); + it('should persist a broken formula on close', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); From 391d0eca27704f8884d77224f49b1632724f5a9d Mon Sep 17 00:00:00 2001 From: Constance Date: Wed, 23 Jun 2021 11:30:36 -0700 Subject: [PATCH 03/95] [App Search] Remove external "Launch App Search" button (#100815) * Remove markup * Remove i18n translations * Remove telemetry metric --- .../components/engines/components/index.ts | 1 - .../components/launch_as_button.test.tsx | 27 ------------ .../engines/components/launch_as_button.tsx | 41 ------------------- .../components/engines/engines_overview.tsx | 7 +--- .../collectors/app_search/telemetry.test.ts | 7 +--- .../server/collectors/app_search/telemetry.ts | 4 -- .../schema/xpack_plugins.json | 3 -- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 9 files changed, 4 insertions(+), 88 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/index.ts index 1d8e578e0edf2..63235f8a992f0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/index.ts @@ -5,6 +5,5 @@ * 2.0. */ -export { LaunchAppSearchButton } from './launch_as_button'; export { EmptyState } from './empty_state'; export { EmptyMetaEnginesState } from './empty_meta_engines_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.test.tsx deleted file mode 100644 index 93c91cc3830f4..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.test.tsx +++ /dev/null @@ -1,27 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import '../../../../__mocks__/enterprise_search_url.mock'; -import { mockTelemetryActions } from '../../../../__mocks__/kea_logic'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { LaunchAppSearchButton } from './'; - -describe('LaunchAppSearchButton', () => { - it('renders a launch app search button that sends telemetry on click', () => { - const button = shallow(); - - expect(button.prop('href')).toBe('http://localhost:3002/as'); - expect(button.prop('isDisabled')).toBeFalsy(); - - button.simulate('click'); - expect(mockTelemetryActions.sendAppSearchTelemetry).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.tsx deleted file mode 100644 index 41102cb4fba2e..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.tsx +++ /dev/null @@ -1,41 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { useActions } from 'kea'; - -import { EuiButton } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -import { getAppSearchUrl } from '../../../../shared/enterprise_search_url'; -import { TelemetryLogic } from '../../../../shared/telemetry'; - -export const LaunchAppSearchButton: React.FC = () => { - const { sendAppSearchTelemetry } = useActions(TelemetryLogic); - - return ( - // eslint-disable-next-line @elastic/eui/href-or-on-click - - sendAppSearchTelemetry({ - action: 'clicked', - metric: 'header_launch_button', - }) - } - data-test-subj="launchButton" - > - {i18n.translate('xpack.enterpriseSearch.appSearch.productCta', { - defaultMessage: 'Launch App Search', - })} - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx index 4dff246052138..d1dd5514757d7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx @@ -20,7 +20,7 @@ import { ENGINE_CREATION_PATH, META_ENGINE_CREATION_PATH } from '../../routes'; import { DataPanel } from '../data_panel'; import { AppSearchPageTemplate } from '../layout'; -import { LaunchAppSearchButton, EmptyState, EmptyMetaEnginesState } from './components'; +import { EmptyState, EmptyMetaEnginesState } from './components'; import { EnginesTable } from './components/tables/engines_table'; import { MetaEnginesTable } from './components/tables/meta_engines_table'; import { @@ -65,10 +65,7 @@ export const EnginesOverview: React.FC = () => { ], - }} + pageHeader={{ pageTitle: ENGINES_OVERVIEW_TITLE }} isLoading={dataLoading} isEmptyState={!engines.length} emptyState={} diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts index 350c27fa43cd3..5580c3dac5996 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts @@ -25,8 +25,7 @@ describe('App Search Telemetry Usage Collector', () => { 'ui_error.cannot_connect': 3, 'ui_error.not_found': 7, 'ui_clicked.create_first_engine_button': 40, - 'ui_clicked.header_launch_button': 50, - 'ui_clicked.engine_table_link': 60, + 'ui_clicked.engine_table_link': 50, }, }), incrementCounter: jest.fn(), @@ -66,8 +65,7 @@ describe('App Search Telemetry Usage Collector', () => { }, ui_clicked: { create_first_engine_button: 40, - header_launch_button: 50, - engine_table_link: 60, + engine_table_link: 50, }, }); }); @@ -93,7 +91,6 @@ describe('App Search Telemetry Usage Collector', () => { }, ui_clicked: { create_first_engine_button: 0, - header_launch_button: 0, engine_table_link: 0, }, }); diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts index 36ba2976f929a..4dca6ed58e0c5 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts @@ -23,7 +23,6 @@ interface Telemetry { }; ui_clicked: { create_first_engine_button: number; - header_launch_button: number; engine_table_link: number; }; } @@ -54,7 +53,6 @@ export const registerTelemetryUsageCollector = ( }, ui_clicked: { create_first_engine_button: { type: 'long' }, - header_launch_button: { type: 'long' }, engine_table_link: { type: 'long' }, }, }, @@ -85,7 +83,6 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log }, ui_clicked: { create_first_engine_button: 0, - header_launch_button: 0, engine_table_link: 0, }, }; @@ -110,7 +107,6 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log 'ui_clicked.create_first_engine_button', 0 ), - header_launch_button: get(savedObjectAttributes, 'ui_clicked.header_launch_button', 0), engine_table_link: get(savedObjectAttributes, 'ui_clicked.engine_table_link', 0), }, } as Telemetry; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index fa387ddc151fc..9230b4d829853 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -1924,9 +1924,6 @@ "create_first_engine_button": { "type": "long" }, - "header_launch_button": { - "type": "long" - }, "engine_table_link": { "type": "long" } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c6716a1fa77d4..e246cd0681053 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7857,7 +7857,6 @@ "xpack.enterpriseSearch.appSearch.multiInputRows.removeValueButtonLabel": "値を削除", "xpack.enterpriseSearch.appSearch.ownerRoleTypeDescription": "所有者はすべての操作を実行できます。アカウントには複数の所有者がいる場合がありますが、一度に少なくとも1人の所有者が必要です。", "xpack.enterpriseSearch.appSearch.productCardDescription": "Elastic App Search には、強力な検索を設計し、Web サイトや Web/モバイルアプリケーションにデプロイするための使いやすいツールがあります。", - "xpack.enterpriseSearch.appSearch.productCta": "App Searchの起動", "xpack.enterpriseSearch.appSearch.productDescription": "ダッシュボード、分析、APIを活用し、高度なアプリケーション検索をシンプルにします。", "xpack.enterpriseSearch.appSearch.productName": "App Search", "xpack.enterpriseSearch.appSearch.result.documentDetailLink": "ドキュメントの詳細を表示", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8b654a821d4dc..6a96769e2da1e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7924,7 +7924,6 @@ "xpack.enterpriseSearch.appSearch.multiInputRows.removeValueButtonLabel": "删除值", "xpack.enterpriseSearch.appSearch.ownerRoleTypeDescription": "所有者可以执行任何操作。该帐户可以有很多所有者,但任何时候必须至少有一个所有者。", "xpack.enterpriseSearch.appSearch.productCardDescription": "Elastic App Search 提供用户友好的工具,用于设计强大的搜索功能,并将其部署到您的网站或 Web/移动应用程序。", - "xpack.enterpriseSearch.appSearch.productCta": "启动 App Search", "xpack.enterpriseSearch.appSearch.productDescription": "利用仪表板、分析和 API 执行高级应用程序搜索简单易行。", "xpack.enterpriseSearch.appSearch.productName": "App Search", "xpack.enterpriseSearch.appSearch.result.documentDetailLink": "访问文档详情", From 73382cebafabe60e50d3f66841fcb7c61934b844 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Wed, 23 Jun 2021 13:36:26 -0500 Subject: [PATCH 04/95] [ML] Add Index Pattern Management to Index Data Visualizer (#101316) * [ML] Add index pattern editor flyout * [ML] Add indexPatternField editor plugin as opt dependency * [ML] Remove lens from ML's dependency * [ML] Fix custom display name cause field to be missing * [ML] Add delete option * [ML] Fix aggregatableFields logic * [ML] Add functional tests * [ML] Fix labels & consolidate addRuntimeFields * [ML] Add tooltip to show or hide distributions * Consolidate refreshPage * [ML] Fix tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/field_editor/field_editor.tsx | 6 +- x-pack/plugins/data_visualizer/kibana.json | 3 +- .../date_picker_wrapper.tsx | 4 +- .../field_data_row/action_menu/actions.ts | 84 +++++- .../data_visualizer_stats_table.tsx | 57 ++-- .../stats_table/types/field_vis_config.ts | 3 + .../index_data_visualizer_view.tsx | 86 ++++-- .../index_pattern_management/index.ts | 8 + .../index_pattern_management.tsx | 128 ++++++++ .../data_loader/data_loader.ts | 4 +- .../index_data_visualizer.tsx | 12 +- .../services/timefilter_refresh_service.ts | 3 +- .../public/application/kibana_context.ts | 3 +- .../plugins/data_visualizer/public/plugin.ts | 2 + x-pack/plugins/ml/kibana.json | 1 - .../ml/public/__mocks__/ml_start_deps.ts | 2 - x-pack/plugins/ml/public/application/app.tsx | 1 - .../contexts/kibana/kibana_context.ts | 2 - x-pack/plugins/ml/public/plugin.ts | 3 - x-pack/plugins/ml/tsconfig.json | 3 +- .../data_visualizer/file_data_visualizer.ts | 1 + .../apps/ml/data_visualizer/index.ts | 1 + ...ata_visualizer_index_pattern_management.ts | 274 ++++++++++++++++++ ...ata_visualizer_index_pattern_management.ts | 118 ++++++++ .../services/ml/data_visualizer_table.ts | 144 ++++++++- x-pack/test/functional/services/ml/index.ts | 6 + 26 files changed, 886 insertions(+), 73 deletions(-) create mode 100644 x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index.ts create mode 100644 x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index_pattern_management.tsx create mode 100644 x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_index_pattern_management.ts create mode 100644 x-pack/test/functional/services/ml/data_visualizer_index_pattern_management.ts diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx index fc25879b128ec..77ef0903bc6fc 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx @@ -216,7 +216,11 @@ const FieldEditorComponent = ({ Boolean(field?.type) && field?.type !== (updatedType && updatedType[0].value); return ( -
+ {/* Name */} diff --git a/x-pack/plugins/data_visualizer/kibana.json b/x-pack/plugins/data_visualizer/kibana.json index b024a52e64721..00eb3d7bf142c 100644 --- a/x-pack/plugins/data_visualizer/kibana.json +++ b/x-pack/plugins/data_visualizer/kibana.json @@ -16,7 +16,8 @@ "security", "maps", "home", - "lens" + "lens", + "indexPatternFieldEditor" ], "requiredBundles": [ "home", diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/date_picker_wrapper/date_picker_wrapper.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/date_picker_wrapper/date_picker_wrapper.tsx index f6f53f40d6b9e..52ae5e685316d 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/date_picker_wrapper/date_picker_wrapper.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/date_picker_wrapper/date_picker_wrapper.tsx @@ -18,7 +18,7 @@ import { import { useUrlState } from '../../util/url_state'; import { useDataVisualizerKibana } from '../../../kibana_context'; -import { dataVisualizerTimefilterRefresh$ } from '../../../index_data_visualizer/services/timefilter_refresh_service'; +import { dataVisualizerRefresh$ } from '../../../index_data_visualizer/services/timefilter_refresh_service'; interface TimePickerQuickRange { from: string; @@ -50,7 +50,7 @@ function getRecentlyUsedRangesFactory(timeHistory: TimeHistoryContract) { } function updateLastRefresh(timeRange: OnRefreshProps) { - dataVisualizerTimefilterRefresh$.next({ lastRefresh: Date.now(), timeRange }); + dataVisualizerRefresh$.next({ lastRefresh: Date.now(), timeRange }); } export const DatePickerWrapper: FC = () => { diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts index 414c72c33f057..a77ca1d589349 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts @@ -7,19 +7,37 @@ import { i18n } from '@kbn/i18n'; import { Action } from '@elastic/eui/src/components/basic_table/action_types'; +import { MutableRefObject } from 'react'; import { getCompatibleLensDataType, getLensAttributes } from './lens_utils'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; import { CombinedQuery } from '../../../../index_data_visualizer/types/combined_query'; import { FieldVisConfig } from '../../stats_table/types'; -import { LensPublicStart } from '../../../../../../../lens/public'; +import { DataVisualizerKibanaReactContextValue } from '../../../../kibana_context'; +import { + dataVisualizerRefresh$, + Refresh, +} from '../../../../index_data_visualizer/services/timefilter_refresh_service'; + export function getActions( indexPattern: IndexPattern, - lensPlugin: LensPublicStart, - combinedQuery: CombinedQuery + services: DataVisualizerKibanaReactContextValue['services'], + combinedQuery: CombinedQuery, + actionFlyoutRef: MutableRefObject<(() => void | undefined) | undefined> ): Array> { - const canUseLensEditor = lensPlugin.canUseEditor(); - return [ - { + const { lens: lensPlugin, indexPatternFieldEditor } = services; + + const actions: Array> = []; + + const refreshPage = () => { + const refresh: Refresh = { + lastRefresh: Date.now(), + }; + dataVisualizerRefresh$.next(refresh); + }; + // Navigate to Lens with prefilled chart for data field + if (lensPlugin !== undefined) { + const canUseLensEditor = lensPlugin?.canUseEditor(); + actions.push({ name: i18n.translate('xpack.dataVisualizer.index.dataGrid.exploreInLensTitle', { defaultMessage: 'Explore in Lens', }), @@ -40,6 +58,56 @@ export function getActions( } }, 'data-test-subj': 'dataVisualizerActionViewInLensButton', - }, - ]; + }); + } + + // Allow to edit index pattern field + if (indexPatternFieldEditor?.userPermissions.editIndexPattern()) { + actions.push({ + name: i18n.translate('xpack.dataVisualizer.index.dataGrid.editIndexPatternFieldTitle', { + defaultMessage: 'Edit index pattern field', + }), + description: i18n.translate( + 'xpack.dataVisualizer.index.dataGrid.editIndexPatternFieldDescription', + { + defaultMessage: 'Edit index pattern field', + } + ), + type: 'icon', + icon: 'indexEdit', + onClick: (item: FieldVisConfig) => { + actionFlyoutRef.current = indexPatternFieldEditor?.openEditor({ + ctx: { indexPattern }, + fieldName: item.fieldName, + onSave: refreshPage, + }); + }, + 'data-test-subj': 'dataVisualizerActionEditIndexPatternFieldButton', + }); + actions.push({ + name: i18n.translate('xpack.dataVisualizer.index.dataGrid.deleteIndexPatternFieldTitle', { + defaultMessage: 'Delete index pattern field', + }), + description: i18n.translate( + 'xpack.dataVisualizer.index.dataGrid.deleteIndexPatternFieldDescription', + { + defaultMessage: 'Delete index pattern field', + } + ), + type: 'icon', + icon: 'trash', + available: (item: FieldVisConfig) => { + return item.deletable === true; + }, + onClick: (item: FieldVisConfig) => { + actionFlyoutRef.current = indexPatternFieldEditor?.openDeleteModal({ + ctx: { indexPattern }, + fieldName: item.fieldName!, + onDelete: refreshPage, + }); + }, + 'data-test-subj': 'dataVisualizerActionDeleteIndexPatternFieldButton', + }); + } + return actions; } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx index afadc5c5ae4a4..02e4e29dcc05e 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx @@ -15,6 +15,7 @@ import { EuiIcon, EuiInMemoryTable, EuiText, + EuiToolTip, HorizontalAlignment, LEFT_ALIGNMENT, RIGHT_ALIGNMENT, @@ -111,6 +112,7 @@ export const DataVisualizerTable = ({ width: '40px', isExpander: true, render: (item: DataVisualizerTableItem) => { + const displayName = item.displayName ?? item.fieldName; if (item.fieldName === undefined) return null; const direction = expandedRowItemIds.includes(item.fieldName) ? 'arrowUp' : 'arrowDown'; return ( @@ -121,11 +123,11 @@ export const DataVisualizerTable = ({ expandedRowItemIds.includes(item.fieldName) ? i18n.translate('xpack.dataVisualizer.dataGrid.rowCollapse', { defaultMessage: 'Hide details for {fieldName}', - values: { fieldName: item.fieldName }, + values: { fieldName: displayName }, }) : i18n.translate('xpack.dataVisualizer.dataGrid.rowExpand', { defaultMessage: 'Show details for {fieldName}', - values: { fieldName: item.fieldName }, + values: { fieldName: displayName }, }) } iconType={direction} @@ -157,11 +159,15 @@ export const DataVisualizerTable = ({ }), sortable: true, truncateText: true, - render: (fieldName: string) => ( - - {fieldName} - - ), + render: (fieldName: string, item: DataVisualizerTableItem) => { + const displayName = item.displayName ?? item.fieldName; + + return ( + + {displayName} + + ); + }, align: LEFT_ALIGNMENT as HorizontalAlignment, 'data-test-subj': 'dataVisualizerTableColumnName', }, @@ -194,18 +200,33 @@ export const DataVisualizerTable = ({ {i18n.translate('xpack.dataVisualizer.dataGrid.distributionsColumnName', { defaultMessage: 'Distributions', })} - toggleShowDistribution()} - aria-label={i18n.translate( - 'xpack.dataVisualizer.dataGrid.showDistributionsAriaLabel', - { - defaultMessage: 'Show distributions', + + toggleShowDistribution()} + aria-label={ + !showDistributions + ? i18n.translate('xpack.dataVisualizer.dataGrid.showDistributionsAriaLabel', { + defaultMessage: 'Show distributions', + }) + : i18n.translate('xpack.dataVisualizer.dataGrid.hideDistributionsAriaLabel', { + defaultMessage: 'Hide distributions', + }) } - )} - /> + /> + ), render: (item: DataVisualizerTableItem) => { diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/types/field_vis_config.ts b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/types/field_vis_config.ts index d58497f6cd7cc..eeb9fe12692fd 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/types/field_vis_config.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/types/field_vis_config.ts @@ -24,17 +24,20 @@ export interface MetricFieldVisStats { export interface FieldVisConfig { type: JobFieldType; fieldName?: string; + displayName?: string; existsInDocs: boolean; aggregatable: boolean; loading: boolean; stats?: FieldVisStats; fieldFormat?: any; isUnsupportedType?: boolean; + deletable?: boolean; } export interface FileBasedFieldVisConfig { type: JobFieldType; fieldName?: string; + displayName?: string; stats?: FieldVisStats; format?: string; } diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx index 12441bcfbbb23..b116b25670ad2 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC, Fragment, useEffect, useMemo, useState, useCallback } from 'react'; +import React, { FC, Fragment, useEffect, useMemo, useState, useCallback, useRef } from 'react'; import { merge } from 'rxjs'; import { EuiFlexGroup, @@ -62,10 +62,11 @@ import { kbnTypeToJobType } from '../../../common/util/field_types_utils'; import { SearchPanel } from '../search_panel'; import { ActionsPanel } from '../actions_panel'; import { DatePickerWrapper } from '../../../common/components/date_picker_wrapper'; -import { dataVisualizerTimefilterRefresh$ } from '../../services/timefilter_refresh_service'; +import { dataVisualizerRefresh$ } from '../../services/timefilter_refresh_service'; import { HelpMenu } from '../../../common/components/help_menu'; import { TimeBuckets } from '../../services/time_buckets'; import { extractSearchData } from '../../utils/saved_search_utils'; +import { DataVisualizerIndexPatternManagement } from '../index_pattern_management'; interface DataVisualizerPageState { overallStats: OverallStats; @@ -123,9 +124,8 @@ export interface IndexDataVisualizerViewProps { const restorableDefaults = getDefaultDataVisualizerListState(); export const IndexDataVisualizerView: FC = (dataVisualizerProps) => { - const { - services: { lens: lensPlugin, docLinks, notifications, uiSettings }, - } = useDataVisualizerKibana(); + const { services } = useDataVisualizerKibana(); + const { docLinks, notifications, uiSettings } = services; const { toasts } = notifications; const [dataVisualizerListState, setDataVisualizerListState] = usePageUrlState( @@ -299,7 +299,7 @@ export const IndexDataVisualizerView: FC = (dataVi useEffect(() => { const timeUpdateSubscription = merge( timefilter.getTimeUpdate$(), - dataVisualizerTimefilterRefresh$ + dataVisualizerRefresh$ ).subscribe(() => { setGlobalState({ time: timefilter.getTime(), @@ -533,7 +533,7 @@ export const IndexDataVisualizerView: FC = (dataVi }); const metricExistsFields = allMetricFields.filter((f) => { return aggregatableExistsFields.find((existsF) => { - return existsF.fieldName === f.displayName; + return existsF.fieldName === f.spec.name; }); }); @@ -562,7 +562,7 @@ export const IndexDataVisualizerView: FC = (dataVi metricFieldsToShow.forEach((field) => { const fieldData = aggregatableFields.find((f) => { - return f.fieldName === field.displayName; + return f.fieldName === field.spec.name; }); const metricConfig: FieldVisConfig = { @@ -571,7 +571,11 @@ export const IndexDataVisualizerView: FC = (dataVi type: JOB_FIELD_TYPES.NUMBER, loading: true, aggregatable: true, + deletable: field.runtimeField !== undefined, }; + if (field.displayName !== metricConfig.fieldName) { + metricConfig.displayName = field.displayName; + } configs.push(metricConfig); }); @@ -607,7 +611,7 @@ export const IndexDataVisualizerView: FC = (dataVi allNonMetricFields.forEach((f) => { const checkAggregatableField = aggregatableExistsFields.find( - (existsField) => existsField.fieldName === f.displayName + (existsField) => existsField.fieldName === f.spec.name ); if (checkAggregatableField !== undefined) { @@ -615,7 +619,7 @@ export const IndexDataVisualizerView: FC = (dataVi nonMetricFieldData.push(checkAggregatableField); } else { const checkNonAggregatableField = nonAggregatableExistsFields.find( - (existsField) => existsField.fieldName === f.displayName + (existsField) => existsField.fieldName === f.spec.name ); if (checkNonAggregatableField !== undefined) { @@ -643,7 +647,7 @@ export const IndexDataVisualizerView: FC = (dataVi const configs: FieldVisConfig[] = []; nonMetricFieldsToShow.forEach((field) => { - const fieldData = nonMetricFieldData.find((f) => f.fieldName === field.displayName); + const fieldData = nonMetricFieldData.find((f) => f.fieldName === field.spec.name); const nonMetricConfig = { ...fieldData, @@ -651,6 +655,7 @@ export const IndexDataVisualizerView: FC = (dataVi aggregatable: field.aggregatable, scripted: field.scripted, loading: fieldData.existsInDocs, + deletable: field.runtimeField !== undefined, }; // Map the field type from the Kibana index pattern to the field type @@ -665,6 +670,10 @@ export const IndexDataVisualizerView: FC = (dataVi nonMetricConfig.isUnsupportedType = true; } + if (field.displayName !== nonMetricConfig.fieldName) { + nonMetricConfig.displayName = field.displayName; + } + configs.push(nonMetricConfig); }); @@ -735,13 +744,33 @@ export const IndexDataVisualizerView: FC = (dataVi [currentIndexPattern, searchQueryLanguage, searchString] ); + // Some actions open up fly-out or popup + // This variable is used to keep track of them and clean up when unmounting + const actionFlyoutRef = useRef<() => void | undefined>(); + useEffect(() => { + const ref = actionFlyoutRef; + return () => { + // Clean up any of the flyout/editor opened from the actions + if (ref.current) { + ref.current(); + } + }; + }, []); + // Inject custom action column for the index based visualizer + // Hide the column completely if no access to any of the plugins const extendedColumns = useMemo(() => { - if (lensPlugin === undefined) { - // eslint-disable-next-line no-console - console.error('Lens plugin not available'); - return; - } + const actions = getActions( + currentIndexPattern, + services, + { + searchQueryLanguage, + searchString, + }, + actionFlyoutRef + ); + if (!Array.isArray(actions) || actions.length < 1) return; + const actionColumn: EuiTableActionsColumnType = { name: ( = (dataVi defaultMessage="Actions" /> ), - actions: getActions(currentIndexPattern, lensPlugin, { searchQueryLanguage, searchString }), + actions, width: '100px', }; return [actionColumn]; - }, [currentIndexPattern, lensPlugin, searchQueryLanguage, searchString]); + }, [currentIndexPattern, services, searchQueryLanguage, searchString]); const helpLink = docLinks.links.ml.guide; + return ( @@ -765,10 +795,24 @@ export const IndexDataVisualizerView: FC = (dataVi - -

{currentIndexPattern.title}

-
+
+ +

{currentIndexPattern.title}

+
+ +
+ {currentIndexPattern.timeFieldName !== undefined && ( diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index.ts new file mode 100644 index 0000000000000..c26f84a4c22fc --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { DataVisualizerIndexPatternManagement } from './index_pattern_management'; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index_pattern_management.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index_pattern_management.tsx new file mode 100644 index 0000000000000..cb81640f328c5 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index_pattern_management.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { IndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; +import { useDataVisualizerKibana } from '../../../kibana_context'; +import { dataVisualizerRefresh$, Refresh } from '../../services/timefilter_refresh_service'; + +export interface DataVisualizerIndexPatternManagementProps { + /** + * Currently selected index pattern + */ + currentIndexPattern?: IndexPattern; + /** + * Read from the Fields API + */ + useNewFieldsApi?: boolean; +} + +export function DataVisualizerIndexPatternManagement( + props: DataVisualizerIndexPatternManagementProps +) { + const { + services: { indexPatternFieldEditor, application }, + } = useDataVisualizerKibana(); + + const { useNewFieldsApi, currentIndexPattern } = props; + const indexPatternFieldEditPermission = indexPatternFieldEditor?.userPermissions.editIndexPattern(); + const canEditIndexPatternField = !!indexPatternFieldEditPermission && useNewFieldsApi; + const [isAddIndexPatternFieldPopoverOpen, setIsAddIndexPatternFieldPopoverOpen] = useState(false); + + const closeFieldEditor = useRef<() => void | undefined>(); + useEffect(() => { + return () => { + // Make sure to close the editor when unmounting + if (closeFieldEditor.current) { + closeFieldEditor.current(); + } + }; + }, []); + + if (indexPatternFieldEditor === undefined || !currentIndexPattern || !canEditIndexPatternField) { + return null; + } + + const addField = () => { + closeFieldEditor.current = indexPatternFieldEditor.openEditor({ + ctx: { + indexPattern: currentIndexPattern, + }, + onSave: () => { + const refresh: Refresh = { + lastRefresh: Date.now(), + }; + dataVisualizerRefresh$.next(refresh); + }, + }); + }; + + return ( + { + setIsAddIndexPatternFieldPopoverOpen(false); + }} + ownFocus + data-test-subj="dataVisualizerIndexPatternManagementPopover" + button={ + { + setIsAddIndexPatternFieldPopoverOpen(!isAddIndexPatternFieldPopoverOpen); + }} + /> + } + > + { + setIsAddIndexPatternFieldPopoverOpen(false); + addField(); + }} + > + {i18n.translate('xpack.dataVisualizer.index.indexPatternManagement.addFieldButton', { + defaultMessage: 'Add field to index pattern', + })} + , + { + setIsAddIndexPatternFieldPopoverOpen(false); + application.navigateToApp('management', { + path: `/kibana/indexPatterns/patterns/${props.currentIndexPattern?.id}`, + }); + }} + > + {i18n.translate('xpack.dataVisualizer.index.indexPatternManagement.manageFieldButton', { + defaultMessage: 'Manage index pattern fields', + })} + , + ]} + /> + + ); +} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts index 3cb0d4d672f48..468bd3a2bd7ee 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts @@ -50,9 +50,9 @@ export class DataLoader { const fieldName = field.displayName !== undefined ? field.displayName : field.name; if (this.isDisplayField(fieldName) === true) { if (field.aggregatable === true && field.type !== KBN_FIELD_TYPES.GEO_SHAPE) { - aggregatableFields.push(fieldName); + aggregatableFields.push(field.name); } else { - nonAggregatableFields.push(fieldName); + nonAggregatableFields.push(field.name); } } }); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx index 82a9b93b31a71..f9e9aece48a06 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx @@ -178,7 +178,16 @@ export const DataVisualizerUrlStateContextProvider: FC { const coreStart = getCoreStart(); - const { data, maps, embeddable, share, security, fileUpload, lens } = getPluginsStart(); + const { + data, + maps, + embeddable, + share, + security, + fileUpload, + lens, + indexPatternFieldEditor, + } = getPluginsStart(); const services = { data, maps, @@ -187,6 +196,7 @@ export const IndexDataVisualizer: FC = () => { security, fileUpload, lens, + indexPatternFieldEditor, ...coreStart, }; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/services/timefilter_refresh_service.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/services/timefilter_refresh_service.ts index 49ef9107c3ece..11f286e781219 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/services/timefilter_refresh_service.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/services/timefilter_refresh_service.ts @@ -6,11 +6,10 @@ */ import { Subject } from 'rxjs'; -import { Required } from 'utility-types'; export interface Refresh { lastRefresh: number; timeRange?: { start: string; end: string }; } -export const dataVisualizerTimefilterRefresh$ = new Subject>(); +export const dataVisualizerRefresh$ = new Subject(); diff --git a/x-pack/plugins/data_visualizer/public/application/kibana_context.ts b/x-pack/plugins/data_visualizer/public/application/kibana_context.ts index f7ce13d2fd48d..58d0ac021ff22 100644 --- a/x-pack/plugins/data_visualizer/public/application/kibana_context.ts +++ b/x-pack/plugins/data_visualizer/public/application/kibana_context.ts @@ -6,8 +6,9 @@ */ import { CoreStart } from 'kibana/public'; -import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { KibanaReactContextValue, useKibana } from '../../../../../src/plugins/kibana_react/public'; import type { DataVisualizerStartDependencies } from '../plugin'; export type StartServices = CoreStart & DataVisualizerStartDependencies; +export type DataVisualizerKibanaReactContextValue = KibanaReactContextValue; export const useDataVisualizerKibana = () => useKibana(); diff --git a/x-pack/plugins/data_visualizer/public/plugin.ts b/x-pack/plugins/data_visualizer/public/plugin.ts index 66109de1b1463..4b71b08e9cf27 100644 --- a/x-pack/plugins/data_visualizer/public/plugin.ts +++ b/x-pack/plugins/data_visualizer/public/plugin.ts @@ -17,6 +17,7 @@ import type { FileUploadPluginStart } from '../../file_upload/public'; import type { MapsStartApi } from '../../maps/public'; import type { SecurityPluginSetup } from '../../security/public'; import type { LensPublicStart } from '../../lens/public'; +import type { IndexPatternFieldEditorStart } from '../../../../src/plugins/index_pattern_field_editor/public'; import { getFileDataVisualizerComponent, getIndexDataVisualizerComponent } from './api'; import { getMaxBytesFormatted } from './application/common/util/get_max_bytes'; import { registerHomeAddData, registerHomeFeatureCatalogue } from './register_home'; @@ -32,6 +33,7 @@ export interface DataVisualizerStartDependencies { security?: SecurityPluginSetup; share: SharePluginStart; lens?: LensPublicStart; + indexPatternFieldEditor?: IndexPatternFieldEditorStart; } export type DataVisualizerPluginSetup = ReturnType; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index e3bcf307e6f00..7b3f457106033 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -27,7 +27,6 @@ "management", "licenseManagement", "maps", - "lens", "usageCollection" ], "server": true, diff --git a/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts b/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts index 0907cce832bf8..f16ba27524670 100644 --- a/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts +++ b/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts @@ -9,7 +9,6 @@ import { uiActionsPluginMock } from '../../../../../src/plugins/ui_actions/publi import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { kibanaLegacyPluginMock } from '../../../../../src/plugins/kibana_legacy/public/mocks'; import { embeddablePluginMock } from '../../../../../src/plugins/embeddable/public/mocks'; -import { lensPluginMock } from '../../../lens/public/mocks'; import { triggersActionsUiMock } from '../../../triggers_actions_ui/public/mocks'; export const createMlStartDepsMock = () => ({ @@ -22,7 +21,6 @@ export const createMlStartDepsMock = () => ({ spaces: jest.fn(), embeddable: embeddablePluginMock.createStartContract(), maps: jest.fn(), - lens: lensPluginMock.createStartContract(), triggersActionsUi: triggersActionsUiMock.createStart(), dataVisualizer: jest.fn(), }); diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 8be513f372e56..222d23acb40a7 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -77,7 +77,6 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { data: deps.data, security: deps.security, licenseManagement: deps.licenseManagement, - lens: deps.lens, storage: localStorage, embeddable: deps.embeddable, maps: deps.maps, 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 841f0d03fa21c..1ade617fa60a5 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 @@ -19,7 +19,6 @@ import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/p import type { EmbeddableStart } from '../../../../../../../src/plugins/embeddable/public'; import type { MapsStartApi } from '../../../../../maps/public'; import type { DataVisualizerPluginStart } from '../../../../../data_visualizer/public'; -import type { LensPublicStart } from '../../../../../lens/public'; import { TriggersAndActionsUIPublicPluginStart } from '../../../../../triggers_actions_ui/public'; interface StartPlugins { @@ -29,7 +28,6 @@ interface StartPlugins { share: SharePluginStart; embeddable: EmbeddableStart; maps?: MapsStartApi; - lens?: LensPublicStart; triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; dataVisualizer?: DataVisualizerPluginStart; } diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index e3a4a8348ebc1..917619a67fea9 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -44,7 +44,6 @@ import { registerFeature } from './register_feature'; // Not importing from `ml_url_generator/index` here to avoid importing unnecessary code import { registerUrlGenerator } from './ml_url_generator/ml_url_generator'; import type { MapsStartApi } from '../../maps/public'; -import { LensPublicStart } from '../../lens/public'; import { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, @@ -62,7 +61,6 @@ export interface MlStartDependencies { spaces?: SpacesPluginStart; embeddable: EmbeddableStart; maps?: MapsStartApi; - lens?: LensPublicStart; triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; dataVisualizer: DataVisualizerPluginStart; } @@ -119,7 +117,6 @@ export class MlPlugin implements Plugin { embeddable: { ...pluginsSetup.embeddable, ...pluginsStart.embeddable }, maps: pluginsStart.maps, uiActions: pluginsStart.uiActions, - lens: pluginsStart.lens, kibanaVersion, triggersActionsUi: pluginsStart.triggersActionsUi, dataVisualizer: pluginsStart.dataVisualizer, diff --git a/x-pack/plugins/ml/tsconfig.json b/x-pack/plugins/ml/tsconfig.json index 221718d423383..8e859c35e3f85 100644 --- a/x-pack/plugins/ml/tsconfig.json +++ b/x-pack/plugins/ml/tsconfig.json @@ -16,7 +16,7 @@ "../../../typings/**/*", // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 "public/**/*.json", - "server/**/*.json", + "server/**/*.json" ], "references": [ { "path": "../../../src/core/tsconfig.json" }, @@ -28,7 +28,6 @@ { "path": "../license_management/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, { "path": "../maps/tsconfig.json" }, - { "path": "../lens/tsconfig.json" }, { "path": "../security/tsconfig.json" }, { "path": "../spaces/tsconfig.json" }, { "path": "../alerting/tsconfig.json" }, diff --git a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts index a8fed205a9e56..3867ed6f7dfea 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts @@ -223,6 +223,7 @@ export default function ({ getService }: FtrProviderContext) { fieldRow.docCountFormatted, fieldRow.topValuesCount, false, + false, false ); } diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index.ts b/x-pack/test/functional/apps/ml/data_visualizer/index.ts index 65f7033b5bd66..3e6b644a0b494 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index.ts @@ -13,6 +13,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./index_data_visualizer')); loadTestFile(require.resolve('./index_data_visualizer_actions_panel')); + loadTestFile(require.resolve('./index_data_visualizer_index_pattern_management')); loadTestFile(require.resolve('./file_data_visualizer')); }); } diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_index_pattern_management.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_index_pattern_management.ts new file mode 100644 index 0000000000000..0d9163a872043 --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_index_pattern_management.ts @@ -0,0 +1,274 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { ML_JOB_FIELD_TYPES } from '../../../../../plugins/ml/common/constants/field_types'; +import { FieldVisConfig } from '../../../../../plugins/data_visualizer/public/application/common/components/stats_table/types'; + +interface MetricFieldVisConfig extends FieldVisConfig { + statsMaxDecimalPlaces: number; + docCountFormatted: string; + topValuesCount: number; + viewableInLens: boolean; + hasActionMenu: boolean; +} + +interface NonMetricFieldVisConfig extends FieldVisConfig { + docCountFormatted: string; + exampleCount: number; + viewableInLens: boolean; + hasActionMenu: boolean; +} + +interface TestData { + suiteTitle: string; + sourceIndexOrSavedSearch: string; + rowsPerPage?: 10 | 25 | 50; + newFields?: Array<{ fieldName: string; type: string; script: string }>; + fieldsToRename?: Array<{ originalName: string; newName: string }>; + expected: { + totalDocCountFormatted: string; + metricFields?: MetricFieldVisConfig[]; + nonMetricFields?: NonMetricFieldVisConfig[]; + visibleMetricFieldsCount: number; + totalMetricFieldsCount: number; + populatedFieldsCount: number; + totalFieldsCount: number; + }; +} + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + + const originalTestData: TestData = { + suiteTitle: 'original index pattern', + sourceIndexOrSavedSearch: 'ft_farequote', + expected: { + totalDocCountFormatted: '86,274', + metricFields: [], + nonMetricFields: [], + visibleMetricFieldsCount: 1, + totalMetricFieldsCount: 1, + populatedFieldsCount: 7, + totalFieldsCount: 8, + }, + }; + const addDeleteFieldTestData: TestData = { + suiteTitle: 'add field', + sourceIndexOrSavedSearch: 'ft_farequote', + newFields: [ + { + fieldName: 'rt_airline_lowercase', + type: 'Keyword', + script: 'emit(params._source.airline.toLowerCase())', + }, + ], + expected: { + totalDocCountFormatted: '86,274', + metricFields: [], + nonMetricFields: [ + { + fieldName: 'rt_airline_lowercase', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 10, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + hasActionMenu: true, + }, + ], + visibleMetricFieldsCount: 2, + totalMetricFieldsCount: 2, + populatedFieldsCount: 9, + totalFieldsCount: 10, + }, + }; + const customLabelTestData: TestData = { + suiteTitle: 'custom label', + sourceIndexOrSavedSearch: 'ft_farequote', + fieldsToRename: [ + { + originalName: 'responsetime', + newName: 'new_responsetime', + }, + ], + expected: { + totalDocCountFormatted: '86,274', + metricFields: [ + { + fieldName: 'new_responsetime', + type: ML_JOB_FIELD_TYPES.NUMBER, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '5000 (100%)', + statsMaxDecimalPlaces: 3, + topValuesCount: 10, + viewableInLens: true, + hasActionMenu: false, + }, + ], + nonMetricFields: [], + visibleMetricFieldsCount: 1, + totalMetricFieldsCount: 1, + populatedFieldsCount: 7, + totalFieldsCount: 8, + }, + }; + + async function navigateToIndexDataVisualizer(testData: TestData) { + // Start navigation from the base of the ML app. + await ml.testExecution.logTestStep( + `${testData.suiteTitle} loads the data visualizer selector page` + ); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataVisualizer(); + + await ml.testExecution.logTestStep( + `${testData.suiteTitle} loads the saved search selection page` + ); + await ml.dataVisualizer.navigateToIndexPatternSelection(); + + await ml.testExecution.logTestStep( + `${testData.suiteTitle} loads the index data visualizer page` + ); + await ml.jobSourceSelection.selectSourceForIndexBasedDataVisualizer( + testData.sourceIndexOrSavedSearch + ); + + await ml.testExecution.logTestStep(`${testData.suiteTitle} displays the time range step`); + await ml.dataVisualizerIndexBased.assertTimeRangeSelectorSectionExists(); + + await ml.testExecution.logTestStep(`${testData.suiteTitle} loads data for full time range`); + await ml.dataVisualizerIndexBased.clickUseFullDataButton( + testData.expected.totalDocCountFormatted + ); + } + + async function checkPageDetails(testData: TestData) { + await ml.testExecution.logTestStep( + `${testData.suiteTitle} displays elements in the doc count panel correctly` + ); + await ml.dataVisualizerIndexBased.assertTotalDocCountHeaderExist(); + await ml.dataVisualizerIndexBased.assertTotalDocCountChartExist(); + + await ml.testExecution.logTestStep( + `${testData.suiteTitle} displays elements in the data visualizer table correctly` + ); + await ml.dataVisualizerIndexBased.assertDataVisualizerTableExist(); + + if (testData.rowsPerPage) { + await ml.dataVisualizerTable.ensureNumRowsPerPage(testData.rowsPerPage); + } + + await ml.dataVisualizerTable.assertSearchPanelExist(); + await ml.dataVisualizerTable.assertSampleSizeInputExists(); + await ml.dataVisualizerTable.assertFieldTypeInputExists(); + await ml.dataVisualizerTable.assertFieldNameInputExists(); + + await ml.dataVisualizerIndexBased.assertFieldCountPanelExist(); + await ml.dataVisualizerIndexBased.assertMetricFieldsSummaryExist(); + await ml.dataVisualizerIndexBased.assertFieldsSummaryExist(); + await ml.dataVisualizerIndexBased.assertVisibleMetricFieldsCount( + testData.expected.visibleMetricFieldsCount + ); + await ml.dataVisualizerIndexBased.assertTotalMetricFieldsCount( + testData.expected.totalMetricFieldsCount + ); + await ml.dataVisualizerIndexBased.assertVisibleFieldsCount( + testData.expected.populatedFieldsCount + ); + await ml.dataVisualizerIndexBased.assertTotalFieldsCount(testData.expected.totalFieldsCount); + } + + describe('index pattern management', function () { + this.tags(['mlqa']); + const indexPatternTitle = 'ft_farequote'; + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); + + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.securityUI.loginAsMlPowerUser(); + }); + + beforeEach(async () => { + await ml.testResources.createIndexPatternIfNeeded(indexPatternTitle, '@timestamp'); + await navigateToIndexDataVisualizer(originalTestData); + }); + + afterEach(async () => { + await ml.testResources.deleteIndexPatternByTitle(indexPatternTitle); + }); + + it(`adds new field`, async () => { + await ml.testExecution.logTestStep('adds new runtime fields'); + for (const newField of addDeleteFieldTestData.newFields!) { + await ml.dataVisualizerIndexPatternManagement.addRuntimeField( + newField.fieldName, + newField.script, + newField.type + ); + } + + await ml.testExecution.logTestStep('displays details for added runtime metric fields'); + for (const fieldRow of addDeleteFieldTestData.expected.metricFields as Array< + Required + >) { + await ml.dataVisualizerTable.assertNumberFieldContents( + fieldRow.fieldName, + fieldRow.docCountFormatted, + fieldRow.topValuesCount, + fieldRow.viewableInLens, + fieldRow.hasActionMenu + ); + } + await ml.testExecution.logTestStep('displays details for added runtime non metric fields'); + for (const fieldRow of addDeleteFieldTestData.expected.nonMetricFields!) { + await ml.dataVisualizerTable.assertNonMetricFieldContents( + fieldRow.type, + fieldRow.fieldName!, + fieldRow.docCountFormatted, + fieldRow.exampleCount, + fieldRow.viewableInLens, + fieldRow.hasActionMenu + ); + } + await checkPageDetails(addDeleteFieldTestData); + }); + + it(`sets custom label for existing field`, async () => { + for (const field of customLabelTestData.fieldsToRename!) { + await ml.dataVisualizerIndexPatternManagement.renameField( + field.originalName, + field.newName + ); + await ml.dataVisualizerTable.assertDisplayName(field.originalName, field.newName); + } + }); + + it(`deletes existing field`, async () => { + await ml.testExecution.logTestStep('adds new runtime fields'); + for (const newField of addDeleteFieldTestData.newFields!) { + await ml.dataVisualizerIndexPatternManagement.addRuntimeField( + newField.fieldName, + newField.script, + newField.type + ); + } + await ml.testExecution.logTestStep('deletes newly added runtime fields'); + for (const fieldToDelete of addDeleteFieldTestData.newFields!) { + await ml.dataVisualizerIndexPatternManagement.deleteField(fieldToDelete.fieldName); + } + + await ml.testExecution.logTestStep('displays page details without the deleted fields'); + await checkPageDetails(originalTestData); + }); + }); +} diff --git a/x-pack/test/functional/services/ml/data_visualizer_index_pattern_management.ts b/x-pack/test/functional/services/ml/data_visualizer_index_pattern_management.ts new file mode 100644 index 0000000000000..e5d884b22514b --- /dev/null +++ b/x-pack/test/functional/services/ml/data_visualizer_index_pattern_management.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { MlDataVisualizerTable } from './data_visualizer_table'; + +export function MachineLearningDataVisualizerIndexPatternManagementProvider( + { getService }: FtrProviderContext, + dataVisualizerTable: MlDataVisualizerTable +) { + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const fieldEditor = getService('fieldEditor'); + const comboBox = getService('comboBox'); + + return { + async assertIndexPatternManagementButtonExists() { + await testSubjects.existOrFail('dataVisualizerIndexPatternManagementButton'); + }, + async assertIndexPatternManagementMenuExists() { + await testSubjects.existOrFail('dataVisualizerIndexPatternManagementMenu'); + }, + async assertIndexPatternFieldEditorExists() { + await testSubjects.existOrFail('indexPatternFieldEditorForm'); + }, + + async assertIndexPatternFieldEditorNotExist() { + await testSubjects.missingOrFail('indexPatternFieldEditorForm'); + }, + + async clickIndexPatternManagementButton() { + await retry.tryForTime(5000, async () => { + await testSubjects.clickWhenNotDisabled('dataVisualizerIndexPatternManagementButton'); + await this.assertIndexPatternManagementMenuExists(); + }); + }, + async clickAddIndexPatternFieldAction() { + await retry.tryForTime(5000, async () => { + await this.assertIndexPatternManagementMenuExists(); + await testSubjects.clickWhenNotDisabled('dataVisualizerAddIndexPatternFieldAction'); + await this.assertIndexPatternFieldEditorExists(); + }); + }, + + async clickManageIndexPatternAction() { + await retry.tryForTime(5000, async () => { + await this.assertIndexPatternManagementMenuExists(); + await testSubjects.clickWhenNotDisabled('dataVisualizerManageIndexPatternAction'); + await testSubjects.existOrFail('editIndexPattern'); + }); + }, + + async assertIndexPatternFieldEditorFieldType(expectedIdentifier: string) { + await retry.tryForTime(2000, async () => { + const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( + 'typeField > comboBoxInput' + ); + expect(comboBoxSelectedOptions).to.eql( + expectedIdentifier === '' ? [] : [expectedIdentifier], + `Expected type field to be '${expectedIdentifier}' (got '${comboBoxSelectedOptions}')` + ); + }); + }, + + async setIndexPatternFieldEditorFieldType(type: string) { + await comboBox.set('typeField > comboBoxInput', type); + + await this.assertIndexPatternFieldEditorFieldType(type); + }, + + async addRuntimeField(name: string, script: string, fieldType: string) { + await retry.tryForTime(5000, async () => { + await this.clickIndexPatternManagementButton(); + await this.clickAddIndexPatternFieldAction(); + + await this.assertIndexPatternFieldEditorExists(); + await fieldEditor.setName(name); + await fieldEditor.enableValue(); + await fieldEditor.typeScript(script); + await this.setIndexPatternFieldEditorFieldType(fieldType); + + await fieldEditor.save(); + await this.assertIndexPatternFieldEditorNotExist(); + }); + }, + + async renameField(originalName: string, newName: string) { + await retry.tryForTime(5000, async () => { + await dataVisualizerTable.clickEditIndexPatternFieldButton(originalName); + await this.assertIndexPatternFieldEditorExists(); + await fieldEditor.enableCustomLabel(); + await fieldEditor.setCustomLabel(newName); + await fieldEditor.save(); + await this.assertIndexPatternFieldEditorNotExist(); + }); + }, + + async confirmDeleteField() { + await testSubjects.existOrFail('deleteModalConfirmText'); + await testSubjects.setValue('deleteModalConfirmText', 'remove'); + await testSubjects.click('confirmModalConfirmButton'); + await testSubjects.missingOrFail('deleteModalConfirmText'); + }, + + async deleteField(fieldName: string) { + await retry.tryForTime(5000, async () => { + await dataVisualizerTable.clickActionMenuDeleteIndexPatternFieldButton(fieldName); + await this.confirmDeleteField(); + await dataVisualizerTable.assertRowNotExists(fieldName); + }); + }, + }; +} diff --git a/x-pack/test/functional/services/ml/data_visualizer_table.ts b/x-pack/test/functional/services/ml/data_visualizer_table.ts index 1eb0edbe01c8e..2f67a9b75e3d6 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_table.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_table.ts @@ -18,6 +18,8 @@ export function MachineLearningDataVisualizerTableProvider( ) { const retry = getService('retry'); const testSubjects = getService('testSubjects'); + const find = getService('find'); + const browser = getService('browser'); return new (class DataVisualizerTable { public async parseDataVisualizerTable() { @@ -79,6 +81,25 @@ export function MachineLearningDataVisualizerTableProvider( await testSubjects.existOrFail(this.rowSelector(fieldName)); } + public async assertRowNotExists(fieldName: string) { + await retry.tryForTime(1000, async () => { + await testSubjects.missingOrFail(this.rowSelector(fieldName)); + }); + } + + public async assertDisplayName(fieldName: string, expectedDisplayName: string) { + await retry.tryForTime(10000, async () => { + const subj = await testSubjects.find( + this.rowSelector(fieldName, `dataVisualizerDisplayName-${fieldName}`) + ); + const displayName = await subj.getVisibleText(); + expect(displayName).to.eql( + expectedDisplayName, + `Expected display name of ${fieldName} to be '${expectedDisplayName}' (got '${displayName}')` + ); + }); + } + public detailsSelector(fieldName: string, subSelector?: string) { const row = `~dataVisualizerTable > ~dataVisualizerFieldExpandedRow-${fieldName}`; return !subSelector ? row : `${row} > ${subSelector}`; @@ -133,10 +154,85 @@ export function MachineLearningDataVisualizerTableProvider( ); } - public async assertViewInLensActionEnabled(fieldName: string) { + public async ensureAllMenuPopoversClosed() { + await retry.tryForTime(5000, async () => { + await browser.pressKeys(browser.keys.ESCAPE); + const popoverExists = await find.existsByCssSelector('euiContextMenuPanel'); + expect(popoverExists).to.eql(false, 'All popovers should be closed'); + }); + } + + public async ensureActionsMenuOpen(fieldName: string) { + await retry.tryForTime(30 * 1000, async () => { + await this.ensureAllMenuPopoversClosed(); + await testSubjects.click(this.rowSelector(fieldName, 'euiCollapsedItemActionsButton')); + await find.existsByCssSelector('euiContextMenuPanel'); + }); + } + + public async assertActionsMenuClosed(fieldName: string, action: string) { + await retry.tryForTime(30 * 1000, async () => { + await testSubjects.missingOrFail(action, { timeout: 5000 }); + }); + } + + public async assertActionMenuViewInLensEnabled(fieldName: string, expectedValue: boolean) { + await retry.tryForTime(30 * 1000, async () => { + await this.ensureActionsMenuOpen(fieldName); + const actionMenuViewInLensButton = await find.byCssSelector( + '[data-test-subj="dataVisualizerActionViewInLensButton"][class="euiContextMenuItem"]' + ); + const isEnabled = await actionMenuViewInLensButton.isEnabled(); + expect(isEnabled).to.eql( + expectedValue, + `Expected "Explore in lens" action menu button for '${fieldName}' to be '${ + expectedValue ? 'enabled' : 'disabled' + }' (got '${isEnabled ? 'enabled' : 'disabled'}')` + ); + }); + } + + public async assertActionMenuDeleteIndexPatternFieldButtonEnabled( + fieldName: string, + expectedValue: boolean + ) { + await this.ensureActionsMenuOpen(fieldName); + const actionMenuViewInLensButton = await find.byCssSelector( + '[data-test-subj="dataVisualizerActionDeleteIndexPatternFieldButton"][class="euiContextMenuItem"]' + ); + const isEnabled = await actionMenuViewInLensButton.isEnabled(); + expect(isEnabled).to.eql( + expectedValue, + `Expected "Delete index pattern field" action menu button for '${fieldName}' to be '${ + expectedValue ? 'enabled' : 'disabled' + }' (got '${isEnabled ? 'enabled' : 'disabled'}')` + ); + } + + public async clickActionMenuDeleteIndexPatternFieldButton(fieldName: string) { + const testSubj = 'dataVisualizerActionDeleteIndexPatternFieldButton'; + await retry.tryForTime(5000, async () => { + await this.ensureActionsMenuOpen(fieldName); + + const button = await find.byCssSelector( + `[data-test-subj="${testSubj}"][class="euiContextMenuItem"]` + ); + await button.click(); + await this.assertActionsMenuClosed(fieldName, testSubj); + await testSubjects.existOrFail('runtimeFieldDeleteConfirmModal'); + }); + } + + public async assertViewInLensActionEnabled(fieldName: string, expectedValue: boolean) { const actionButton = this.rowSelector(fieldName, 'dataVisualizerActionViewInLensButton'); await testSubjects.existOrFail(actionButton); - await testSubjects.isEnabled(actionButton); + const isEnabled = await testSubjects.isEnabled(actionButton); + expect(isEnabled).to.eql( + expectedValue, + `Expected "Explore in lens" button for '${fieldName}' to be '${ + expectedValue ? 'enabled' : 'disabled' + }' (got '${isEnabled ? 'enabled' : 'disabled'}')` + ); } public async assertViewInLensActionNotExists(fieldName: string) { @@ -144,6 +240,34 @@ export function MachineLearningDataVisualizerTableProvider( await testSubjects.missingOrFail(actionButton); } + public async assertEditIndexPatternFieldButtonEnabled( + fieldName: string, + expectedValue: boolean + ) { + const selector = this.rowSelector( + fieldName, + 'dataVisualizerActionEditIndexPatternFieldButton' + ); + await testSubjects.existOrFail(selector); + const isEnabled = await testSubjects.isEnabled(selector); + expect(isEnabled).to.eql( + expectedValue, + `Expected "Edit index pattern" button for '${fieldName}' to be '${ + expectedValue ? 'enabled' : 'disabled' + }' (got '${isEnabled ? 'enabled' : 'disabled'}')` + ); + } + + public async clickEditIndexPatternFieldButton(fieldName: string) { + await retry.tryForTime(5000, async () => { + await this.assertEditIndexPatternFieldButtonEnabled(fieldName, true); + await testSubjects.click( + this.rowSelector(fieldName, 'dataVisualizerActionEditIndexPatternFieldButton') + ); + await testSubjects.existOrFail('indexPatternFieldEditorForm'); + }); + } + public async assertFieldDistinctValuesExist(fieldName: string) { const selector = this.rowSelector(fieldName, 'dataVisualizerTableColumnDistinctValues'); await testSubjects.existOrFail(selector); @@ -263,6 +387,7 @@ export function MachineLearningDataVisualizerTableProvider( docCountFormatted: string, topValuesCount: number, viewableInLens: boolean, + hasActionMenu = false, checkDistributionPreviewExist = true ) { await this.assertRowExists(fieldName); @@ -282,7 +407,11 @@ export function MachineLearningDataVisualizerTableProvider( await this.assertDistributionPreviewExist(fieldName); } if (viewableInLens) { - await this.assertViewInLensActionEnabled(fieldName); + if (hasActionMenu) { + await this.assertActionMenuViewInLensEnabled(fieldName, true); + } else { + await this.assertViewInLensActionEnabled(fieldName, true); + } } else { await this.assertViewInLensActionNotExists(fieldName); } @@ -378,7 +507,8 @@ export function MachineLearningDataVisualizerTableProvider( fieldName: string, docCountFormatted: string, exampleCount: number, - viewableInLens: boolean + viewableInLens: boolean, + hasActionMenu?: boolean ) { // Currently the data used in the data visualizer tests only contains these field types. if (fieldType === ML_JOB_FIELD_TYPES.DATE) { @@ -394,7 +524,11 @@ export function MachineLearningDataVisualizerTableProvider( } if (viewableInLens) { - await this.assertViewInLensActionEnabled(fieldName); + if (hasActionMenu) { + await this.assertActionMenuViewInLensEnabled(fieldName, true); + } else { + await this.assertViewInLensActionEnabled(fieldName, true); + } } else { await this.assertViewInLensActionNotExists(fieldName); } diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index 64298bbdedd63..2cc9a3afa442b 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -23,6 +23,7 @@ import { MachineLearningDataFrameAnalyticsTableProvider } from './data_frame_ana import { MachineLearningDataVisualizerProvider } from './data_visualizer'; import { MachineLearningDataVisualizerFileBasedProvider } from './data_visualizer_file_based'; import { MachineLearningDataVisualizerIndexBasedProvider } from './data_visualizer_index_based'; +import { MachineLearningDataVisualizerIndexPatternManagementProvider } from './data_visualizer_index_pattern_management'; import { MachineLearningJobManagementProvider } from './job_management'; import { MachineLearningJobSelectionProvider } from './job_selection'; import { MachineLearningJobSourceSelectionProvider } from './job_source_selection'; @@ -86,6 +87,10 @@ export function MachineLearningProvider(context: FtrProviderContext) { const dataVisualizerFileBased = MachineLearningDataVisualizerFileBasedProvider(context, commonUI); const dataVisualizerIndexBased = MachineLearningDataVisualizerIndexBasedProvider(context); + const dataVisualizerIndexPatternManagement = MachineLearningDataVisualizerIndexPatternManagementProvider( + context, + dataVisualizerTable + ); const jobManagement = MachineLearningJobManagementProvider(context, api); const jobSelection = MachineLearningJobSelectionProvider(context); @@ -131,6 +136,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { dataVisualizer, dataVisualizerFileBased, dataVisualizerIndexBased, + dataVisualizerIndexPatternManagement, dataVisualizerTable, jobManagement, jobSelection, From 874dfc62f41e604369ba906f82036342c9b7ddfe Mon Sep 17 00:00:00 2001 From: ymao1 Date: Wed, 23 Jun 2021 14:37:31 -0400 Subject: [PATCH 05/95] [Actions] Rename `tls.*` configs to `ssl.*` (#102902) * Changing tls to ssl * Changing tls to ssl * Updating docs Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/settings/alert-action-settings.asciidoc | 34 ++++++++--------- .../resources/base/bin/kibana-docker | 4 +- .../actions/server/actions_client.test.ts | 2 +- .../actions/server/actions_config.mock.ts | 2 +- .../actions/server/actions_config.test.ts | 38 +++++++++---------- .../plugins/actions/server/actions_config.ts | 14 +++---- .../server/builtin_action_types/email.test.ts | 4 +- .../lib/axios_utils.test.ts | 12 +++--- .../lib/axios_utils_connection.test.ts | 22 +++++------ .../lib/get_custom_agents.test.ts | 38 +++++++++---------- .../lib/get_custom_agents.ts | 38 +++++++++---------- ...s.test.ts => get_node_ssl_options.test.ts} | 28 +++++++------- ...tls_options.ts => get_node_ssl_options.ts} | 8 ++-- .../lib/send_email.test.ts | 18 ++++----- .../builtin_action_types/lib/send_email.ts | 24 ++++++------ .../server/builtin_action_types/slack.test.ts | 10 ++--- .../server/builtin_action_types/teams.test.ts | 4 +- .../builtin_action_types/webhook.test.ts | 4 +- x-pack/plugins/actions/server/config.test.ts | 6 +-- x-pack/plugins/actions/server/config.ts | 8 ++-- x-pack/plugins/actions/server/index.ts | 10 ++--- .../server/lib/custom_host_settings.test.ts | 26 ++++++------- .../server/lib/custom_host_settings.ts | 14 +++---- x-pack/plugins/actions/server/types.ts | 4 +- .../alerting_api_integration/common/config.ts | 22 +++++------ .../tests/actions/get_all.ts | 24 ++++++------ .../spaces_only/config.ts | 2 +- .../actions/builtin_action_types/webhook.ts | 16 ++++---- .../spaces_only/tests/actions/get_all.ts | 24 ++++++------ .../spaces_only_legacy/config.ts | 2 +- .../actions/builtin_action_types/webhook.ts | 16 ++++---- 31 files changed, 239 insertions(+), 239 deletions(-) rename x-pack/plugins/actions/server/builtin_action_types/lib/{get_node_tls_options.test.ts => get_node_ssl_options.test.ts} (67%) rename x-pack/plugins/actions/server/builtin_action_types/lib/{get_node_tls_options.ts => get_node_ssl_options.ts} (92%) diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 71f141d1ed5d6..d1d283ca60fbb 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -69,7 +69,7 @@ You can configure the following settings in the `kibana.yml` file. -- xpack.actions.customHostSettings: - url: smtp://mail.example.com:465 - tls: + ssl: verificationMode: 'full' certificateAuthoritiesFiles: [ 'one.crt' ] certificateAuthoritiesData: | @@ -79,7 +79,7 @@ xpack.actions.customHostSettings: smtp: requireTLS: true - url: https://webhook.example.com - tls: + ssl: // legacy rejectUnauthorized: false verificationMode: 'none' @@ -97,8 +97,8 @@ xpack.actions.customHostSettings: server, and the `https` URLs are used for actions which use `https` to connect to services. + + - Entries with `https` URLs can use the `tls` options, and entries with `smtp` - URLs can use both the `tls` and `smtp` options. + + Entries with `https` URLs can use the `ssl` options, and entries with `smtp` + URLs can use both the `ssl` and `smtp` options. + + No other URL values should be part of this URL, including paths, query strings, and authentication information. When an http or smtp request @@ -117,24 +117,24 @@ xpack.actions.customHostSettings: The options `smtp.ignoreTLS` and `smtp.requireTLS` can not both be set to true. | `xpack.actions.customHostSettings[n]` -`.tls.rejectUnauthorized` {ess-icon} - | Deprecated. Use <> instead. A boolean value indicating whether to bypass server certificate validation. +`.ssl.rejectUnauthorized` {ess-icon} + | Deprecated. Use <> instead. A boolean value indicating whether to bypass server certificate validation. Overrides the general `xpack.actions.rejectUnauthorized` configuration for requests made for this hostname/port. |[[action-config-custom-host-verification-mode]] `xpack.actions.customHostSettings[n]` -`.tls.verificationMode` +`.ssl.verificationMode` | Controls the verification of the server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection to the host server. Valid values are `full`, `certificate`, and `none`. - Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. Overrides the general `xpack.actions.tls.verificationMode` configuration + Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. Overrides the general `xpack.actions.ssl.verificationMode` configuration for requests made for this hostname/port. | `xpack.actions.customHostSettings[n]` -`.tls.certificateAuthoritiesFiles` +`.ssl.certificateAuthoritiesFiles` | A file name or list of file names of PEM-encoded certificate files to use to validate the server. | `xpack.actions.customHostSettings[n]` -`.tls.certificateAuthoritiesData` {ess-icon} +`.ssl.certificateAuthoritiesData` {ess-icon} | The contents of a PEM-encoded certificate file, or multiple files appended into a single string. This configuration can be used for environments where the files cannot be made available. @@ -165,28 +165,28 @@ xpack.actions.customHostSettings: a|`xpack.actions.` `proxyRejectUnauthorizedCertificates` {ess-icon} - | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for the proxy, if using a proxy for actions. Default: `true`. + | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for the proxy, if using a proxy for actions. Default: `true`. |[[action-config-proxy-verification-mode]] `xpack.actions[n]` -`.tls.proxyVerificationMode` {ess-icon} +`.ssl.proxyVerificationMode` {ess-icon} | Controls the verification for the proxy server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection to the proxy server. Valid values are `full`, `certificate`, and `none`. Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. | `xpack.actions.rejectUnauthorized` {ess-icon} - | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for actions. Default: `true`. + + | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for actions. Default: `true`. + + As an alternative to setting `xpack.actions.rejectUnauthorized`, you can use the setting - `xpack.actions.customHostSettings` to set TLS options for specific servers. + `xpack.actions.customHostSettings` to set SSL options for specific servers. |[[action-config-verification-mode]] `xpack.actions[n]` -`.tls.verificationMode` {ess-icon} +`.ssl.verificationMode` {ess-icon} | Controls the verification for the server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection for actions. Valid values are `full`, `certificate`, and `none`. Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. + + - As an alternative to setting `xpack.actions.tls.verificationMode`, you can use the setting - `xpack.actions.customHostSettings` to set TLS options for specific servers. + As an alternative to setting `xpack.actions.ssl.verificationMode`, you can use the setting + `xpack.actions.customHostSettings` to set SSL options for specific servers. diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 9ea6e8960e373..d109a824ca81d 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -203,8 +203,8 @@ kibana_vars=( xpack.actions.proxyUrl xpack.actions.rejectUnauthorized xpack.actions.responseTimeout - xpack.actions.tls.proxyVerificationMode - xpack.actions.tls.verificationMode + xpack.actions.ssl.proxyVerificationMode + xpack.actions.ssl.verificationMode xpack.alerting.healthCheck.interval xpack.alerting.invalidateApiKeysTask.interval xpack.alerting.invalidateApiKeysTask.removalDelay diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 16388b2faf52e..012cd1a58de7e 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -429,7 +429,7 @@ describe('create()', () => { idleInterval: schema.duration().validate('1h'), pageSize: 100, }, - tls: { + ssl: { verificationMode: 'full', proxyVerificationMode: 'full', }, diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index 19a43951377b6..36298d84acabc 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -15,7 +15,7 @@ const createActionsConfigMock = () => { ensureHostnameAllowed: jest.fn().mockReturnValue({}), ensureUriAllowed: jest.fn().mockReturnValue({}), ensureActionTypeEnabled: jest.fn().mockReturnValue({}), - getTLSSettings: jest.fn().mockReturnValue({ + getSSLSettings: jest.fn().mockReturnValue({ verificationMode: 'full', }), getProxySettings: jest.fn().mockReturnValue(undefined), diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 93dad226e0c99..51cd9e5599472 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -37,7 +37,7 @@ const defaultActionsConfig: ActionsConfig = { idleInterval: schema.duration().validate('1h'), pageSize: 100, }, - tls: { + ssl: { proxyVerificationMode: 'full', verificationMode: 'full', }, @@ -316,38 +316,38 @@ describe('getProxySettings', () => { proxyRejectUnauthorizedCertificates: true, }; let proxySettings = getActionsConfigurationUtilities(configTrue).getProxySettings(); - expect(proxySettings?.proxyTLSSettings.verificationMode).toBe('full'); + expect(proxySettings?.proxySSLSettings.verificationMode).toBe('full'); const configFalse: ActionsConfig = { ...defaultActionsConfig, proxyUrl: 'https://proxy.elastic.co', proxyRejectUnauthorizedCertificates: false, - tls: {}, + ssl: {}, }; proxySettings = getActionsConfigurationUtilities(configFalse).getProxySettings(); - expect(proxySettings?.proxyTLSSettings.verificationMode).toBe('none'); + expect(proxySettings?.proxySSLSettings.verificationMode).toBe('none'); }); - test('returns proper verificationMode value, based on the TLS proxy configuration', () => { + test('returns proper verificationMode value, based on the SSL proxy configuration', () => { const configTrue: ActionsConfig = { ...defaultActionsConfig, proxyUrl: 'https://proxy.elastic.co', - tls: { + ssl: { proxyVerificationMode: 'full', }, }; let proxySettings = getActionsConfigurationUtilities(configTrue).getProxySettings(); - expect(proxySettings?.proxyTLSSettings.verificationMode).toBe('full'); + expect(proxySettings?.proxySSLSettings.verificationMode).toBe('full'); const configFalse: ActionsConfig = { ...defaultActionsConfig, proxyUrl: 'https://proxy.elastic.co', - tls: { + ssl: { proxyVerificationMode: 'none', }, }; proxySettings = getActionsConfigurationUtilities(configFalse).getProxySettings(); - expect(proxySettings?.proxyTLSSettings.verificationMode).toBe('none'); + expect(proxySettings?.proxySSLSettings.verificationMode).toBe('none'); }); test('returns proxy headers', () => { @@ -432,13 +432,13 @@ describe('getProxySettings', () => { customHostSettings: [ { url: 'https://elastic.co', - tls: { + ssl: { verificationMode: 'full', }, }, { url: 'smtp://elastic.co:123', - tls: { + ssl: { verificationMode: 'none', }, smtp: { @@ -465,24 +465,24 @@ describe('getProxySettings', () => { }); }); -describe('getTLSSettings', () => { - test('returns proper verificationMode value, based on the TLS proxy configuration', () => { +describe('getSSLSettings', () => { + test('returns proper verificationMode value, based on the SSL proxy configuration', () => { const configTrue: ActionsConfig = { ...defaultActionsConfig, - tls: { + ssl: { verificationMode: 'full', }, }; - let tlsSettings = getActionsConfigurationUtilities(configTrue).getTLSSettings(); - expect(tlsSettings.verificationMode).toBe('full'); + let sslSettings = getActionsConfigurationUtilities(configTrue).getSSLSettings(); + expect(sslSettings.verificationMode).toBe('full'); const configFalse: ActionsConfig = { ...defaultActionsConfig, - tls: { + ssl: { verificationMode: 'none', }, }; - tlsSettings = getActionsConfigurationUtilities(configFalse).getTLSSettings(); - expect(tlsSettings.verificationMode).toBe('none'); + sslSettings = getActionsConfigurationUtilities(configFalse).getSSLSettings(); + expect(sslSettings.verificationMode).toBe('none'); }); }); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index d25101f8279f8..9ce9439b726d4 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -14,8 +14,8 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { ActionsConfig, AllowedHosts, EnabledActionTypes, CustomHostSettings } from './config'; import { getCanonicalCustomHostUrl } from './lib/custom_host_settings'; import { ActionTypeDisabledError } from './lib'; -import { ProxySettings, ResponseSettings, TLSSettings } from './types'; -import { getTLSSettingsFromConfig } from './builtin_action_types/lib/get_node_tls_options'; +import { ProxySettings, ResponseSettings, SSLSettings } from './types'; +import { getSSLSettingsFromConfig } from './builtin_action_types/lib/get_node_ssl_options'; export { AllowedHosts, EnabledActionTypes } from './config'; @@ -31,7 +31,7 @@ export interface ActionsConfigurationUtilities { ensureHostnameAllowed: (hostname: string) => void; ensureUriAllowed: (uri: string) => void; ensureActionTypeEnabled: (actionType: string) => void; - getTLSSettings: () => TLSSettings; + getSSLSettings: () => SSLSettings; getProxySettings: () => undefined | ProxySettings; getResponseSettings: () => ResponseSettings; getCustomHostSettings: (targetUrl: string) => CustomHostSettings | undefined; @@ -94,8 +94,8 @@ function getProxySettingsFromConfig(config: ActionsConfig): undefined | ProxySet proxyBypassHosts: arrayAsSet(config.proxyBypassHosts), proxyOnlyHosts: arrayAsSet(config.proxyOnlyHosts), proxyHeaders: config.proxyHeaders, - proxyTLSSettings: getTLSSettingsFromConfig( - config.tls?.proxyVerificationMode, + proxySSLSettings: getSSLSettingsFromConfig( + config.ssl?.proxyVerificationMode, config.proxyRejectUnauthorizedCertificates ), }; @@ -146,8 +146,8 @@ export function getActionsConfigurationUtilities( isActionTypeEnabled, getProxySettings: () => getProxySettingsFromConfig(config), getResponseSettings: () => getResponseSettingsFromConfig(config), - getTLSSettings: () => - getTLSSettingsFromConfig(config.tls?.verificationMode, config.rejectUnauthorized), + getSSLSettings: () => + getSSLSettingsFromConfig(config.ssl?.verificationMode, config.rejectUnauthorized), ensureUriAllowed(uri: string) { if (!isUriAllowed(uri)) { throw new Error(allowListErrorMessage(AllowListingField.URL, uri)); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 98ea436b17f3e..8e9ea1c5e4aa9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -285,7 +285,7 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], - "getTLSSettings": [MockFunction], + "getSSLSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], @@ -346,7 +346,7 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], - "getTLSSettings": [MockFunction], + "getSSLSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts index ccd5a044971df..292471aaf9b6d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts @@ -75,7 +75,7 @@ describe('request', () => { test('it have been called with proper proxy agent for a valid url', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'full', }, proxyUrl: 'https://localhost:1212', @@ -110,7 +110,7 @@ describe('request', () => { test('it have been called with proper proxy agent for an invalid url', async () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: ':nope:', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -141,7 +141,7 @@ describe('request', () => { test('it bypasses with proxyBypassHosts when expected', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'full', }, proxyUrl: 'https://elastic.proxy.co', @@ -164,7 +164,7 @@ describe('request', () => { test('it does not bypass with proxyBypassHosts when expected', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'full', }, proxyUrl: 'https://elastic.proxy.co', @@ -187,7 +187,7 @@ describe('request', () => { test('it proxies with proxyOnlyHosts when expected', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'full', }, proxyUrl: 'https://elastic.proxy.co', @@ -210,7 +210,7 @@ describe('request', () => { test('it does not proxy with proxyOnlyHosts when expected', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'full', }, proxyUrl: 'https://elastic.proxy.co', diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts index 235fca005e225..4ed9485e923a7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts @@ -86,7 +86,7 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - tls: { + ssl: { verificationMode: 'none', }, }); @@ -99,7 +99,7 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url, tls: { verificationMode: 'none' } }], + customHostSettings: [{ url, ssl: { verificationMode: 'none' } }], }); const res = await request({ axios, url, logger, configurationUtilities }); expect(res.status).toBe(200); @@ -110,7 +110,7 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url, tls: { certificateAuthoritiesData: CA } }], + customHostSettings: [{ url, ssl: { certificateAuthoritiesData: CA } }], }); const res = await request({ axios, url, logger, configurationUtilities }); expect(res.status).toBe(200); @@ -121,7 +121,7 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url, tls: { certificateAuthoritiesData: KIBANA_CRT } }], + customHostSettings: [{ url, ssl: { certificateAuthoritiesData: KIBANA_CRT } }], }); const fn = async () => await request({ axios, url, logger, configurationUtilities }); await expect(fn()).rejects.toThrow('certificate'); @@ -135,7 +135,7 @@ describe('axios connections', () => { customHostSettings: [ { url, - tls: { + ssl: { certificateAuthoritiesData: CA, verificationMode: 'none', }, @@ -151,13 +151,13 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - tls: { + ssl: { verificationMode: 'none', }, customHostSettings: [ { url, - tls: { + ssl: { certificateAuthoritiesData: CA, }, }, @@ -173,7 +173,7 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url: otherUrl, tls: { verificationMode: 'none' } }], + customHostSettings: [{ url: otherUrl, ssl: { verificationMode: 'none' } }], }); const fn = async () => await request({ axios, url, logger, configurationUtilities }); await expect(fn()).rejects.toThrow('certificate'); @@ -184,7 +184,7 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url, tls: { certificateAuthoritiesData: 'garbage' } }], + customHostSettings: [{ url, ssl: { certificateAuthoritiesData: 'garbage' } }], }); const fn = async () => await request({ axios, url, logger, configurationUtilities }); await expect(fn()).rejects.toThrow('certificate'); @@ -196,7 +196,7 @@ describe('axios connections', () => { const ca = '-----BEGIN CERTIFICATE-----\ngarbage\n-----END CERTIFICATE-----\n'; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url, tls: { certificateAuthoritiesData: ca } }], + customHostSettings: [{ url, ssl: { certificateAuthoritiesData: ca } }], }); const fn = async () => await request({ axios, url, logger, configurationUtilities }); await expect(fn()).rejects.toThrow('certificate'); @@ -255,7 +255,7 @@ const BaseActionsConfig: ActionsConfig = { proxyUrl: undefined, proxyHeaders: undefined, proxyRejectUnauthorizedCertificates: true, - tls: { + ssl: { proxyVerificationMode: 'full', verificationMode: 'full', }, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts index 8b4abe86e271a..0c1112da5909f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts @@ -30,7 +30,7 @@ describe('getCustomAgents', () => { test('get agents for valid proxy URL', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -44,7 +44,7 @@ describe('getCustomAgents', () => { test('return default agents for invalid proxy URL', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: ':nope: not a valid URL', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -64,7 +64,7 @@ describe('getCustomAgents', () => { test('returns non-proxy agents for matching proxyBypassHosts', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: new Set([targetHost]), @@ -78,7 +78,7 @@ describe('getCustomAgents', () => { test('returns proxy agents for non-matching proxyBypassHosts', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: new Set([targetHost]), @@ -96,7 +96,7 @@ describe('getCustomAgents', () => { test('returns proxy agents for matching proxyOnlyHosts', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -110,7 +110,7 @@ describe('getCustomAgents', () => { test('returns non-proxy agents for non-matching proxyOnlyHosts', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -128,7 +128,7 @@ describe('getCustomAgents', () => { test('handles custom host settings', () => { configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, - tls: { + ssl: { verificationMode: 'none', certificateAuthoritiesData: 'ca data here', }, @@ -141,7 +141,7 @@ describe('getCustomAgents', () => { test('handles custom host settings with proxy', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -149,7 +149,7 @@ describe('getCustomAgents', () => { }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, - tls: { + ssl: { verificationMode: 'none', certificateAuthoritiesData: 'ca data here', }, @@ -163,12 +163,12 @@ describe('getCustomAgents', () => { }); test('handles overriding global verificationMode "none"', () => { - configurationUtilities.getTLSSettings.mockReturnValue({ + configurationUtilities.getSSLSettings.mockReturnValue({ verificationMode: 'none', }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, - tls: { + ssl: { verificationMode: 'certificate', }, }); @@ -181,12 +181,12 @@ describe('getCustomAgents', () => { }); test('handles overriding global verificationMode "full"', () => { - configurationUtilities.getTLSSettings.mockReturnValue({ + configurationUtilities.getSSLSettings.mockReturnValue({ verificationMode: 'full', }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, - tls: { + ssl: { verificationMode: 'none', }, }); @@ -199,12 +199,12 @@ describe('getCustomAgents', () => { }); test('handles overriding global verificationMode "none" with a proxy', () => { - configurationUtilities.getTLSSettings.mockReturnValue({ + configurationUtilities.getSSLSettings.mockReturnValue({ verificationMode: 'none', }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, - tls: { + ssl: { verificationMode: 'full', }, }); @@ -212,7 +212,7 @@ describe('getCustomAgents', () => { proxyUrl: 'https://someproxyhost', // note: this setting doesn't come into play, it's for the connection to // the proxy, not the target url - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -226,12 +226,12 @@ describe('getCustomAgents', () => { }); test('handles overriding global verificationMode "full" with a proxy', () => { - configurationUtilities.getTLSSettings.mockReturnValue({ + configurationUtilities.getSSLSettings.mockReturnValue({ verificationMode: 'full', }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, - tls: { + ssl: { verificationMode: 'none', }, }); @@ -239,7 +239,7 @@ describe('getCustomAgents', () => { proxyUrl: 'https://someproxyhost', // note: this setting doesn't come into play, it's for the connection to // the proxy, not the target url - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts index a327ee3ffe931..83d31ae1355d3 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts @@ -11,7 +11,7 @@ import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; import { ActionsConfigurationUtilities } from '../../actions_config'; -import { getNodeTLSOptions, getTLSSettingsFromConfig } from './get_node_tls_options'; +import { getNodeSSLOptions, getSSLSettingsFromConfig } from './get_node_ssl_options'; interface GetCustomAgentsResponse { httpAgent: HttpAgent | undefined; @@ -23,14 +23,14 @@ export function getCustomAgents( logger: Logger, url: string ): GetCustomAgentsResponse { - const generalTLSSettings = configurationUtilities.getTLSSettings(); - const agentTLSOptions = getNodeTLSOptions(logger, generalTLSSettings.verificationMode); + const generalSSLSettings = configurationUtilities.getSSLSettings(); + const agentSSLOptions = getNodeSSLOptions(logger, generalSSLSettings.verificationMode); // the default for rejectUnauthorized is the global setting, which can // be overridden (below) with a custom host setting const defaultAgents = { httpAgent: undefined, httpsAgent: new HttpsAgent({ - ...agentTLSOptions, + ...agentSSLOptions, }), }; @@ -43,28 +43,28 @@ export function getCustomAgents( } // update the defaultAgents.httpsAgent if configured - const tlsSettings = customHostSettings?.tls; + const sslSettings = customHostSettings?.ssl; let agentOptions: AgentOptions | undefined; - if (tlsSettings) { + if (sslSettings) { logger.debug(`Creating customized connection settings for: ${url}`); agentOptions = defaultAgents.httpsAgent.options; - if (tlsSettings.certificateAuthoritiesData) { - agentOptions.ca = tlsSettings.certificateAuthoritiesData; + if (sslSettings.certificateAuthoritiesData) { + agentOptions.ca = sslSettings.certificateAuthoritiesData; } - const tlsSettingsFromConfig = getTLSSettingsFromConfig( - tlsSettings.verificationMode, - tlsSettings.rejectUnauthorized + const sslSettingsFromConfig = getSSLSettingsFromConfig( + sslSettings.verificationMode, + sslSettings.rejectUnauthorized ); // see: src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts // This is where the global rejectUnauthorized is overridden by a custom host - const customHostNodeTLSOptions = getNodeTLSOptions( + const customHostNodeSSLOptions = getNodeSSLOptions( logger, - tlsSettingsFromConfig.verificationMode + sslSettingsFromConfig.verificationMode ); - if (customHostNodeTLSOptions.rejectUnauthorized !== undefined) { - agentOptions.rejectUnauthorized = customHostNodeTLSOptions.rejectUnauthorized; + if (customHostNodeSSLOptions.rejectUnauthorized !== undefined) { + agentOptions.rejectUnauthorized = customHostNodeSSLOptions.rejectUnauthorized; } } @@ -107,12 +107,12 @@ export function getCustomAgents( return defaultAgents; } - const proxyNodeTLSOptions = getNodeTLSOptions( + const proxyNodeSSLOptions = getNodeSSLOptions( logger, - proxySettings.proxyTLSSettings.verificationMode + proxySettings.proxySSLSettings.verificationMode ); // At this point, we are going to use a proxy, so we need new agents. - // We will though, copy over the calculated tls options from above, into + // We will though, copy over the calculated ssl options from above, into // the https agent. const httpAgent = new HttpProxyAgent(proxySettings.proxyUrl); const httpsAgent = (new HttpsProxyAgent({ @@ -121,7 +121,7 @@ export function getCustomAgents( protocol: proxyUrl.protocol, headers: proxySettings.proxyHeaders, // do not fail on invalid certs if value is false - ...proxyNodeTLSOptions, + ...proxyNodeSSLOptions, }) as unknown) as HttpsAgent; // vsCode wasn't convinced HttpsProxyAgent is an https.Agent, so we convinced it diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_ssl_options.test.ts similarity index 67% rename from x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.test.ts rename to x-pack/plugins/actions/server/builtin_action_types/lib/get_node_ssl_options.test.ts index 7d131985053f1..893191b2ca2b4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_ssl_options.test.ts @@ -4,35 +4,35 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { getNodeTLSOptions, getTLSSettingsFromConfig } from './get_node_tls_options'; +import { getNodeSSLOptions, getSSLSettingsFromConfig } from './get_node_ssl_options'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; const logger = loggingSystemMock.create().get() as jest.Mocked; -describe('getNodeTLSOptions', () => { - test('get node.js TLS options: rejectUnauthorized eql true for the verification mode "full"', () => { - const nodeOption = getNodeTLSOptions(logger, 'full'); +describe('getNodeSSLOptions', () => { + test('get node.js SSL options: rejectUnauthorized eql true for the verification mode "full"', () => { + const nodeOption = getNodeSSLOptions(logger, 'full'); expect(nodeOption).toMatchObject({ rejectUnauthorized: true, }); }); - test('get node.js TLS options: rejectUnauthorized eql true for the verification mode "certificate"', () => { - const nodeOption = getNodeTLSOptions(logger, 'certificate'); + test('get node.js SSL options: rejectUnauthorized eql true for the verification mode "certificate"', () => { + const nodeOption = getNodeSSLOptions(logger, 'certificate'); expect(nodeOption.checkServerIdentity).not.toBeNull(); expect(nodeOption.rejectUnauthorized).toBeTruthy(); }); - test('get node.js TLS options: rejectUnauthorized eql false for the verification mode "none"', () => { - const nodeOption = getNodeTLSOptions(logger, 'none'); + test('get node.js SSL options: rejectUnauthorized eql false for the verification mode "none"', () => { + const nodeOption = getNodeSSLOptions(logger, 'none'); expect(nodeOption).toMatchObject({ rejectUnauthorized: false, }); }); - test('get node.js TLS options: rejectUnauthorized eql true for the verification mode value which does not exist, the logger called with the proper warning message', () => { - const nodeOption = getNodeTLSOptions(logger, 'notexist'); + test('get node.js SSL options: rejectUnauthorized eql true for the verification mode value which does not exist, the logger called with the proper warning message', () => { + const nodeOption = getNodeSSLOptions(logger, 'notexist'); expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ @@ -46,23 +46,23 @@ describe('getNodeTLSOptions', () => { }); }); -describe('getTLSSettingsFromConfig', () => { +describe('getSSLSettingsFromConfig', () => { test('get verificationMode eql "none" if legacy rejectUnauthorized eql false', () => { - const nodeOption = getTLSSettingsFromConfig(undefined, false); + const nodeOption = getSSLSettingsFromConfig(undefined, false); expect(nodeOption).toMatchObject({ verificationMode: 'none', }); }); test('get verificationMode eql "none" if legacy rejectUnauthorized eql true', () => { - const nodeOption = getTLSSettingsFromConfig(undefined, true); + const nodeOption = getSSLSettingsFromConfig(undefined, true); expect(nodeOption).toMatchObject({ verificationMode: 'full', }); }); test('get verificationMode eql "certificate", ignore rejectUnauthorized', () => { - const nodeOption = getTLSSettingsFromConfig('certificate', false); + const nodeOption = getSSLSettingsFromConfig('certificate', false); expect(nodeOption).toMatchObject({ verificationMode: 'certificate', }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_ssl_options.ts similarity index 92% rename from x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.ts rename to x-pack/plugins/actions/server/builtin_action_types/lib/get_node_ssl_options.ts index 423e9756b13f8..46e90ec3be697 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_ssl_options.ts @@ -6,10 +6,10 @@ */ import { PeerCertificate } from 'tls'; -import { TLSSettings } from '../../types'; +import { SSLSettings } from '../../types'; import { Logger } from '../../../../../../src/core/server'; -export function getNodeTLSOptions( +export function getNodeSSLOptions( logger: Logger, verificationMode?: string ): { @@ -44,10 +44,10 @@ export function getNodeTLSOptions( return agentOptions; } -export function getTLSSettingsFromConfig( +export function getSSLSettingsFromConfig( verificationMode?: 'none' | 'certificate' | 'full', rejectUnauthorized?: boolean -): TLSSettings { +): SSLSettings { if (verificationMode) { return { verificationMode }; } else if (rejectUnauthorized !== undefined) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index 9bdb2d9481142..3719dd8cd737c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -76,7 +76,7 @@ describe('send_email module', () => { }, { proxyUrl: 'https://example.com', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -238,7 +238,7 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: new Set(['example.com']), @@ -272,7 +272,7 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: new Set(['not-example.com']), @@ -308,7 +308,7 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -344,7 +344,7 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyTLSSettings: {}, + proxySSLSettings: {}, proxyBypassHosts: undefined, proxyOnlyHosts: new Set(['not-example.com']), } @@ -377,7 +377,7 @@ describe('send_email module', () => { undefined, { url: 'smtp://example.com:1025', - tls: { + ssl: { certificateAuthoritiesData: 'ca cert data goes here', }, smtp: { @@ -419,7 +419,7 @@ describe('send_email module', () => { undefined, { url: 'smtp://example.com:1025', - tls: { + ssl: { certificateAuthoritiesData: 'ca cert data goes here', rejectUnauthorized: true, }, @@ -461,13 +461,13 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyTLSSettings: {}, + proxySSLSettings: {}, proxyBypassHosts: undefined, proxyOnlyHosts: undefined, }, { url: 'smtp://example.com:1025', - tls: { + ssl: { certificateAuthoritiesData: 'ca cert data goes here', rejectUnauthorized: true, }, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index 9f601840bc982..b32ea7d74f025 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -12,7 +12,7 @@ import { default as MarkdownIt } from 'markdown-it'; import { Logger } from '../../../../../../src/core/server'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { CustomHostSettings } from '../../config'; -import { getNodeTLSOptions, getTLSSettingsFromConfig } from './get_node_tls_options'; +import { getNodeSSLOptions, getSSLSettingsFromConfig } from './get_node_ssl_options'; // an email "service" which doesn't actually send, just returns what it would send export const JSON_TRANSPORT_SERVICE = '__json'; @@ -59,7 +59,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom // eslint-disable-next-line @typescript-eslint/no-explicit-any const transportConfig: Record = {}; const proxySettings = configurationUtilities.getProxySettings(); - const generalTLSSettings = configurationUtilities.getTLSSettings(); + const generalSSLSettings = configurationUtilities.getSSLSettings(); if (hasAuth && user != null && password != null) { transportConfig.auth = { @@ -92,9 +92,9 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom customHostSettings = configurationUtilities.getCustomHostSettings(`smtp://${host}:${port}`); if (proxySettings && useProxy) { - transportConfig.tls = getNodeTLSOptions( + transportConfig.tls = getNodeSSLOptions( logger, - proxySettings?.proxyTLSSettings.verificationMode + proxySettings?.proxySSLSettings.verificationMode ); transportConfig.proxy = proxySettings.proxyUrl; transportConfig.headers = proxySettings.proxyHeaders; @@ -104,25 +104,25 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom // authenticate rarely have valid certs; eg cloud proxy, and npm maildev transportConfig.tls = { rejectUnauthorized: false }; } else { - transportConfig.tls = getNodeTLSOptions(logger, generalTLSSettings.verificationMode); + transportConfig.tls = getNodeSSLOptions(logger, generalSSLSettings.verificationMode); } // finally, allow customHostSettings to override some of the settings // see: https://nodemailer.com/smtp/ if (customHostSettings) { const tlsConfig: Record = {}; - const tlsSettings = customHostSettings.tls; + const sslSettings = customHostSettings.ssl; const smtpSettings = customHostSettings.smtp; - if (tlsSettings?.certificateAuthoritiesData) { - tlsConfig.ca = tlsSettings?.certificateAuthoritiesData; + if (sslSettings?.certificateAuthoritiesData) { + tlsConfig.ca = sslSettings?.certificateAuthoritiesData; } - const tlsSettingsFromConfig = getTLSSettingsFromConfig( - tlsSettings?.verificationMode, - tlsSettings?.rejectUnauthorized + const sslSettingsFromConfig = getSSLSettingsFromConfig( + sslSettings?.verificationMode, + sslSettings?.rejectUnauthorized ); - const nodeTLSOptions = getNodeTLSOptions(logger, tlsSettingsFromConfig.verificationMode); + const nodeTLSOptions = getNodeSSLOptions(logger, sslSettingsFromConfig.verificationMode); if (!transportConfig.tls) { transportConfig.tls = { ...tlsConfig, ...nodeTLSOptions }; } else { diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts index 4108424e26ac4..7953f0ab365e8 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts @@ -194,7 +194,7 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -221,7 +221,7 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: new Set(['example.com']), @@ -248,7 +248,7 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: new Set(['not-example.com']), @@ -275,7 +275,7 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -302,7 +302,7 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts index bf34789e03fae..497300b86bdea 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts @@ -170,7 +170,7 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], - "getTLSSettings": [MockFunction], + "getSSLSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], @@ -234,7 +234,7 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], - "getTLSSettings": [MockFunction], + "getSSLSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index b2c865c2f5374..c04c79075abdc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -293,7 +293,7 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], - "getTLSSettings": [MockFunction], + "getSSLSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], @@ -386,7 +386,7 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], - "getTLSSettings": [MockFunction], + "getSSLSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index 9774bfb05d4ff..d99b9349e977b 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -178,9 +178,9 @@ describe('config validation', () => { ); }); - test('action with tls configuration', () => { + test('action with ssl configuration', () => { const config: Record = { - tls: { + ssl: { verificationMode: 'none', proxyVerificationMode: 'none', }, @@ -208,7 +208,7 @@ describe('config validation', () => { "proxyRejectUnauthorizedCertificates": true, "rejectUnauthorized": true, "responseTimeout": "PT1M", - "tls": Object { + "ssl": Object { "proxyVerificationMode": "none", "verificationMode": "none", }, diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 8859a2d8881a2..1ae196c25a756 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -31,7 +31,7 @@ const customHostSettingsSchema = schema.object({ requireTLS: schema.maybe(schema.boolean()), }) ), - tls: schema.maybe( + ssl: schema.maybe( schema.object({ /** * @deprecated in favor of `verificationMode` @@ -78,16 +78,16 @@ export const configSchema = schema.object({ proxyUrl: schema.maybe(schema.string()), proxyHeaders: schema.maybe(schema.recordOf(schema.string(), schema.string())), /** - * @deprecated in favor of `tls.proxyVerificationMode` + * @deprecated in favor of `ssl.proxyVerificationMode` **/ proxyRejectUnauthorizedCertificates: schema.boolean({ defaultValue: true }), proxyBypassHosts: schema.maybe(schema.arrayOf(schema.string({ hostname: true }))), proxyOnlyHosts: schema.maybe(schema.arrayOf(schema.string({ hostname: true }))), /** - * @deprecated in favor of `tls.verificationMode` + * @deprecated in favor of `ssl.verificationMode` **/ rejectUnauthorized: schema.boolean({ defaultValue: true }), - tls: schema.maybe( + ssl: schema.maybe( schema.object({ verificationMode: schema.maybe( schema.oneOf( diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index 692ff6fa0a508..bcfc91d673bcc 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -64,19 +64,19 @@ export const config: PluginConfigDescriptor = { if ( customHostSettings.find( (customHostSchema: CustomHostSettings) => - !!customHostSchema.tls && !!customHostSchema.tls.rejectUnauthorized + !!customHostSchema.ssl && !!customHostSchema.ssl.rejectUnauthorized ) ) { addDeprecation({ message: - `"xpack.actions.customHostSettings[].tls.rejectUnauthorized" is deprecated.` + - `Use "xpack.actions.customHostSettings[].tls.verificationMode" instead, ` + + `"xpack.actions.customHostSettings[].ssl.rejectUnauthorized" is deprecated.` + + `Use "xpack.actions.customHostSettings[].ssl.verificationMode" instead, ` + `with the setting "verificationMode:full" eql to "rejectUnauthorized:true", ` + `and "verificationMode:none" eql to "rejectUnauthorized:false".`, correctiveActions: { manualSteps: [ - `Remove "xpack.actions.customHostSettings[].tls.rejectUnauthorized" from your kibana configs.`, - `Use "xpack.actions.customHostSettings[].tls.verificationMode" ` + + `Remove "xpack.actions.customHostSettings[].ssl.rejectUnauthorized" from your kibana configs.`, + `Use "xpack.actions.customHostSettings[].ssl.verificationMode" ` + `with the setting "verificationMode:full" eql to "rejectUnauthorized:true", ` + `and "verificationMode:none" eql to "rejectUnauthorized:false".`, ], diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts index ad07ea21d7917..ec7b46e545112 100644 --- a/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts @@ -112,14 +112,14 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://elastic.co:443', - tls: { + ssl: { certificateAuthoritiesData: 'xyz', rejectUnauthorized: false, }, }, { url: 'smtp://mail.elastic.com:25', - tls: { + ssl: { certificateAuthoritiesData: 'abc', rejectUnauthorized: true, }, @@ -338,7 +338,7 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com/', - tls: { + ssl: { certificateAuthoritiesFiles: 'this-file-does-not-exist', }, }, @@ -350,7 +350,7 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com:443', - tls: { + ssl: { certificateAuthoritiesFiles: 'this-file-does-not-exist', }, }, @@ -371,7 +371,7 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com/', - tls: { + ssl: { certificateAuthoritiesFiles: CA_FILE1, }, }, @@ -380,7 +380,7 @@ describe('custom_host_settings', () => { const resConfig = resolveCustomHosts(mockLogger, config); // not checking the full structure anymore, just ca bits - expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe(CA_CONTENTS1); + expect(resConfig?.customHostSettings?.[0].ssl?.certificateAuthoritiesData).toBe(CA_CONTENTS1); expect(warningLogs()).toEqual([]); }); @@ -390,7 +390,7 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com/', - tls: { + ssl: { certificateAuthoritiesFiles: [CA_FILE1, CA_FILE2], }, }, @@ -399,7 +399,7 @@ describe('custom_host_settings', () => { const resConfig = resolveCustomHosts(mockLogger, config); // not checking the full structure anymore, just ca bits - expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + expect(resConfig?.customHostSettings?.[0].ssl?.certificateAuthoritiesData).toBe( `${CA_CONTENTS1}\n${CA_CONTENTS2}` ); expect(warningLogs()).toEqual([]); @@ -411,7 +411,7 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com/', - tls: { + ssl: { certificateAuthoritiesFiles: [CA_FILE2], certificateAuthoritiesData: CA_CONTENTS1, }, @@ -421,7 +421,7 @@ describe('custom_host_settings', () => { const resConfig = resolveCustomHosts(mockLogger, config); // not checking the full structure anymore, just ca bits - expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + expect(resConfig?.customHostSettings?.[0].ssl?.certificateAuthoritiesData).toBe( `${CA_CONTENTS1}\n${CA_CONTENTS2}` ); expect(warningLogs()).toEqual([]); @@ -468,13 +468,13 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com/', - tls: { + ssl: { rejectUnauthorized: true, }, }, { url: 'https://almost.purrfect.com:443', - tls: { + ssl: { rejectUnauthorized: false, }, }, @@ -486,7 +486,7 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com:443', - tls: { + ssl: { rejectUnauthorized: true, }, }, diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.ts index bfc8dad48aab6..0ff8624d42cfe 100644 --- a/x-pack/plugins/actions/server/lib/custom_host_settings.ts +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.ts @@ -86,8 +86,8 @@ export function resolveCustomHosts(logger: Logger, config: ActionsConfig): Actio } // read the specified ca files, add their content to certificateAuthoritiesData - if (customHostSetting.tls) { - let files = customHostSetting.tls?.certificateAuthoritiesFiles || []; + if (customHostSetting.ssl) { + let files = customHostSetting.ssl?.certificateAuthoritiesFiles || []; if (typeof files === 'string') { files = [files]; } @@ -134,12 +134,12 @@ export function resolveCustomHosts(logger: Logger, config: ActionsConfig): Actio } function appendToCertificateAuthoritiesData(customHost: CustomHostSettingsWriteable, cert: string) { - const tls = customHost.tls; - if (tls) { - if (!tls.certificateAuthoritiesData) { - tls.certificateAuthoritiesData = cert; + const ssl = customHost.ssl; + if (ssl) { + if (!ssl.certificateAuthoritiesData) { + ssl.certificateAuthoritiesData = cert; } else { - tls.certificateAuthoritiesData += '\n' + cert; + ssl.certificateAuthoritiesData += '\n' + cert; } } } diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index c8c9967afca1a..a191728a20489 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -142,7 +142,7 @@ export interface ProxySettings { proxyBypassHosts: Set | undefined; proxyOnlyHosts: Set | undefined; proxyHeaders?: Record; - proxyTLSSettings: TLSSettings; + proxySSLSettings: SSLSettings; } export interface ResponseSettings { @@ -150,6 +150,6 @@ export interface ResponseSettings { timeout: number; } -export interface TLSSettings { +export interface SSLSettings { verificationMode?: 'none' | 'certificate' | 'full'; } diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 7ee6e146b2a50..61b452fc11835 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -22,7 +22,7 @@ interface CreateTestConfigOptions { verificationMode?: 'full' | 'none' | 'certificate'; publicBaseUrl?: boolean; preconfiguredAlertHistoryEsIndex?: boolean; - customizeLocalHostTls?: boolean; + customizeLocalHostSsl?: boolean; rejectUnauthorized?: boolean; // legacy } @@ -52,7 +52,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ssl = false, verificationMode = 'full', preconfiguredAlertHistoryEsIndex = false, - customizeLocalHostTls = false, + customizeLocalHostSsl = false, rejectUnauthorized = true, // legacy } = options; @@ -102,25 +102,25 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) const customHostSettingsValue = [ { url: tlsWebhookServers.rejectUnauthorizedFalse, - tls: { + ssl: { verificationMode: 'none', }, }, { url: tlsWebhookServers.rejectUnauthorizedTrue, - tls: { + ssl: { verificationMode: 'full', }, }, { url: tlsWebhookServers.caFile, - tls: { + ssl: { verificationMode: 'certificate', certificateAuthoritiesFiles: [CA_CERT_PATH], }, }, ]; - const customHostSettings = customizeLocalHostTls + const customHostSettings = customizeLocalHostSsl ? [`--xpack.actions.customHostSettings=${JSON.stringify(customHostSettingsValue)}`] : []; @@ -153,7 +153,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) '--xpack.alerting.healthCheck.interval="1s"', `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`, - `--xpack.actions.tls.verificationMode=${verificationMode}`, + `--xpack.actions.ssl.verificationMode=${verificationMode}`, ...actionsProxyUrl, ...customHostSettings, '--xpack.eventLog.logEntries=true', @@ -198,28 +198,28 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) encrypted: 'this-is-also-ignored-and-also-required', }, }, - 'custom.tls.noCustom': { + 'custom.ssl.noCustom': { actionTypeId: '.webhook', name: `${tlsWebhookServers.noCustom}`, config: { url: tlsWebhookServers.noCustom, }, }, - 'custom.tls.rejectUnauthorizedFalse': { + 'custom.ssl.rejectUnauthorizedFalse': { actionTypeId: '.webhook', name: `${tlsWebhookServers.rejectUnauthorizedFalse}`, config: { url: tlsWebhookServers.rejectUnauthorizedFalse, }, }, - 'custom.tls.rejectUnauthorizedTrue': { + 'custom.ssl.rejectUnauthorizedTrue': { actionTypeId: '.webhook', name: `${tlsWebhookServers.rejectUnauthorizedTrue}`, config: { url: tlsWebhookServers.rejectUnauthorizedTrue, }, }, - 'custom.tls.caFile': { + 'custom.ssl.caFile': { actionTypeId: '.webhook', name: `${tlsWebhookServers.caFile}`, config: { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts index 9a3a78342c5aa..a88a394863dbf 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts @@ -61,12 +61,12 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); - // the custom tls connectors have dynamic ports, so remove them before + // the custom ssl connectors have dynamic ports, so remove them before // comparing to what we expect - const nonCustomTlsConnectors = response.body.filter( - (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + const nonCustomSslConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); - expect(nonCustomTlsConnectors).to.eql([ + expect(nonCustomSslConnectors).to.eql([ { id: createdAction.id, is_preconfigured: false, @@ -175,12 +175,12 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); - // the custom tls connectors have dynamic ports, so remove them before + // the custom ssl connectors have dynamic ports, so remove them before // comparing to what we expect - const nonCustomTlsConnectors = response.body.filter( - (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + const nonCustomSslConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); - expect(nonCustomTlsConnectors).to.eql([ + expect(nonCustomSslConnectors).to.eql([ { id: createdAction.id, is_preconfigured: false, @@ -265,12 +265,12 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'superuser at space1': expect(response.statusCode).to.eql(200); - // the custom tls connectors have dynamic ports, so remove them before + // the custom ssl connectors have dynamic ports, so remove them before // comparing to what we expect - const nonCustomTlsConnectors = response.body.filter( - (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + const nonCustomSslConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); - expect(nonCustomTlsConnectors).to.eql([ + expect(nonCustomSslConnectors).to.eql([ { id: 'preconfigured-es-index-action', is_preconfigured: true, diff --git a/x-pack/test/alerting_api_integration/spaces_only/config.ts b/x-pack/test/alerting_api_integration/spaces_only/config.ts index 788d9d0698a19..204f5b27da9d5 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/config.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/config.ts @@ -13,6 +13,6 @@ export default createTestConfig('spaces_only', { license: 'trial', enableActionsProxy: false, verificationMode: 'none', - customizeLocalHostTls: true, + customizeLocalHostSsl: true, preconfiguredAlertHistoryEsIndex: true, }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts index 4af33136cd42c..9822254db444a 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts @@ -123,9 +123,9 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); }); - describe('tls customization', () => { + describe('ssl customization', () => { it('should handle the xpack.actions.rejectUnauthorized: false', async () => { - const connectorId = 'custom.tls.noCustom'; + const connectorId = 'custom.ssl.noCustom'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest @@ -143,11 +143,11 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); it('should handle the customized rejectUnauthorized: false', async () => { - const connectorId = 'custom.tls.rejectUnauthorizedFalse'; + const connectorId = 'custom.ssl.rejectUnauthorizedFalse'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest - .post(`/api/actions/connector/custom.tls.rejectUnauthorizedFalse/_execute`) + .post(`/api/actions/connector/custom.ssl.rejectUnauthorizedFalse/_execute`) .set('kbn-xsrf', 'test') .send({ params: { @@ -161,11 +161,11 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); it('should handle the customized rejectUnauthorized: true', async () => { - const connectorId = 'custom.tls.rejectUnauthorizedTrue'; + const connectorId = 'custom.ssl.rejectUnauthorizedTrue'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest - .post(`/api/actions/connector/custom.tls.rejectUnauthorizedTrue/_execute`) + .post(`/api/actions/connector/custom.ssl.rejectUnauthorizedTrue/_execute`) .set('kbn-xsrf', 'test') .send({ params: { @@ -180,11 +180,11 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); it('should handle the customized ca file', async () => { - const connectorId = 'custom.tls.caFile'; + const connectorId = 'custom.ssl.caFile'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest - .post(`/api/actions/connector/custom.tls.caFile/_execute`) + .post(`/api/actions/connector/custom.ssl.caFile/_execute`) .set('kbn-xsrf', 'test') .send({ params: { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts index e7f500f2771e3..a965b1716a671 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts @@ -40,13 +40,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .get(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connectors`) .expect(200); - // the custom tls connectors have dynamic ports, so remove them before + // the custom ssl connectors have dynamic ports, so remove them before // comparing to what we expect - const nonCustomTlsConnectors = connectors.filter( - (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + const nonCustomSslConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); - expect(nonCustomTlsConnectors).to.eql([ + expect(nonCustomSslConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', @@ -117,13 +117,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .get(`${getUrlPrefix(Spaces.other.id)}/api/actions/connectors`) .expect(200); - // the custom tls connectors have dynamic ports, so remove them before + // the custom ssl connectors have dynamic ports, so remove them before // comparing to what we expect - const nonCustomTlsConnectors = connectors.filter( - (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + const nonCustomSslConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); - expect(nonCustomTlsConnectors).to.eql([ + expect(nonCustomSslConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', @@ -184,13 +184,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .get(`${getUrlPrefix(Spaces.space1.id)}/api/actions`) .expect(200); - // the custom tls connectors have dynamic ports, so remove them before + // the custom ssl connectors have dynamic ports, so remove them before // comparing to what we expect - const nonCustomTlsConnectors = connectors.filter( - (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + const nonCustomSslConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); - expect(nonCustomTlsConnectors).to.eql([ + expect(nonCustomSslConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', diff --git a/x-pack/test/alerting_api_integration/spaces_only_legacy/config.ts b/x-pack/test/alerting_api_integration/spaces_only_legacy/config.ts index 511e97b96e35d..b322b8dffbf95 100644 --- a/x-pack/test/alerting_api_integration/spaces_only_legacy/config.ts +++ b/x-pack/test/alerting_api_integration/spaces_only_legacy/config.ts @@ -14,6 +14,6 @@ export default createTestConfig('spaces_only', { enableActionsProxy: false, rejectUnauthorized: false, verificationMode: undefined, - customizeLocalHostTls: true, + customizeLocalHostSsl: true, preconfiguredAlertHistoryEsIndex: true, }); diff --git a/x-pack/test/alerting_api_integration/spaces_only_legacy/tests/actions/builtin_action_types/webhook.ts b/x-pack/test/alerting_api_integration/spaces_only_legacy/tests/actions/builtin_action_types/webhook.ts index 4af33136cd42c..9822254db444a 100644 --- a/x-pack/test/alerting_api_integration/spaces_only_legacy/tests/actions/builtin_action_types/webhook.ts +++ b/x-pack/test/alerting_api_integration/spaces_only_legacy/tests/actions/builtin_action_types/webhook.ts @@ -123,9 +123,9 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); }); - describe('tls customization', () => { + describe('ssl customization', () => { it('should handle the xpack.actions.rejectUnauthorized: false', async () => { - const connectorId = 'custom.tls.noCustom'; + const connectorId = 'custom.ssl.noCustom'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest @@ -143,11 +143,11 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); it('should handle the customized rejectUnauthorized: false', async () => { - const connectorId = 'custom.tls.rejectUnauthorizedFalse'; + const connectorId = 'custom.ssl.rejectUnauthorizedFalse'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest - .post(`/api/actions/connector/custom.tls.rejectUnauthorizedFalse/_execute`) + .post(`/api/actions/connector/custom.ssl.rejectUnauthorizedFalse/_execute`) .set('kbn-xsrf', 'test') .send({ params: { @@ -161,11 +161,11 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); it('should handle the customized rejectUnauthorized: true', async () => { - const connectorId = 'custom.tls.rejectUnauthorizedTrue'; + const connectorId = 'custom.ssl.rejectUnauthorizedTrue'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest - .post(`/api/actions/connector/custom.tls.rejectUnauthorizedTrue/_execute`) + .post(`/api/actions/connector/custom.ssl.rejectUnauthorizedTrue/_execute`) .set('kbn-xsrf', 'test') .send({ params: { @@ -180,11 +180,11 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); it('should handle the customized ca file', async () => { - const connectorId = 'custom.tls.caFile'; + const connectorId = 'custom.ssl.caFile'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest - .post(`/api/actions/connector/custom.tls.caFile/_execute`) + .post(`/api/actions/connector/custom.ssl.caFile/_execute`) .set('kbn-xsrf', 'test') .send({ params: { From bb7bff5c960bab77b30c42d399c1121faf5e1eae Mon Sep 17 00:00:00 2001 From: John Schulz Date: Wed, 23 Jun 2021 14:46:04 -0400 Subject: [PATCH 06/95] [Fleet] Add UI and mappings for agent policy unenroll_timeout (#102970) ## Summary closes https://github.com/elastic/kibana/issues/100617 UI and mappings related to ephemeral agents - [x] Adds mapping/type/schema definition for the new field in agent policy saved object - [x] Shows input field labelled `Unenrollment timeout` in agent policy settings that reads/writes to the new field - [x] Same input in `Advanced options` section of create agent flyout - [x] `unenroll_timeout` can be set using preconfigured agent policies defined in `kibana.yml` - [x] `unenroll_timeout` can be populated if the user has a preconfigured policy that _does not_ have this field initially, but then updates their `kibana.yml` later to include it
Screenshot - editing an existing agent policy Screen Shot 2021-06-22 at 1 42 50 PM
Screenshots - adding a new agent policy Screen Shot 2021-06-22 at 1 45 01 PM Screen Shot 2021-06-22 at 1 45 35 PM Screen Shot 2021-06-22 at 1 45 44 PM Screen Shot 2021-06-22 at 1 45 56 PM
Using kibana.dev.yml

No unenroll_timeout

```yml xpack.fleet.agentPolicies: - name: Preconfigured Policy From Config description: From kibana.dev.yml (no timeout given) id: 1 namespace: test package_policies: - package: name: system name: System Integration inputs: - type: system/metrics enabled: true vars: - name: system.hostfs value: home/test streams: - data_stream: dataset: system.core enabled: true vars: - name: period value: 20s - type: winlog enabled: false ```

UI (saved object)

Screen Shot 2021-06-23 at 10 28 03 AM

fleet-policiesindex

Screen Shot 2021-06-23 at 10 52 39 AM

Updated kibana.dev.yml to include unenroll_timeout

```yml xpack.fleet.agentPolicies: - name: Preconfigured Policy From Config description: From kibana.dev.yml (updated with timeout) id: 1 namespace: test unenroll_timeout: 234 package_policies: - package: name: system name: System Integration inputs: - type: system/metrics enabled: true vars: - name: system.hostfs value: home/test streams: - data_stream: dataset: system.core enabled: true vars: - name: period value: 20s - type: winlog enabled: false ```

UI (saved object)

Screen Shot 2021-06-23 at 10 35 17 AM

fleet-policiesindex

Screen Shot 2021-06-23 at 10 35 41 AM
### Checklist - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- x-pack/plugins/fleet/common/constants/epm.ts | 3 +++ .../common/constants/preconfiguration.ts | 5 ++-- .../plugins/fleet/common/constants/routes.ts | 2 +- .../fleet/common/types/models/agent_policy.ts | 9 +++++-- .../plugins/fleet/common/types/models/epm.ts | 3 ++- .../components/agent_policy_form.tsx | 26 +++++++++++++++++++ .../components/settings/index.tsx | 3 ++- .../server/routes/preconfiguration/index.ts | 6 ++--- .../fleet/server/saved_objects/index.ts | 1 + .../fleet/server/services/agent_policy.ts | 1 + .../fleet/server/services/preconfiguration.ts | 2 +- .../fleet/server/types/models/agent_policy.ts | 1 + .../apis/preconfiguration/preconfiguration.ts | 2 +- 13 files changed, 52 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/fleet/common/constants/epm.ts b/x-pack/plugins/fleet/common/constants/epm.ts index e9dd968d3f048..81ea2a630d3db 100644 --- a/x-pack/plugins/fleet/common/constants/epm.ts +++ b/x-pack/plugins/fleet/common/constants/epm.ts @@ -48,6 +48,9 @@ export const dataTypes = { Metrics: 'metrics', } as const; +// currently identical but may be a subset or otherwise different some day +export const monitoringTypes = Object.values(dataTypes); + export const installationStatuses = { Installed: 'installed', NotInstalled: 'not_installed', diff --git a/x-pack/plugins/fleet/common/constants/preconfiguration.ts b/x-pack/plugins/fleet/common/constants/preconfiguration.ts index 937c08b7e8cb5..2ec67393df76b 100644 --- a/x-pack/plugins/fleet/common/constants/preconfiguration.ts +++ b/x-pack/plugins/fleet/common/constants/preconfiguration.ts @@ -12,6 +12,7 @@ import { FLEET_SYSTEM_PACKAGE, FLEET_SERVER_PACKAGE, autoUpdatePackages, + monitoringTypes, } from './epm'; export const PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE = @@ -40,7 +41,7 @@ export const DEFAULT_AGENT_POLICY: PreconfiguredAgentPolicyWithDefaultInputs = { ], is_default: true, is_managed: false, - monitoring_enabled: ['logs', 'metrics'] as Array<'logs' | 'metrics'>, + monitoring_enabled: monitoringTypes, }; export const DEFAULT_FLEET_SERVER_AGENT_POLICY: PreconfiguredAgentPolicyWithDefaultInputs = { @@ -58,7 +59,7 @@ export const DEFAULT_FLEET_SERVER_AGENT_POLICY: PreconfiguredAgentPolicyWithDefa is_default: false, is_default_fleet_server: true, is_managed: false, - monitoring_enabled: ['logs', 'metrics'] as Array<'logs' | 'metrics'>, + monitoring_enabled: monitoringTypes, }; export const DEFAULT_PACKAGES = defaultPackages.map((name) => ({ diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index 037c0ee506a05..0b892bacf53a7 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -117,5 +117,5 @@ export const INSTALL_SCRIPT_API_ROUTES = `${API_ROOT}/install/{osType}`; // Policy preconfig API routes export const PRECONFIGURATION_API_ROUTES = { - PUT_PRECONFIG: `${API_ROOT}/setup/preconfiguration`, + UPDATE_PATTERN: `${API_ROOT}/setup/preconfiguration`, }; diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index a9393abcc57ef..f64467ca674fb 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -6,7 +6,7 @@ */ import type { agentPolicyStatuses } from '../../constants'; -import type { DataType, ValueOf } from '../../types'; +import type { MonitoringType, ValueOf } from '../../types'; import type { PackagePolicy, PackagePolicyPackage } from './package_policy'; import type { Output } from './output'; @@ -20,7 +20,8 @@ export interface NewAgentPolicy { is_default?: boolean; is_default_fleet_server?: boolean; // Optional when creating a policy is_managed?: boolean; // Optional when creating a policy - monitoring_enabled?: Array>; + monitoring_enabled?: MonitoringType; + unenroll_timeout?: number; is_preconfigured?: boolean; } @@ -138,4 +139,8 @@ export interface FleetServerPolicy { * True when this policy is the default policy to start Fleet Server */ default_fleet_server: boolean; + /** + * Auto unenroll any Elastic Agents which have not checked in for this many seconds + */ + unenroll_timeout?: number; } diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index c4441fb6e0d95..36554b8409364 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -14,6 +14,7 @@ import type { ASSETS_SAVED_OBJECT_TYPE, agentAssetTypes, dataTypes, + monitoringTypes, installationStatuses, } from '../../constants'; import type { ValueOf } from '../../types'; @@ -92,7 +93,7 @@ export enum ElasticsearchAssetType { } export type DataType = typeof dataTypes; - +export type MonitoringType = typeof monitoringTypes; export type InstallablePackage = RegistryPackage | ArchivePackage; export type ArchivePackage = PackageSpecManifest & diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx index 25a0993242822..633f8a2c57409 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx @@ -21,6 +21,7 @@ import { EuiCheckboxGroup, EuiButton, EuiLink, + EuiFieldNumber, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -158,6 +159,10 @@ export const AgentPolicyForm: React.FunctionComponent = ({ ); }); + const unenrollmentTimeoutText = i18n.translate( + 'xpack.fleet.agentPolicyForm.unenrollmentTimeoutLabel', + { defaultMessage: 'Unenrollment timeout' } + ); const advancedOptionsContent = ( <> @@ -297,6 +302,27 @@ export const AgentPolicyForm: React.FunctionComponent = ({ }} /> + {unenrollmentTimeoutText}} + description={ + + } + > + + updateAgentPolicy({ unenroll_timeout: Number(e.target.value) })} + isInvalid={Boolean(touchedFields.unenroll_timeout && validation.unenroll_timeout)} + onBlur={() => setTouchedFields({ ...touchedFields, unenroll_timeout: true })} + placeholder={unenrollmentTimeoutText} + /> + + {isEditing && 'id' in agentPolicy && !agentPolicy.is_managed && diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx index 1ea1a7de53b95..0c6451e3f34a2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx @@ -65,12 +65,13 @@ export const SettingsView = memo<{ agentPolicy: AgentPolicy }>( setIsLoading(true); try { // eslint-disable-next-line @typescript-eslint/naming-convention - const { name, description, namespace, monitoring_enabled } = agentPolicy; + const { name, description, namespace, monitoring_enabled, unenroll_timeout } = agentPolicy; const { data, error } = await sendUpdateAgentPolicy(agentPolicy.id, { name, description, namespace, monitoring_enabled, + unenroll_timeout, }); if (data) { notifications.toasts.addSuccess( diff --git a/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts b/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts index 77fe74fda54d9..d6c483ffe30d9 100644 --- a/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts +++ b/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts @@ -15,7 +15,7 @@ import { PutPreconfigurationSchema } from '../../types'; import { defaultIngestErrorHandler } from '../../errors'; import { ensurePreconfiguredPackagesAndPolicies, outputService } from '../../services'; -export const putPreconfigurationHandler: RequestHandler< +export const updatePreconfigurationHandler: RequestHandler< undefined, undefined, TypeOf @@ -43,10 +43,10 @@ export const putPreconfigurationHandler: RequestHandler< export const registerRoutes = (router: IRouter) => { router.put( { - path: PRECONFIGURATION_API_ROUTES.PUT_PRECONFIG, + path: PRECONFIGURATION_API_ROUTES.UPDATE_PATTERN, validate: PutPreconfigurationSchema, options: { tags: [`access:${PLUGIN_ID}-all`] }, }, - putPreconfigurationHandler + updatePreconfigurationHandler ); }; diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index bd7bb98eb7c07..fe8771115a217 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -149,6 +149,7 @@ const getSavedObjectTypes = ( is_managed: { type: 'boolean' }, status: { type: 'keyword' }, package_policies: { type: 'keyword' }, + unenroll_timeout: { type: 'integer' }, updated_at: { type: 'date' }, updated_by: { type: 'keyword' }, revision: { type: 'integer' }, diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 2a6036d99281e..465075cca7a0b 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -642,6 +642,7 @@ class AgentPolicyService { data: (fullPolicy as unknown) as FleetServerPolicy['data'], policy_id: fullPolicy.id, default_fleet_server: policy.is_default_fleet_server === true, + unenroll_timeout: policy.unenroll_timeout, }; await esClient.create({ diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index a8be94ca61c0a..e016fafe5459d 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -108,7 +108,7 @@ export async function ensurePreconfiguredPackagesAndPolicies( policies.map(async (preconfiguredAgentPolicy) => { if (preconfiguredAgentPolicy.id) { // Check to see if a preconfigured policy with the same preconfiguration id was already deleted by the user - const preconfigurationId = String(preconfiguredAgentPolicy.id); + const preconfigurationId = preconfiguredAgentPolicy.id.toString(); const searchParams = { searchFields: ['id'], search: escapeSearchQueryPhrase(preconfigurationId), diff --git a/x-pack/plugins/fleet/server/types/models/agent_policy.ts b/x-pack/plugins/fleet/server/types/models/agent_policy.ts index db551b25e9ebb..48aea1b5cbcc4 100644 --- a/x-pack/plugins/fleet/server/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/server/types/models/agent_policy.ts @@ -16,6 +16,7 @@ export const AgentPolicyBaseSchema = { namespace: NamespaceSchema, description: schema.maybe(schema.string()), is_managed: schema.maybe(schema.boolean()), + unenroll_timeout: schema.maybe(schema.number({ min: 1 })), monitoring_enabled: schema.maybe( schema.arrayOf( schema.oneOf([schema.literal(dataTypes.Logs), schema.literal(dataTypes.Metrics)]) diff --git a/x-pack/test/fleet_api_integration/apis/preconfiguration/preconfiguration.ts b/x-pack/test/fleet_api_integration/apis/preconfiguration/preconfiguration.ts index 7fc784ee11af1..7c5c7d7f3f804 100644 --- a/x-pack/test/fleet_api_integration/apis/preconfiguration/preconfiguration.ts +++ b/x-pack/test/fleet_api_integration/apis/preconfiguration/preconfiguration.ts @@ -37,7 +37,7 @@ export default function (providerContext: FtrProviderContext) { // Basic health check for the API; functionality is covered by the unit tests it('should succeed with an empty payload', async () => { const { body } = await supertest - .put(PRECONFIGURATION_API_ROUTES.PUT_PRECONFIG) + .put(PRECONFIGURATION_API_ROUTES.UPDATE_PATTERN) .set('kbn-xsrf', 'xxxx') .send({}) .expect(200); From eb7e0fa5f18ecbadddf9d7273ef0c4536553f50f Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 23 Jun 2021 12:06:23 -0700 Subject: [PATCH 07/95] Reporting: Check for pending jobs scheduled with ESQueue (#101447) * Reporting: Check for pending jobs scheduled with ESQueue * Update x-pack/plugins/reporting/server/lib/tasks/execute_report.ts Co-authored-by: Vadim Dalecky * update test assertions, use more explicit types * update comment * Update x-pack/plugins/reporting/server/lib/store/store.ts Co-authored-by: Vadim Dalecky * fix field mapping * Update x-pack/plugins/reporting/server/lib/store/store.ts Co-authored-by: Jean-Louis Leysens * Report also implements ReportDocumentHead * the actual ID of the task is prefixed with `task:` * remove pointless update to the report instance after failing * comment clarification Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Vadim Dalecky Co-authored-by: Jean-Louis Leysens --- x-pack/plugins/reporting/common/types.ts | 3 +- .../reporting/server/lib/enqueue_job.ts | 2 +- .../plugins/reporting/server/lib/statuses.ts | 5 +- .../reporting/server/lib/store/mapping.ts | 18 +- .../reporting/server/lib/store/report.test.ts | 55 ++-- .../reporting/server/lib/store/report.ts | 49 ++-- .../reporting/server/lib/store/store.test.ts | 152 ++++++----- .../reporting/server/lib/store/store.ts | 239 +++++++++++------- .../server/lib/tasks/execute_report.ts | 133 ++++++---- .../reporting/server/lib/tasks/index.ts | 8 - .../server/lib/tasks/monitor_reports.ts | 105 ++++---- .../reporting_without_security/job_apis.ts | 1 - 12 files changed, 415 insertions(+), 355 deletions(-) diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 2148cf983d889..8205b4f13a320 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -68,6 +68,7 @@ export interface ReportSource { }; meta: { objectType: string; layout?: string }; browser_type: string; + migration_version: string; max_attempts: number; timeout: number; @@ -77,7 +78,7 @@ export interface ReportSource { started_at?: string; completed_at?: string; created_at: string; - process_expiration?: string; + process_expiration?: string | null; // must be set to null to clear the expiration } /* diff --git a/x-pack/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/plugins/reporting/server/lib/enqueue_job.ts index b0e5d7bafb03c..70492b415f961 100644 --- a/x-pack/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/plugins/reporting/server/lib/enqueue_job.ts @@ -68,7 +68,7 @@ export function enqueueJobFactory( // 2. Schedule the report with Task Manager const task = await reporting.scheduleTask(report.toReportTaskJSON()); logger.info( - `Scheduled ${exportType.name} reporting task. Task ID: ${task.id}. Report ID: ${report._id}` + `Scheduled ${exportType.name} reporting task. Task ID: task:${task.id}. Report ID: ${report._id}` ); return report; diff --git a/x-pack/plugins/reporting/server/lib/statuses.ts b/x-pack/plugins/reporting/server/lib/statuses.ts index 1aa6b6d5ac8ff..2c25708078aaf 100644 --- a/x-pack/plugins/reporting/server/lib/statuses.ts +++ b/x-pack/plugins/reporting/server/lib/statuses.ts @@ -5,11 +5,12 @@ * 2.0. */ -export const statuses = { +import { JobStatus } from '../../common/types'; + +export const statuses: Record = { JOB_STATUS_PENDING: 'pending', JOB_STATUS_PROCESSING: 'processing', JOB_STATUS_COMPLETED: 'completed', JOB_STATUS_WARNINGS: 'completed_with_warnings', JOB_STATUS_FAILED: 'failed', - JOB_STATUS_CANCELLED: 'cancelled', }; diff --git a/x-pack/plugins/reporting/server/lib/store/mapping.ts b/x-pack/plugins/reporting/server/lib/store/mapping.ts index ce8f768ef077f..69f432562ec98 100644 --- a/x-pack/plugins/reporting/server/lib/store/mapping.ts +++ b/x-pack/plugins/reporting/server/lib/store/mapping.ts @@ -7,15 +7,10 @@ export const mapping = { meta: { - // We are indexing these properties with both text and keyword fields because that's what will be auto generated - // when an index already exists. This schema is only used when a reporting index doesn't exist. This way existing - // reporting indexes and new reporting indexes will look the same and the data can be queried in the same - // manner. + // We are indexing these properties with both text and keyword fields + // because that's what will be auto generated when an index already exists. properties: { - /** - * Type of object that is triggering this report. Should be either search, visualization or dashboard. - * Used for job listing and telemetry stats only. - */ + // ID of the app this report: search, visualization or dashboard, etc objectType: { type: 'text', fields: { @@ -25,10 +20,6 @@ export const mapping = { }, }, }, - /** - * Can be either preserve_layout, print or none (in the case of csv export). - * Used for phone home stats only. - */ layout: { type: 'text', fields: { @@ -41,9 +32,10 @@ export const mapping = { }, }, browser_type: { type: 'keyword' }, + migration_version: { type: 'keyword' }, // new field (7.14) to distinguish reports that were scheduled with Task Manager jobtype: { type: 'keyword' }, payload: { type: 'object', enabled: false }, - priority: { type: 'byte' }, // NOTE: this is unused, but older data may have a mapping for this field + priority: { type: 'byte' }, // TODO: remove: this is unused timeout: { type: 'long' }, process_expiration: { type: 'date' }, created_by: { type: 'keyword' }, // `null` if security is disabled diff --git a/x-pack/plugins/reporting/server/lib/store/report.test.ts b/x-pack/plugins/reporting/server/lib/store/report.test.ts index 23d766f2190f6..a8d14e12a738b 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.test.ts @@ -20,21 +20,18 @@ describe('Class Report', () => { timeout: 30000, }); - expect(report.toEsDocsJSON()).toMatchObject({ - _index: '.reporting-test-index-12345', - _source: { - attempts: 0, - browser_type: 'browser_type_test_string', - completed_at: undefined, - created_by: 'created_by_test_string', - jobtype: 'test-report', - max_attempts: 50, - meta: { objectType: 'test' }, - payload: { headers: 'payload_test_field', objectType: 'testOt' }, - started_at: undefined, - status: 'pending', - timeout: 30000, - }, + expect(report.toReportSource()).toMatchObject({ + attempts: 0, + browser_type: 'browser_type_test_string', + completed_at: undefined, + created_by: 'created_by_test_string', + jobtype: 'test-report', + max_attempts: 50, + meta: { objectType: 'test' }, + payload: { headers: 'payload_test_field', objectType: 'testOt' }, + started_at: undefined, + status: 'pending', + timeout: 30000, }); expect(report.toReportTaskJSON()).toMatchObject({ attempts: 0, @@ -80,22 +77,18 @@ describe('Class Report', () => { }; report.updateWithEsDoc(metadata); - expect(report.toEsDocsJSON()).toMatchObject({ - _id: '12342p9o387549o2345', - _index: '.reporting-test-update', - _source: { - attempts: 0, - browser_type: 'browser_type_test_string', - completed_at: undefined, - created_by: 'created_by_test_string', - jobtype: 'test-report', - max_attempts: 50, - meta: { objectType: 'stange' }, - payload: { objectType: 'testOt' }, - started_at: undefined, - status: 'pending', - timeout: 30000, - }, + expect(report.toReportSource()).toMatchObject({ + attempts: 0, + browser_type: 'browser_type_test_string', + completed_at: undefined, + created_by: 'created_by_test_string', + jobtype: 'test-report', + max_attempts: 50, + meta: { objectType: 'stange' }, + payload: { objectType: 'testOt' }, + started_at: undefined, + status: 'pending', + timeout: 30000, }); expect(report.toReportTaskJSON()).toMatchObject({ attempts: 0, diff --git a/x-pack/plugins/reporting/server/lib/store/report.ts b/x-pack/plugins/reporting/server/lib/store/report.ts index 9b98650e1d984..fa5b91527ccc4 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.ts @@ -21,8 +21,13 @@ export { ReportDocument }; export { ReportApiJSON, ReportSource }; const puid = new Puid(); +export const MIGRATION_VERSION = '7.14.0'; -export class Report implements Partial { +/* + * The public fields are a flattened version what Elasticsearch returns when you + * `GET` a document. + */ +export class Report implements Partial { public _index?: string; public _id: string; public _primary_term?: number; // set by ES @@ -47,6 +52,7 @@ export class Report implements Partial { public readonly timeout?: ReportSource['timeout']; public process_expiration?: ReportSource['process_expiration']; + public migration_version: string; /* * Create an unsaved report @@ -58,6 +64,8 @@ export class Report implements Partial { this._primary_term = opts._primary_term; this._seq_no = opts._seq_no; + this.migration_version = MIGRATION_VERSION; + this.payload = opts.payload!; this.kibana_name = opts.kibana_name!; this.kibana_id = opts.kibana_id!; @@ -80,7 +88,7 @@ export class Report implements Partial { /* * Update the report with "live" storage metadata */ - updateWithEsDoc(doc: Partial) { + updateWithEsDoc(doc: Partial): void { if (doc._index == null || doc._id == null) { throw new Error(`Report object from ES has missing fields!`); } @@ -89,30 +97,31 @@ export class Report implements Partial { this._index = doc._index; this._primary_term = doc._primary_term; this._seq_no = doc._seq_no; + this.migration_version = MIGRATION_VERSION; } /* * Data structure for writing to Elasticsearch index */ - toEsDocsJSON() { + toReportSource(): ReportSource { return { - _id: this._id, - _index: this._index, - _source: { - jobtype: this.jobtype, - created_at: this.created_at, - created_by: this.created_by, - payload: this.payload, - meta: this.meta, - timeout: this.timeout, - max_attempts: this.max_attempts, - browser_type: this.browser_type, - status: this.status, - attempts: this.attempts, - started_at: this.started_at, - completed_at: this.completed_at, - process_expiration: this.process_expiration, - }, + migration_version: MIGRATION_VERSION, + kibana_name: this.kibana_name, + kibana_id: this.kibana_id, + jobtype: this.jobtype, + created_at: this.created_at, + created_by: this.created_by, + payload: this.payload, + meta: this.meta, + timeout: this.timeout!, + max_attempts: this.max_attempts, + browser_type: this.browser_type!, + status: this.status, + attempts: this.attempts, + started_at: this.started_at, + completed_at: this.completed_at, + process_expiration: this.process_expiration, + output: this.output || null, }; } diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts index 7f96433fcc6ce..8bb5c7fb8bbf9 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -184,6 +184,7 @@ describe('ReportingStore', () => { _source: { kibana_name: 'test', kibana_id: 'test123', + migration_version: 'X.0.0', created_at: 'some time', created_by: 'some security person', jobtype: 'csv', @@ -222,6 +223,7 @@ describe('ReportingStore', () => { "meta": Object { "testMeta": "meta", }, + "migration_version": "7.14.0", "output": null, "payload": Object { "testPayload": "payload", @@ -239,6 +241,8 @@ describe('ReportingStore', () => { const report = new Report({ _id: 'id-of-processing', _index: '.reporting-test-index-12345', + _seq_no: 42, + _primary_term: 10002, jobtype: 'test-report', created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', @@ -254,24 +258,12 @@ describe('ReportingStore', () => { await store.setReportClaimed(report, { testDoc: 'test' } as any); - const [updateCall] = mockEsClient.update.mock.calls; - expect(updateCall).toMatchInlineSnapshot(` - Array [ - Object { - "body": Object { - "doc": Object { - "status": "processing", - "testDoc": "test", - }, - }, - "id": "id-of-processing", - "if_primary_term": undefined, - "if_seq_no": undefined, - "index": ".reporting-test-index-12345", - "refresh": true, - }, - ] - `); + const [[updateCall]] = mockEsClient.update.mock.calls; + const response = updateCall.body?.doc as Report; + expect(response.migration_version).toBe(`7.14.0`); + expect(response.status).toBe(`processing`); + expect(updateCall.if_seq_no).toBe(42); + expect(updateCall.if_primary_term).toBe(10002); }); it('setReportFailed sets the status of a record to failed', async () => { @@ -279,6 +271,8 @@ describe('ReportingStore', () => { const report = new Report({ _id: 'id-of-failure', _index: '.reporting-test-index-12345', + _seq_no: 43, + _primary_term: 10002, jobtype: 'test-report', created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', @@ -294,24 +288,12 @@ describe('ReportingStore', () => { await store.setReportFailed(report, { errors: 'yes' } as any); - const [updateCall] = mockEsClient.update.mock.calls; - expect(updateCall).toMatchInlineSnapshot(` - Array [ - Object { - "body": Object { - "doc": Object { - "errors": "yes", - "status": "failed", - }, - }, - "id": "id-of-failure", - "if_primary_term": undefined, - "if_seq_no": undefined, - "index": ".reporting-test-index-12345", - "refresh": true, - }, - ] - `); + const [[updateCall]] = mockEsClient.update.mock.calls; + const response = updateCall.body?.doc as Report; + expect(response.migration_version).toBe(`7.14.0`); + expect(response.status).toBe(`failed`); + expect(updateCall.if_seq_no).toBe(43); + expect(updateCall.if_primary_term).toBe(10002); }); it('setReportCompleted sets the status of a record to completed', async () => { @@ -319,6 +301,8 @@ describe('ReportingStore', () => { const report = new Report({ _id: 'vastly-great-report-id', _index: '.reporting-test-index-12345', + _seq_no: 44, + _primary_term: 10002, jobtype: 'test-report', created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', @@ -334,31 +318,21 @@ describe('ReportingStore', () => { await store.setReportCompleted(report, { certainly_completed: 'yes' } as any); - const [updateCall] = mockEsClient.update.mock.calls; - expect(updateCall).toMatchInlineSnapshot(` - Array [ - Object { - "body": Object { - "doc": Object { - "certainly_completed": "yes", - "status": "completed", - }, - }, - "id": "vastly-great-report-id", - "if_primary_term": undefined, - "if_seq_no": undefined, - "index": ".reporting-test-index-12345", - "refresh": true, - }, - ] - `); + const [[updateCall]] = mockEsClient.update.mock.calls; + const response = updateCall.body?.doc as Report; + expect(response.migration_version).toBe(`7.14.0`); + expect(response.status).toBe(`completed`); + expect(updateCall.if_seq_no).toBe(44); + expect(updateCall.if_primary_term).toBe(10002); }); - it('setReportCompleted sets the status of a record to completed_with_warnings', async () => { + it('sets the status of a record to completed_with_warnings', async () => { const store = new ReportingStore(mockCore, mockLogger); const report = new Report({ _id: 'vastly-great-report-id', _index: '.reporting-test-index-12345', + _seq_no: 45, + _primary_term: 10002, jobtype: 'test-report', created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', @@ -379,28 +353,52 @@ describe('ReportingStore', () => { }, } as any); - const [updateCall] = mockEsClient.update.mock.calls; - expect(updateCall).toMatchInlineSnapshot(` - Array [ - Object { - "body": Object { - "doc": Object { - "certainly_completed": "pretty_much", - "output": Object { - "warnings": Array [ - "those pants don't go with that shirt", - ], - }, - "status": "completed_with_warnings", - }, - }, - "id": "vastly-great-report-id", - "if_primary_term": undefined, - "if_seq_no": undefined, - "index": ".reporting-test-index-12345", - "refresh": true, - }, - ] + const [[updateCall]] = mockEsClient.update.mock.calls; + const response = updateCall.body?.doc as Report; + + expect(response.migration_version).toBe(`7.14.0`); + expect(response.status).toBe(`completed_with_warnings`); + expect(updateCall.if_seq_no).toBe(45); + expect(updateCall.if_primary_term).toBe(10002); + expect(response.output).toMatchInlineSnapshot(` + Object { + "warnings": Array [ + "those pants don't go with that shirt", + ], + } `); }); + + it('prepareReportForRetry resets the expiration and status on the report document', async () => { + const store = new ReportingStore(mockCore, mockLogger); + const report = new Report({ + _id: 'pretty-good-report-id', + _index: '.reporting-test-index-94058763', + _seq_no: 46, + _primary_term: 10002, + jobtype: 'test-report-2', + created_by: 'created_by_test_string', + browser_type: 'browser_type_test_string', + status: 'processing', + process_expiration: '2002', + max_attempts: 3, + payload: { + title: 'test report', + headers: 'rp_test_headers', + objectType: 'testOt', + browserTimezone: 'utc', + }, + timeout: 30000, + }); + + await store.prepareReportForRetry(report); + + const [[updateCall]] = mockEsClient.update.mock.calls; + const response = updateCall.body?.doc as Report; + + expect(response.migration_version).toBe(`7.14.0`); + expect(response.status).toBe(`pending`); + expect(updateCall.if_seq_no).toBe(46); + expect(updateCall.if_primary_term).toBe(10002); + }); }); diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index fc7bd9c23d769..8f1e6c315a2d1 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -5,15 +5,38 @@ * 2.0. */ +import { IndexResponse, UpdateResponse } from '@elastic/elasticsearch/api/types'; import { ElasticsearchClient } from 'src/core/server'; import { LevelLogger, statuses } from '../'; import { ReportingCore } from '../../'; -import { numberToDuration } from '../../../common/schema_utils'; import { JobStatus } from '../../../common/types'; import { ReportTaskParams } from '../tasks'; import { indexTimestamp } from './index_timestamp'; import { mapping } from './mapping'; -import { Report, ReportDocument, ReportSource } from './report'; +import { MIGRATION_VERSION, Report, ReportDocument, ReportSource } from './report'; + +/* + * When an instance of Kibana claims a report job, this information tells us about that instance + */ +export type ReportProcessingFields = Required<{ + kibana_id: Report['kibana_id']; + kibana_name: Report['kibana_name']; + browser_type: Report['browser_type']; + attempts: Report['attempts']; + started_at: Report['started_at']; + timeout: Report['timeout']; + process_expiration: Report['process_expiration']; +}>; + +export type ReportFailedFields = Required<{ + completed_at: Report['completed_at']; + output: Report['output']; +}>; + +export type ReportCompletedFields = Required<{ + completed_at: Report['completed_at']; + output: Report['output']; +}>; /* * When searching for long-pending reports, we get a subset of fields @@ -24,15 +47,38 @@ export interface ReportRecordTimeout { _source: { status: JobStatus; process_expiration?: string; - created_at?: string; }; } const checkReportIsEditable = (report: Report) => { - if (!report._id || !report._index) { - throw new Error(`Report object is not synced with ES!`); + const { _id, _index, _seq_no, _primary_term } = report; + if (_id == null || _index == null) { + throw new Error(`Report is not editable: Job [${_id}] is not synced with ES!`); + } + + if (_seq_no == null || _primary_term == null) { + throw new Error( + `Report is not editable: Job [${_id}] is missing _seq_no and _primary_term fields!` + ); } }; +/* + * When searching for long-pending reports, we get a subset of fields + */ +const sourceDoc = (doc: Partial): Partial => { + return { + ...doc, + migration_version: MIGRATION_VERSION, + }; +}; + +const jobDebugMessage = (report: Report) => + `${report._id} ` + + `[_index: ${report._index}] ` + + `[_seq_no: ${report._seq_no}] ` + + `[_primary_term: ${report._primary_term}]` + + `[attempts: ${report.attempts}] ` + + `[process_expiration: ${report.process_expiration}]`; /* * A class to give an interface to historical reports in the reporting.index @@ -43,7 +89,6 @@ const checkReportIsEditable = (report: Report) => { export class ReportingStore { private readonly indexPrefix: string; // config setting of index prefix in system index name private readonly indexInterval: string; // config setting of index prefix: how often to poll for pending work - private readonly queueTimeoutMins: number; // config setting of queue timeout, rounded up to nearest minute private client?: ElasticsearchClient; constructor(private reportingCore: ReportingCore, private logger: LevelLogger) { @@ -52,7 +97,6 @@ export class ReportingStore { this.indexPrefix = config.get('index'); this.indexInterval = config.get('queue', 'indexInterval'); this.logger = logger.clone(['store']); - this.queueTimeoutMins = Math.ceil(numberToDuration(config.get('queue', 'timeout')).asMinutes()); } private async getClient() { @@ -103,18 +147,20 @@ export class ReportingStore { /* * Called from addReport, which handles any errors */ - private async indexReport(report: Report) { + private async indexReport(report: Report): Promise { const doc = { index: report._index!, id: report._id, + refresh: true, body: { - ...report.toEsDocsJSON()._source, - process_expiration: new Date(0), // use epoch so the job query works - attempts: 0, - status: statuses.JOB_STATUS_PENDING, + ...report.toReportSource(), + ...sourceDoc({ + process_expiration: new Date(0).toISOString(), + attempts: 0, + status: statuses.JOB_STATUS_PENDING, + }), }, }; - const client = await this.getClient(); const { body } = await client.index(doc); @@ -140,8 +186,7 @@ export class ReportingStore { await this.createIndex(index); try { - const doc = await this.indexReport(report); - report.updateWithEsDoc(doc); + report.updateWithEsDoc(await this.indexReport(report)); await this.refreshIndex(index); @@ -156,7 +201,9 @@ export class ReportingStore { /* * Search for a report from task data and return back the report */ - public async findReportFromTask(taskJson: ReportTaskParams): Promise { + public async findReportFromTask( + taskJson: Pick + ): Promise { if (!taskJson.index) { throw new Error('Task JSON is missing index field!'); } @@ -186,41 +233,23 @@ export class ReportingStore { timeout: document._source?.timeout, }); } catch (err) { - this.logger.error('Error in finding a report! ' + JSON.stringify({ report: taskJson })); - this.logger.error(err); - throw err; - } - } - - public async setReportPending(report: Report) { - const doc = { status: statuses.JOB_STATUS_PENDING }; - - try { - checkReportIsEditable(report); - - const client = await this.getClient(); - const { body } = await client.update({ - id: report._id, - index: report._index!, - if_seq_no: report._seq_no, - if_primary_term: report._primary_term, - refresh: true, - body: { doc }, - }); - - return (body as unknown) as ReportDocument; - } catch (err) { - this.logger.error('Error in setting report pending status!'); + this.logger.error( + `Error in finding the report from the scheduled task info! ` + + `[id: ${taskJson.id}] [index: ${taskJson.index}]` + ); this.logger.error(err); throw err; } } - public async setReportClaimed(report: Report, stats: Partial): Promise { - const doc = { - ...stats, + public async setReportClaimed( + report: Report, + processingInfo: ReportProcessingFields + ): Promise> { + const doc = sourceDoc({ + ...processingInfo, status: statuses.JOB_STATUS_PROCESSING, - }; + }); try { checkReportIsEditable(report); @@ -235,19 +264,24 @@ export class ReportingStore { body: { doc }, }); - return (body as unknown) as ReportDocument; + return body; } catch (err) { - this.logger.error('Error in setting report processing status!'); + this.logger.error( + `Error in updating status to processing! Report: ` + jobDebugMessage(report) + ); this.logger.error(err); throw err; } } - public async setReportFailed(report: Report, stats: Partial): Promise { - const doc = { - ...stats, + public async setReportFailed( + report: Report, + failedInfo: ReportFailedFields + ): Promise> { + const doc = sourceDoc({ + ...failedInfo, status: statuses.JOB_STATUS_FAILED, - }; + }); try { checkReportIsEditable(report); @@ -261,26 +295,29 @@ export class ReportingStore { refresh: true, body: { doc }, }); - - return (body as unknown) as ReportDocument; + return body; } catch (err) { - this.logger.error('Error in setting report failed status!'); + this.logger.error(`Error in updating status to failed! Report: ` + jobDebugMessage(report)); this.logger.error(err); throw err; } } - public async setReportCompleted(report: Report, stats: Partial): Promise { + public async setReportCompleted( + report: Report, + completedInfo: ReportCompletedFields + ): Promise> { + const { output } = completedInfo; + const status = + output && output.warnings && output.warnings.length > 0 + ? statuses.JOB_STATUS_WARNINGS + : statuses.JOB_STATUS_COMPLETED; + const doc = sourceDoc({ + ...completedInfo, + status, + }); + try { - const { output } = stats; - const status = - output && output.warnings && output.warnings.length > 0 - ? statuses.JOB_STATUS_WARNINGS - : statuses.JOB_STATUS_COMPLETED; - const doc = { - ...stats, - status, - }; checkReportIsEditable(report); const client = await this.getClient(); @@ -292,16 +329,20 @@ export class ReportingStore { refresh: true, body: { doc }, }); - - return (body as unknown) as ReportDocument; + return body; } catch (err) { - this.logger.error('Error in setting report complete status!'); + this.logger.error(`Error in updating status to complete! Report: ` + jobDebugMessage(report)); this.logger.error(err); throw err; } } - public async clearExpiration(report: Report): Promise { + public async prepareReportForRetry(report: Report): Promise> { + const doc = sourceDoc({ + status: statuses.JOB_STATUS_PENDING, + process_expiration: null, + }); + try { checkReportIsEditable(report); @@ -312,50 +353,54 @@ export class ReportingStore { if_seq_no: report._seq_no, if_primary_term: report._primary_term, refresh: true, - body: { doc: { process_expiration: null } }, + body: { doc }, }); - - return (body as unknown) as ReportDocument; + return body; } catch (err) { - this.logger.error('Error in clearing expiration!'); + this.logger.error( + `Error in clearing expiration and status for retry! Report: ` + jobDebugMessage(report) + ); this.logger.error(err); throw err; } } /* - * A zombie report document is one that isn't completed or failed, isn't - * being executed, and isn't scheduled to run. They arise: - * - when the cluster has processing documents in ESQueue before upgrading to v7.13 when ESQueue was removed - * - if Kibana crashes while a report task is executing and it couldn't be rescheduled on its own - * - * Pending reports are not included in this search: they may be scheduled in TM just not run yet. - * TODO Should we get a list of the reports that are pending and scheduled in TM so we can exclude them from this query? + * A report needs to be rescheduled when: + * 1. An older version of Kibana created jobs with ESQueue, and they have + * not yet started running. + * 2. The report process_expiration field is overdue, which happens if the + * report runs too long or Kibana restarts during execution */ - public async findZombieReportDocuments(): Promise { + public async findStaleReportJob(): Promise { const client = await this.getClient(); + + const expiredFilter = { + bool: { + must: [ + { range: { process_expiration: { lt: `now` } } }, + { terms: { status: [statuses.JOB_STATUS_PROCESSING] } }, + ], + }, + }; + const oldVersionFilter = { + bool: { + must: [{ terms: { status: [statuses.JOB_STATUS_PENDING] } }], + must_not: [{ exists: { field: 'migration_version' } }], + }, + }; + const { body } = await client.search({ + size: 1, index: this.indexPrefix + '-*', - filter_path: 'hits.hits', + seq_no_primary_term: true, + _source_excludes: ['output'], body: { - sort: { created_at: { order: 'desc' } }, - query: { - bool: { - filter: [ - { - bool: { - must: [ - { range: { process_expiration: { lt: `now-${this.queueTimeoutMins}m` } } }, - { terms: { status: [statuses.JOB_STATUS_PROCESSING] } }, - ], - }, - }, - ], - }, - }, + sort: { created_at: { order: 'asc' as const } }, // find the oldest first + query: { bool: { filter: { bool: { should: [expiredFilter, oldVersionFilter] } } } }, }, }); - return body.hits?.hits as ReportRecordTimeout[]; + return body.hits?.hits[0] as ReportRecordTimeout; } } diff --git a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts index 2960ce457b7ae..f9e2cd82b0805 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { UpdateResponse } from '@elastic/elasticsearch/api/types'; import moment from 'moment'; import * as Rx from 'rxjs'; import { timeout } from 'rxjs/operators'; @@ -19,9 +20,9 @@ import { CancellationToken } from '../../../common'; import { durationToNumber, numberToDuration } from '../../../common/schema_utils'; import { ReportingConfigType } from '../../config'; import { BasePayload, RunTaskFn } from '../../types'; -import { Report, ReportingStore } from '../store'; +import { Report, ReportDocument, ReportingStore } from '../store'; +import { ReportFailedFields, ReportProcessingFields } from '../store/store'; import { - ReportingExecuteTaskInstance, ReportingTask, ReportingTaskStatus, REPORTING_EXECUTE_TYPE, @@ -30,6 +31,13 @@ import { } from './'; import { errorLogger } from './error_logger'; +interface ReportingExecuteTaskInstance { + state: object; + taskType: string; + params: ReportTaskParams; + runAt?: Date; +} + function isOutput(output: TaskRunResult | Error): output is TaskRunResult { return typeof output === 'object' && (output as TaskRunResult).content != null; } @@ -101,15 +109,21 @@ export class ExecuteReportTask implements ReportingTask { } public async _claimJob(task: ReportTaskParams): Promise { - const store = await this.getStore(); + if (this.kibanaId == null) { + throw new Error(`Kibana instance ID is undefined!`); + } + if (this.kibanaName == null) { + throw new Error(`Kibana instance name is undefined!`); + } + const store = await this.getStore(); let report: Report; if (task.id && task.index) { // if this is an ad-hoc report, there is a corresponding "pending" record in ReportingStore in need of updating - report = await store.findReportFromTask(task); // update seq_no + report = await store.findReportFromTask(task); // receives seq_no and primary_term } else { // if this is a scheduled report (not implemented), the report object needs to be instantiated - throw new Error('scheduled reports are not supported!'); + throw new Error('Could not find matching report document!'); } // Check if this is a completed job. This may happen if the `reports:monitor` @@ -126,7 +140,7 @@ export class ExecuteReportTask implements ReportingTask { const maxAttempts = task.max_attempts; if (report.attempts >= maxAttempts) { const err = new Error(`Max attempts reached (${maxAttempts}). Queue timeout reached.`); - await this._failJob(task, err); + await this._failJob(report, err); throw err; } @@ -134,7 +148,7 @@ export class ExecuteReportTask implements ReportingTask { const startTime = m.toISOString(); const expirationTime = m.add(queueTimeout).toISOString(); - const stats = { + const doc: ReportProcessingFields = { kibana_id: this.kibanaId, kibana_name: this.kibanaName, browser_type: this.config.capture.browser.type, @@ -144,19 +158,28 @@ export class ExecuteReportTask implements ReportingTask { process_expiration: expirationTime, }; - this.logger.debug(`Claiming ${report.jobtype} job ${report._id}`); - const claimedReport = new Report({ ...report, - ...stats, + ...doc, }); - await store.setReportClaimed(claimedReport, stats); + this.logger.debug( + `Claiming ${claimedReport.jobtype} ${report._id} ` + + `[_index: ${report._index}] ` + + `[_seq_no: ${report._seq_no}] ` + + `[_primary_term: ${report._primary_term}] ` + + `[attempts: ${report.attempts}] ` + + `[process_expiration: ${expirationTime}]` + ); + + const resp = await store.setReportClaimed(claimedReport, doc); + claimedReport._seq_no = resp._seq_no; + claimedReport._primary_term = resp._primary_term; return claimedReport; } - private async _failJob(task: ReportTaskParams, error?: Error) { - const message = `Failing ${task.jobtype} job ${task.id}`; + private async _failJob(report: Report, error?: Error): Promise> { + const message = `Failing ${report.jobtype} job ${report._id}`; // log the error let docOutput; @@ -169,9 +192,8 @@ export class ExecuteReportTask implements ReportingTask { // update the report in the store const store = await this.getStore(); - const report = await store.findReportFromTask(task); const completedTime = moment().toISOString(); - const doc = { + const doc: ReportFailedFields = { completed_at: completedTime, output: docOutput, }; @@ -179,7 +201,7 @@ export class ExecuteReportTask implements ReportingTask { return await store.setReportFailed(report, doc); } - private _formatOutput(output: TaskRunResult | Error) { + private _formatOutput(output: TaskRunResult | Error): TaskRunResult { const docOutput = {} as TaskRunResult; const unknownMime = null; @@ -201,7 +223,10 @@ export class ExecuteReportTask implements ReportingTask { return docOutput; } - public async _performJob(task: ReportTaskParams, cancellationToken: CancellationToken) { + public async _performJob( + task: ReportTaskParams, + cancellationToken: CancellationToken + ): Promise { if (!this.taskExecutors) { throw new Error(`Task run function factories have not been called yet!`); } @@ -220,10 +245,10 @@ export class ExecuteReportTask implements ReportingTask { .toPromise(); } - public async _completeJob(task: ReportTaskParams, output: TaskRunResult) { - let docId = `/${task.index}/_doc/${task.id}`; + public async _completeJob(report: Report, output: TaskRunResult): Promise { + let docId = `/${report._index}/_doc/${report._id}`; - this.logger.info(`Saving ${task.jobtype} job ${docId}.`); + this.logger.debug(`Saving ${report.jobtype} to ${docId}.`); const completedTime = moment().toISOString(); const docOutput = this._formatOutput(output); @@ -233,16 +258,13 @@ export class ExecuteReportTask implements ReportingTask { completed_at: completedTime, output: docOutput, }; - const report = await store.findReportFromTask(task); // update seq_no and primary_term docId = `/${report._index}/_doc/${report._id}`; - try { - await store.setReportCompleted(report, doc); - this.logger.debug(`Saved ${report.jobtype} job ${docId}`); - } catch (err) { - if (err.statusCode === 409) return false; - errorLogger(this.logger, `Failure saving completed job ${docId}!`); - } + const resp = await store.setReportCompleted(report, doc); + this.logger.info(`Saved ${report.jobtype} job ${docId}`); + report._seq_no = resp._seq_no; + report._primary_term = resp._primary_term; + return report; } /* @@ -264,7 +286,6 @@ export class ExecuteReportTask implements ReportingTask { */ run: async () => { let report: Report | undefined; - let attempts = 0; // find the job in the store and set status to processing const task = context.taskInstance.params as ReportTaskParams; @@ -278,64 +299,73 @@ export class ExecuteReportTask implements ReportingTask { // Update job status to claimed report = await this._claimJob(task); - - const { jobtype: jobType, attempts: attempt, max_attempts: maxAttempts } = task; - this.logger.info( - `Starting ${jobType} report ${jobId}: attempt ${attempt + 1} of ${maxAttempts}.` - ); - this.logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`); } catch (failedToClaim) { // error claiming report - log the error // could be version conflict, or no longer connected to ES - errorLogger(this.logger, `Error in claiming report!`, failedToClaim); + errorLogger(this.logger, `Error in claiming ${jobId}`, failedToClaim); } if (!report) { - errorLogger(this.logger, `Report could not be claimed. Exiting...`); + errorLogger(this.logger, `Job ${jobId} could not be claimed. Exiting...`); return; } - attempts = report.attempts; + const { jobtype: jobType, attempts, max_attempts: maxAttempts } = report; + this.logger.debug( + `Starting ${jobType} report ${jobId}: attempt ${attempts} of ${maxAttempts}.` + ); + this.logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`); try { const output = await this._performJob(task, cancellationToken); if (output) { - await this._completeJob(task, output); + report = await this._completeJob(report, output); } - // untrack the report for concurrency awareness this.logger.debug(`Stopping ${jobId}.`); - this.reporting.untrackReport(jobId); - this.logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`); } catch (failedToExecuteErr) { cancellationToken.cancel(); - const maxAttempts = this.config.capture.maxAttempts; if (attempts < maxAttempts) { - // attempts remain - reschedule + // attempts remain, reschedule try { + if (report == null) { + throw new Error(`Report ${jobId} is null!`); + } // reschedule to retry const remainingAttempts = maxAttempts - report.attempts; errorLogger( this.logger, - `Scheduling retry. Retries remaining: ${remainingAttempts}.`, + `Scheduling retry for job ${jobId}. Retries remaining: ${remainingAttempts}.`, failedToExecuteErr ); await this.rescheduleTask(reportFromTask(task).toReportTaskJSON(), this.logger); } catch (rescheduleErr) { // can not be rescheduled - log the error - errorLogger(this.logger, `Could not reschedule the errored job!`, rescheduleErr); + errorLogger( + this.logger, + `Could not reschedule the errored job ${jobId}!`, + rescheduleErr + ); } } else { // 0 attempts remain - fail the job try { - const maxAttemptsMsg = `Max attempts reached (${attempts}). Failed with: ${failedToExecuteErr}`; - await this._failJob(task, new Error(maxAttemptsMsg)); + const maxAttemptsMsg = `Max attempts (${attempts}) reached for job ${jobId}. Failed with: ${failedToExecuteErr}`; + if (report == null) { + throw new Error(`Report ${jobId} is null!`); + } + const resp = await this._failJob(report, new Error(maxAttemptsMsg)); + report._seq_no = resp._seq_no; + report._primary_term = resp._primary_term; } catch (failedToFailError) { - errorLogger(this.logger, `Could not fail the job!`, failedToFailError); + errorLogger(this.logger, `Could not fail ${jobId}!`, failedToFailError); } } + } finally { + this.reporting.untrackReport(jobId); + this.logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`); } }, @@ -374,11 +404,12 @@ export class ExecuteReportTask implements ReportingTask { state: {}, params: report, }; + return await this.getTaskManagerStart().schedule(taskInstance); } private async rescheduleTask(task: ReportTaskParams, logger: LevelLogger) { - logger.info(`Rescheduling ${task.id} to retry after error.`); + logger.info(`Rescheduling task:${task.id} to retry after error.`); const oldTaskInstance: ReportingExecuteTaskInstance = { taskType: REPORTING_EXECUTE_TYPE, @@ -386,7 +417,7 @@ export class ExecuteReportTask implements ReportingTask { params: task, }; const newTask = await this.getTaskManagerStart().schedule(oldTaskInstance); - logger.debug(`Rescheduled ${task.id}`); + logger.debug(`Rescheduled task:${task.id}. New task: task:${newTask.id}`); return newTask; } diff --git a/x-pack/plugins/reporting/server/lib/tasks/index.ts b/x-pack/plugins/reporting/server/lib/tasks/index.ts index ec9e85e957d03..c02b06d97adc7 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/index.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/index.ts @@ -32,13 +32,6 @@ export interface ReportTaskParams { meta: ReportSource['meta']; } -export interface ReportingExecuteTaskInstance /* extends TaskInstanceWithDeprecatedFields */ { - state: object; - taskType: string; - params: ReportTaskParams; - runAt?: Date; -} - export enum ReportingTaskStatus { UNINITIALIZED = 'uninitialized', INITIALIZED = 'initialized', @@ -52,6 +45,5 @@ export interface ReportingTask { maxAttempts: number; timeout: string; }; - getStatus: () => ReportingTaskStatus; } diff --git a/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts b/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts index 36380f767e6d9..9e1bc49739c93 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts @@ -11,21 +11,29 @@ import { ReportingCore } from '../../'; import { TaskManagerStartContract, TaskRunCreatorFunction } from '../../../../task_manager/server'; import { numberToDuration } from '../../../common/schema_utils'; import { ReportingConfigType } from '../../config'; +import { statuses } from '../statuses'; import { Report } from '../store'; -import { - ReportingExecuteTaskInstance, - ReportingTask, - ReportingTaskStatus, - REPORTING_EXECUTE_TYPE, - REPORTING_MONITOR_TYPE, - ReportTaskParams, -} from './'; +import { ReportingTask, ReportingTaskStatus, REPORTING_MONITOR_TYPE, ReportTaskParams } from './'; /* - * Task for finding the ReportingRecords left in the ReportingStore and stuck - * in pending or processing. It could happen if the server crashed while running - * a report and was cancelled. Normally a failure would mean scheduling a - * retry or failing the report, but the retry is not guaranteed to be scheduled. + * Task for finding the ReportingRecords left in the ReportingStore (.reporting index) and stuck in + * a pending or processing status. + * + * Stuck in pending: + * - This can happen if the report was scheduled in an earlier version of Kibana that used ESQueue. + * - Task Manager doesn't know about these types of reports because there was never a task + * scheduled for them. + * Stuck in processing: + * - This can could happen if the server crashed while a report was executing. + * - Task Manager doesn't know about these reports, because the task is completed in Task + * Manager when Reporting starts executing the report. We are not using Task Manager's retry + * mechanisms, which defer the retry for a few minutes. + * + * These events require us to reschedule the report with Task Manager, so that the jobs can be + * distributed and executed. + * + * The runner function reschedules a single report job per task run, to avoid flooding Task Manager + * in case many report jobs need to be recovered. */ export class MonitorReportsTask implements ReportingTask { public TYPE = REPORTING_MONITOR_TYPE; @@ -77,36 +85,41 @@ export class MonitorReportsTask implements ReportingTask { const reportingStore = await this.getStore(); try { - const results = await reportingStore.findZombieReportDocuments(); - if (results && results.length) { - this.logger.info( - `Found ${results.length} reports to reschedule: ${results - .map((pending) => pending._id) - .join(',')}` - ); - } else { - this.logger.debug(`Found 0 pending reports.`); + const recoveredJob = await reportingStore.findStaleReportJob(); + if (!recoveredJob) { + // no reports need to be rescheduled return; } - for (const pending of results) { - const { - _id: jobId, - _source: { process_expiration: processExpiration, status }, - } = pending; - const expirationTime = moment(processExpiration); // If it is the start of the Epoch, something went wrong - const timeWaitValue = moment().valueOf() - expirationTime.valueOf(); - const timeWaitTime = moment.duration(timeWaitValue); + const { + _id: jobId, + _source: { process_expiration: processExpiration, status }, + } = recoveredJob; + + if (![statuses.JOB_STATUS_PENDING, statuses.JOB_STATUS_PROCESSING].includes(status)) { + throw new Error(`Invalid job status in the monitoring search result: ${status}`); // only pending or processing jobs possibility need rescheduling + } + + if (status === statuses.JOB_STATUS_PENDING) { this.logger.info( - `Task ${jobId} has ${status} status for ${timeWaitTime.humanize()}. The queue timeout is ${this.timeout.humanize()}.` + `${jobId} was scheduled in a previous version and left in [${status}] status. Rescheduling...` ); + } - // clear process expiration and reschedule - const oldReport = new Report({ ...pending, ...pending._source }); - const reschedulingTask = oldReport.toReportTaskJSON(); - await reportingStore.clearExpiration(oldReport); - await this.rescheduleTask(reschedulingTask, this.logger); + if (status === statuses.JOB_STATUS_PROCESSING) { + const expirationTime = moment(processExpiration); + const overdueValue = moment().valueOf() - expirationTime.valueOf(); + this.logger.info( + `${jobId} status is [${status}] and the expiration time was [${overdueValue}ms] ago. Rescheduling...` + ); } + + // clear process expiration and set status to pending + const report = new Report({ ...recoveredJob, ...recoveredJob._source }); + await reportingStore.prepareReportForRetry(report); // if there is a version conflict response, this just throws and logs an error + + // clear process expiration and reschedule + await this.rescheduleTask(report.toReportTaskJSON(), this.logger); // a recovered report job must be scheduled by only a sinle Kibana instance } catch (err) { this.logger.error(err); } @@ -126,33 +139,19 @@ export class MonitorReportsTask implements ReportingTask { createTaskRunner: this.getTaskRunner(), maxAttempts: 1, // round the timeout value up to the nearest second, since Task Manager - // doesn't support milliseconds + // doesn't support milliseconds or > 1s timeout: Math.ceil(this.timeout.asSeconds()) + 's', }; } - // reschedule the task with TM and update the report document status to "Pending" + // reschedule the task with TM private async rescheduleTask(task: ReportTaskParams, logger: LevelLogger) { if (!this.taskManagerStart) { throw new Error('Reporting task runner has not been initialized!'); } - logger.info(`Rescheduling ${task.id} to retry after timeout expiration.`); - - const store = await this.getStore(); - - const oldTaskInstance: ReportingExecuteTaskInstance = { - taskType: REPORTING_EXECUTE_TYPE, // schedule a task to EXECUTE - state: {}, - params: task, - }; - - const [report, newTask] = await Promise.all([ - await store.findReportFromTask(task), - await this.taskManagerStart.schedule(oldTaskInstance), - ]); - - await store.setReportPending(report); + logger.info(`Rescheduling task:${task.id} to retry.`); + const newTask = await this.reporting.scheduleTask(task); return newTask; } diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts b/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts index 3b34e17cd3cb1..4c64176dacc8b 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts @@ -45,7 +45,6 @@ export default function ({ getService }: FtrProviderContext) { created_by: false, jobtype: 'csv', status: 'pending', - // TODO: remove the payload field from the api respones }; forOwn(expectedResJob, (value: any, key: string) => { expect(resJob[key]).to.eql(value, key); From 1813d70b3d8840b352047bc592ade740f84092ab Mon Sep 17 00:00:00 2001 From: Jorge Sanz Date: Wed, 23 Jun 2021 21:12:05 +0200 Subject: [PATCH 08/95] [Maps] Duplicated EMS instructions for Elastic Cloud (#103124) --- .../maps/server/tutorials/ems/index.ts | 85 ++++++++++--------- 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/x-pack/plugins/maps/server/tutorials/ems/index.ts b/x-pack/plugins/maps/server/tutorials/ems/index.ts index 410c833b8ac77..3c63850f87291 100644 --- a/x-pack/plugins/maps/server/tutorials/ems/index.ts +++ b/x-pack/plugins/maps/server/tutorials/ems/index.ts @@ -16,6 +16,48 @@ export function emsBoundariesSpecProvider({ emsLandingPageUrl: string; prependBasePath: (path: string) => string; }) { + const instructions = { + instructionSets: [ + { + instructionVariants: [ + { + id: 'EMS', + instructions: [ + { + title: i18n.translate('xpack.maps.tutorials.ems.downloadStepTitle', { + defaultMessage: 'Download Elastic Maps Service boundaries', + }), + textPre: i18n.translate('xpack.maps.tutorials.ems.downloadStepText', { + defaultMessage: + '1. Navigate to Elastic Maps Service [landing page]({emsLandingPageUrl}/).\n\ +2. In the left sidebar, select an administrative boundary.\n\ +3. Click `Download GeoJSON` button.', + values: { + emsLandingPageUrl, + }, + }), + }, + { + title: i18n.translate('xpack.maps.tutorials.ems.uploadStepTitle', { + defaultMessage: 'Index Elastic Maps Service boundaries', + }), + textPre: i18n.translate('xpack.maps.tutorials.ems.uploadStepText', { + defaultMessage: + '1. Open [Maps]({newMapUrl}).\n\ +2. Click `Add layer`, then select `Upload GeoJSON`.\n\ +3. Upload the GeoJSON file and click `Import file`.', + values: { + newMapUrl: prependBasePath(getNewMapPath()), + }, + }), + }, + ], + }, + ], + }, + ], + }; + return () => ({ id: 'emsBoundaries', name: i18n.translate('xpack.maps.tutorials.ems.nameTitle', { @@ -34,46 +76,7 @@ Indexing EMS administrative boundaries in Elasticsearch allows for search on bou euiIconType: 'emsApp', completionTimeMinutes: 1, previewImagePath: '/plugins/maps/assets/boundaries_screenshot.png', - onPrem: { - instructionSets: [ - { - instructionVariants: [ - { - id: 'EMS', - instructions: [ - { - title: i18n.translate('xpack.maps.tutorials.ems.downloadStepTitle', { - defaultMessage: 'Download Elastic Maps Service boundaries', - }), - textPre: i18n.translate('xpack.maps.tutorials.ems.downloadStepText', { - defaultMessage: - '1. Navigate to Elastic Maps Service [landing page]({emsLandingPageUrl}).\n\ -2. In the left sidebar, select an administrative boundary.\n\ -3. Click `Download GeoJSON` button.', - values: { - emsLandingPageUrl, - }, - }), - }, - { - title: i18n.translate('xpack.maps.tutorials.ems.uploadStepTitle', { - defaultMessage: 'Index Elastic Maps Service boundaries', - }), - textPre: i18n.translate('xpack.maps.tutorials.ems.uploadStepText', { - defaultMessage: - '1. Open [Maps]({newMapUrl}).\n\ -2. Click `Add layer`, then select `Upload GeoJSON`.\n\ -3. Upload the GeoJSON file and click `Import file`.', - values: { - newMapUrl: prependBasePath(getNewMapPath()), - }, - }), - }, - ], - }, - ], - }, - ], - }, + onPrem: instructions, + elasticCloud: instructions, }); } From 2dc1715a8ae75742e839838a3cac6dacd4cc2d4b Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 23 Jun 2021 13:14:43 -0600 Subject: [PATCH 09/95] [Security Solution] [Cases] Swimlane Connector for Cases (#100086) Co-authored-by: Josh Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Christos Nasikas Co-authored-by: Jonathan Buttner Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/management/action-types.asciidoc | 4 + .../connectors/action-types/swimlane.asciidoc | 105 ++++ .../connectors/images/swimlane-connector.png | Bin 0 -> 74730 bytes .../images/swimlane-params-test.png | Bin 0 -> 175258 bytes docs/management/connectors/index.asciidoc | 1 + x-pack/plugins/actions/README.md | 145 ++++-- .../server/builtin_action_types/index.test.ts | 1 + .../server/builtin_action_types/index.ts | 2 + .../server/builtin_action_types/jira/index.ts | 4 +- .../builtin_action_types/jira/schema.ts | 8 - .../builtin_action_types/jira/service.test.ts | 12 +- .../server/builtin_action_types/jira/types.ts | 10 +- .../builtin_action_types/resilient/schema.ts | 8 - .../builtin_action_types/servicenow/schema.ts | 8 - .../builtin_action_types/swimlane/api.test.ts | 142 ++++++ .../builtin_action_types/swimlane/api.ts | 60 +++ .../swimlane/helpers.test.ts | 90 ++++ .../builtin_action_types/swimlane/helpers.ts | 58 +++ .../builtin_action_types/swimlane/index.ts | 116 +++++ .../builtin_action_types/swimlane/mocks.ts | 124 +++++ .../builtin_action_types/swimlane/schema.ts | 75 +++ .../swimlane/service.test.ts | 434 ++++++++++++++++ .../builtin_action_types/swimlane/service.ts | 196 +++++++ .../swimlane/translations.ts | 20 + .../builtin_action_types/swimlane/types.ts | 123 +++++ .../swimlane/validators.ts | 28 + x-pack/plugins/actions/server/index.ts | 1 - x-pack/plugins/actions/server/types.ts | 2 +- .../server/usage/actions_usage_collector.ts | 1 + x-pack/plugins/cases/README.md | 2 +- .../cases/common/api/connectors/index.ts | 14 +- .../cases/common/api/connectors/mappings.ts | 7 +- .../cases/common/api/connectors/swimlane.ts | 21 + x-pack/plugins/cases/common/constants.ts | 16 +- .../cases/public/common/shared_imports.ts | 2 + .../components/case_view/index.test.tsx | 11 +- .../public/components/case_view/index.tsx | 7 +- .../components/configure_cases/index.tsx | 8 +- .../components/configure_cases/utils.ts | 13 +- .../components/connector_selector/form.tsx | 12 +- .../components/connectors/fields_form.tsx | 3 +- .../public/components/connectors/index.ts | 3 + .../components/connectors/jira/index.ts | 4 +- .../public/components/connectors/mock.ts | 18 + .../components/connectors/resilient/index.ts | 4 +- .../components/connectors/servicenow/index.ts | 10 +- .../connectors/swimlane/case_fields.test.tsx | 53 ++ .../connectors/swimlane/case_fields.tsx | 48 ++ .../components/connectors/swimlane/index.ts | 25 + .../connectors/swimlane/translations.ts | 42 ++ .../connectors/swimlane/validator.test.ts | 60 +++ .../connectors/swimlane/validator.ts | 39 ++ .../public/components/connectors/types.ts | 3 +- .../components/create/connector.test.tsx | 52 +- .../public/components/create/connector.tsx | 53 +- .../public/components/create/form.test.tsx | 6 + .../public/components/create/form_context.tsx | 33 +- .../cases/public/components/create/schema.tsx | 4 +- .../components/edit_connector/index.tsx | 10 +- .../plugins/cases/public/components/types.ts | 10 + .../plugins/cases/public/components/utils.ts | 43 ++ .../containers/use_get_action_license.tsx | 3 +- .../plugins/cases/server/client/cases/get.ts | 1 - .../cases/server/client/cases/utils.ts | 1 + .../server/connectors/case/index.test.ts | 14 +- .../cases/server/connectors/case/schema.ts | 26 +- .../server/connectors/case/validators.ts | 3 +- .../cases/server/connectors/factory.ts | 4 +- .../server/connectors/swimlane/format.test.ts | 21 + .../server/connectors/swimlane/format.ts | 15 + .../cases/server/connectors/swimlane/index.ts | 15 + .../server/connectors/swimlane/mapping.ts | 28 + .../cases/server/connectors/swimlane/types.ts | 13 + .../security_solution/common/constants.ts | 1 + .../schema/xpack_plugins.json | 6 + .../components/builtin_action_types/index.ts | 2 + .../jira/jira_connectors.test.tsx | 2 +- .../builtin_action_types/jira/jira_params.tsx | 6 + .../resilient/resilient_connectors.test.tsx | 2 +- .../resilient/resilient_params.tsx | 1 + .../servicenow/servicenow_connectors.test.tsx | 2 +- .../builtin_action_types/swimlane/api.test.ts | 145 ++++++ .../builtin_action_types/swimlane/api.ts | 65 +++ .../builtin_action_types/swimlane/helpers.ts | 62 +++ .../builtin_action_types/swimlane/index.ts | 8 + .../builtin_action_types/swimlane/logo.tsx | 53 ++ .../builtin_action_types/swimlane/mocks.ts | 61 +++ .../swimlane/steps/index.ts | 9 + .../swimlane/steps/swimlane_connection.tsx | 201 ++++++++ .../swimlane/steps/swimlane_fields.tsx | 313 ++++++++++++ .../swimlane/swimlane.test.tsx | 219 ++++++++ .../swimlane/swimlane.tsx | 106 ++++ .../swimlane/swimlane_connectors.test.tsx | 319 ++++++++++++ .../swimlane/swimlane_connectors.tsx | 103 ++++ .../swimlane/swimlane_params.test.tsx | 137 +++++ .../swimlane/swimlane_params.tsx | 159 ++++++ .../swimlane/translations.ts | 282 ++++++++++ .../builtin_action_types/swimlane/types.ts | 56 ++ .../swimlane/use_get_application.test.tsx | 180 +++++++ .../swimlane/use_get_application.tsx | 82 +++ .../actions/builtin_action_types/swimlane.ts | 91 ++++ .../basic/tests/actions/index.ts | 1 + .../alerting_api_integration/common/config.ts | 1 + .../actions_simulators/server/plugin.ts | 6 + .../server/swimlane_simulation.ts | 39 ++ .../actions/builtin_action_types/swimlane.ts | 482 ++++++++++++++++++ .../tests/actions/index.ts | 1 + .../case_api_integration/common/config.ts | 1 + .../common/config.ts | 1 + x-pack/test/functional_with_es_ssl/config.ts | 1 + 110 files changed, 5531 insertions(+), 233 deletions(-) create mode 100644 docs/management/connectors/action-types/swimlane.asciidoc create mode 100644 docs/management/connectors/images/swimlane-connector.png create mode 100644 docs/management/connectors/images/swimlane-params-test.png create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/api.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/api.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/index.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/mocks.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/schema.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/translations.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/types.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/validators.ts create mode 100644 x-pack/plugins/cases/common/api/connectors/swimlane.ts create mode 100644 x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.test.tsx create mode 100644 x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.tsx create mode 100644 x-pack/plugins/cases/public/components/connectors/swimlane/index.ts create mode 100644 x-pack/plugins/cases/public/components/connectors/swimlane/translations.ts create mode 100644 x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts create mode 100644 x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts create mode 100644 x-pack/plugins/cases/public/components/types.ts create mode 100644 x-pack/plugins/cases/public/components/utils.ts create mode 100644 x-pack/plugins/cases/server/connectors/swimlane/format.test.ts create mode 100644 x-pack/plugins/cases/server/connectors/swimlane/format.ts create mode 100644 x-pack/plugins/cases/server/connectors/swimlane/index.ts create mode 100644 x-pack/plugins/cases/server/connectors/swimlane/mapping.ts create mode 100644 x-pack/plugins/cases/server/connectors/swimlane/types.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/helpers.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/index.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/logo.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/mocks.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/index.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_connection.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_fields.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/types.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.tsx create mode 100644 x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/swimlane.ts create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 65b600d4b7281..3d3d7aeb2d777 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -43,6 +43,10 @@ a| <> | Send a message to a Slack channel or user. +a| <> + +| Create an incident in Swimlane. + a| <> | Send a request to a web service. diff --git a/docs/management/connectors/action-types/swimlane.asciidoc b/docs/management/connectors/action-types/swimlane.asciidoc new file mode 100644 index 0000000000000..88447bb496a86 --- /dev/null +++ b/docs/management/connectors/action-types/swimlane.asciidoc @@ -0,0 +1,105 @@ +[role="xpack"] +[[swimlane-action-type]] +=== Swimlane connector and action +++++ +Swimlane +++++ + +The Swimlane connector uses the https://swimlane.com/knowledge-center/docs/developer-guide/rest-api/[Swimlane REST API] to create Swimlane records. + +[float] +[[swimlane-connector-configuration]] +==== Connector configuration + +Swimlane connectors have the following configuration properties. + +Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. +URL:: Swimlane instance URL. +Application ID:: Swimlane application ID. +API token:: Swimlane API authentication token for HTTP Basic authentication. + +[float] +[[Preconfigured-swimlane-configuration]] +==== Preconfigured connector type + +[source,text] +-- + my-swimlane: + name: preconfigured-swimlane-connector-type + actionTypeId: .swimlane + config: + apiUrl: https://elastic.swimlaneurl.us + appId: app-id + mappings: + alertIdConfig: + fieldType: text + id: agp4s + key: alert-id + name: Alert ID + caseIdConfig: + fieldType: text + id: ae1mi + key: case-id + name: Case ID + caseNameConfig: + fieldType: text + id: anxnr + key: case-name + name: Case Name + commentsConfig: + fieldType: comments + id: au18d + key: comments + name: Comments + descriptionConfig: + fieldType: text + id: ae1gd + key: description + name: Description + ruleNameConfig: + fieldType: text + id: avfsl + key: rule-name + name: Rule Name + severityConfig: + fieldType: text + id: a71ik + key: severity + name: severity + secrets: + apiToken: tokenkeystorevalue +-- + +Config defines information for the connector type. + +`apiUrl`:: An address that corresponds to *URL*. +`appId`:: A key that corresponds to *Application ID*. + +Secrets defines sensitive information for the connector type. + +`apiToken`:: A string that corresponds to *API Token*. Should be stored in the <>. + +[float] +[[define-swimlane-ui]] +==== Define connector in Stack Management + +Define Swimlane connector properties. + +[role="screenshot"] +image::management/connectors/images/swimlane-connector.png[Swimlane connector] + +Test Swimlane action parameters. + +[role="screenshot"] +image::management/connectors/images/swimlane-params-test.png[Swimlane params test] + +[float] +[[swimlane-action-configuration]] +==== Action configuration + +Swimlane actions have the following configuration properties. + +Comments:: Additional information for the client, such as how to troubleshoot the issue. +Severity:: The severity of the incident. + +NOTE: Alert ID and Rule Name are filled automatically. Specifically, Alert ID is set to `{{alert.id}}` and Rule Name to `{{rule.name}}`. \ No newline at end of file diff --git a/docs/management/connectors/images/swimlane-connector.png b/docs/management/connectors/images/swimlane-connector.png new file mode 100644 index 0000000000000000000000000000000000000000..520c35d00381bd8d21a0accff1ba56cc1145ffc4 GIT binary patch literal 74730 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 literal 0 HcmV?d00001 diff --git a/docs/management/connectors/images/swimlane-params-test.png b/docs/management/connectors/images/swimlane-params-test.png new file mode 100644 index 0000000000000000000000000000000000000000..c0e02c2c7b18f2fa4118e10d31d0a7c6447d4c19 GIT binary patch literal 175258 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 literal 0 HcmV?d00001 diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index ea4fa46d3e808..033b1c3ac150e 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -6,6 +6,7 @@ include::action-types/teams.asciidoc[] include::action-types/pagerduty.asciidoc[] include::action-types/server-log.asciidoc[] include::action-types/servicenow.asciidoc[] +include::action-types/swimlane.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] include::pre-configured-connectors.asciidoc[] diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 5b4a197eea462..b19e89a599840 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -19,7 +19,7 @@ Table of Contents - [Usage](#usage) - [Kibana Actions Configuration](#kibana-actions-configuration) - [Configuration Options](#configuration-options) - - [Adding Built-in Action Types to allowedHosts](#adding-built-in-action-types-to-allowedhosts) + - [**allowedHosts** configuration](#allowedhosts-configuration) - [Configuration Utilities](#configuration-utilities) - [Action types](#action-types) - [Methods](#methods) @@ -54,6 +54,9 @@ Table of Contents - [`subActionParams (getFields)`](#subactionparams-getfields-2) - [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes) - [`subActionParams (severity)`](#subactionparams-severity) + - [Swimlane](#swimlane) + - [`params`](#params-3) + - [| severity | The severity of the incident. | string _(optional)_ |](#-severity-----the-severity-of-the-incident-----string-optional-) - [Command Line Utility](#command-line-utility) - [Developing New Action Types](#developing-new-action-types) - [licensing](#licensing) @@ -102,8 +105,8 @@ This module provides utilities for interacting with the configuration. | ensureUriAllowed | _uri_: The URI you wish to validate is allowed | Validates whether the URI is allowed. This checks the configuration and validates that the hostname of the URI is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all URI's are allowed (using an "\*") then it will never throw. | No return value, throws if URI isn't allowed | | ensureHostnameAllowed | _hostname_: The Hostname you wish to validate is allowed | Validates whether the Hostname is allowed. This checks the configuration and validates that the hostname is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all Hostnames are allowed (using an "\*") then it will never throw | No return value, throws if Hostname isn't allowed . | | ensureActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Throws an error if the actionType is not enabled | No return value, throws if actionType isn't enabled | -| isRejectUnauthorizedCertificatesEnabled | _none_ | Returns value of `rejectUnauthorized` from configuration. | Boolean | -| getProxySettings | _none_ | If `proxyUrl` is set in the configuration, returns the proxy settings `proxyUrl`, `proxyHeaders` and `proxyRejectUnauthorizedCertificates`. Otherwise returns _undefined_. | Undefined or ProxySettings | +| isRejectUnauthorizedCertificatesEnabled | _none_ | Returns value of `rejectUnauthorized` from configuration. | Boolean | +| getProxySettings | _none_ | If `proxyUrl` is set in the configuration, returns the proxy settings `proxyUrl`, `proxyHeaders` and `proxyRejectUnauthorizedCertificates`. Otherwise returns _undefined_. | Undefined or ProxySettings | ## Action types @@ -113,17 +116,17 @@ This module provides utilities for interacting with the configuration. The following table describes the properties of the `options` object. -| Property | Description | Type | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | -| id | Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `.mySpecialAction` for your action types. | string | -| name | A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types. | string | -| maxAttempts | The maximum number of times this action will attempt to execute when scheduled. | number | -| minimumLicenseRequired | The license required to use the action type. | string | +| Property | Description | Type | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | +| id | Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `.mySpecialAction` for your action types. | string | +| name | A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types. | string | +| maxAttempts | The maximum number of times this action will attempt to execute when scheduled. | number | +| minimumLicenseRequired | The license required to use the action type. | string | | validate.params | When developing an action type, it needs to accept parameters to know what to do with the action. (Example `to`, `from`, `subject`, `body` of an email). See the current built-in email action type for an example of the state-of-the-art validation.

Technically, the value of this property should have a property named `validate()` which is a function that takes a params object to validate and returns a sanitized version of that object to pass to the execution function. Validation errors should be thrown from the `validate()` function and will be available as an error message | schema / validation function | -| validate.config | Similar to params, a config may be required when creating an action (for example `host` and `port` for an email server). | schema / validation function | -| validate.secrets | Similar to params, a secrets object may be required when creating an action (for example `user` and `password` for an email server). | schema / validation function | -| executor | This is where the code of an action type lives. This is a function gets called for executing an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below. | Function | -| renderParameterTemplates | Optionally define a function to provide custom rendering for this action type. | Function | +| validate.config | Similar to params, a config may be required when creating an action (for example `host` and `port` for an email server). | schema / validation function | +| validate.secrets | Similar to params, a secrets object may be required when creating an action (for example `user` and `password` for an email server). | schema / validation function | +| executor | This is where the code of an action type lives. This is a function gets called for executing an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below. | Function | +| renderParameterTemplates | Optionally define a function to provide custom rendering for this action type. | Function | **Important** - The config object is persisted in ElasticSearch and updated via the ElasticSearch update document API. This API allows "partial updates" - and this can cause issues with the encryption used on specified properties. So, a `validate()` function should return values for all configuration properties, so that partial updates do not occur. Setting property values to `null` rather than `undefined`, or not including a property in the config object, is all you need to do to ensure partial updates won't occur. @@ -133,15 +136,15 @@ This is the primary function for an action type. Whenever the action needs to ex **executor(options)** -| Property | Description | -| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| actionId | The action saved object id that the action type is executing for. | -| config | The action configuration. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type. | -| secrets | The decrypted secrets object given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the secrets object before being passed to the executor, define `validate.secrets` within the action type. | -| params | Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function. | -| services.scopedClusterClient | Use this to do Elasticsearch queries on the cluster Kibana connects to. Serves the same purpose as the normal IClusterClient, but exposes an additional `asCurrentUser` method that doesn't use credentials of the Kibana internal user (as `asInternalUser` does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead.| -| services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). | -| services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log) +| Property | Description | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| actionId | The action saved object id that the action type is executing for. | +| config | The action configuration. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type. | +| secrets | The decrypted secrets object given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the secrets object before being passed to the executor, define `validate.secrets` within the action type. | +| params | Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function. | +| services.scopedClusterClient | Use this to do Elasticsearch queries on the cluster Kibana connects to. Serves the same purpose as the normal IClusterClient, but exposes an additional `asCurrentUser` method that doesn't use credentials of the Kibana internal user (as `asInternalUser` does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead. | +| services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). | +| services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log) | ### Example @@ -262,16 +265,16 @@ The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kib The following table describes the properties of the `incident` object. -| Property | Description | Type | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------- | -| short_description | The title of the incident. | string | -| description | The description of the incident. | string _(optional)_ | +| Property | Description | Type | +| ----------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- | +| short_description | The title of the incident. | string | +| description | The description of the incident. | string _(optional)_ | | externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | -| severity | The severity in ServiceNow. | string _(optional)_ | -| urgency | The urgency in ServiceNow. | string _(optional)_ | -| impact | The impact in ServiceNow. | string _(optional)_ | -| category | The category in ServiceNow. | string _(optional)_ | -| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| severity | The severity in ServiceNow. | string _(optional)_ | +| urgency | The urgency in ServiceNow. | string _(optional)_ | +| impact | The impact in ServiceNow. | string _(optional)_ | +| category | The category in ServiceNow. | string _(optional)_ | +| subcategory | The subcategory in ServiceNow. | string _(optional)_ | #### `subActionParams (getFields)` @@ -311,20 +314,20 @@ The [Jira user documentation `params`](https://www.elastic.co/guide/en/kibana/ma The following table describes the properties of the `incident` object. -| Property | Description | Type | -| ----------- | ---------------------------------------------------------------------------------------------------------------- | --------------------- | -| summary | The title of the issue. | string | -| description | The description of the issue. | string _(optional)_ | +| Property | Description | Type | +| ----------- | ------------------------------------------------------------------------------------------------------- | --------------------- | +| summary | The title of the issue. | string | +| description | The description of the issue. | string _(optional)_ | | externalId | The ID of the issue in Jira. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | -| issueType | The ID of the issue type in Jira. | string _(optional)_ | -| priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ | -| labels | An array of labels. Labels cannot contain spaces. | string[] _(optional)_ | -| parent | The ID or key of the parent issue. Only for `Sub-task` issue types. | string _(optional)_ | +| issueType | The ID of the issue type in Jira. | string _(optional)_ | +| priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ | +| labels | An array of labels. Labels cannot contain spaces. | string[] _(optional)_ | +| parent | The ID or key of the parent issue. Only for `Sub-task` issue types. | string _(optional)_ | #### `subActionParams (getIncident)` -| Property | Description | Type | -| ---------- | --------------------------- | ------ | +| Property | Description | Type | +| ---------- | ---------------------------- | ------ | | externalId | The ID of the issue in Jira. | string | #### `subActionParams (issueTypes)` @@ -333,20 +336,20 @@ No parameters for the `issueTypes` subaction. Provide an empty object `{}`. #### `subActionParams (fieldsByIssueType)` -| Property | Description | Type | -| -------- | -------------------------------- | ------ | +| Property | Description | Type | +| -------- | --------------------------------- | ------ | | id | The ID of the issue type in Jira. | string | #### `subActionParams (issues)` -| Property | Description | Type | -| -------- | ----------------------- | ------ | +| Property | Description | Type | +| -------- | ------------------------ | ------ | | title | The title to search for. | string | #### `subActionParams (issue)` -| Property | Description | Type | -| -------- | --------------------------- | ------ | +| Property | Description | Type | +| -------- | ---------------------------- | ------ | | id | The ID of the issue in Jira. | string | #### `subActionParams (getFields)` @@ -360,10 +363,10 @@ The [IBM Resilient user documentation `params`](https://www.elastic.co/guide/en/ ### `params` -| Property | Description | Type | -| --------------- | -------------------------------------------------------------------------------------------------- | ------ | +| Property | Description | Type | +| --------------- | ------------------------------------------------------------------------------------------------- | ------ | | subAction | The subaction to perform. It can be `pushToService`, `getFields`, `incidentTypes`, and `severity. | string | -| subActionParams | The parameters of the subaction. | object | +| subActionParams | The parameters of the subaction. | object | #### `subActionParams (pushToService)` @@ -374,13 +377,13 @@ The [IBM Resilient user documentation `params`](https://www.elastic.co/guide/en/ The following table describes the properties of the `incident` object. -| Property | Description | Type | -| ------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| name | The title of the incident. | string _(optional)_ | -| description | The description of the incident. | string _(optional)_ | +| Property | Description | Type | +| ------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------- | +| name | The title of the incident. | string _(optional)_ | +| description | The description of the incident. | string _(optional)_ | | externalId | The ID of the incident in IBM Resilient. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | -| incidentTypes | An array with the IDs of IBM Resilient incident types. | number[] _(optional)_ | -| severityCode | IBM Resilient ID of the severity code. | number _(optional)_ | +| incidentTypes | An array with the IDs of IBM Resilient incident types. | number[] _(optional)_ | +| severityCode | IBM Resilient ID of the severity code. | number _(optional)_ | #### `subActionParams (getFields)` @@ -394,6 +397,36 @@ No parameters for the `incidentTypes` subaction. Provide an empty object `{}`. No parameters for the `severity` subaction. Provide an empty object `{}`. +--- +## Swimlane + + +### `params` + +| Property | Description | Type | +| --------------- | ---------------------------------------------------- | ------ | +| subAction | The subaction to perform. It can be `pushToService`. | string | +| subActionParams | The parameters of the subaction. | object | + + +`subActionParams (pushToService)` + +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------------------------------- | --------------------- | +| incident | The Swimlane incident. | object | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }`. | object[] _(optional)_ | + + +The following table describes the properties of the `incident` object. + +| Property | Description | Type | +| ----------- | -------------------------------- | ------------------- | +| alertId | The alert id. | string _(optional)_ | +| caseId | The case id of the incident. | string _(optional)_ | +| caseName | The case name of the incident. | string _(optional)_ | +| description | The description of the incident. | string _(optional)_ | +| ruleName | The rule name. | string _(optional)_ | +| severity | The severity of the incident. | string _(optional)_ | --- # Command Line Utility diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts index 10955af2f3b13..5feb47ea6c962 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts @@ -21,6 +21,7 @@ const ACTION_TYPE_IDS = [ '.pagerduty', '.server-log', '.slack', + '.swimlane', '.teams', '.webhook', ]; diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index 551d3d02ff05d..07859cba4c371 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -12,6 +12,7 @@ import { Logger } from '../../../../../src/core/server'; import { getActionType as getEmailActionType } from './email'; import { getActionType as getIndexActionType } from './es_index'; import { getActionType as getPagerDutyActionType } from './pagerduty'; +import { getActionType as getSwimlaneActionType } from './swimlane'; import { getActionType as getServerLogActionType } from './server_log'; import { getActionType as getSlackActionType } from './slack'; import { getActionType as getWebhookActionType } from './webhook'; @@ -65,6 +66,7 @@ export function registerBuiltInActionTypes({ ); actionTypeRegistry.register(getIndexActionType({ logger })); actionTypeRegistry.register(getPagerDutyActionType({ logger, configurationUtilities })); + actionTypeRegistry.register(getSwimlaneActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getServerLogActionType({ logger })); actionTypeRegistry.register(getSlackActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities })); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts index 3161e97583b72..aa439787ad96f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts @@ -25,7 +25,7 @@ import { JiraSecretConfigurationType, JiraExecutorResultData, ExecutorSubActionGetFieldsByIssueTypeParams, - ExecutorSubActionGetIssueTypesParams, + ExecutorSubActionCommonFieldsParams, ExecutorSubActionGetIssuesParams, ExecutorSubActionGetIssueParams, ExecutorSubActionGetIncidentParams, @@ -137,7 +137,7 @@ async function executor( } if (subAction === 'issueTypes') { - const getIssueTypesParams = subActionParams as ExecutorSubActionGetIssueTypesParams; + const getIssueTypesParams = subActionParams as ExecutorSubActionCommonFieldsParams; data = await api.issueTypes({ externalService, params: getIssueTypesParams, diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts index a81dfaeef8175..eb2f540deaa9a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts @@ -25,14 +25,6 @@ export const ExternalIncidentServiceSecretConfigurationSchema = schema.object( ExternalIncidentServiceSecretConfiguration ); -export const ExecutorSubActionSchema = schema.oneOf([ - schema.literal('getIncident'), - schema.literal('pushToService'), - schema.literal('handshake'), - schema.literal('issueTypes'), - schema.literal('fieldsByIssueType'), -]); - export const ExecutorSubActionPushParamsSchema = schema.object({ incident: schema.object({ summary: schema.string(), diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts index f6462bac9d83e..9430d734287d3 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts @@ -155,12 +155,12 @@ describe('Jira service', () => { ).toThrow(); }); - test('throws without username', () => { + test('throws without email/username', () => { expect(() => createExternalService( { - config: { apiUrl: 'test.com' }, - secrets: { apiToken: '', email: 'elastic@elastic.com' }, + config: { apiUrl: 'test.com', projectKey: 'CK' }, + secrets: { apiToken: 'token' }, }, logger, configurationUtilities @@ -168,12 +168,12 @@ describe('Jira service', () => { ).toThrow(); }); - test('throws without password', () => { + test('throws without apiToken/password', () => { expect(() => createExternalService( { - config: { apiUrl: 'test.com' }, - secrets: { apiToken: '', email: undefined }, + config: { apiUrl: 'test.com', projectKey: 'CK' }, + secrets: { email: 'elastic@elastic.com' }, }, logger, configurationUtilities diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts index 89a5551554c4a..74d53901d55d9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts @@ -16,10 +16,10 @@ import { ExecutorSubActionGetIncidentParamsSchema, ExecutorSubActionHandshakeParamsSchema, ExecutorSubActionGetCapabilitiesParamsSchema, - ExecutorSubActionGetIssueTypesParamsSchema, ExecutorSubActionGetFieldsByIssueTypeParamsSchema, ExecutorSubActionGetIssuesParamsSchema, ExecutorSubActionGetIssueParamsSchema, + ExecutorSubActionCommonFieldsParamsSchema, } from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { Logger } from '../../../../../../src/core/server'; @@ -124,8 +124,8 @@ export type ExecutorSubActionGetCapabilitiesParams = TypeOf< typeof ExecutorSubActionGetCapabilitiesParamsSchema >; -export type ExecutorSubActionGetIssueTypesParams = TypeOf< - typeof ExecutorSubActionGetIssueTypesParamsSchema +export type ExecutorSubActionCommonFieldsParams = TypeOf< + typeof ExecutorSubActionCommonFieldsParamsSchema >; export type ExecutorSubActionGetFieldsByIssueTypeParams = TypeOf< @@ -157,12 +157,12 @@ export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs { export interface GetIssueTypesHandlerArgs { externalService: ExternalService; - params: ExecutorSubActionGetIssueTypesParams; + params: ExecutorSubActionCommonFieldsParams; } export interface GetCommonFieldsHandlerArgs { externalService: ExternalService; - params: ExecutorSubActionGetIssueTypesParams; + params: ExecutorSubActionCommonFieldsParams; } export interface GetFieldsByIssueTypeHandlerArgs { diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts index 9095780fea17c..9f76a236cacd5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts @@ -25,14 +25,6 @@ export const ExternalIncidentServiceSecretConfigurationSchema = schema.object( ExternalIncidentServiceSecretConfiguration ); -export const ExecutorSubActionSchema = schema.oneOf([ - schema.literal('getIncident'), - schema.literal('pushToService'), - schema.literal('handshake'), - schema.literal('incidentTypes'), - schema.literal('severity'), -]); - export const ExecutorSubActionPushParamsSchema = schema.object({ incident: schema.object({ name: schema.string(), diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index 59b0803d189cd..6fec30803d6d7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -24,14 +24,6 @@ export const ExternalIncidentServiceSecretConfigurationSchema = schema.object( ExternalIncidentServiceSecretConfiguration ); -export const ExecutorSubActionSchema = schema.oneOf([ - schema.literal('getFields'), - schema.literal('getIncident'), - schema.literal('pushToService'), - schema.literal('handshake'), - schema.literal('getChoices'), -]); - const CommentsSchema = schema.nullable( schema.arrayOf( schema.object({ diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/api.test.ts new file mode 100644 index 0000000000000..1e633e2175808 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/api.test.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { api } from './api'; +import { ExternalService } from './types'; +import { + apiParams, + externalServiceMock, + recordResponseCreate, + recordResponseUpdate, +} from './mocks'; +import { Logger } from '@kbn/logging'; + +let mockedLogger: jest.Mocked; + +describe('api', () => { + let externalService: jest.Mocked; + + beforeEach(() => { + externalService = externalServiceMock.create(); + }); + + describe('pushToService', () => { + test('it pushes a new record', async () => { + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; + const res = await api.pushToService({ + externalService, + logger: mockedLogger, + params, + }); + + expect(externalService.createComment).toHaveBeenCalled(); + expect(externalService.createRecord).toHaveBeenCalled(); + expect(externalService.updateRecord).not.toHaveBeenCalled(); + + expect(res).toEqual({ + ...recordResponseCreate, + comments: [ + { + commentId: '123456', + pushedDate: '2021-06-01T17:29:51.092Z', + }, + { + commentId: '123456', + pushedDate: '2021-06-01T17:29:51.092Z', + }, + ], + }); + }); + + test('it pushes a new record without comment', async () => { + const params = { + ...apiParams, + incident: { ...apiParams.incident, externalId: null }, + comments: [], + }; + const res = await api.pushToService({ + externalService, + logger: mockedLogger, + params, + }); + + expect(externalService.createComment).not.toHaveBeenCalled(); + expect(externalService.createRecord).toHaveBeenCalled(); + expect(res).toEqual(recordResponseCreate); + }); + + test('updates existing record', async () => { + const res = await api.pushToService({ + externalService, + logger: mockedLogger, + params: apiParams, + }); + + expect(externalService.createComment).toHaveBeenCalled(); + expect(externalService.createRecord).not.toHaveBeenCalled(); + expect(externalService.updateRecord).toHaveBeenCalled(); + expect(res).toEqual({ + ...recordResponseUpdate, + comments: [ + { + commentId: '123456', + pushedDate: '2021-06-01T17:29:51.092Z', + }, + { + commentId: '123456', + pushedDate: '2021-06-01T17:29:51.092Z', + }, + ], + }); + }); + + test('it calls createRecord correctly', async () => { + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; + await api.pushToService({ externalService, params, logger: mockedLogger }); + + expect(externalService.createRecord).toHaveBeenCalledWith({ + incident: { + alertId: '123456', + caseId: '123456', + caseName: 'case name', + description: 'case desc', + ruleName: 'rule name', + severity: 'critical', + }, + }); + }); + + test('it calls createComment correctly', async () => { + const mockedToISOString = jest + .spyOn(Date.prototype, 'toISOString') + .mockReturnValue('2021-06-15T18:02:29.404Z'); + + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; + await api.pushToService({ externalService, params, logger: mockedLogger }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + createdDate: '2021-06-15T18:02:29.404Z', + incidentId: '123456', + comment: { + commentId: 'case-comment-1', + comment: 'A comment', + }, + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + createdDate: '2021-06-15T18:02:29.404Z', + incidentId: '123456', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment', + }, + }); + + mockedToISOString.mockRestore(); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/api.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/api.ts new file mode 100644 index 0000000000000..343a94e52711f --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/api.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ExternalServiceIncidentResponse, + ExternalServiceApi, + Incident, + PushToServiceApiHandlerArgs, + PushToServiceResponse, +} from './types'; + +const pushToServiceHandler = async ({ + externalService, + params, +}: PushToServiceApiHandlerArgs): Promise => { + const { comments } = params; + let res: PushToServiceResponse; + const { externalId, ...rest } = params.incident; + const incident: Incident = rest; + + if (externalId != null) { + res = await externalService.updateRecord({ + incidentId: externalId, + incident, + }); + } else { + res = await externalService.createRecord({ incident }); + } + + const createdDate = new Date().toISOString(); + + if (comments && Array.isArray(comments) && comments.length > 0) { + res.comments = []; + for (const currentComment of comments) { + const comment = await externalService.createComment({ + incidentId: res.id, + comment: currentComment, + createdDate, + }); + + res.comments = [ + ...(res.comments ?? []), + { + commentId: comment.commentId, + pushedDate: comment.pushedDate, + }, + ]; + } + } + + return res; +}; + +export const api: ExternalServiceApi = { + pushToService: pushToServiceHandler, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.test.ts new file mode 100644 index 0000000000000..c2974ec28486c --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getBodyForEventAction } from './helpers'; +import { mappings } from './mocks'; + +describe('Create Record Mapping', () => { + const appId = '45678'; + + test('it maps successfully', () => { + const params = { + alertId: 'al123', + ruleName: 'Rule Name', + severity: 'Critical', + caseName: 'Case Name', + caseId: 'es3456789', + description: 'case desc', + externalId: null, + }; + + const data = getBodyForEventAction(appId, mappings, params); + expect(data.applicationId).toEqual(appId); + expect(data.id).not.toBeDefined(); + expect(data.values?.[mappings.alertIdConfig?.id ?? 0]).toEqual(params.alertId); + expect(data.values?.[mappings.ruleNameConfig.id]).toEqual(params.ruleName); + expect(data.values?.[mappings.caseNameConfig?.id ?? 0]).toEqual(params.caseName); + expect(data.values?.[mappings.caseIdConfig?.id ?? 0]).toEqual(params.caseId); + expect(data.values?.[mappings?.severityConfig?.id ?? 0]).toEqual(params.severity); + expect(data.values?.[mappings?.descriptionConfig?.id ?? 0]).toEqual(params.description); + }); + + test('it contains the id if defined', () => { + const params = { + alertId: 'al123', + ruleName: 'Rule Name', + severity: 'Critical', + caseName: 'Case Name', + caseId: 'es3456789', + description: 'case desc', + externalId: null, + }; + const data = getBodyForEventAction(appId, mappings, params, '123'); + expect(data.id).toEqual('123'); + }); + + test('it does not includes null mappings', () => { + const params = { + alertId: 'al123', + ruleName: 'Rule Name', + severity: 'Critical', + caseName: 'Case Name', + caseId: 'es3456789', + description: 'case desc', + externalId: null, + }; + + // @ts-expect-error + const data = getBodyForEventAction(appId, { ...mappings, test: null }, params); + expect(data.values?.test).not.toBeDefined(); + }); + + test('it converts a numeric values correctly', () => { + const params = { + alertId: 'thisIsNotANumber', + ruleName: 'Rule Name', + severity: 'Critical', + caseName: 'Case Name', + caseId: '123', + description: 'case desc', + externalId: null, + }; + + const data = getBodyForEventAction( + appId, + { + ...mappings, + caseIdConfig: { ...mappings.caseIdConfig, fieldType: 'numeric' }, + alertIdConfig: { ...mappings.alertIdConfig, fieldType: 'numeric' }, + }, + params + ); + + expect(data.values?.[mappings.alertIdConfig?.id ?? 0]).toBe(0); + expect(data.values?.[mappings.caseIdConfig?.id ?? 0]).toBe(123); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.ts new file mode 100644 index 0000000000000..13b2df1c97f16 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CreateRecordParams, Incident, SwimlaneRecordPayload, MappingConfigType } from './types'; + +type ConfigMapping = Omit; + +const mappingKeysToIncidentKeys: Record = { + ruleNameConfig: 'ruleName', + alertIdConfig: 'alertId', + caseIdConfig: 'caseId', + caseNameConfig: 'caseName', + severityConfig: 'severity', + descriptionConfig: 'description', +}; + +export const getBodyForEventAction = ( + applicationId: string, + mappingConfig: MappingConfigType, + params: CreateRecordParams['incident'], + incidentId?: string +): SwimlaneRecordPayload => { + const data: SwimlaneRecordPayload = { + applicationId, + ...(incidentId ? { id: incidentId } : {}), + values: {}, + }; + + return (Object.keys(mappingConfig) as Array).reduce((acc, key) => { + const fieldMap = mappingConfig[key]; + + if (!fieldMap) { + return acc; + } + + const { id, fieldType } = fieldMap; + const paramName = mappingKeysToIncidentKeys[key]; + const value = params[paramName]; + + if (value) { + switch (fieldType) { + case 'numeric': { + const number = Number(value); + return { ...acc, values: { ...acc.values, [id]: isNaN(number) ? 0 : number } }; + } + default: { + return { ...acc, values: { ...acc.values, [id]: value } }; + } + } + } + + return acc; + }, data); +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/index.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/index.ts new file mode 100644 index 0000000000000..de5010436b6b3 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { curry } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import { Logger } from '@kbn/logging'; +import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { + SwimlaneExecutorResultData, + SwimlanePublicConfigurationType, + SwimlaneSecretConfigurationType, + ExecutorParams, + ExecutorSubActionPushParams, +} from './types'; +import { validate } from './validators'; +import { + ExecutorParamsSchema, + SwimlaneSecretsConfiguration, + SwimlaneServiceConfiguration, +} from './schema'; +import { createExternalService } from './service'; +import { api } from './api'; + +interface GetActionTypeParams { + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; +} + +const supportedSubActions: string[] = ['pushToService']; + +// action type definition +export function getActionType( + params: GetActionTypeParams +): ActionType< + SwimlanePublicConfigurationType, + SwimlaneSecretConfigurationType, + ExecutorParams, + SwimlaneExecutorResultData | {} +> { + const { logger, configurationUtilities } = params; + + return { + id: '.swimlane', + minimumLicenseRequired: 'gold', + name: i18n.translate('xpack.actions.builtin.swimlaneTitle', { + defaultMessage: 'Swimlane', + }), + validate: { + config: schema.object(SwimlaneServiceConfiguration, { + validate: curry(validate.config)(configurationUtilities), + }), + secrets: schema.object(SwimlaneSecretsConfiguration, { + validate: curry(validate.secrets)(configurationUtilities), + }), + params: ExecutorParamsSchema, + }, + executor: curry(executor)({ logger, configurationUtilities }), + }; +} + +async function executor( + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, + execOptions: ActionTypeExecutorOptions< + SwimlanePublicConfigurationType, + SwimlaneSecretConfigurationType, + ExecutorParams + > +): Promise> { + const { actionId, config, params, secrets } = execOptions; + const { subAction, subActionParams } = params as ExecutorParams; + let data: SwimlaneExecutorResultData | null = null; + + const externalService = createExternalService( + { + config, + secrets, + }, + logger, + configurationUtilities + ); + + if (!api[subAction]) { + const errorMessage = `[Action][ExternalService] -> [Swimlane] Unsupported subAction type ${subAction}.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (!supportedSubActions.includes(subAction)) { + const errorMessage = `[Action][ExternalService] -> [Swimlane] subAction ${subAction} not implemented.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (subAction === 'pushToService') { + const pushToServiceParams = subActionParams as ExecutorSubActionPushParams; + + data = await api.pushToService({ + externalService, + params: pushToServiceParams, + logger, + }); + + logger.debug(`response push to service for incident id: ${data.id}`); + } + + return { status: 'ok', data: data ?? {}, actionId }; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/mocks.ts new file mode 100644 index 0000000000000..f9931049d81c2 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/mocks.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ExecutorSubActionPushParams, ExternalService, PushToServiceApiParams } from './types'; + +export const applicationFields = [ + { + id: 'adnlas', + name: 'Severity', + key: 'severity', + fieldType: 'text', + }, + { + id: 'adnfls', + name: 'Rule Name', + key: 'rule-name', + fieldType: 'text', + }, + { + id: 'a6sst', + name: 'Case Id', + key: 'case-id-name', + fieldType: 'text', + }, + { + id: 'a6fst', + name: 'Case Name', + key: 'case-name', + fieldType: 'text', + }, + { + id: 'a6fdf', + name: 'Comments', + key: 'comments', + fieldType: 'notes', + }, + { + id: 'a6fde', + name: 'Description', + key: 'description', + fieldType: 'text', + }, + { + id: 'dfnkls', + name: 'Alert ID', + key: 'alert-id', + fieldType: 'text', + }, +]; + +export const mappings = { + severityConfig: applicationFields[0], + ruleNameConfig: applicationFields[1], + caseIdConfig: applicationFields[2], + caseNameConfig: applicationFields[3], + commentsConfig: applicationFields[4], + descriptionConfig: applicationFields[5], + alertIdConfig: applicationFields[6], +}; + +export const getApplicationResponse = { fields: applicationFields }; + +export const recordResponseCreate = { + id: '123456', + title: 'neato', + url: 'swimlane.com', + pushedDate: '2021-06-01T17:29:51.092Z', +}; + +export const recordResponseUpdate = { + id: '98765', + title: 'not neato', + url: 'laneswim.com', + pushedDate: '2021-06-01T17:29:51.092Z', +}; + +export const commentResponse = { + commentId: '123456', + pushedDate: '2021-06-01T17:29:51.092Z', +}; + +const createMock = (): jest.Mocked => { + return { + createComment: jest.fn().mockImplementation(() => Promise.resolve(commentResponse)), + createRecord: jest.fn().mockImplementation(() => Promise.resolve(recordResponseCreate)), + updateRecord: jest.fn().mockImplementation(() => Promise.resolve(recordResponseUpdate)), + }; +}; + +const externalServiceMock = { + create: createMock, +}; + +const executorParams: ExecutorSubActionPushParams = { + incident: { + ruleName: 'rule name', + alertId: '123456', + caseName: 'case name', + severity: 'critical', + caseId: '123456', + description: 'case desc', + externalId: 'incident-3', + }, + comments: [ + { + commentId: 'case-comment-1', + comment: 'A comment', + }, + { + commentId: 'case-comment-2', + comment: 'Another comment', + }, + ], +}; + +const apiParams: PushToServiceApiParams = { + ...executorParams, +}; + +export { externalServiceMock, executorParams, apiParams }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/schema.ts new file mode 100644 index 0000000000000..7f4bdc8ca6c0d --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/schema.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const ConfigMap = { + id: schema.string(), + key: schema.string(), + name: schema.string(), + fieldType: schema.string(), +}; + +export const ConfigMapSchema = schema.object(ConfigMap); + +export const ConfigMapping = { + ruleNameConfig: schema.nullable(ConfigMapSchema), + alertIdConfig: schema.nullable(ConfigMapSchema), + caseIdConfig: schema.nullable(ConfigMapSchema), + caseNameConfig: schema.nullable(ConfigMapSchema), + commentsConfig: schema.nullable(ConfigMapSchema), + severityConfig: schema.nullable(ConfigMapSchema), + descriptionConfig: schema.nullable(ConfigMapSchema), +}; + +export const ConfigMappingSchema = schema.object(ConfigMapping); + +export const SwimlaneServiceConfiguration = { + apiUrl: schema.string(), + appId: schema.string(), + connectorType: schema.string(), + mappings: ConfigMappingSchema, +}; + +export const SwimlaneServiceConfigurationSchema = schema.object(SwimlaneServiceConfiguration); + +export const SwimlaneSecretsConfiguration = { + apiToken: schema.string(), +}; + +export const SwimlaneSecretsConfigurationSchema = schema.object(SwimlaneSecretsConfiguration); + +const SwimlaneFields = { + alertId: schema.nullable(schema.string()), + ruleName: schema.nullable(schema.string()), + caseId: schema.nullable(schema.string()), + caseName: schema.nullable(schema.string()), + severity: schema.nullable(schema.string()), + description: schema.nullable(schema.string()), +}; + +export const ExecutorSubActionPushParamsSchema = schema.object({ + incident: schema.object({ + ...SwimlaneFields, + externalId: schema.nullable(schema.string()), + }), + comments: schema.nullable( + schema.arrayOf( + schema.object({ + comment: schema.string(), + commentId: schema.string(), + }) + ) + ), +}); + +export const ExecutorParamsSchema = schema.oneOf([ + schema.object({ + subAction: schema.literal('pushToService'), + subActionParams: ExecutorSubActionPushParamsSchema, + }), +]); diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts new file mode 100644 index 0000000000000..77f4686f8acd0 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts @@ -0,0 +1,434 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import axios from 'axios'; + +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { Logger } from '../../../../../../src/core/server'; +import { actionsConfigMock } from '../../actions_config.mock'; +import * as utils from '../lib/axios_utils'; +import { createExternalService } from './service'; +import { mappings } from './mocks'; +import { ExternalService } from './types'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +jest.mock('axios'); +jest.mock('../lib/axios_utils', () => { + const originalUtils = jest.requireActual('../lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); + +describe('Swimlane Service', () => { + let service: ExternalService; + const config = { + apiUrl: 'https://test.swimlane.com/', + appId: 'bcq16kdTbz5jlwM6h', + connectorType: 'all', + mappings, + }; + const apiToken = 'token'; + + const headers = { + 'Content-Type': 'application/json', + 'Private-Token': apiToken, + }; + + const incident = { + ruleName: 'Rule Name', + caseId: 'Case Id', + caseName: 'Case Name', + severity: 'Severity', + externalId: null, + description: 'Description', + alertId: 'Alert Id', + }; + + const url = config.apiUrl.slice(0, -1); + + beforeAll(() => { + service = createExternalService( + { + // The trailing slash at the end of the url is intended. + // All API calls need to have the trailing slash removed. + config, + secrets: { apiToken }, + }, + logger, + configurationUtilities + ); + }); + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createExternalService', () => { + test('throws without url', () => { + expect(() => + createExternalService( + { + config: { + // @ts-ignore + apiUrl: null, + appId: '99999', + mappings, + }, + secrets: { apiToken }, + }, + logger, + configurationUtilities + ) + ).toThrow(); + }); + + test('throws without app id', () => { + expect(() => + createExternalService( + { + config: { + apiUrl: 'test.com', + // @ts-ignore + appId: null, + }, + secrets: { apiToken }, + }, + logger, + configurationUtilities + ) + ).toThrow(); + }); + + test('throws without mappings', () => { + expect(() => + createExternalService( + { + config: { + apiUrl: 'test.com', + appId: '987987', + // @ts-ignore + mappings: null, + }, + secrets: { apiToken }, + }, + logger, + configurationUtilities + ) + ).toThrow(); + }); + + test('throws without api token', () => { + expect(() => { + return createExternalService( + { + config: { apiUrl: 'test.com', appId: '78978', mappings, connectorType: 'all' }, + secrets: { + // @ts-ignore + apiToken: null, + }, + }, + logger, + configurationUtilities + ); + }).toThrow(); + }); + }); + + describe('createRecord', () => { + const data = { + id: '123', + name: 'title', + createdDate: '2021-06-01T17:29:51.092Z', + }; + + test('it creates a record correctly', async () => { + requestMock.mockImplementation(() => ({ + data, + })); + + const res = await service.createRecord({ + incident, + }); + + expect(res).toEqual({ + id: '123', + title: 'title', + pushedDate: '2021-06-01T17:29:51.092Z', + url: `${url}/record/${config.appId}/123`, + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data, + })); + + await service.createRecord({ + incident, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + headers, + data: { + applicationId: config.appId, + values: { + [mappings.ruleNameConfig.id]: 'Rule Name', + [mappings.caseNameConfig.id]: 'Case Name', + [mappings.caseIdConfig.id]: 'Case Id', + [mappings.severityConfig.id]: 'Severity', + [mappings.descriptionConfig.id]: 'Description', + [mappings.alertIdConfig.id]: 'Alert Id', + }, + }, + url: `${url}/api/app/${config.appId}/record`, + method: 'post', + configurationUtilities, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect(service.createRecord({ incident })).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` + ); + }); + }); + + describe('updateRecord', () => { + const data = { + id: '123', + name: 'title', + modifiedDate: '2021-06-01T17:29:51.092Z', + }; + const incidentId = '123'; + + test('it updates a record correctly', async () => { + requestMock.mockImplementation(() => ({ + data, + })); + + const res = await service.updateRecord({ + incident, + incidentId, + }); + + expect(res).toEqual({ + id: '123', + title: 'title', + pushedDate: '2021-06-01T17:29:51.092Z', + url: `${url}/record/${config.appId}/123`, + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data, + })); + + await service.updateRecord({ + incident, + incidentId, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + headers, + data: { + applicationId: config.appId, + id: incidentId, + values: { + [mappings.ruleNameConfig.id]: 'Rule Name', + [mappings.caseNameConfig.id]: 'Case Name', + [mappings.caseIdConfig.id]: 'Case Id', + [mappings.severityConfig.id]: 'Severity', + [mappings.descriptionConfig.id]: 'Description', + [mappings.alertIdConfig.id]: 'Alert Id', + }, + }, + url: `${url}/api/app/${config.appId}/record/${incidentId}`, + method: 'patch', + configurationUtilities, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect(service.updateRecord({ incident, incidentId })).rejects.toThrow( + `[Action][Swimlane]: Unable to update record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` + ); + }); + }); + + describe('createComment', () => { + const data = { + id: '123', + name: 'title', + modifiedDate: '2021-06-01T17:29:51.092Z', + }; + const incidentId = '123'; + const comment = { commentId: '456', comment: 'A comment' }; + const createdDate = '2021-06-01T17:29:51.092Z'; + + test('it updates a record correctly', async () => { + requestMock.mockImplementation(() => ({ + data, + })); + + const res = await service.createComment({ + comment, + incidentId, + createdDate, + }); + + expect(res).toEqual({ + commentId: '456', + pushedDate: '2021-06-01T17:29:51.092Z', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data, + })); + + await service.createComment({ + comment, + incidentId, + createdDate, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + headers, + data: { + createdDate, + fieldId: mappings.commentsConfig.id, + isRichText: true, + message: comment.comment, + }, + url: `${url}/api/app/${config.appId}/record/${incidentId}/${mappings.commentsConfig.id}/comment`, + method: 'post', + configurationUtilities, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect(service.createComment({ comment, incidentId, createdDate })).rejects.toThrow( + `[Action][Swimlane]: Unable to create comment in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` + ); + }); + }); + + describe('error messages', () => { + const errorResponse = { ErrorCode: '1', Argument: 'Invalid field' }; + + test('it contains the response error', async () => { + requestMock.mockImplementation(() => { + const error = new Error('An error has occurred'); + // @ts-ignore + error.response = { data: errorResponse }; + throw error; + }); + + await expect( + service.createRecord({ + incident, + }) + ).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: Invalid field (1)` + ); + }); + + test('it shows an empty string for reason if the ErrorCode is undefined', async () => { + requestMock.mockImplementation(() => { + const error = new Error('An error has occurred'); + // @ts-ignore + error.response = { data: { ErrorCode: '1' } }; + throw error; + }); + + await expect( + service.createRecord({ + incident, + }) + ).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` + ); + }); + + test('it shows an empty string for reason if the Argument is undefined', async () => { + requestMock.mockImplementation(() => { + const error = new Error('An error has occurred'); + // @ts-ignore + error.response = { data: { Argument: 'Invalid field' } }; + throw error; + }); + + await expect( + service.createRecord({ + incident, + }) + ).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` + ); + }); + + test('it shows an empty string for reason if data is undefined', async () => { + requestMock.mockImplementation(() => { + const error = new Error('An error has occurred'); + // @ts-ignore + error.response = {}; + throw error; + }); + + await expect( + service.createRecord({ + incident, + }) + ).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` + ); + }); + + test('it shows the status code', async () => { + requestMock.mockImplementation(() => { + const error = new Error('An error has occurred'); + // @ts-ignore + error.response = { data: errorResponse, status: 400 }; + throw error; + }); + + await expect( + service.createRecord({ + incident, + }) + ).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 400. Error: An error has occurred. Reason: Invalid field (1)` + ); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts new file mode 100644 index 0000000000000..f68d22121dbcc --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from '@kbn/logging'; +import axios from 'axios'; + +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { getErrorMessage, request } from '../lib/axios_utils'; +import { getBodyForEventAction } from './helpers'; +import { + CreateCommentParams, + CreateRecordParams, + ExternalService, + ExternalServiceCredentials, + ExternalServiceIncidentResponse, + MappingConfigType, + ResponseError, + SwimlanePublicConfigurationType, + SwimlaneRecordPayload, + SwimlaneSecretConfigurationType, + UpdateRecordParams, +} from './types'; +import * as i18n from './translations'; + +const createErrorMessage = (errorResponse: ResponseError | null | undefined): string => { + if (errorResponse == null) { + return 'unknown'; + } + + const { ErrorCode, Argument } = errorResponse; + return Argument != null && ErrorCode != null ? `${Argument} (${ErrorCode})` : 'unknown'; +}; + +export const createExternalService = ( + { config, secrets }: ExternalServiceCredentials, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities +): ExternalService => { + const { apiUrl: url, appId, mappings } = config as SwimlanePublicConfigurationType; + const { apiToken } = secrets as SwimlaneSecretConfigurationType; + + const axiosInstance = axios.create(); + + if (!url || !appId || !apiToken || !mappings) { + throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); + } + + const headers: Record = { + 'Content-Type': 'application/json', + 'Private-Token': `${secrets.apiToken}`, + }; + + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const apiUrl = urlWithoutTrailingSlash.endsWith('api') + ? urlWithoutTrailingSlash + : urlWithoutTrailingSlash + '/api'; + + const getPostRecordUrl = (id: string) => `${apiUrl}/app/${id}/record`; + + const getPostRecordIdUrl = (id: string, recordId: string) => + `${getPostRecordUrl(id)}/${recordId}`; + + const getRecordIdUrl = (id: string, recordId: string) => + `${urlWithoutTrailingSlash}/record/${id}/${recordId}`; + + const getPostCommentUrl = (id: string, recordId: string, commentFieldId: string) => + `${getPostRecordIdUrl(id, recordId)}/${commentFieldId}/comment`; + + const getCommentFieldId = (fieldMappings: MappingConfigType): string | null => + fieldMappings.commentsConfig?.id || null; + + const createRecord = async ( + params: CreateRecordParams + ): Promise => { + try { + const mappingConfig = mappings as MappingConfigType; + const data = getBodyForEventAction(appId, mappingConfig, params.incident); + + const res = await request({ + axios: axiosInstance, + configurationUtilities, + data, + headers, + logger, + method: 'post', + url: getPostRecordUrl(appId), + }); + return { + id: res.data.id, + title: res.data.name, + url: getRecordIdUrl(appId, res.data.id), + pushedDate: new Date(res.data.createdDate).toISOString(), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to create record in application with id ${appId}. Status: ${ + error.response?.status ?? 500 + }. Error: ${error.message}. Reason: ${createErrorMessage(error.response?.data)}` + ) + ); + } + }; + + const updateRecord = async ( + params: UpdateRecordParams + ): Promise => { + try { + const mappingConfig = mappings as MappingConfigType; + const data = getBodyForEventAction(appId, mappingConfig, params.incident, params.incidentId); + + const res = await request({ + axios: axiosInstance, + configurationUtilities, + data, + headers, + logger, + method: 'patch', + url: getPostRecordIdUrl(appId, params.incidentId), + }); + + return { + id: res.data.id, + title: res.data.name, + url: getRecordIdUrl(appId, params.incidentId), + pushedDate: new Date(res.data.modifiedDate).toISOString(), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to update record in application with id ${appId}. Status: ${ + error.response?.status ?? 500 + }. Error: ${error.message}. Reason: ${createErrorMessage(error.response?.data)}` + ) + ); + } + }; + + const createComment = async ({ incidentId, comment, createdDate }: CreateCommentParams) => { + try { + const mappingConfig = mappings as MappingConfigType; + const fieldId = getCommentFieldId(mappingConfig); + + if (fieldId == null) { + throw new Error(`No comment field mapped in ${i18n.NAME} connector`); + } + + const data = { + createdDate, + fieldId, + isRichText: true, + message: comment.comment, + }; + + await request({ + axios: axiosInstance, + configurationUtilities, + data, + headers, + logger, + method: 'post', + url: getPostCommentUrl(appId, incidentId, fieldId), + }); + + /** + * Swimlane response does not contain any data. + * We cannot get an externalCommentId + */ + return { + commentId: comment.commentId, + pushedDate: createdDate, + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to create comment in application with id ${appId}. Status: ${ + error.response?.status ?? 500 + }. Error: ${error.message}. Reason: ${createErrorMessage(error.response?.data)}` + ) + ); + } + }; + + return { + createComment, + createRecord, + updateRecord, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/translations.ts new file mode 100644 index 0000000000000..671cf224448f6 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/translations.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const NAME = i18n.translate('xpack.actions.builtin.case.swimlaneTitle', { + defaultMessage: 'Swimlane', +}); + +export const ALLOWED_HOSTS_ERROR = (message: string) => + i18n.translate('xpack.actions.builtin.swimlane.configuration.apiAllowedHostsError', { + defaultMessage: 'error configuring connector action: {message}', + values: { + message, + }, + }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/types.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/types.ts new file mode 100644 index 0000000000000..5cb3b10989621 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/types.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { TypeOf } from '@kbn/config-schema'; +import { Logger } from '@kbn/logging'; +import { + ConfigMappingSchema, + ExecutorParamsSchema, + ExecutorSubActionPushParamsSchema, + SwimlaneSecretsConfigurationSchema, + SwimlaneServiceConfigurationSchema, +} from './schema'; +import { ActionsConfigurationUtilities } from '../../actions_config'; + +export type SwimlanePublicConfigurationType = TypeOf; +export type SwimlaneSecretConfigurationType = TypeOf; + +export type MappingConfigType = TypeOf; +export type ExecutorParams = TypeOf; +export type ExecutorSubActionPushParams = TypeOf; + +export interface ExternalServiceCredentials { + config: SwimlanePublicConfigurationType; + secrets: SwimlaneSecretConfigurationType; +} + +export interface ExternalServiceValidation { + config: (configurationUtilities: ActionsConfigurationUtilities, configObject: any) => void; + secrets: (configurationUtilities: ActionsConfigurationUtilities, secrets: any) => void; +} + +export interface CreateRecordParams { + incident: Incident; +} +export interface UpdateRecordParams extends CreateRecordParams { + incidentId: string; +} + +export type PushToServiceApiParams = ExecutorSubActionPushParams; +export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { + params: PushToServiceApiParams; + logger: Logger; +} + +export interface ExternalServiceIncidentResponse { + id: string; + title: string; + url: string; + pushedDate: string; +} +export interface ExternalServiceCommentResponse { + commentId: string; + pushedDate: string; + externalCommentId?: string; +} + +export interface FieldConfig { + id: string; + name: string; + key: string; + fieldType: string; +} + +export interface SwimlaneRecordPayload { + applicationId: string; + values: SwimlaneDataValues; + id?: string; +} + +export interface ExternalService { + createComment: (params: CreateCommentParams) => Promise; + createRecord: (params: CreateRecordParams) => Promise; + updateRecord: (params: UpdateRecordParams) => Promise; +} + +export type Incident = Omit; + +export interface ExternalServiceApiHandlerArgs { + externalService: ExternalService; +} + +export interface GetApplicationHandlerArgs { + externalService: ExternalService; +} + +export interface PushToServiceResponse extends ExternalServiceIncidentResponse { + comments?: ExternalServiceCommentResponse[]; +} + +export interface ExternalServiceApi { + pushToService: (args: PushToServiceApiHandlerArgs) => Promise; +} + +export type SwimlaneExecutorResultData = ExternalServiceIncidentResponse; +export type SwimlaneDataValues = Record; +export interface SwimlaneComment { + fieldId: string; + message: string | number; + createdDate: string; + isRichText: boolean; +} +export type SwimlaneDataComments = Record; + +export interface SimpleComment { + comment: SwimlaneComment['message']; + commentId: string; +} + +export interface CreateCommentParams { + incidentId: string; + comment: SimpleComment; + createdDate: string; +} + +export interface ResponseError { + ErrorCode: number; + Argument: string; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/validators.ts new file mode 100644 index 0000000000000..1972cd7e6af0b --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/validators.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { ExternalServiceValidation, SwimlanePublicConfigurationType } from './types'; +import * as i18n from './translations'; + +export const validateCommonConfig = ( + configurationUtilities: ActionsConfigurationUtilities, + configObject: SwimlanePublicConfigurationType +) => { + try { + configurationUtilities.ensureUriAllowed(configObject.apiUrl); + } catch (allowedListError) { + return i18n.ALLOWED_HOSTS_ERROR(allowedListError.message); + } +}; + +export const validateCommonSecrets = () => {}; + +export const validate: ExternalServiceValidation = { + config: validateCommonConfig, + secrets: validateCommonSecrets, +}; diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index bcfc91d673bcc..230ed826cb108 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -47,7 +47,6 @@ export type { TeamsActionTypeId, TeamsActionParams, } from './builtin_action_types'; - export type { PluginSetupContract, PluginStartContract } from './plugin'; export { asSavedObjectExecutionSource, asHttpRequestExecutionSource } from './lib'; diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index a191728a20489..7c05d16923b9d 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -22,7 +22,7 @@ export { ActionTypeExecutorResult } from '../common'; export { GetFieldsByIssueTypeResponse as JiraGetFieldsResponse } from './builtin_action_types/jira/types'; export { GetCommonFieldsResponse as ServiceNowGetFieldsResponse } from './builtin_action_types/servicenow/types'; export { GetCommonFieldsResponse as ResilientGetFieldsResponse } from './builtin_action_types/resilient/types'; - +export { SwimlanePublicConfigurationType } from './builtin_action_types/swimlane/types'; export type WithoutQueryAndParams = Pick>; export type GetServicesFunction = (request: KibanaRequest) => Services; export type ActionTypeRegistryContract = PublicMethodsOf; diff --git a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts index 06248e1fa95a8..80e0c19092c78 100644 --- a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts +++ b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts @@ -18,6 +18,7 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { __email: { type: 'long' }, __index: { type: 'long' }, __pagerduty: { type: 'long' }, + __swimlane: { type: 'long' }, '__server-log': { type: 'long' }, __slack: { type: 'long' }, __webhook: { type: 'long' }, diff --git a/x-pack/plugins/cases/README.md b/x-pack/plugins/cases/README.md index a1660911567da..cfff8c79ee2d4 100644 --- a/x-pack/plugins/cases/README.md +++ b/x-pack/plugins/cases/README.md @@ -215,7 +215,7 @@ This action type has no `secrets` properties. | -------- | ------------------------------------------------------------------------------------------------- | ----------------- | | id | ID of the connector used for pushing case updates to external systems. | string | | name | The connector name. | string | -| type | The type of the connector. Must be one of these: `.servicenow`, `jira`, `.resilient`, and `.none` | string | +| type | The type of the connector. Must be one of these: `.servicenow`, `.servicenow-sir`, `.swimlane`, `jira`, `.resilient`, and `.none` | string | | fields | Object containing the connector’s fields. | [fields](#fields) | #### `fields` diff --git a/x-pack/plugins/cases/common/api/connectors/index.ts b/x-pack/plugins/cases/common/api/connectors/index.ts index 2a81396025d9a..cee432b17933b 100644 --- a/x-pack/plugins/cases/common/api/connectors/index.ts +++ b/x-pack/plugins/cases/common/api/connectors/index.ts @@ -12,12 +12,14 @@ import { JiraFieldsRT } from './jira'; import { ResilientFieldsRT } from './resilient'; import { ServiceNowITSMFieldsRT } from './servicenow_itsm'; import { ServiceNowSIRFieldsRT } from './servicenow_sir'; +import { SwimlaneFieldsRT } from './swimlane'; export * from './jira'; export * from './servicenow_itsm'; export * from './servicenow_sir'; export * from './resilient'; export * from './mappings'; +export * from './swimlane'; export type ActionConnector = ActionResult; export type ActionTypeConnector = ActionType; @@ -32,10 +34,11 @@ export const ConnectorFieldsRt = rt.union([ export enum ConnectorTypes { jira = '.jira', + none = '.none', resilient = '.resilient', serviceNowITSM = '.servicenow', serviceNowSIR = '.servicenow-sir', - none = '.none', + swimlane = '.swimlane', } export const connectorTypes = Object.values(ConnectorTypes); @@ -55,6 +58,11 @@ const ConnectorServiceNowITSMTypeFieldsRt = rt.type({ fields: rt.union([ServiceNowITSMFieldsRT, rt.null]), }); +const ConnectorSwimlaneTypeFieldsRt = rt.type({ + type: rt.literal(ConnectorTypes.swimlane), + fields: rt.union([SwimlaneFieldsRT, rt.null]), +}); + const ConnectorServiceNowSIRTypeFieldsRt = rt.type({ type: rt.literal(ConnectorTypes.serviceNowSIR), fields: rt.union([ServiceNowSIRFieldsRT, rt.null]), @@ -67,10 +75,11 @@ const ConnectorNoneTypeFieldsRt = rt.type({ export const ConnectorTypeFieldsRt = rt.union([ ConnectorJiraTypeFieldsRt, + ConnectorNoneTypeFieldsRt, ConnectorResillientTypeFieldsRt, ConnectorServiceNowITSMTypeFieldsRt, ConnectorServiceNowSIRTypeFieldsRt, - ConnectorNoneTypeFieldsRt, + ConnectorSwimlaneTypeFieldsRt, ]); export const CaseConnectorRt = rt.intersection([ @@ -85,6 +94,7 @@ export type CaseConnector = rt.TypeOf; export type ConnectorTypeFields = rt.TypeOf; export type ConnectorJiraTypeFields = rt.TypeOf; export type ConnectorResillientTypeFields = rt.TypeOf; +export type ConnectorSwimlaneTypeFields = rt.TypeOf; export type ConnectorServiceNowITSMTypeFields = rt.TypeOf< typeof ConnectorServiceNowITSMTypeFieldsRt >; diff --git a/x-pack/plugins/cases/common/api/connectors/mappings.ts b/x-pack/plugins/cases/common/api/connectors/mappings.ts index e0fdd2d7e62dc..8737a6c5a6462 100644 --- a/x-pack/plugins/cases/common/api/connectors/mappings.ts +++ b/x-pack/plugins/cases/common/api/connectors/mappings.ts @@ -48,9 +48,6 @@ const ConnectorFieldRt = rt.type({ export type ConnectorField = rt.TypeOf; -const GetFieldsResponseRt = rt.type({ - defaultMappings: rt.array(ConnectorMappingsAttributesRT), - fields: rt.array(ConnectorFieldRt), -}); +const GetDefaultMappingsResponseRt = rt.array(ConnectorMappingsAttributesRT); -export type GetFieldsResponse = rt.TypeOf; +export type GetDefaultMappingsResponse = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/connectors/swimlane.ts b/x-pack/plugins/cases/common/api/connectors/swimlane.ts new file mode 100644 index 0000000000000..bc4d9df9ae6a0 --- /dev/null +++ b/x-pack/plugins/cases/common/api/connectors/swimlane.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +// New fields should also be added at: x-pack/plugins/cases/server/connectors/case/schema.ts +export const SwimlaneFieldsRT = rt.type({ + caseId: rt.union([rt.string, rt.null]), +}); + +export enum SwimlaneConnectorType { + All = 'all', + Alerts = 'alerts', + Cases = 'cases', +} + +export type SwimlaneFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index 317fe1d8ed144..5d7ee47bb8ea0 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -4,6 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { ConnectorTypes } from './api'; + export const DEFAULT_DATE_FORMAT = 'dateFormat'; export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz'; @@ -59,16 +61,12 @@ export const CASE_DETAILS_ALERTS_URL = `${CASE_DETAILS_URL}/alerts`; export const ACTION_URL = '/api/actions'; export const ACTION_TYPES_URL = '/api/actions/list_action_types'; -export const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow'; -export const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir'; -export const JIRA_ACTION_TYPE_ID = '.jira'; -export const RESILIENT_ACTION_TYPE_ID = '.resilient'; - export const SUPPORTED_CONNECTORS = [ - SERVICENOW_ITSM_ACTION_TYPE_ID, - SERVICENOW_SIR_ACTION_TYPE_ID, - JIRA_ACTION_TYPE_ID, - RESILIENT_ACTION_TYPE_ID, + `${ConnectorTypes.serviceNowITSM}`, + `${ConnectorTypes.serviceNowSIR}`, + `${ConnectorTypes.jira}`, + `${ConnectorTypes.resilient}`, + `${ConnectorTypes.swimlane}`, ]; /** diff --git a/x-pack/plugins/cases/public/common/shared_imports.ts b/x-pack/plugins/cases/public/common/shared_imports.ts index 675204076b02a..4641fcfa2167c 100644 --- a/x-pack/plugins/cases/public/common/shared_imports.ts +++ b/x-pack/plugins/cases/public/common/shared_imports.ts @@ -24,6 +24,8 @@ export { ValidationError, ValidationFunc, VALIDATION_TYPES, + FieldConfig, + ValidationConfig, } from '../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; export { Field, diff --git a/x-pack/plugins/cases/public/components/case_view/index.test.tsx b/x-pack/plugins/cases/public/components/case_view/index.test.tsx index 55de4d07b13b9..1fafbac50c2b9 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.test.tsx @@ -608,6 +608,7 @@ describe('CaseView ', () => { ).toBe(connectorName); }); }); + it('should update connector', async () => { const wrapper = mount( @@ -628,15 +629,19 @@ describe('CaseView ', () => { wrapper.find('[data-test-subj="connector-edit"] button').simulate('click'); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); - await waitFor(() => wrapper.update()); + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); + }); + wrapper.find(`button[data-test-subj="edit-connectors-submit"]`).first().simulate('click'); await waitFor(() => { - const updateObject = updateCaseProperty.mock.calls[0][0]; + wrapper.update(); expect(updateCaseProperty).toHaveBeenCalledTimes(1); + const updateObject = updateCaseProperty.mock.calls[0][0]; expect(updateObject.updateKey).toEqual('connector'); expect(updateObject.updateValue).toEqual({ id: 'resilient-2', diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index 05f1c6727b168..9c6e9442c8f56 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -31,17 +31,14 @@ import { useGetCaseUserActions } from '../../containers/use_get_case_user_action import { usePushToService } from '../use_push_to_service'; import { EditConnector } from '../edit_connector'; import { useConnectors } from '../../containers/configure/use_connectors'; -import { - getConnectorById, - normalizeActionConnector, - getNoneConnector, -} from '../configure_cases/utils'; +import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils'; import { StatusActionButton } from '../status/button'; import * as i18n from './translations'; import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../timeline_context'; import { useTimelineContext } from '../timeline_context/use_timeline_context'; import { CasesNavigation } from '../links'; import { OwnerProvider } from '../owner_context'; +import { getConnectorById } from '../utils'; import { DoesNotExist } from './does_not_exist'; const gutterTimeline = '70px'; // seems to be a timeline reference from the original file diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.tsx index 3ee4bc77cd237..ac43ec05319a0 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -24,15 +24,11 @@ import { ActionConnectorTableItem } from '../../../../triggers_actions_ui/public import { SectionWrapper } from '../wrappers'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; -import { - getConnectorById, - getNoneConnector, - normalizeActionConnector, - normalizeCaseConnector, -} from './utils'; +import { getNoneConnector, normalizeActionConnector, normalizeCaseConnector } from './utils'; import * as i18n from './translations'; import { Owner } from '../../types'; import { OwnerProvider } from '../owner_context'; +import { getConnectorById } from '../utils'; const FormWrapper = styled.div` ${({ theme }) => css` diff --git a/x-pack/plugins/cases/public/components/configure_cases/utils.ts b/x-pack/plugins/cases/public/components/configure_cases/utils.ts index ade1a5e0c2bba..6597417b5068a 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/utils.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/utils.ts @@ -10,10 +10,10 @@ import { CaseField, ActionType, ThirdPartyField, - ActionConnector, CaseConnector, CaseConnectorMapping, } from '../../containers/configure/types'; +import { CaseActionConnector } from '../types'; export const setActionTypeToMapping = ( caseField: CaseField, @@ -54,13 +54,8 @@ export const getNoneConnector = (): CaseConnector => ({ fields: null, }); -export const getConnectorById = ( - id: string, - connectors: ActionConnector[] -): ActionConnector | null => connectors.find((c) => c.id === id) ?? null; - export const normalizeActionConnector = ( - actionConnector: ActionConnector, + actionConnector: CaseActionConnector, fields: CaseConnector['fields'] = null ): CaseConnector => { const caseConnectorFieldsType = { @@ -75,6 +70,6 @@ export const normalizeActionConnector = ( }; export const normalizeCaseConnector = ( - connectors: ActionConnector[], + connectors: CaseActionConnector[], caseConnector: CaseConnector -): ActionConnector | null => connectors.find((c) => c.id === caseConnector.id) ?? null; +): CaseActionConnector | null => connectors.find((c) => c.id === caseConnector.id) ?? null; diff --git a/x-pack/plugins/cases/public/components/connector_selector/form.tsx b/x-pack/plugins/cases/public/components/connector_selector/form.tsx index 210334e93adb8..71a65ae030d9d 100644 --- a/x-pack/plugins/cases/public/components/connector_selector/form.tsx +++ b/x-pack/plugins/cases/public/components/connector_selector/form.tsx @@ -8,6 +8,7 @@ import React, { useCallback } from 'react'; import { isEmpty } from 'lodash/fp'; import { EuiFormRow } from '@elastic/eui'; +import styled from 'styled-components'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../common/shared_imports'; import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown'; @@ -24,6 +25,13 @@ interface ConnectorSelectorProps { handleChange?: (newValue: string) => void; hideConnectorServiceNowSir?: boolean; } + +const EuiFormRowWrapper = styled(EuiFormRow)` + .euiFormErrorText { + display: none; + } +`; + export const ConnectorSelector = ({ connectors, dataTestSubj, @@ -47,7 +55,7 @@ export const ConnectorSelector = ({ ); return isEdit ? ( - - + ) : null; }; diff --git a/x-pack/plugins/cases/public/components/connectors/fields_form.tsx b/x-pack/plugins/cases/public/components/connectors/fields_form.tsx index d71da6f87689d..062695fa41cc2 100644 --- a/x-pack/plugins/cases/public/components/connectors/fields_form.tsx +++ b/x-pack/plugins/cases/public/components/connectors/fields_form.tsx @@ -8,7 +8,8 @@ import React, { memo, Suspense } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import { CaseActionConnector, ConnectorFieldsProps } from './types'; +import { CaseActionConnector } from '../types'; +import { ConnectorFieldsProps } from './types'; import { getCaseConnectors } from '.'; import { ConnectorTypeFields } from '../../../common'; diff --git a/x-pack/plugins/cases/public/components/connectors/index.ts b/x-pack/plugins/cases/public/components/connectors/index.ts index ad202365ae967..3aa10c56dd8e9 100644 --- a/x-pack/plugins/cases/public/components/connectors/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/index.ts @@ -8,6 +8,7 @@ import { CaseConnectorsRegistry } from './types'; import { createCaseConnectorsRegistry } from './connectors_registry'; import { getCaseConnector as getJiraCaseConnector } from './jira'; +import { getCaseConnector as getSwimlaneCaseConnector } from './swimlane'; import { getCaseConnector as getResilientCaseConnector } from './resilient'; import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow'; import { @@ -15,6 +16,7 @@ import { ServiceNowITSMFieldsType, ServiceNowSIRFieldsType, ResilientFieldsType, + SwimlaneFieldsType, } from '../../../common'; export { getActionType as getCaseConnectorUi } from './case'; @@ -40,6 +42,7 @@ class CaseConnectors { getServiceNowITSMCaseConnector() ); this.caseConnectorsRegistry.register(getServiceNowSIRCaseConnector()); + this.caseConnectorsRegistry.register(getSwimlaneCaseConnector()); } registry(): CaseConnectorsRegistry { diff --git a/x-pack/plugins/cases/public/components/connectors/jira/index.ts b/x-pack/plugins/cases/public/components/connectors/jira/index.ts index f987d9823af8e..d59d20177c14d 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/jira/index.ts @@ -8,13 +8,13 @@ import { lazy } from 'react'; import { CaseConnector } from '../types'; -import { JiraFieldsType } from '../../../../common'; +import { ConnectorTypes, JiraFieldsType } from '../../../../common'; import * as i18n from './translations'; export * from './types'; export const getCaseConnector = (): CaseConnector => ({ - id: '.jira', + id: ConnectorTypes.jira, fieldsComponent: lazy(() => import('./case_fields')), }); export const fieldLabels = { diff --git a/x-pack/plugins/cases/public/components/connectors/mock.ts b/x-pack/plugins/cases/public/components/connectors/mock.ts index f5429fa2396aa..663b397e6f4fe 100644 --- a/x-pack/plugins/cases/public/components/connectors/mock.ts +++ b/x-pack/plugins/cases/public/components/connectors/mock.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { SwimlaneConnectorType } from '../../../common'; + export const connector = { id: '123', name: 'My connector', @@ -13,6 +15,22 @@ export const connector = { isPreconfigured: false, }; +export const swimlaneConnector = { + id: '123', + name: 'My connector', + actionTypeId: '.swimlane', + config: { + connectorType: SwimlaneConnectorType.Cases, + mappings: { + caseIdConfig: {}, + caseNameConfig: {}, + descriptionConfig: {}, + commentsConfig: {}, + }, + }, + isPreconfigured: false, +}; + export const issues = [ { id: 'personId', title: 'Person Task', key: 'personKey' }, { id: 'womanId', title: 'Woman Task', key: 'womanKey' }, diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/index.ts b/x-pack/plugins/cases/public/components/connectors/resilient/index.ts index 9bf96b16f358c..8a429c0dea091 100644 --- a/x-pack/plugins/cases/public/components/connectors/resilient/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/resilient/index.ts @@ -8,13 +8,13 @@ import { lazy } from 'react'; import { CaseConnector } from '../types'; -import { ResilientFieldsType } from '../../../../common'; +import { ConnectorTypes, ResilientFieldsType } from '../../../../common'; import * as i18n from './translations'; export * from './types'; export const getCaseConnector = (): CaseConnector => ({ - id: '.resilient', + id: ConnectorTypes.resilient, fieldsComponent: lazy(() => import('./case_fields')), }); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/index.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/index.ts index 9df5f87b416e1..88afd902ccf60 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/index.ts @@ -8,16 +8,20 @@ import { lazy } from 'react'; import { CaseConnector } from '../types'; -import { ServiceNowITSMFieldsType, ServiceNowSIRFieldsType } from '../../../../common'; +import { + ConnectorTypes, + ServiceNowITSMFieldsType, + ServiceNowSIRFieldsType, +} from '../../../../common'; import * as i18n from './translations'; export const getServiceNowITSMCaseConnector = (): CaseConnector => ({ - id: '.servicenow', + id: ConnectorTypes.serviceNowITSM, fieldsComponent: lazy(() => import('./servicenow_itsm_case_fields')), }); export const getServiceNowSIRCaseConnector = (): CaseConnector => ({ - id: '.servicenow-sir', + id: ConnectorTypes.serviceNowSIR, fieldsComponent: lazy(() => import('./servicenow_sir_case_fields')), }); diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.test.tsx new file mode 100644 index 0000000000000..1a035d92611bd --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.test.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { SwimlaneConnectorType } from '../../../../common'; +import Fields from './case_fields'; +import * as i18n from './translations'; +import { swimlaneConnector as connector } from '../mock'; + +const fields = { + caseId: '123', +}; + +const onChange = jest.fn(); + +describe('Swimlane Cases Fields', () => { + test('it does not shows the mapping error callout', () => { + render(); + expect(screen.queryByText(i18n.EMPTY_MAPPING_WARNING_TITLE)).toBeFalsy(); + }); + + test('it shows the mapping error callout when mapping is invalid', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + mappings: {}, + }, + }; + + render(); + expect(screen.queryByText(i18n.EMPTY_MAPPING_WARNING_TITLE)).toBeTruthy(); + }); + + test('it shows the mapping error callout when the connector is of type alerts', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + connectorType: SwimlaneConnectorType.Alerts, + }, + }; + + render(); + expect(screen.queryByText(i18n.EMPTY_MAPPING_WARNING_TITLE)).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.tsx new file mode 100644 index 0000000000000..b6370504edbb6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiCallOut } from '@elastic/eui'; +import * as i18n from './translations'; + +import { ConnectorTypes, SwimlaneFieldsType } from '../../../../common'; +import { ConnectorFieldsProps } from '../types'; +import { ConnectorCard } from '../card'; +import { connectorValidator } from './validator'; + +const SwimlaneComponent: React.FunctionComponent> = ({ + connector, + isEdit = true, +}) => { + const showMappingWarning = useMemo(() => connectorValidator(connector) != null, [connector]); + + return ( + <> + {!isEdit && ( + + )} + {showMappingWarning && ( + + {i18n.EMPTY_MAPPING_WARNING_DESC} + + )} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SwimlaneComponent as default }; diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/index.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/index.ts new file mode 100644 index 0000000000000..bd2eaae9e0174 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; + +import { CaseConnector } from '../types'; +import { ConnectorTypes, SwimlaneFieldsType } from '../../../../common'; +import * as i18n from './translations'; + +export const getCaseConnector = (): CaseConnector => { + return { + id: ConnectorTypes.swimlane, + fieldsComponent: lazy(() => import('./case_fields')), + }; +}; + +export const fieldLabels = { + caseId: i18n.CASE_ID_LABEL, + caseName: i18n.CASE_NAME_LABEL, + severity: i18n.SEVERITY_LABEL, +}; diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/translations.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/translations.ts new file mode 100644 index 0000000000000..eb6cd168fab99 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/translations.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ALERT_SOURCE_LABEL = i18n.translate( + 'xpack.cases.connectors.swimlane.alertSourceLabel', + { + defaultMessage: 'Alert Source', + } +); + +export const CASE_ID_LABEL = i18n.translate('xpack.cases.connectors.swimlane.caseIdLabel', { + defaultMessage: 'Case Id', +}); + +export const CASE_NAME_LABEL = i18n.translate('xpack.cases.connectors.swimlane.caseNameLabel', { + defaultMessage: 'Case Name', +}); + +export const SEVERITY_LABEL = i18n.translate('xpack.cases.connectors.swimlane.severityLabel', { + defaultMessage: 'Severity', +}); + +export const EMPTY_MAPPING_WARNING_TITLE = i18n.translate( + 'xpack.cases.connectors.swimlane.emptyMappingWarningTitle', + { + defaultMessage: 'This connector has missing field mappings', + } +); + +export const EMPTY_MAPPING_WARNING_DESC = i18n.translate( + 'xpack.cases.connectors.swimlane.emptyMappingWarningDesc', + { + defaultMessage: + 'This connector cannot be selected because it is missing the required case field mappings. You can edit this connector to add required field mappings or select a connector of type Cases.', + } +); diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts new file mode 100644 index 0000000000000..552d988c26330 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SwimlaneConnectorType } from '../../../../common'; +import { swimlaneConnector as connector } from '../mock'; +import { isAnyRequiredFieldNotSet, connectorValidator } from './validator'; + +describe('Swimlane validator', () => { + describe('isAnyRequiredFieldNotSet', () => { + test('it returns true if a required field is not set', () => { + expect(isAnyRequiredFieldNotSet({ notRequired: 'test' })).toBeTruthy(); + }); + + test('it returns false if all required fields are set', () => { + expect(isAnyRequiredFieldNotSet(connector.config.mappings)).toBeFalsy(); + }); + }); + + describe('connectorValidator', () => { + test('it returns an error message if the mapping is not correct', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + mappings: {}, + }, + }; + expect(connectorValidator(invalidConnector)).toEqual({ message: 'Invalid connector' }); + }); + + test('it returns an error message if the connector is of type alerts', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + connectorType: SwimlaneConnectorType.Alerts, + }, + }; + expect(connectorValidator(invalidConnector)).toEqual({ message: 'Invalid connector' }); + }); + + test.each([SwimlaneConnectorType.Cases, SwimlaneConnectorType.All])( + 'it does not return an error message if the connector is of type %s', + (connectorType) => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + connectorType, + }, + }; + expect(connectorValidator(invalidConnector)).toBe(undefined); + } + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts new file mode 100644 index 0000000000000..4ead75e5854f9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SwimlaneConnectorType } from '../../../../common'; +import { ValidationConfig } from '../../../common/shared_imports'; +import { CaseActionConnector } from '../../types'; + +const casesRequiredFields = [ + 'caseIdConfig', + 'caseNameConfig', + 'descriptionConfig', + 'commentsConfig', +]; + +export const isAnyRequiredFieldNotSet = (mapping: Record | undefined) => + casesRequiredFields.some((field) => mapping?.[field] == null); + +/** + * The user can use either a connector of type cases or all. + * If the connector is of type all we should check if all + * required field have been configured. + */ + +export const connectorValidator = ( + connector: CaseActionConnector +): ReturnType => { + const { + config: { mappings, connectorType }, + } = connector; + if (connectorType === SwimlaneConnectorType.Alerts || isAnyRequiredFieldNotSet(mappings)) { + return { + message: 'Invalid connector', + }; + } +}; diff --git a/x-pack/plugins/cases/public/components/connectors/types.ts b/x-pack/plugins/cases/public/components/connectors/types.ts index 4eb97513b9f58..5bbd77c790901 100644 --- a/x-pack/plugins/cases/public/components/connectors/types.ts +++ b/x-pack/plugins/cases/public/components/connectors/types.ts @@ -11,12 +11,11 @@ import React from 'react'; import { ActionType as ThirdPartySupportedActions, CaseField, - ActionConnector, ConnectorTypeFields, } from '../../../common'; +import { CaseActionConnector } from '../types'; export { ThirdPartyField as AllThirdPartyFields } from '../../../common'; -export type CaseActionConnector = ActionConnector; export interface ThirdPartyField { label: string; diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx index c453838f6cd7a..bc6d5c8717ece 100644 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.test.tsx @@ -18,6 +18,9 @@ import { useGetSeverity } from '../connectors/resilient/use_get_severity'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { incidentTypes, severity, choices } from '../connectors/mock'; import { schema, FormProps } from './schema'; +import { TestProviders } from '../../common/mock'; +import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useCaseConfigureResponse } from '../configure_cases/__mock__'; jest.mock('../../common/lib/kibana', () => ({ useKibana: () => ({ @@ -39,10 +42,12 @@ jest.mock('../../common/lib/kibana', () => ({ jest.mock('../connectors/resilient/use_get_incident_types'); jest.mock('../connectors/resilient/use_get_severity'); jest.mock('../connectors/servicenow/use_get_choices'); +jest.mock('../../containers/configure/use_configure'); const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; const useGetChoicesMock = useGetChoices as jest.Mock; +const useCaseConfigureMock = useCaseConfigure as jest.Mock; const useGetIncidentTypesResponse = { isLoading: false, @@ -87,35 +92,30 @@ describe('Connector', () => { useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); }); it('it renders', async () => { const wrapper = mount( - - - + + + + + ); expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeTruthy(); - - await waitFor(() => { - expect(wrapper.find(`button[data-test-subj="dropdown-connectors"]`).first().text()).toBe( - 'My Connector' - ); - }); - - await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeTruthy(); - }); + // Selected connector is set to none so no fields should be displayed + expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeFalsy(); }); it('it is disabled and loading when isLoadingConnectors=true', async () => { const wrapper = mount( - - - + + + + + ); expect( @@ -129,9 +129,11 @@ describe('Connector', () => { it('it is disabled and loading when isLoading=true', async () => { const wrapper = mount( - - - + + + + + ); expect( @@ -144,9 +146,11 @@ describe('Connector', () => { it(`it should change connector`, async () => { const wrapper = mount( - - - + + + + + ); expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); diff --git a/x-pack/plugins/cases/public/components/create/connector.tsx b/x-pack/plugins/cases/public/components/create/connector.tsx index 2049f2a083a6f..2ec6d1ffef23d 100644 --- a/x-pack/plugins/cases/public/components/create/connector.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.tsx @@ -5,15 +5,22 @@ * 2.0. */ -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useMemo, useEffect } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { ActionConnector, ConnectorTypes } from '../../../common'; -import { UseField, useFormData, FieldHook, useFormContext } from '../../common/shared_imports'; +import { ConnectorTypes, ActionConnector } from '../../../common'; +import { + UseField, + useFormData, + FieldHook, + useFormContext, + FieldConfig, +} from '../../common/shared_imports'; import { ConnectorSelector } from '../connector_selector/form'; import { ConnectorFieldsForm } from '../connectors/fields_form'; -import { getConnectorById } from '../configure_cases/utils'; -import { FormProps } from './schema'; +import { FormProps, schema } from './schema'; +import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { getConnectorById, getConnectorsFormValidators } from '../utils'; interface Props { connectors: ActionConnector[]; @@ -26,6 +33,7 @@ interface ConnectorsFieldProps { connectors: ActionConnector[]; field: FieldHook; isEdit: boolean; + setErrors: (errors: boolean) => void; hideConnectorServiceNowSir?: boolean; } @@ -33,11 +41,13 @@ const ConnectorFields = ({ connectors, isEdit, field, + setErrors, hideConnectorServiceNowSir = false, }: ConnectorsFieldProps) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); const { setValue } = field; let connector = getConnectorById(connectorId, connectors) ?? null; + if ( connector && hideConnectorServiceNowSir && @@ -61,18 +71,49 @@ const ConnectorComponent: React.FC = ({ isLoading, isLoadingConnectors, }) => { - const { getFields } = useFormContext(); + const { getFields, setFieldValue } = useFormContext(); + const { connector: configurationConnector } = useCaseConfigure(); + const handleConnectorChange = useCallback(() => { const { fields } = getFields(); fields.setValue(null); }, [getFields]); + const defaultConnectorId = useMemo(() => { + if ( + hideConnectorServiceNowSir && + configurationConnector.type === ConnectorTypes.serviceNowSIR + ) { + return 'none'; + } + return connectors.some((connector) => connector.id === configurationConnector.id) + ? configurationConnector.id + : 'none'; + }, [ + configurationConnector.id, + configurationConnector.type, + connectors, + hideConnectorServiceNowSir, + ]); + + useEffect(() => setFieldValue('connectorId', defaultConnectorId), [ + defaultConnectorId, + setFieldValue, + ]); + + const connectorIdConfig = getConnectorsFormValidators({ + config: schema.connectorId as FieldConfig, + connectors, + }); + return ( { jest.resetAllMocks(); useGetTagsMock.mockReturnValue({ tags: ['test'] }); useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock }); + useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); }); it('it renders with steps', async () => { diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx index 30a60fb5c1e47..65c102583455a 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -5,23 +5,19 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { schema, FormProps } from './schema'; import { Form, useForm } from '../../common/shared_imports'; -import { - getConnectorById, - getNoneConnector, - normalizeActionConnector, -} from '../configure_cases/utils'; +import { getNoneConnector, normalizeActionConnector } from '../configure_cases/utils'; import { usePostCase } from '../../containers/use_post_case'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { useConnectors } from '../../containers/configure/use_connectors'; -import { useCaseConfigure } from '../../containers/configure/use_configure'; import { Case } from '../../containers/types'; -import { CaseType, ConnectorTypes } from '../../../common'; +import { CaseType } from '../../../common'; import { UsePostComment, usePostComment } from '../../containers/use_post_comment'; import { useOwnerContext } from '../owner_context/use_owner_context'; +import { getConnectorById } from '../utils'; const initialCaseValue: FormProps = { description: '', @@ -49,28 +45,10 @@ export const FormContext: React.FC = ({ }) => { const { connectors, loading: isLoadingConnectors } = useConnectors(); const owner = useOwnerContext(); - const { connector: configurationConnector } = useCaseConfigure(); const { postCase } = usePostCase(); const { postComment } = usePostComment(); const { pushCaseToExternalService } = usePostPushToService(); - const connectorId = useMemo(() => { - if ( - hideConnectorServiceNowSir && - configurationConnector.type === ConnectorTypes.serviceNowSIR - ) { - return 'none'; - } - return connectors.some((connector) => connector.id === configurationConnector.id) - ? configurationConnector.id - : 'none'; - }, [ - configurationConnector.id, - configurationConnector.type, - connectors, - hideConnectorServiceNowSir, - ]); - const submitCase = useCallback( async ( { connectorId: dataConnectorId, fields, syncAlerts = true, ...dataWithoutConnectorId }, @@ -125,9 +103,6 @@ export const FormContext: React.FC = ({ schema, onSubmit: submitCase, }); - const { setFieldValue } = form; - // Set the selected connector to the configuration connector - useEffect(() => setFieldValue('connectorId', connectorId), [connectorId, setFieldValue]); const childrenWithExtraProp = useMemo( () => diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx index 6e6d1a414280e..bea1a46d93760 100644 --- a/x-pack/plugins/cases/public/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -49,7 +49,9 @@ export const schema: FormSchema = { label: i18n.CONNECTORS, defaultValue: 'none', }, - fields: {}, + fields: { + defaultValue: null, + }, syncAlerts: { helpText: i18n.SYNC_ALERTS_HELP, type: FIELD_TYPES.TOGGLE, diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.tsx index 570f6e34d2528..8057d188b8c04 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.tsx @@ -20,15 +20,15 @@ import { import styled from 'styled-components'; import { noop } from 'lodash/fp'; -import { Form, UseField, useForm } from '../../common/shared_imports'; +import { FieldConfig, Form, UseField, useForm } from '../../common/shared_imports'; import { ActionConnector, ConnectorTypeFields } from '../../../common'; import { ConnectorSelector } from '../connector_selector/form'; import { ConnectorFieldsForm } from '../connectors/fields_form'; -import { getConnectorById } from '../configure_cases/utils'; import { CaseUserActions } from '../../containers/types'; import { schema } from './schema'; import { getConnectorFieldsFromUserActions } from './helpers'; import * as i18n from './translations'; +import { getConnectorById, getConnectorsFormValidators } from '../utils'; export interface EditConnectorProps { caseFields: ConnectorTypeFields['fields']; @@ -205,6 +205,11 @@ export const EditConnector = React.memo( }); }, [dispatch]); + const connectorIdConfig = getConnectorsFormValidators({ + config: schema.connectorId as FieldConfig, + connectors, + }); + /** * if this evaluates to true it means that the connector was likely deleted because the case connector was set to something * other than none but we don't find it in the list of connectors returned from the actions plugin @@ -243,6 +248,7 @@ export const EditConnector = React.memo( connectors.find((c) => c.id === id) ?? null; + +const validators: Record< + string, + (connector: CaseActionConnector) => ReturnType +> = { + [ConnectorTypes.swimlane]: swimlaneConnectorValidator, +}; + +export const getConnectorsFormValidators = ({ + connectors = [], + config = {}, +}: { + connectors: CaseActionConnector[]; + config: FieldConfig; +}): FieldConfig => ({ + ...config, + validations: [ + { + validator: ({ value: connectorId }) => { + const connector = getConnectorById(connectorId as string, connectors); + if (connector != null) { + return validators[connector.actionTypeId]?.(connector); + } + }, + }, + ], +}); diff --git a/x-pack/plugins/cases/public/containers/use_get_action_license.tsx b/x-pack/plugins/cases/public/containers/use_get_action_license.tsx index 4f28d88c14b25..e4ea6d05011a7 100644 --- a/x-pack/plugins/cases/public/containers/use_get_action_license.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_action_license.tsx @@ -11,6 +11,7 @@ import { useToasts } from '../common/lib/kibana'; import { getActionLicense } from './api'; import * as i18n from './translations'; import { ActionLicense } from './types'; +import { ConnectorTypes } from '../../common'; export interface ActionLicenseState { actionLicense: ActionLicense | null; @@ -24,7 +25,7 @@ export const initialData: ActionLicenseState = { isError: false, }; -const MINIMUM_LICENSE_REQUIRED_CONNECTOR = '.jira'; +const MINIMUM_LICENSE_REQUIRED_CONNECTOR = ConnectorTypes.jira; export const useGetActionLicense = (): ActionLicenseState => { const [actionLicenseState, setActionLicensesState] = useState(initialData); diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index 3df1891391c75..4f8713704361b 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -173,7 +173,6 @@ export const get = async ( let theCase: SavedObject; let subCaseIds: string[] = []; - if (ENABLE_CASE_CONNECTOR) { const [caseInfo, subCasesForCaseId] = await Promise.all([ caseService.getCase({ diff --git a/x-pack/plugins/cases/server/client/cases/utils.ts b/x-pack/plugins/cases/server/client/cases/utils.ts index d920c517a0004..f5a10d705e095 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.ts @@ -252,6 +252,7 @@ export const prepareFieldsForTransformation = ({ mappings.reduce( (acc: PipedField[], mapping) => mapping != null && + mapping.target != null && mapping.target !== 'not_mapped' && mapping.action_type !== 'nothing' && mapping.source !== 'comments' diff --git a/x-pack/plugins/cases/server/connectors/case/index.test.ts b/x-pack/plugins/cases/server/connectors/case/index.test.ts index 7b8f57bf0d3bf..51c45bd25444e 100644 --- a/x-pack/plugins/cases/server/connectors/case/index.test.ts +++ b/x-pack/plugins/cases/server/connectors/case/index.test.ts @@ -60,7 +60,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { issueType: '10006', priority: 'High', @@ -99,7 +99,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { issueType: '10006', priority: 'High', @@ -293,7 +293,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { priority: 'High', parent: null, @@ -438,7 +438,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { issueType: '10006', priority: 'High', @@ -640,7 +640,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { priority: 'High', parent: null, @@ -974,7 +974,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { issueType: '10006', priority: 'High', @@ -1003,7 +1003,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { issueType: '10006', priority: 'High', diff --git a/x-pack/plugins/cases/server/connectors/case/schema.ts b/x-pack/plugins/cases/server/connectors/case/schema.ts index 596a5a4aae45e..79d3bf62e8a9e 100644 --- a/x-pack/plugins/cases/server/connectors/case/schema.ts +++ b/x-pack/plugins/cases/server/connectors/case/schema.ts @@ -6,7 +6,7 @@ */ import { schema } from '@kbn/config-schema'; -import { CommentType } from '../../../common'; +import { CommentType, ConnectorTypes } from '../../../common'; import { validateConnector } from './validators'; // Reserved for future implementation @@ -77,23 +77,29 @@ const ServiceNowSIRFieldsSchema = schema.object({ subcategory: schema.nullable(schema.string()), }); +const SwimlaneFieldsSchema = schema.object({ + caseId: schema.nullable(schema.string()), +}); + const NoneFieldsSchema = schema.nullable(schema.object({})); const ReducedConnectorFieldsSchema: { [x: string]: any } = { - '.jira': JiraFieldsSchema, - '.resilient': ResilientFieldsSchema, - '.servicenow-sir': ServiceNowSIRFieldsSchema, + [ConnectorTypes.jira]: JiraFieldsSchema, + [ConnectorTypes.resilient]: ResilientFieldsSchema, + [ConnectorTypes.serviceNowSIR]: ServiceNowSIRFieldsSchema, + [ConnectorTypes.swimlane]: SwimlaneFieldsSchema, }; export const ConnectorProps = { id: schema.string(), name: schema.string(), type: schema.oneOf([ - schema.literal('.servicenow'), - schema.literal('.jira'), - schema.literal('.resilient'), - schema.literal('.servicenow-sir'), - schema.literal('.none'), + schema.literal(ConnectorTypes.jira), + schema.literal(ConnectorTypes.none), + schema.literal(ConnectorTypes.resilient), + schema.literal(ConnectorTypes.serviceNowITSM), + schema.literal(ConnectorTypes.serviceNowSIR), + schema.literal(ConnectorTypes.swimlane), ]), // Chain of conditional schemes fields: Object.keys(ReducedConnectorFieldsSchema).reduce( @@ -106,7 +112,7 @@ export const ConnectorProps = { ), schema.conditional( schema.siblingRef('type'), - '.servicenow', + ConnectorTypes.serviceNowITSM, ServiceNowITSMFieldsSchema, NoneFieldsSchema ) diff --git a/x-pack/plugins/cases/server/connectors/case/validators.ts b/x-pack/plugins/cases/server/connectors/case/validators.ts index 03110d15c9d3f..6ab4f3a21a24f 100644 --- a/x-pack/plugins/cases/server/connectors/case/validators.ts +++ b/x-pack/plugins/cases/server/connectors/case/validators.ts @@ -6,9 +6,10 @@ */ import { Connector } from './types'; +import { ConnectorTypes } from '../../../common'; export const validateConnector = (connector: Connector) => { - if (connector.type === '.none' && connector.fields !== null) { + if (connector.type === ConnectorTypes.none && connector.fields !== null) { return 'Fields must be set to null for connectors of type .none'; } }; diff --git a/x-pack/plugins/cases/server/connectors/factory.ts b/x-pack/plugins/cases/server/connectors/factory.ts index 5ed7eb4ade4ca..d0ae7154fe5d9 100644 --- a/x-pack/plugins/cases/server/connectors/factory.ts +++ b/x-pack/plugins/cases/server/connectors/factory.ts @@ -6,16 +6,18 @@ */ import { ConnectorTypes } from '../../common'; +import { ICasesConnector, CasesConnectorsMap } from './types'; import { getCaseConnector as getJiraCaseConnector } from './jira'; import { getCaseConnector as getResilientCaseConnector } from './resilient'; import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow'; -import { ICasesConnector, CasesConnectorsMap } from './types'; +import { getCaseConnector as getSwimlaneCaseConnector } from './swimlane'; const mapping: Record = { [ConnectorTypes.jira]: getJiraCaseConnector(), [ConnectorTypes.serviceNowITSM]: getServiceNowITSMCaseConnector(), [ConnectorTypes.serviceNowSIR]: getServiceNowSIRCaseConnector(), [ConnectorTypes.resilient]: getResilientCaseConnector(), + [ConnectorTypes.swimlane]: getSwimlaneCaseConnector(), [ConnectorTypes.none]: null, }; diff --git a/x-pack/plugins/cases/server/connectors/swimlane/format.test.ts b/x-pack/plugins/cases/server/connectors/swimlane/format.test.ts new file mode 100644 index 0000000000000..55cbbdb68691e --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/swimlane/format.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseResponse } from '../../../common'; +import { format } from './format'; + +describe('Swimlane formatter', () => { + const theCase = { + id: 'case-id', + connector: { fields: null }, + } as CaseResponse; + + it('it formats correctly', async () => { + const res = await format(theCase, []); + expect(res).toEqual({ caseId: theCase.id }); + }); +}); diff --git a/x-pack/plugins/cases/server/connectors/swimlane/format.ts b/x-pack/plugins/cases/server/connectors/swimlane/format.ts new file mode 100644 index 0000000000000..9531e4099a4f4 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/swimlane/format.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConnectorSwimlaneTypeFields } from '../../../common'; +import { Format } from './types'; + +export const format: Format = (theCase) => { + const { caseId = theCase.id } = + (theCase.connector.fields as ConnectorSwimlaneTypeFields['fields']) ?? {}; + return { caseId }; +}; diff --git a/x-pack/plugins/cases/server/connectors/swimlane/index.ts b/x-pack/plugins/cases/server/connectors/swimlane/index.ts new file mode 100644 index 0000000000000..2cad92391bdec --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/swimlane/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getMapping } from './mapping'; +import { format } from './format'; +import { SwimlaneCaseConnector } from './types'; + +export const getCaseConnector = (): SwimlaneCaseConnector => ({ + getMapping, + format, +}); diff --git a/x-pack/plugins/cases/server/connectors/swimlane/mapping.ts b/x-pack/plugins/cases/server/connectors/swimlane/mapping.ts new file mode 100644 index 0000000000000..e1e34054463e5 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/swimlane/mapping.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GetMapping } from './types'; + +export const getMapping: GetMapping = () => { + return [ + { + source: 'title', + target: 'caseName', + action_type: 'overwrite', + }, + { + source: 'description', + target: 'description', + action_type: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + action_type: 'append', + }, + ]; +}; diff --git a/x-pack/plugins/cases/server/connectors/swimlane/types.ts b/x-pack/plugins/cases/server/connectors/swimlane/types.ts new file mode 100644 index 0000000000000..22a1e9f6372d5 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/swimlane/types.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SwimlaneFieldsType } from '../../../common/api'; +import { ICasesConnector } from '../types'; + +export type SwimlaneCaseConnector = ICasesConnector; +export type Format = ICasesConnector['format']; +export type GetMapping = ICasesConnector['getMapping']; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index d112630facbc6..d59d7e7b7da4f 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -241,6 +241,7 @@ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ '.email', '.slack', '.pagerduty', + '.swimlane', '.webhook', '.servicenow', '.jira', diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 9230b4d829853..39852ebaeb46b 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -31,6 +31,9 @@ "__index": { "type": "long" }, + "__swimlane": { + "type": "long" + }, "__pagerduty": { "type": "long" }, @@ -68,6 +71,9 @@ "__index": { "type": "long" }, + "__swimlane": { + "type": "long" + }, "__pagerduty": { "type": "long" }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts index 2eda435d045a4..4266822bda1fc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts @@ -10,6 +10,7 @@ import { getSlackActionType } from './slack'; import { getEmailActionType } from './email'; import { getIndexActionType } from './es_index'; import { getPagerDutyActionType } from './pagerduty'; +import { getSwimlaneActionType } from './swimlane'; import { getWebhookActionType } from './webhook'; import { TypeRegistry } from '../../type_registry'; import { ActionTypeModel } from '../../../types'; @@ -28,6 +29,7 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getEmailActionType()); actionTypeRegistry.register(getIndexActionType()); actionTypeRegistry.register(getPagerDutyActionType()); + actionTypeRegistry.register(getSwimlaneActionType()); actionTypeRegistry.register(getWebhookActionType()); actionTypeRegistry.register(getServiceNowITSMActionType()); actionTypeRegistry.register(getServiceNowSIRActionType()); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx index b89f71b0fc354..be5250ccf8b29 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx @@ -12,7 +12,7 @@ import { JiraActionConnector } from './types'; jest.mock('../../../../common/lib/kibana'); describe('JiraActionConnectorFields renders', () => { - test('alerting Jira connector fields is rendered', () => { + test('alerting Jira connector fields are rendered', () => { const actionConnector = { secrets: { email: 'email', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index 5897de46f94df..99d7e9510454f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -63,6 +63,7 @@ const JiraParamsFields: React.FunctionComponent { if (key === 'issueType') { @@ -75,9 +76,11 @@ const JiraParamsFields: React.FunctionComponent { if (incident.issueType != null && fields != null) { const priorities = fields.priority != null ? fields.priority.allowedValues : []; @@ -141,6 +145,7 @@ const JiraParamsFields: React.FunctionComponent { if (!hasPriority && incident.priority != null) { editSubActionProperty('priority', null); @@ -167,6 +172,7 @@ const JiraParamsFields: React.FunctionComponent { if (!actionParams.subAction) { editAction('subAction', 'pushToService', index); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx index b7b68b9485d8a..bbd237a7cec89 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx @@ -12,7 +12,7 @@ import { ResilientActionConnector } from './types'; jest.mock('../../../../common/lib/kibana'); describe('ResilientActionConnectorFields renders', () => { - test('alerting Resilient connector fields is rendered', () => { + test('alerting Resilient connector fields are rendered', () => { const actionConnector = { secrets: { apiKeyId: 'key', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx index 54a138a2bc7cf..b0f5198b6b5fd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx @@ -147,6 +147,7 @@ const ResilientParamsFields: React.FunctionComponent { if (!actionParams.subAction) { editAction('subAction', 'pushToService', index); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx index 330844b93b6b5..4993c51f350ad 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx @@ -12,7 +12,7 @@ import { ServiceNowActionConnector } from './types'; jest.mock('../../../../common/lib/kibana'); describe('ServiceNowActionConnectorFields renders', () => { - test('alerting servicenow connector fields is rendered', () => { + test('alerting servicenow connector fields are rendered', () => { const actionConnector = { secrets: { username: 'user', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts new file mode 100644 index 0000000000000..90bab65b83bfd --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getApplication } from './api'; + +const getApplicationResponse = { + fields: [], +}; + +describe('Swimlane API', () => { + let fetchMock: jest.SpyInstance>; + + beforeAll(() => jest.spyOn(window, 'fetch')); + beforeEach(() => { + jest.resetAllMocks(); + fetchMock = jest.spyOn(window, 'fetch'); + }); + + describe('getApplication', () => { + it('should call getApplication API correctly', async () => { + const abortCtrl = new AbortController(); + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => getApplicationResponse, + }); + const res = await getApplication({ + signal: abortCtrl.signal, + apiToken: '', + appId: '', + url: '', + }); + + expect(res).toEqual(getApplicationResponse); + }); + + it('returns an error when the response fails', async () => { + const abortCtrl = new AbortController(); + + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => getApplicationResponse, + }); + + try { + await getApplication({ + signal: abortCtrl.signal, + apiToken: '', + appId: '', + url: '', + }); + } catch (e) { + expect(e.message).toContain('Received status:'); + } + }); + + it('returns an error when parsing the json fails', async () => { + const abortCtrl = new AbortController(); + + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new Error('bad'); + }, + }); + + try { + await getApplication({ + signal: abortCtrl.signal, + apiToken: '', + appId: '', + url: '', + }); + } catch (e) { + expect(e.message).toContain('bad'); + } + }); + + it('it removes unsafe fields', async () => { + const abortCtrl = new AbortController(); + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + fields: [ + { + id: '__proto__', + name: 'Alert Id', + key: 'alert-id', + fieldType: 'text', + }, + { + id: 'a6ide', + name: '__proto__', + key: 'alert-id', + fieldType: 'text', + }, + { + id: 'a6ide', + name: 'Alert Id', + key: '__proto__', + fieldType: 'text', + }, + { + id: 'a6ide', + name: 'Alert Id', + key: 'alert-id', + fieldType: '__proto__', + }, + { + id: 'safe-id', + name: 'Safe', + key: 'safe-key', + fieldType: 'safe-text', + }, + ], + }), + }); + + const res = await getApplication({ + signal: abortCtrl.signal, + apiToken: '', + appId: '', + url: '', + }); + + expect(res).toEqual({ + fields: [ + { + id: 'safe-id', + name: 'Safe', + key: 'safe-key', + fieldType: 'safe-text', + }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.ts new file mode 100644 index 0000000000000..c6f9d4bee3e13 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SwimlaneFieldMappingConfig } from './types'; + +const removeUnsafeFields = (fields: SwimlaneFieldMappingConfig[]): SwimlaneFieldMappingConfig[] => + fields.filter( + (filter) => + filter.id !== '__proto__' && + filter.key !== '__proto__' && + filter.name !== '__proto__' && + filter.fieldType !== '__proto__' + ); +export async function getApplication({ + signal, + url, + appId, + apiToken, +}: { + signal: AbortSignal; + url: string; + appId: string; + apiToken: string; +}): Promise> { + const headers: Record = { + 'Content-Type': 'application/json', + 'Private-Token': `${apiToken}`, + }; + + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const apiUrl = urlWithoutTrailingSlash.endsWith('api') + ? urlWithoutTrailingSlash + : urlWithoutTrailingSlash + '/api'; + const applicationUrl = `${apiUrl}/app/{appId}`; + + const getApplicationUrl = (id: string) => applicationUrl.replace('{appId}', id); + + try { + const response = await fetch(getApplicationUrl(appId), { + method: 'GET', + headers, + signal, + }); + + /** + * Fetch do not throw when there is an HTTP error (status >= 400). + * We need to do it manually. + */ + + if (!response.ok) { + throw new Error( + `Received status: ${response.status} when attempting to get application with id: ${appId}` + ); + } + + const data = await response.json(); + return { ...data, fields: removeUnsafeFields(data?.fields ?? []) }; + } catch (error) { + throw new Error(`Unable to get application with id ${appId}. Error: ${error.message}`); + } +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/helpers.ts new file mode 100644 index 0000000000000..413b952675b8c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/helpers.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SwimlaneConnectorType, SwimlaneMappingConfig, MappingConfigurationKeys } from './types'; +import * as i18n from './translations'; + +const casesRequiredFields: MappingConfigurationKeys[] = [ + 'caseNameConfig', + 'descriptionConfig', + 'commentsConfig', + 'caseIdConfig', +]; +const casesFields = [...casesRequiredFields]; +const alertsRequiredFields: MappingConfigurationKeys[] = ['ruleNameConfig', 'alertIdConfig']; +const alertsFields = ['severityConfig', 'commentsConfig', ...alertsRequiredFields]; + +const translationMapping: Record = { + caseIdConfig: i18n.SW_REQUIRED_CASE_ID, + alertIdConfig: i18n.SW_REQUIRED_ALERT_ID, + caseNameConfig: i18n.SW_REQUIRED_CASE_NAME, + descriptionConfig: i18n.SW_REQUIRED_DESCRIPTION, + commentsConfig: i18n.SW_REQUIRED_COMMENTS, + ruleNameConfig: i18n.SW_REQUIRED_RULE_NAME, + severityConfig: i18n.SW_REQUIRED_SEVERITY, +}; + +export const isValidFieldForConnector = ( + connector: SwimlaneConnectorType, + field: MappingConfigurationKeys +): boolean => { + if (connector === SwimlaneConnectorType.All) { + return true; + } + + return connector === SwimlaneConnectorType.Alerts + ? alertsFields.includes(field) + : casesFields.includes(field); +}; + +export const validateMappingForConnector = ( + connectorType: SwimlaneConnectorType, + mapping: SwimlaneMappingConfig +): Record => { + if (connectorType === SwimlaneConnectorType.All || connectorType == null) { + return {}; + } + + const requiredFields = + connectorType === SwimlaneConnectorType.Alerts ? alertsRequiredFields : casesRequiredFields; + + return requiredFields.reduce((errors, field) => { + if (mapping?.[field] == null) { + errors = { ...errors, [field]: translationMapping[field] }; + } + + return errors; + }, {} as Record); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/index.ts new file mode 100644 index 0000000000000..39a57e1bccb61 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getActionType as getSwimlaneActionType } from './swimlane'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/logo.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/logo.tsx new file mode 100644 index 0000000000000..d22ff809fe74d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/logo.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +const Logo = () => { + return ( + + + + + + + + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { Logo as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/mocks.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/mocks.ts new file mode 100644 index 0000000000000..1574dfe2f5384 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/mocks.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const applicationFields = [ + { + id: 'a6ide', + name: 'Alert Id', + key: 'alert-id', + fieldType: 'text', + }, + { + id: 'adnlas', + name: 'Severity', + key: 'severity', + fieldType: 'text', + }, + { + id: 'adnfls', + name: 'Rule Name', + key: 'rule-name', + fieldType: 'text', + }, + { + id: 'a6sst', + name: 'Case Id', + key: 'case-id-name', + fieldType: 'text', + }, + { + id: 'a6fst', + name: 'Case Name', + key: 'case-name', + fieldType: 'text', + }, + { + id: 'a6fdf', + name: 'Comments', + key: 'notes', + fieldType: 'comments', + }, + { + id: 'a6fde', + name: 'Description', + key: 'description', + fieldType: 'text', + }, +]; + +export const mappings = { + alertIdConfig: applicationFields[0], + severityConfig: applicationFields[1], + ruleNameConfig: applicationFields[2], + caseIdConfig: applicationFields[3], + caseNameConfig: applicationFields[4], + commentsConfig: applicationFields[5], + descriptionConfig: applicationFields[6], +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/index.ts new file mode 100644 index 0000000000000..ca7c39bf1378c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SwimlaneConnection } from './swimlane_connection'; +export { SwimlaneFields } from './swimlane_fields'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_connection.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_connection.tsx new file mode 100644 index 0000000000000..cd29037e3535f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_connection.tsx @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + EuiButton, + EuiCallOut, + EuiFieldText, + EuiFormRow, + EuiLink, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { FormattedMessage } from 'react-intl'; +import * as i18n from '../translations'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { useGetApplication } from '../use_get_application'; +import { SwimlaneActionConnector, SwimlaneFieldMappingConfig } from '../types'; +import { IErrorObject } from '../../../../../types'; + +interface Props { + action: SwimlaneActionConnector; + editActionConfig: (property: string, value: any) => void; + editActionSecrets: (property: string, value: any) => void; + errors: IErrorObject; + readOnly: boolean; + updateCurrentStep: (step: number) => void; + updateFields: (items: SwimlaneFieldMappingConfig[]) => void; +} + +const SwimlaneConnectionComponent: React.FunctionComponent = ({ + action, + editActionConfig, + editActionSecrets, + errors, + readOnly, + updateCurrentStep, + updateFields, +}) => { + const { + notifications: { toasts }, + } = useKibana().services; + const { apiUrl, appId } = action.config; + const { apiToken } = action.secrets; + const { docLinks } = useKibana().services; + const { getApplication } = useGetApplication({ + toastNotifications: toasts, + apiToken, + appId, + apiUrl, + }); + const isValid = apiUrl && apiToken && appId; + + const connectSwimlane = useCallback(async () => { + // fetch swimlane application configuration + const application = await getApplication(); + + if (application?.fields) { + const allFields = application.fields; + updateFields(allFields); + updateCurrentStep(2); + } + }, [getApplication, updateCurrentStep, updateFields]); + + const onChangeConfig = useCallback( + (e: React.ChangeEvent, key: 'apiUrl' | 'appId') => { + editActionConfig(key, e.target.value); + }, + [editActionConfig] + ); + + const onBlurConfig = useCallback( + (key: 'apiUrl' | 'appId') => { + if (!action.config[key]) { + editActionConfig(key, ''); + } + }, + [action.config, editActionConfig] + ); + + const onChangeSecrets = useCallback( + (e: React.ChangeEvent) => { + editActionSecrets('apiToken', e.target.value); + }, + [editActionSecrets] + ); + + const onBlurSecrets = useCallback(() => { + if (!apiToken) { + editActionSecrets('apiToken', ''); + } + }, [apiToken, editActionSecrets]); + + const isApiUrlInvalid = errors.apiUrl?.length > 0 && apiToken !== undefined; + const isAppIdInvalid = errors.appId?.length > 0 && apiToken !== undefined; + const isApiTokenInvalid = errors.apiToken?.length > 0 && apiToken !== undefined; + + return ( + <> + + onChangeConfig(e, 'apiUrl')} + onBlur={() => onBlurConfig('apiUrl')} + /> + + + onChangeConfig(e, 'appId')} + onBlur={() => onBlurConfig('appId')} + /> + + + + + } + error={errors.apiToken} + isInvalid={isApiTokenInvalid} + label={i18n.SW_API_TOKEN_TEXT_FIELD_LABEL} + > + <> + {!action.id ? ( + <> + + + {i18n.SW_REMEMBER_VALUE_LABEL} + + + + ) : ( + <> + + + + + )} + + + + + + {i18n.SW_RETRIEVE_CONFIGURATION_LABEL} + + + ); +}; + +export const SwimlaneConnection = React.memo(SwimlaneConnectionComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_fields.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_fields.tsx new file mode 100644 index 0000000000000..87d0964322e14 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_fields.tsx @@ -0,0 +1,313 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useCallback, useEffect, useRef } from 'react'; +import { + EuiButton, + EuiFormRow, + EuiComboBox, + EuiComboBoxOptionOption, + EuiButtonGroup, +} from '@elastic/eui'; +import * as i18n from '../translations'; +import { + SwimlaneActionConnector, + SwimlaneConnectorType, + SwimlaneFieldMappingConfig, + SwimlaneMappingConfig, +} from '../types'; +import { IErrorObject } from '../../../../../types'; +import { isValidFieldForConnector } from '../helpers'; + +const SINGLE_SELECTION = { asPlainText: true }; +const EMPTY_COMBO_BOX_ARRAY: Array> | undefined = []; + +const formatOption = (field: SwimlaneFieldMappingConfig) => ({ + label: `${field.name} (${field.key})`, + value: field.id, +}); + +const createSelectedOption = (field: SwimlaneFieldMappingConfig | null | undefined) => + field != null ? [formatOption(field)] : EMPTY_COMBO_BOX_ARRAY; + +interface Props { + action: SwimlaneActionConnector; + editActionConfig: (property: string, value: any) => void; + updateCurrentStep: (step: number) => void; + fields: SwimlaneFieldMappingConfig[]; + errors: IErrorObject; +} + +const connectorTypeButtons = [ + { id: 'all', label: 'All' }, + { id: 'alerts', label: 'Alerts' }, + { id: 'cases', label: 'Cases' }, +]; + +const SwimlaneFieldsComponent: React.FC = ({ + action, + editActionConfig, + updateCurrentStep, + fields, + errors, +}) => { + const { mappings, connectorType = SwimlaneConnectorType.All } = action.config; + const prevConnectorType = useRef(connectorType); + const hasChangedConnectorType = connectorType !== prevConnectorType.current; + + const [fieldTypeMap, fieldIdMap] = useMemo( + () => + fields.reduce( + ([typeMap, idMap], field) => { + if (field != null) { + typeMap.set(field.fieldType, [ + ...(typeMap.get(field.fieldType) ?? []), + formatOption(field), + ]); + idMap.set(field.id, field); + } + + return [typeMap, idMap]; + }, + [ + new Map>>(), + new Map(), + ] + ), + [fields] + ); + + const textOptions = useMemo(() => fieldTypeMap.get('text') ?? [], [fieldTypeMap]); + const commentsOptions = useMemo(() => fieldTypeMap.get('comments') ?? [], [fieldTypeMap]); + + const state = useMemo( + () => ({ + alertIdConfig: createSelectedOption(mappings?.alertIdConfig), + severityConfig: createSelectedOption(mappings?.severityConfig), + ruleNameConfig: createSelectedOption(mappings?.ruleNameConfig), + caseIdConfig: createSelectedOption(mappings?.caseIdConfig), + caseNameConfig: createSelectedOption(mappings?.caseNameConfig), + commentsConfig: createSelectedOption(mappings?.commentsConfig), + descriptionConfig: createSelectedOption(mappings?.descriptionConfig), + }), + [mappings] + ); + + const mappingErrors: Record = useMemo( + () => (Array.isArray(errors?.mappings) ? errors?.mappings[0] : {}), + [errors] + ); + + const resetConnection = useCallback(() => { + updateCurrentStep(1); + }, [updateCurrentStep]); + + const editMappings = useCallback( + (key: keyof SwimlaneMappingConfig, e: Array>) => { + if (e.length === 0) { + const newProps = { + ...mappings, + [key]: null, + }; + editActionConfig('mappings', newProps); + return; + } + + const option = e[0]; + const item = fieldIdMap.get(option.value ?? ''); + if (!item) { + return; + } + + const newProps = { + ...mappings, + [key]: { id: item.id, name: item.name, key: item.key, fieldType: item.fieldType }, + }; + editActionConfig('mappings', newProps); + }, + [editActionConfig, fieldIdMap, mappings] + ); + + /** + * Connector type needs to be updated on mount to All. + * Otherwise it is undefined and this will cause an error + * if the user saves the connector without any mapping + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => editActionConfig('connectorType', connectorType), []); + + useEffect(() => { + if (connectorType !== prevConnectorType.current) { + prevConnectorType.current = connectorType; + } + }, [connectorType]); + + return ( + <> + + editActionConfig('connectorType', type)} + buttonSize="compressed" + /> + + {isValidFieldForConnector(connectorType as SwimlaneConnectorType.All, 'alertIdConfig') && ( + <> + + editMappings('alertIdConfig', e)} + isInvalid={mappingErrors?.alertIdConfig != null && !hasChangedConnectorType} + /> + + + )} + {isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'ruleNameConfig') && ( + <> + + editMappings('ruleNameConfig', e)} + isInvalid={mappingErrors?.ruleNameConfig != null && !hasChangedConnectorType} + /> + + + )} + {isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'severityConfig') && ( + <> + + editMappings('severityConfig', e)} + isInvalid={mappingErrors?.severityConfig != null && !hasChangedConnectorType} + /> + + + )} + {isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'caseIdConfig') && ( + <> + + editMappings('caseIdConfig', e)} + isInvalid={mappingErrors?.caseIdConfig != null && !hasChangedConnectorType} + /> + + + )} + {isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'caseNameConfig') && ( + <> + + editMappings('caseNameConfig', e)} + isInvalid={mappingErrors?.caseNameConfig != null && !hasChangedConnectorType} + /> + + + )} + {isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'commentsConfig') && ( + <> + + editMappings('commentsConfig', e)} + isInvalid={mappingErrors?.commentsConfig != null && !hasChangedConnectorType} + /> + + + )} + {isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'descriptionConfig') && ( + <> + + editMappings('descriptionConfig', e)} + isInvalid={mappingErrors?.descriptionConfig != null && !hasChangedConnectorType} + /> + + + )} + {i18n.SW_CONFIGURE_API_LABEL} + + ); +}; + +export const SwimlaneFields = React.memo(SwimlaneFieldsComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.test.tsx new file mode 100644 index 0000000000000..07d78a8885c51 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.test.tsx @@ -0,0 +1,219 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '.././index'; +import { ActionTypeModel } from '../../../../types'; +import { SwimlaneActionConnector } from './types'; + +const ACTION_TYPE_ID = '.swimlane'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + }); +}); + +describe('swimlane connector validation', () => { + test('connector validation succeeds when connector is valid', async () => { + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'all', + mappings: { + alertIdConfig: { id: '1234' }, + severityConfig: { id: '1234' }, + ruleNameConfig: { id: '1234' }, + caseIdConfig: { id: '1234' }, + caseNameConfig: { id: '1234' }, + descriptionConfig: { id: '1234' }, + commentsConfig: { id: '1234' }, + }, + }, + } as SwimlaneActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { errors: { apiUrl: [], appId: [], mappings: [], connectorType: [] } }, + secrets: { errors: { apiToken: [] } }, + }); + }); + + test('it validates correctly when connectorType=all', async () => { + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'all', + mappings: {}, + }, + } as SwimlaneActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { errors: { apiUrl: [], appId: [], mappings: [], connectorType: [] } }, + secrets: { errors: { apiToken: [] } }, + }); + }); + + test('it validates correctly when connectorType=cases', async () => { + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'cases', + mappings: {}, + }, + } as SwimlaneActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { + errors: { + apiUrl: [], + appId: [], + mappings: [ + { + caseIdConfig: 'Case ID is required.', + caseNameConfig: 'Case name is required.', + commentsConfig: 'Comments are required.', + descriptionConfig: 'Description is required.', + }, + ], + connectorType: [], + }, + }, + secrets: { errors: { apiToken: [] } }, + }); + }); + + test('it validates correctly when connectorType=alerts', async () => { + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'alerts', + mappings: {}, + }, + } as SwimlaneActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { + errors: { + apiUrl: [], + appId: [], + mappings: [ + { + alertIdConfig: 'Alert ID is required.', + ruleNameConfig: 'Rule name is required.', + }, + ], + connectorType: [], + }, + }, + secrets: { errors: { apiToken: [] } }, + }); + }); + + test('it validates correctly required config/secrets fields', async () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: {}, + } as SwimlaneActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { + errors: { + apiUrl: ['URL is required.'], + appId: ['An App ID is required.'], + mappings: [], + connectorType: [], + }, + }, + secrets: { errors: { apiToken: ['An API token is required.'] } }, + }); + }); +}); + +describe('swimlane action params validation', () => { + test('action params validation succeeds when action params is valid', async () => { + const actionParams = { + subActionParams: { + ruleName: 'Rule Name', + alertId: 'alert-id', + }, + }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + 'subActionParams.incident.ruleName': [], + 'subActionParams.incident.alertId': [], + }, + }); + }); + + test('it validates correctly required fields', async () => { + const actionParams = { + subActionParams: { incident: {} }, + }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + 'subActionParams.incident.ruleName': ['Rule name is required.'], + 'subActionParams.incident.alertId': ['Alert ID is required.'], + }, + }); + }); + + test('it succeeds when missing incident', async () => { + const actionParams = { + subActionParams: {}, + }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + 'subActionParams.incident.ruleName': [], + 'subActionParams.incident.alertId': [], + }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.tsx new file mode 100644 index 0000000000000..5e06e3935eebd --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash'; +import { lazy } from 'react'; +import { + ActionTypeModel, + ConnectorValidationResult, + GenericValidationResult, +} from '../../../../types'; +import { + SwimlaneActionConnector, + SwimlaneConfig, + SwimlaneSecrets, + SwimlaneActionParams, +} from './types'; +import * as i18n from './translations'; +import { isValidUrl } from '../../../lib/value_validators'; +import { validateMappingForConnector } from './helpers'; + +export function getActionType(): ActionTypeModel< + SwimlaneConfig, + SwimlaneSecrets, + SwimlaneActionParams +> { + return { + id: '.swimlane', + iconClass: lazy(() => import('./logo')), + selectMessage: i18n.SW_SELECT_MESSAGE_TEXT, + actionTypeTitle: i18n.SW_ACTION_TYPE_TITLE, + validateConnector: async ( + action: SwimlaneActionConnector + ): Promise> => { + const configErrors = { + apiUrl: new Array(), + appId: new Array(), + connectorType: new Array(), + mappings: new Array>(), + }; + const secretsErrors = { + apiToken: new Array(), + }; + + const validationResult = { + config: { errors: configErrors }, + secrets: { errors: secretsErrors }, + }; + + if (!action.config.apiUrl) { + configErrors.apiUrl = [...configErrors.apiUrl, i18n.SW_API_URL_REQUIRED]; + } else if (action.config.apiUrl) { + if (!isValidUrl(action.config.apiUrl)) { + configErrors.apiUrl = [...configErrors.apiUrl, i18n.SW_API_URL_INVALID]; + } + } + + if (!action.secrets.apiToken) { + secretsErrors.apiToken = [...secretsErrors.apiToken, i18n.SW_REQUIRED_API_TOKEN_TEXT]; + } + + if (!action.config.appId) { + configErrors.appId = [...configErrors.appId, i18n.SW_REQUIRED_APP_ID_TEXT]; + } + + const mappingErrors = validateMappingForConnector( + action.config.connectorType, + action.config.mappings + ); + + if (!isEmpty(mappingErrors)) { + configErrors.mappings = [...configErrors.mappings, mappingErrors]; + } + + return validationResult; + }, + validateParams: async ( + actionParams: SwimlaneActionParams + ): Promise> => { + const errors = { + 'subActionParams.incident.ruleName': new Array(), + 'subActionParams.incident.alertId': new Array(), + }; + const validationResult = { + errors, + }; + + const hasIncident = actionParams.subActionParams && actionParams.subActionParams.incident; + + if (hasIncident && !actionParams.subActionParams.incident.ruleName?.length) { + errors['subActionParams.incident.ruleName'].push(i18n.SW_REQUIRED_RULE_NAME); + } + + if (hasIncident && !actionParams.subActionParams.incident.alertId?.length) { + errors['subActionParams.incident.alertId'].push(i18n.SW_REQUIRED_ALERT_ID); + } + + return validationResult; + }, + actionConnectorFields: lazy(() => import('./swimlane_connectors')), + actionParamsFields: lazy(() => import('./swimlane_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx new file mode 100644 index 0000000000000..6740179d786f2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx @@ -0,0 +1,319 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; +import { SwimlaneActionConnector } from './types'; +import SwimlaneActionConnectorFields from './swimlane_connectors'; +import { useGetApplication } from './use_get_application'; +import { applicationFields, mappings } from './mocks'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('./use_get_application'); + +const useGetApplicationMock = useGetApplication as jest.Mock; +const getApplication = jest.fn(); + +describe('SwimlaneActionConnectorFields renders', () => { + beforeAll(() => { + useGetApplicationMock.mockReturnValue({ + getApplication, + isLoading: false, + }); + }); + + test('all connector fields are rendered', async () => { + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'all', + mappings, + }, + } as SwimlaneActionConnector; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="swimlaneApiUrlInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneAppIdInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneApiTokenInput"]').exists()).toBeTruthy(); + }); + + test('should display a message on create to remember credentials', () => { + const actionConnector = { + actionTypeId: '.swimlane', + secrets: {}, + config: {}, + } as SwimlaneActionConnector; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toEqual(0); + }); + + test('should display a message on edit to re-enter credentials', () => { + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'all', + mappings, + }, + } as SwimlaneActionConnector; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); + }); + + test('renders the mappings correctly - connector type all', async () => { + getApplication.mockResolvedValue({ + fields: applicationFields, + }); + + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'all', + mappings, + }, + } as SwimlaneActionConnector; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + + await act(async () => { + wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneSeverityInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').exists()).toBeTruthy(); + }); + + test('renders the mappings correctly - connector type cases', async () => { + getApplication.mockResolvedValue({ + fields: applicationFields, + }); + + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'cases', + mappings, + }, + } as SwimlaneActionConnector; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + + await act(async () => { + wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="swimlaneSeverityInput"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').exists()).toBeTruthy(); + }); + + test('renders the mappings correctly - connector type alerts', async () => { + getApplication.mockResolvedValue({ + fields: applicationFields, + }); + + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'alerts', + mappings, + }, + } as SwimlaneActionConnector; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + + await act(async () => { + wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneSeverityInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').exists()).toBeFalsy(); + }); + + test('renders the correct options per field', async () => { + getApplication.mockResolvedValue({ + fields: applicationFields, + }); + + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'all', + mappings, + }, + } as SwimlaneActionConnector; + + const textOptions = [ + { label: 'Alert Id (alert-id)', value: 'a6ide' }, + { label: 'Severity (severity)', value: 'adnlas' }, + { label: 'Rule Name (rule-name)', value: 'adnfls' }, + { label: 'Case Id (case-id-name)', value: 'a6sst' }, + { label: 'Case Name (case-name)', value: 'a6fst' }, + { label: 'Description (description)', value: 'a6fde' }, + ]; + + const commentOptions = [{ label: 'Comments (notes)', value: 'a6fdf' }]; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + + await act(async () => { + wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').first().prop('options')).toEqual( + textOptions + ); + expect( + wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').first().prop('options') + ).toEqual(textOptions); + expect( + wrapper.find('[data-test-subj="swimlaneSeverityInput"]').first().prop('options') + ).toEqual(textOptions); + expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').first().prop('options')).toEqual( + textOptions + ); + expect( + wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').first().prop('options') + ).toEqual(textOptions); + expect( + wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').first().prop('options') + ).toEqual(commentOptions); + expect( + wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').first().prop('options') + ).toEqual(textOptions); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.tsx new file mode 100644 index 0000000000000..acf9f38e9ba48 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment, useCallback, useMemo, useState } from 'react'; +import { EuiForm, EuiSpacer, EuiStepsHorizontal, EuiStepStatus } from '@elastic/eui'; +import * as i18n from './translations'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { SwimlaneActionConnector, SwimlaneFieldMappingConfig } from './types'; +import { SwimlaneConnection, SwimlaneFields } from './steps'; + +const SwimlaneActionConnectorFields: React.FunctionComponent< + ActionConnectorFieldsProps +> = ({ errors, action, editActionConfig, editActionSecrets, readOnly }) => { + const [currentStep, setCurrentStep] = useState(1); + const [stepsStatuses, setStepsStatuses] = useState<{ + connection: EuiStepStatus; + fields: EuiStepStatus; + }>({ connection: 'incomplete', fields: 'incomplete' }); + const [fields, setFields] = useState([]); + + const updateCurrentStep = useCallback( + (step: number) => { + setCurrentStep(step); + if (step === 2) { + setStepsStatuses((statuses) => ({ ...statuses, connection: 'complete' })); + } else if (step === 1) { + setStepsStatuses({ + fields: 'incomplete', + connection: 'incomplete', + }); + editActionConfig('mappings', action.config.mappings); + } + }, + [action.config.mappings, editActionConfig] + ); + + const setupSteps = useMemo( + () => [ + { + title: i18n.SW_CONFIGURE_CONNECTION_LABEL, + status: stepsStatuses.connection, + onClick: () => updateCurrentStep(1), + }, + { + title: i18n.SW_MAPPING_TITLE_TEXT_FIELD_LABEL, + disabled: stepsStatuses.connection !== 'complete', + status: stepsStatuses.fields, + onClick: () => updateCurrentStep(2), + }, + ], + [stepsStatuses.connection, stepsStatuses.fields, updateCurrentStep] + ); + + const editActionConfigCb = useCallback( + (k: string, v: string) => { + editActionConfig(k, v); + if ( + Object.values(errors?.mappings ?? {}).every((mappingError) => mappingError.length === 0) + ) { + setStepsStatuses((statuses) => ({ ...statuses, fields: 'complete' })); + } else { + setStepsStatuses((statuses) => ({ ...statuses, fields: 'incomplete' })); + } + }, + [editActionConfig, errors?.mappings] + ); + + return ( + + + + + {currentStep === 1 && ( + + )} + {currentStep === 2 && ( + + )} + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SwimlaneActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.test.tsx new file mode 100644 index 0000000000000..32cf2c3c786d3 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.test.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import SwimlaneParamsFields from './swimlane_params'; +import { SwimlaneConnectorType } from './types'; +import { mappings } from './mocks'; + +describe('SwimlaneParamsFields renders', () => { + const editAction = jest.fn(); + const actionParams = { + subAction: 'pushToService', + subActionParams: { + incident: { + alertId: '3456789', + ruleName: 'rule name', + severity: 'critical', + caseId: null, + caseName: null, + description: null, + externalId: null, + }, + comments: [], + }, + }; + + const connector = { + secrets: {}, + config: { mappings, connectorType: SwimlaneConnectorType.All }, + id: 'test', + actionTypeId: '.test', + name: 'Test', + isPreconfigured: false, + }; + + const defaultProps = { + actionParams, + errors: { + 'subActionParams.incident.ruleName': [], + 'subActionParams.incident.alertId': [], + }, + editAction, + index: 0, + messageVariables: [], + actionConnector: connector, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('all params fields are rendered', () => { + const wrapper = mountWithIntl(); + + expect(wrapper.find('[data-test-subj="severity"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="comments"]').exists()).toBeTruthy(); + }); + + test('it set the correct default params', () => { + mountWithIntl(); + expect(editAction).toHaveBeenCalledWith('subAction', 'pushToService', 0); + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { + incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' }, + comments: [], + }, + 0 + ); + }); + + test('it reset the fields when connector changes', () => { + const wrapper = mountWithIntl(); + expect(editAction).not.toHaveBeenCalled(); + + wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { + incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' }, + comments: [], + }, + 0 + ); + }); + + test('it set the severity', () => { + const wrapper = mountWithIntl(); + expect(editAction).not.toHaveBeenCalled(); + + wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { + incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' }, + comments: [], + }, + 0 + ); + }); + + describe('UI updates', () => { + const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent; + const simpleFields = [ + { dataTestSubj: 'input[data-test-subj="severityInput"]', key: 'severity' }, + ]; + + simpleFields.forEach((field) => + test(`${field.key} update triggers editAction`, () => { + const wrapper = mountWithIntl(); + const theField = wrapper.find(field.dataTestSubj).first(); + theField.prop('onChange')!(changeEvent); + expect(editAction.mock.calls[0][1].incident[field.key]).toEqual(changeEvent.target.value); + }) + ); + + test('A comment triggers editAction', () => { + const wrapper = mountWithIntl(); + const comments = wrapper.find('textarea[data-test-subj="commentsTextArea"]'); + expect(comments.simulate('change', changeEvent)); + expect(editAction.mock.calls[0][1].comments.length).toEqual(1); + }); + + test('An empty comment does not trigger editAction', () => { + const wrapper = mountWithIntl(); + const emptyComment = { target: { value: '' } }; + const comments = wrapper.find('[data-test-subj="commentsTextArea"] textarea'); + expect(comments.simulate('change', emptyComment)); + expect(editAction.mock.calls.length).toEqual(0); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.tsx new file mode 100644 index 0000000000000..9bd14a06d657a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useRef, useMemo } from 'react'; +import { EuiCallOut, EuiFormRow, EuiSpacer } from '@elastic/eui'; +import * as i18n from './translations'; +import { ActionParamsProps } from '../../../../types'; +import { SwimlaneActionConnector, SwimlaneActionParams, SwimlaneConnectorType } from './types'; +import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; +import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; + +const SwimlaneParamsFields: React.FunctionComponent> = ({ + actionParams, + editAction, + index, + messageVariables, + actionConnector, +}) => { + const { incident, comments } = useMemo( + () => + actionParams.subActionParams ?? + (({ + incident: {}, + comments: [], + } as unknown) as SwimlaneActionParams['subActionParams']), + [actionParams.subActionParams] + ); + + const actionConnectorRef = useRef(actionConnector?.id ?? ''); + + const { + mappings, + connectorType, + } = ((actionConnector as unknown) as SwimlaneActionConnector).config; + const { hasAlertId, hasRuleName, hasComments, hasSeverity } = useMemo( + () => ({ + hasAlertId: mappings.alertIdConfig != null, + hasRuleName: mappings.ruleNameConfig != null, + hasComments: mappings.commentsConfig != null, + hasSeverity: mappings.severityConfig != null, + }), + [ + mappings.alertIdConfig, + mappings.ruleNameConfig, + mappings.commentsConfig, + mappings.severityConfig, + ] + ); + + /** + * The user can use either a connector of type alerts or all. + * If the connector is of type all we should check if all + * required field have been configured. + */ + const showMappingWarning = + connectorType === SwimlaneConnectorType.Cases || !hasRuleName || !hasAlertId; + + const editSubActionProperty = useCallback( + (key: string, value: any) => { + if (key === 'comments') { + return editAction('subActionParams', { incident, comments: value }, index); + } + + return editAction( + 'subActionParams', + { + incident: { ...incident, [key]: value }, + comments, + }, + index + ); + }, + [editAction, incident, comments, index] + ); + + const editComment = useCallback( + (key, value) => { + if (value.length > 0) { + editSubActionProperty(key, [{ commentId: '1', comment: value }]); + } + }, + [editSubActionProperty] + ); + + useEffect(() => { + if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) { + actionConnectorRef.current = actionConnector.id; + editAction( + 'subActionParams', + { + incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' }, + comments: [], + }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionConnector]); + + useEffect(() => { + if (!actionParams.subAction) { + editAction('subAction', 'pushToService', index); + } + + if (!actionParams.subActionParams) { + editAction( + 'subActionParams', + { + incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' }, + comments: [], + }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionParams]); + + return !showMappingWarning ? ( + <> + {hasSeverity && ( + <> + + + + + + )} + {hasComments && ( + 0 ? comments[0].comment : undefined} + label={i18n.SW_COMMENTS_FIELD_LABEL} + /> + )} + + ) : ( + + {i18n.EMPTY_MAPPING_WARNING_DESC} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SwimlaneParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/translations.ts new file mode 100644 index 0000000000000..726997cb4456a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/translations.ts @@ -0,0 +1,282 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SW_SELECT_MESSAGE_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.selectMessageText', + { + defaultMessage: 'Create record in Swimlane', + } +); + +export const SW_ACTION_TYPE_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.actionTypeTitle', + { + defaultMessage: 'Create Swimlane Record', + } +); + +export const SW_REQUIRED_RULE_NAME = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredRuleName', + { + defaultMessage: 'Rule name is required.', + } +); + +export const SW_REQUIRED_APP_ID_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredAppIdText', + { + defaultMessage: 'An App ID is required.', + } +); + +export const SW_REQUIRED_FIELD_MAPPINGS_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredFieldMappingsText', + { + defaultMessage: 'Field mappings are required.', + } +); + +export const SW_REQUIRED_API_TOKEN_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredApiTokenText', + { + defaultMessage: 'An API token is required.', + } +); + +export const SW_GET_APPLICATION_API_ERROR = (id: string | null) => + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlane.unableToGetApplicationMessage', + { + defaultMessage: 'Unable to get application with id {id}', + values: { id }, + } + ); + +export const SW_GET_APPLICATION_API_NO_FIELDS_ERROR = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlane.unableToGetApplicationFieldsMessage', + { + defaultMessage: 'Unable to get application fields', + } +); + +export const SW_API_URL_TEXT_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.apiUrlTextFieldLabel', + { + defaultMessage: 'API Url', + } +); + +export const SW_API_URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.requiredApiUrlTextField', + { + defaultMessage: 'URL is required.', + } +); + +export const SW_API_URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.invalidApiUrlTextField', + { + defaultMessage: 'URL is invalid.', + } +); + +export const SW_APP_ID_TEXT_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.appIdTextFieldLabel', + { + defaultMessage: 'Application ID', + } +); + +export const SW_API_TOKEN_TEXT_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.apiTokenTextFieldLabel', + { + defaultMessage: 'API Token', + } +); + +export const SW_MAPPING_TITLE_TEXT_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.mappingTitleTextFieldLabel', + { + defaultMessage: 'Configure Field Mappings', + } +); + +export const SW_ALERT_SOURCE_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.alertSourceFieldLabel', + { + defaultMessage: 'Alert source', + } +); + +export const SW_SEVERITY_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.severityFieldLabel', + { + defaultMessage: 'Severity', + } +); + +export const SW_MAPPING_DESCRIPTION_TEXT_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.mappingDescriptionTextFieldLabel', + { + defaultMessage: 'Used to specify the field names in the Swimlane Application', + } +); + +export const SW_RULE_NAME_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.ruleNameFieldLabel', + { + defaultMessage: 'Rule name', + } +); + +export const SW_ALERT_ID_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.alertIdFieldLabel', + { + defaultMessage: 'Alert ID', + } +); + +export const SW_CASE_ID_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.caseIdFieldLabel', + { + defaultMessage: 'Case ID', + } +); + +export const SW_CASE_NAME_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.caseNameFieldLabel', + { + defaultMessage: 'Case name', + } +); + +export const SW_COMMENTS_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.commentsFieldLabel', + { + defaultMessage: 'Comments', + } +); + +export const SW_DESCRIPTION_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.descriptionFieldLabel', + { + defaultMessage: 'Description', + } +); + +export const SW_REMEMBER_VALUE_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.rememberValueLabel', + { defaultMessage: 'Remember this value. You must reenter it each time you edit the connector.' } +); + +export const SW_REENTER_VALUE_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.reenterValueLabel', + { defaultMessage: 'This key is encrypted. Please reenter a value for this field.' } +); + +export const SW_CONFIGURE_CONNECTION_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.configureConnectionLabel', + { defaultMessage: 'Configure API Connection' } +); + +export const SW_RETRIEVE_CONFIGURATION_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.retrieveConfigurationLabel', + { defaultMessage: 'Configure Fields' } +); + +export const SW_CONFIGURE_API_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.configureAPILabel', + { defaultMessage: 'Configure API' } +); + +export const SW_CONNECTOR_TYPE_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.connectorType', + { + defaultMessage: 'Connector Type', + } +); + +export const SW_FIELD_MAPPING_IS_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.mappingFieldRequired', + { + defaultMessage: 'Field mapping is required.', + } +); + +export const EMPTY_MAPPING_WARNING_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.emptyMappingWarningTitle', + { + defaultMessage: 'This connector has missing field mappings', + } +); + +export const EMPTY_MAPPING_WARNING_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.emptyMappingWarningDesc', + { + defaultMessage: + 'This connector cannot be selected because it is missing the required case field mappings. You can edit this connector to add required field mappings or select a connector of type Alerts.', + } +); + +export const SW_REQUIRED_ALERT_SOURCE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredAlertSource', + { + defaultMessage: 'Alert source is required.', + } +); + +export const SW_REQUIRED_SEVERITY = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredSeverity', + { + defaultMessage: 'Severity is required.', + } +); + +export const SW_REQUIRED_CASE_NAME = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredCaseName', + { + defaultMessage: 'Case name is required.', + } +); + +export const SW_REQUIRED_CASE_ID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredCaseID', + { + defaultMessage: 'Case ID is required.', + } +); + +export const SW_REQUIRED_COMMENTS = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredComments', + { + defaultMessage: 'Comments are required.', + } +); + +export const SW_REQUIRED_DESCRIPTION = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredDescription', + { + defaultMessage: 'Description is required.', + } +); + +export const SW_REQUIRED_ALERT_ID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredAlertID', + { + defaultMessage: 'Alert ID is required.', + } +); + +export const SW_ALERT_SOURCE_TOOLTIP = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.alertSourceTooltip', + { + defaultMessage: 'The index of the alert. Use {index} in Detections.', + values: { index: '{{context.rule.output_index}}' }, + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/types.ts new file mode 100644 index 0000000000000..f0a54e8b6c3bf --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/types.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @kbn/eslint/no-restricted-paths */ + +import { UserConfiguredActionConnector } from '../../../../types'; +import { + ExecutorSubActionPushParams, + MappingConfigType, +} from '../../../../../../actions/server/builtin_action_types/swimlane/types'; + +export type SwimlaneActionConnector = UserConfiguredActionConnector< + SwimlaneConfig, + SwimlaneSecrets +>; + +export interface SwimlaneConfig { + apiUrl: string; + appId: string; + connectorType: SwimlaneConnectorType; + mappings: SwimlaneMappingConfig; +} + +export type MappingConfigurationKeys = keyof MappingConfigType; +export type SwimlaneMappingConfig = Record; + +export interface SwimlaneFieldMappingConfig { + id: string; + key: string; + name: string; + fieldType: string; +} + +export interface SwimlaneSecrets { + apiToken: string; +} + +export interface SwimlaneActionParams { + subAction: string; + subActionParams: ExecutorSubActionPushParams; +} + +export interface SwimlaneFieldMap { + key: string; + name: string; +} + +export enum SwimlaneConnectorType { + All = 'all', + Alerts = 'alerts', + Cases = 'cases', +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.test.tsx new file mode 100644 index 0000000000000..4744c4d22fdc9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.test.tsx @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { getApplication } from './api'; +import { SwimlaneActionConnector } from './types'; +import { useGetApplication, UseGetApplication } from './use_get_application'; + +jest.mock('./api'); +jest.mock('../../../../common/lib/kibana'); + +const useKibanaMock = useKibana as jest.Mocked; +const getApplicationMock = getApplication as jest.Mock; + +const action = { + secrets: { apiToken: 'token' }, + id: 'test', + actionTypeId: '.swimlane', + name: 'Swimlane', + isPreconfigured: false, + config: { + apiUrl: 'https://test.swimlane.com/', + appId: 'bcq16kdTbz5jlwM6h', + mappings: {}, + }, +} as SwimlaneActionConnector; + +describe('useGetApplication', () => { + const { services } = useKibanaMock(); + getApplicationMock.mockResolvedValue({ + data: { fields: [] }, + }); + const abortCtrl = new AbortController(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetApplication({ + appId: action.config.appId, + apiToken: action.secrets.apiToken, + apiUrl: action.config.apiUrl, + toastNotifications: services.notifications.toasts, + }) + ); + + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + getApplication: result.current.getApplication, + }); + }); + }); + + it('calls getApplication with correct arguments', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetApplication({ + appId: action.config.appId, + apiToken: action.secrets.apiToken, + apiUrl: action.config.apiUrl, + toastNotifications: services.notifications.toasts, + }) + ); + + await waitForNextUpdate(); + + result.current.getApplication(); + await waitForNextUpdate(); + expect(getApplicationMock).toBeCalledWith({ + signal: abortCtrl.signal, + appId: action.config.appId, + apiToken: action.secrets.apiToken, + url: action.config.apiUrl, + }); + }); + }); + + it('get application', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetApplication({ + appId: action.config.appId, + apiToken: action.secrets.apiToken, + apiUrl: action.config.apiUrl, + toastNotifications: services.notifications.toasts, + }) + ); + + await waitForNextUpdate(); + result.current.getApplication(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + isLoading: false, + getApplication: result.current.getApplication, + }); + }); + }); + + it('set isLoading to true when getting the application', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetApplication({ + appId: action.config.appId, + apiToken: action.secrets.apiToken, + apiUrl: action.config.apiUrl, + toastNotifications: services.notifications.toasts, + }) + ); + + await waitForNextUpdate(); + result.current.getApplication(); + + expect(result.current.isLoading).toBe(true); + }); + }); + + it('it displays an error when http throws an error', async () => { + getApplicationMock.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetApplication({ + appId: action.config.appId, + apiToken: action.secrets.apiToken, + apiUrl: action.config.apiUrl, + toastNotifications: services.notifications.toasts, + }) + ); + await waitForNextUpdate(); + result.current.getApplication(); + + expect(result.current).toEqual({ + isLoading: false, + getApplication: result.current.getApplication, + }); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Unable to get application with id bcq16kdTbz5jlwM6h', + text: 'Something went wrong', + }); + }); + }); + + it('it displays an error when the response does not contain the correct fields', async () => { + getApplicationMock.mockResolvedValue({}); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetApplication({ + appId: action.config.appId, + apiToken: action.secrets.apiToken, + apiUrl: action.config.apiUrl, + toastNotifications: services.notifications.toasts, + }) + ); + await waitForNextUpdate(); + result.current.getApplication(); + await waitForNextUpdate(); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Unable to get application with id bcq16kdTbz5jlwM6h', + text: 'Unable to get application fields', + }); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.tsx new file mode 100644 index 0000000000000..f18770067b8a8 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useCallback, useRef } from 'react'; +import { ToastsApi } from 'kibana/public'; +import { getApplication as getApplicationApi } from './api'; +import * as i18n from './translations'; +import { SwimlaneFieldMappingConfig } from './types'; + +interface Props { + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + appId: string; + apiToken: string; + apiUrl: string; +} + +export interface UseGetApplication { + getApplication: () => Promise<{ fields?: SwimlaneFieldMappingConfig[] } | undefined>; + isLoading: boolean; +} + +export const useGetApplication = ({ + toastNotifications, + appId, + apiToken, + apiUrl, +}: Props): UseGetApplication => { + const [isLoading, setIsLoading] = useState(false); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + + const getApplication = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + setIsLoading(true); + + const data = await getApplicationApi({ + signal: abortCtrlRef.current.signal, + appId, + apiToken, + url: apiUrl, + }); + + if (!isCancelledRef.current) { + setIsLoading(false); + if (!data.fields) { + // If the response was malformed and fields doesn't exist, show an error toast + toastNotifications.addDanger({ + title: i18n.SW_GET_APPLICATION_API_ERROR(appId), + text: i18n.SW_GET_APPLICATION_API_NO_FIELDS_ERROR, + }); + return; + } + return data; + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.SW_GET_APPLICATION_API_ERROR(appId), + text: error.message, + }); + } + setIsLoading(false); + } + } + }, [apiToken, apiUrl, appId, toastNotifications]); + + return { + isLoading, + getApplication, + }; +}; diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/swimlane.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/swimlane.ts new file mode 100644 index 0000000000000..95e041bbeb03a --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/swimlane.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default function swimlaneTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + const mockSwimlane = { + name: 'A swimlane action', + actionTypeId: '.swimlane', + config: { + apiUrl: 'http://swimlane.mynonexistent.co', + appId: '123456asdf', + connectorType: 'all', + mappings: { + severityConfig: { + id: 'adnlas', + name: 'Severity', + key: 'severity', + fieldType: 'text', + }, + ruleNameConfig: { + id: 'adnfls', + name: 'Rule Name', + key: 'rule-name', + fieldType: 'text', + }, + caseIdConfig: { + id: 'a6sst', + name: 'Case Id', + key: 'case-id-name', + fieldType: 'text', + }, + caseNameConfig: { + id: 'a6fst', + name: 'Case Name', + key: 'case-name', + fieldType: 'text', + }, + commentsConfig: { + id: 'a6fdf', + name: 'Comments', + key: 'comments', + fieldType: 'text', + }, + }, + }, + secrets: { + apiToken: 'swimlane-api-key', + }, + }; + + describe('swimlane', () => { + let swimlaneSimulatorURL: string = ''; + + // need to wait for kibanaServer to settle ... + before(() => { + swimlaneSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SWIMLANE) + ); + }); + it('should return 403 when creating a swimlane action', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + ...mockSwimlane, + config: { + ...mockSwimlane.config, + apiUrl: swimlaneSimulatorURL, + }, + }) + .expect(403, { + statusCode: 403, + error: 'Forbidden', + message: + 'Action type .swimlane is disabled because your basic license does not support it. Please upgrade your license.', + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts index 3f0524750d5f8..21cb0db3057bb 100644 --- a/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts @@ -14,6 +14,7 @@ export default function actionsTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./builtin_action_types/es_index')); loadTestFile(require.resolve('./builtin_action_types/jira')); loadTestFile(require.resolve('./builtin_action_types/pagerduty')); + loadTestFile(require.resolve('./builtin_action_types/swimlane')); loadTestFile(require.resolve('./builtin_action_types/server_log')); loadTestFile(require.resolve('./builtin_action_types/servicenow')); loadTestFile(require.resolve('./builtin_action_types/slack')); diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 61b452fc11835..3dcbde5f21149 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -31,6 +31,7 @@ const enabledActionTypes = [ '.email', '.index', '.pagerduty', + '.swimlane', '.server-log', '.servicenow', '.jira', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index 878507bcf4afc..a479070c824f2 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -13,6 +13,7 @@ import { PluginSetupContract as FeaturesPluginSetup } from '../../../../../../.. import { PluginSetupContract as ActionsPluginSetupContract } from '../../../../../../../plugins/actions/server/plugin'; import { ActionType } from '../../../../../../../plugins/actions/server'; import { initPlugin as initPagerduty } from './pagerduty_simulation'; +import { initPlugin as initSwimlane } from './swimlane_simulation'; import { initPlugin as initServiceNow } from './servicenow_simulation'; import { initPlugin as initJira } from './jira_simulation'; import { initPlugin as initResilient } from './resilient_simulation'; @@ -23,6 +24,7 @@ export const NAME = 'actions-FTS-external-service-simulators'; export enum ExternalServiceSimulator { PAGERDUTY = 'pagerduty', + SWIMLANE = 'swimlane', SERVICENOW = 'servicenow', SLACK = 'slack', JIRA = 'jira', @@ -66,6 +68,10 @@ export async function getSlackServer(): Promise { return await initSlack(); } +export async function getSwimlaneServer(): Promise { + return await initSwimlane(); +} + interface FixtureSetupDeps { actions: ActionsPluginSetupContract; features: FeaturesPluginSetup; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts new file mode 100644 index 0000000000000..afba550908ddc --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import http from 'http'; + +export const initPlugin = async () => http.createServer(handler); + +const sendResponse = (response: http.ServerResponse, data: any) => { + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(data, null, 4)); +}; + +const handler = (request: http.IncomingMessage, response: http.ServerResponse) => { + if (request.method === 'POST') { + return sendResponse(response, { + id: 'wowzeronza', + name: 'ET-69', + createdDate: '2021-06-01T17:29:51.092Z', + }); + } + + if (request.method === 'PATCH') { + return sendResponse(response, { + id: 'wowzeronza', + name: 'ET-69', + modifiedDate: '2021-06-01T17:29:51.092Z', + }); + } + + // Return an 400 error if http method is not supported + response.statusCode = 400; + response.setHeader('Content-Type', 'application/json'); + response.end('Not supported http method to request slack simulator'); +}; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts new file mode 100644 index 0000000000000..92e99a9d504f3 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts @@ -0,0 +1,482 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import httpProxy from 'http-proxy'; +import expect from '@kbn/expect'; +import getPort from 'get-port'; +import http from 'http'; + +import { getHttpProxyServer } from '../../../../common/lib/get_proxy_server'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { getSwimlaneServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default function swimlaneTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const configService = getService('config'); + + const mockSwimlane = { + name: 'A swimlane action', + actionTypeId: '.swimlane', + config: { + apiUrl: 'http://swimlane.mynonexistent.com', + appId: '123456asdf', + connectorType: 'all', + mappings: { + alertIdConfig: { + id: 'ednjls', + name: 'Alert id', + key: 'alert-id', + fieldType: 'text', + }, + severityConfig: { + id: 'adnlas', + name: 'Severity', + key: 'severity', + fieldType: 'text', + }, + ruleNameConfig: { + id: 'adnfls', + name: 'Rule Name', + key: 'rule-name', + fieldType: 'text', + }, + caseIdConfig: { + id: 'a6sst', + name: 'Case Id', + key: 'case-id-name', + fieldType: 'text', + }, + caseNameConfig: { + id: 'a6fst', + name: 'Case Name', + key: 'case-name', + fieldType: 'text', + }, + commentsConfig: { + id: 'a6fdf', + name: 'Comments', + key: 'comments', + fieldType: 'notes', + }, + descriptionConfig: { + id: 'a6fdf', + name: 'Description', + key: 'description', + fieldType: 'text', + }, + }, + }, + secrets: { + apiToken: 'swimlane-api-key', + }, + params: { + subAction: 'pushToService', + subActionParams: { + incident: { + alertId: 'fs345f78g', + ruleName: 'Rule Name', + severity: 'Critical', + caseName: 'Case Name', + caseId: 'es3456789', + description: 'This is a description', + externalId: null, + }, + comments: [ + { + comment: 'first comment', + commentId: '123', + }, + ], + }, + }, + }; + + describe('Swimlane', () => { + let simulatedActionId = ''; + let swimlaneSimulatorURL: string = ''; + let swimlaneServer: http.Server; + let proxyServer: httpProxy | undefined; + let proxyHaveBeenCalled = false; + + before(async () => { + swimlaneServer = await getSwimlaneServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!swimlaneServer.listening) { + swimlaneServer.listen(availablePort); + } + swimlaneSimulatorURL = `http://localhost:${availablePort}`; + proxyServer = await getHttpProxyServer( + swimlaneSimulatorURL, + configService.get('kbnTestServer.serverArgs'), + () => { + proxyHaveBeenCalled = true; + } + ); + }); + + after(() => { + swimlaneServer.close(); + if (proxyServer) { + proxyServer.close(); + } + }); + + describe('Swimlane - Action Creation', () => { + it('should return 200 when creating a swimlane action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A swimlane action', + connector_type_id: '.swimlane', + config: { + ...mockSwimlane.config, + apiUrl: swimlaneSimulatorURL, + }, + secrets: mockSwimlane.secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + config: { + ...mockSwimlane.config, + apiUrl: swimlaneSimulatorURL, + }, + connector_type_id: '.swimlane', + id: createdAction.id, + is_missing_secrets: false, + is_preconfigured: false, + name: 'A swimlane action', + }); + + expect(typeof createdAction.id).to.be('string'); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + is_preconfigured: false, + is_missing_secrets: false, + name: 'A swimlane action', + connector_type_id: '.swimlane', + config: { + ...mockSwimlane.config, + apiUrl: swimlaneSimulatorURL, + }, + }); + }); + + it('should respond with a 400 Bad Request when creating a swimlane action with no apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A swimlane action', + connector_type_id: '.swimlane', + config: { + appId: mockSwimlane.config.appId, + mappings: mockSwimlane.config.mappings, + }, + secrets: mockSwimlane.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a swimlane action with no appId', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A swimlane action', + connector_type_id: '.swimlane', + config: { + mappings: mockSwimlane.config.mappings, + apiUrl: swimlaneSimulatorURL, + }, + secrets: mockSwimlane.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [appId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a swimlane action without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A swimlane action', + connector_type_id: '.swimlane', + config: { + ...mockSwimlane.config, + apiUrl: swimlaneSimulatorURL, + }, + secrets: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [apiToken]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request default swimlane url is not present in allowedHosts', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A swimlane action', + connector_type_id: '.swimlane', + config: mockSwimlane.config, + secrets: mockSwimlane.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: `error validating action type config: error configuring connector action: target url "${mockSwimlane.config.apiUrl}" is not added to the Kibana config xpack.actions.allowedHosts`, + }); + }); + }); + }); + + describe('Swimlane - Executor', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A swimlane simulator', + connector_type_id: '.swimlane', + config: { + ...mockSwimlane.config, + apiUrl: swimlaneSimulatorURL, + }, + secrets: mockSwimlane.secrets, + }); + simulatedActionId = body.id; + }); + + describe('Validation', () => { + it('should handle failing with a simulated success without action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); + expect(resp.body.connector_id).to.eql(simulatedActionId); + expect(resp.body.status).to.eql('error'); + expect(resp.body.retry).to.eql(false); + // Node.js 12 oddity: + // + // The first time after the server is booted, the error message will be: + // + // undefined is not iterable (cannot read property Symbol(Symbol.iterator)) + // + // After this, the error will be: + // + // Cannot destructure property 'value' of 'undefined' as it is undefined. + // + // The error seems to come from the exact same place in the code based on the + // exact same circomstances: + // + // https://github.com/elastic/kibana/blob/b0a223ebcbac7e404e8ae6da23b2cc6a4b509ff1/packages/kbn-config-schema/src/types/literal_type.ts#L28 + // + // What triggers the error is that the `handleError` function expects its 2nd + // argument to be an object containing a `valids` property of type array. + // + // In this test the object does not contain a `valids` property, so hence the + // error. + // + // Why the error message isn't the same in all scenarios is unknown to me and + // could be a bug in V8. + expect(resp.body.message).to.match( + /^error validating action params: (undefined is not iterable \(cannot read property Symbol\(Symbol.iterator\)\)|Cannot destructure property 'value' of 'undefined' as it is undefined\.)$/ + ); + }); + }); + + it('should handle failing with a simulated success without unsupported action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'non-supported' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [subAction]: expected value to equal [pushToService]', + }); + }); + }); + + /** + * All subActionParams are optional. + * If subActionParams is not provided all + * the subActionParams attributes will be set to null + * and the validation will succeed. For that reason, + * the subActionParams need to be set to null. + */ + it('should handle failing with a simulated success without subActionParams', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService', subActionParams: null }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [subActionParams]: expected a plain object value, but found [null] instead.', + }); + }); + }); + + it('should handle failing with a simulated success without commentId', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockSwimlane.params, + subActionParams: { + ...mockSwimlane.params.subActionParams, + comments: [{ comment: 'comment' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [subActionParams.comments]: types that failed validation:\n- [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n- [subActionParams.comments.1]: expected value to equal [null]', + }); + }); + }); + + it('should handle failing with a simulated success without comment message', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockSwimlane.params, + subActionParams: { + ...mockSwimlane.params.subActionParams, + comments: [{ commentId: 'success' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [subActionParams.comments]: types that failed validation:\n- [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n- [subActionParams.comments.1]: expected value to equal [null]', + }); + }); + }); + }); + + describe('Execution', () => { + it('should handle creating an incident', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockSwimlane.params, + subActionParams: { + ...mockSwimlane.params.subActionParams, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(body).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: 'wowzeronza', + title: 'ET-69', + pushedDate: '2021-06-01T17:29:51.092Z', + url: `${swimlaneSimulatorURL}/record/123456asdf/wowzeronza`, + }, + }); + }); + + it('should handle updating an incident', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockSwimlane.params, + subActionParams: { + incident: { + ...mockSwimlane.params.subActionParams.incident, + externalId: 'wowzeronza', + }, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(body).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: 'wowzeronza', + title: 'ET-69', + pushedDate: '2021-06-01T17:29:51.092Z', + url: `${swimlaneSimulatorURL}/record/123456asdf/wowzeronza`, + }, + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index b5ff287ac58f6..db57af0ba1a98 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -23,6 +23,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./builtin_action_types/es_index')); loadTestFile(require.resolve('./builtin_action_types/es_index_preconfigured')); loadTestFile(require.resolve('./builtin_action_types/pagerduty')); + loadTestFile(require.resolve('./builtin_action_types/swimlane')); loadTestFile(require.resolve('./builtin_action_types/server_log')); loadTestFile(require.resolve('./builtin_action_types/servicenow')); loadTestFile(require.resolve('./builtin_action_types/jira')); diff --git a/x-pack/test/case_api_integration/common/config.ts b/x-pack/test/case_api_integration/common/config.ts index 6c81f1fcfa264..887e6e7894f98 100644 --- a/x-pack/test/case_api_integration/common/config.ts +++ b/x-pack/test/case_api_integration/common/config.ts @@ -26,6 +26,7 @@ const enabledActionTypes = [ '.index', '.jira', '.pagerduty', + '.swimlane', '.resilient', '.server-log', '.servicenow', diff --git a/x-pack/test/detection_engine_api_integration/common/config.ts b/x-pack/test/detection_engine_api_integration/common/config.ts index 5cbf9598dc4a1..ef822b0af2a29 100644 --- a/x-pack/test/detection_engine_api_integration/common/config.ts +++ b/x-pack/test/detection_engine_api_integration/common/config.ts @@ -20,6 +20,7 @@ const enabledActionTypes = [ '.email', '.index', '.pagerduty', + '.swimlane', '.server-log', '.servicenow', '.slack', diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index 3ed382053f561..b8010c089ad03 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -16,6 +16,7 @@ const enabledActionTypes = [ '.email', '.index', '.pagerduty', + '.swimlane', '.servicenow', '.slack', '.webhook', From 77fe1c10870a3fb72eb3643d373c3ba0e7405a1a Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Wed, 23 Jun 2021 22:40:56 +0300 Subject: [PATCH 10/95] [Query] Use a minimal index pattern interface for es query (#102364) * Move JSON utils to utils package * Imports from tests * delete * split package * docs * test * test * imports * minimal index pattern * move some functions out and use miniaml ip in all es-kuery * docs * docs * rename Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...na-plugin-plugins-data-public.esfilters.md | 8 +++--- ...bana-plugin-plugins-data-public.eskuery.md | 2 +- ...bana-plugin-plugins-data-public.esquery.md | 2 +- ...lugins-data-public.iindexpattern.fields.md | 11 -------- ...in-plugins-data-public.iindexpattern.id.md | 11 -------- ...lugin-plugins-data-public.iindexpattern.md | 4 +-- ...na-plugin-plugins-data-server.esfilters.md | 8 +++--- ...bana-plugin-plugins-data-server.eskuery.md | 2 +- ...bana-plugin-plugins-data-server.esquery.md | 2 +- .../es_query/es_query/build_es_query.ts | 4 +-- .../es_query/es_query/filter_matches_index.ts | 5 ++-- .../common/es_query/es_query/from_filters.ts | 4 +-- .../common/es_query/es_query/from_kuery.ts | 6 ++--- .../es_query/handle_nested_filter.test.ts | 7 ++--- .../es_query/es_query/handle_nested_filter.ts | 4 +-- .../data/common/es_query/es_query/index.ts | 1 + .../es_query/es_query/migrate_filter.ts | 4 +-- .../data/common/es_query/es_query/types.ts | 14 ++++++++++ .../common/es_query/filters/build_filters.ts | 6 ++--- .../common/es_query/filters/exists_filter.ts | 5 ++-- .../data/common/es_query/filters/index.ts | 2 -- .../common/es_query/filters/phrase_filter.ts | 5 ++-- .../common/es_query/filters/phrases_filter.ts | 5 ++-- .../common/es_query/filters/range_filter.ts | 5 ++-- .../data/common/es_query/kuery/ast/ast.ts | 4 +-- .../common/es_query/kuery/functions/and.ts | 4 +-- .../common/es_query/kuery/functions/exists.ts | 4 +-- .../kuery/functions/geo_bounding_box.ts | 4 +-- .../es_query/kuery/functions/geo_polygon.ts | 4 +-- .../common/es_query/kuery/functions/is.ts | 4 +-- .../common/es_query/kuery/functions/nested.ts | 4 +-- .../common/es_query/kuery/functions/not.ts | 4 +-- .../common/es_query/kuery/functions/or.ts | 4 +-- .../common/es_query/kuery/functions/range.ts | 4 +-- .../kuery/functions/utils/get_fields.ts | 4 +-- .../utils/get_full_field_name_node.ts | 4 +-- .../es_query/kuery/node_types/function.ts | 4 +-- .../common/es_query/kuery/node_types/types.ts | 4 +-- .../data/common/index_patterns/types.ts | 5 ++-- src/plugins/data/public/index.ts | 2 +- src/plugins/data/public/public.api.md | 27 +++++++++---------- .../data/public/query/filter_manager/index.ts | 2 ++ .../filter_manager/lib}/get_display_value.ts | 3 +-- .../get_index_pattern_from_filter.test.ts | 0 .../lib}/get_index_pattern_from_filter.ts | 3 +-- .../apply_filter_popover_content.tsx | 4 +-- .../ui/filter_bar/filter_editor/index.tsx | 2 +- .../data/public/ui/filter_bar/filter_item.tsx | 3 +-- src/plugins/data/server/server.api.md | 12 ++++----- 49 files changed, 118 insertions(+), 128 deletions(-) delete mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.fields.md delete mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.id.md create mode 100644 src/plugins/data/common/es_query/es_query/types.ts rename src/plugins/data/{common/es_query/filters => public/query/filter_manager/lib}/get_display_value.ts (95%) rename src/plugins/data/{common/es_query/filters => public/query/filter_manager/lib}/get_index_pattern_from_filter.test.ts (100%) rename src/plugins/data/{common/es_query/filters => public/query/filter_manager/lib}/get_index_pattern_from_filter.ts (88%) diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md index 54b5a33ccf682..2ca4847d6dc39 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md @@ -13,11 +13,11 @@ esFilters: { FILTERS: typeof FILTERS; FilterStateStore: typeof FilterStateStore; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IIndexPattern) => import("../common").PhrasesFilter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IIndexPattern) => import("../common").ExistsFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IIndexPattern) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").MinimalIndexPattern) => import("../common").PhrasesFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").MinimalIndexPattern) => import("../common").ExistsFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").MinimalIndexPattern) => import("../common").PhraseFilter; buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").MinimalIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; isPhraseFilter: (filter: any) => filter is import("../common").PhraseFilter; isExistsFilter: (filter: any) => filter is import("../common").ExistsFilter; isPhrasesFilter: (filter: any) => filter is import("../common").PhrasesFilter; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md index 2cde2b7455585..881a1fa803ca6 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md @@ -10,6 +10,6 @@ esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").MinimalIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; } ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md index 2430e6a93bd2b..70805aaaaee8c 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md @@ -10,7 +10,7 @@ esQuery: { buildEsQuery: typeof buildEsQuery; getEsQueryConfig: typeof getEsQueryConfig; - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").MinimalIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.fields.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.fields.md deleted file mode 100644 index 792bee44f96a8..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.fields.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPattern](./kibana-plugin-plugins-data-public.iindexpattern.md) > [fields](./kibana-plugin-plugins-data-public.iindexpattern.fields.md) - -## IIndexPattern.fields property - -Signature: - -```typescript -fields: IFieldType[]; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.id.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.id.md deleted file mode 100644 index 917a80975df6c..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.id.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPattern](./kibana-plugin-plugins-data-public.iindexpattern.md) > [id](./kibana-plugin-plugins-data-public.iindexpattern.id.md) - -## IIndexPattern.id property - -Signature: - -```typescript -id?: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md index bf7f88ab37039..88d8520a373c6 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md @@ -12,7 +12,7 @@ Signature: ```typescript -export interface IIndexPattern +export interface IIndexPattern extends MinimalIndexPattern ``` ## Properties @@ -20,9 +20,7 @@ export interface IIndexPattern | Property | Type | Description | | --- | --- | --- | | [fieldFormatMap](./kibana-plugin-plugins-data-public.iindexpattern.fieldformatmap.md) | Record<string, SerializedFieldFormat<unknown> | undefined> | | -| [fields](./kibana-plugin-plugins-data-public.iindexpattern.fields.md) | IFieldType[] | | | [getFormatterForField](./kibana-plugin-plugins-data-public.iindexpattern.getformatterforfield.md) | (field: IndexPatternField | IndexPatternField['spec'] | IFieldType) => FieldFormat | Look up a formatter for a given field | -| [id](./kibana-plugin-plugins-data-public.iindexpattern.id.md) | string | | | [timeFieldName](./kibana-plugin-plugins-data-public.iindexpattern.timefieldname.md) | string | | | [title](./kibana-plugin-plugins-data-public.iindexpattern.title.md) | string | | | [type](./kibana-plugin-plugins-data-public.iindexpattern.type.md) | string | Type is used for identifying rollup indices, otherwise left undefined | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md index d7e80d94db4e6..d951cb2426943 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md @@ -11,11 +11,11 @@ esFilters: { buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; buildCustomFilter: typeof buildCustomFilter; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IIndexPattern) => import("../common").ExistsFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").MinimalIndexPattern) => import("../common").ExistsFilter; buildFilter: typeof buildFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IIndexPattern) => import("../common").PhraseFilter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IIndexPattern) => import("../common").PhrasesFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").MinimalIndexPattern) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").MinimalIndexPattern) => import("../common").PhrasesFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").MinimalIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; isFilterDisabled: (filter: import("../common").Filter) => boolean; } ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md index 4b96d8af756f3..6274eb5f4f4a5 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md @@ -10,6 +10,6 @@ esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").MinimalIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; } ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md index ac9be23bc6b6f..0d1baecb014f5 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md @@ -8,7 +8,7 @@ ```typescript esQuery: { - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").MinimalIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; diff --git a/src/plugins/data/common/es_query/es_query/build_es_query.ts b/src/plugins/data/common/es_query/es_query/build_es_query.ts index 45724796c3518..d7b3c630d1a6e 100644 --- a/src/plugins/data/common/es_query/es_query/build_es_query.ts +++ b/src/plugins/data/common/es_query/es_query/build_es_query.ts @@ -10,9 +10,9 @@ import { groupBy, has, isEqual } from 'lodash'; import { buildQueryFromKuery } from './from_kuery'; import { buildQueryFromFilters } from './from_filters'; import { buildQueryFromLucene } from './from_lucene'; -import { IIndexPattern } from '../../index_patterns'; import { Filter } from '../filters'; import { Query } from '../../query/types'; +import { IndexPatternBase } from './types'; export interface EsQueryConfig { allowLeadingWildcards: boolean; @@ -36,7 +36,7 @@ function removeMatchAll(filters: T[]) { * config contains dateformat:tz */ export function buildEsQuery( - indexPattern: IIndexPattern | undefined, + indexPattern: IndexPatternBase | undefined, queries: Query | Query[], filters: Filter | Filter[], config: EsQueryConfig = { diff --git a/src/plugins/data/common/es_query/es_query/filter_matches_index.ts b/src/plugins/data/common/es_query/es_query/filter_matches_index.ts index 478263d5ce601..b376436756092 100644 --- a/src/plugins/data/common/es_query/es_query/filter_matches_index.ts +++ b/src/plugins/data/common/es_query/es_query/filter_matches_index.ts @@ -6,15 +6,16 @@ * Side Public License, v 1. */ -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; import { Filter } from '../filters'; +import { IndexPatternBase } from './types'; /* * TODO: We should base this on something better than `filter.meta.key`. We should probably modify * this to check if `filter.meta.index` matches `indexPattern.id` instead, but that's a breaking * change. */ -export function filterMatchesIndex(filter: Filter, indexPattern?: IIndexPattern | null) { +export function filterMatchesIndex(filter: Filter, indexPattern?: IndexPatternBase | null) { if (!filter.meta?.key || !indexPattern) { return true; } diff --git a/src/plugins/data/common/es_query/es_query/from_filters.ts b/src/plugins/data/common/es_query/es_query/from_filters.ts index e50862235af1d..7b3c58d45a569 100644 --- a/src/plugins/data/common/es_query/es_query/from_filters.ts +++ b/src/plugins/data/common/es_query/es_query/from_filters.ts @@ -10,7 +10,7 @@ import { isUndefined } from 'lodash'; import { migrateFilter } from './migrate_filter'; import { filterMatchesIndex } from './filter_matches_index'; import { Filter, cleanFilter, isFilterDisabled } from '../filters'; -import { IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; import { handleNestedFilter } from './handle_nested_filter'; /** @@ -45,7 +45,7 @@ const translateToQuery = (filter: Filter) => { export const buildQueryFromFilters = ( filters: Filter[] = [], - indexPattern: IIndexPattern | undefined, + indexPattern: IndexPatternBase | undefined, ignoreFilterIfFieldNotInIndex: boolean = false ) => { filters = filters.filter((filter) => filter && !isFilterDisabled(filter)); diff --git a/src/plugins/data/common/es_query/es_query/from_kuery.ts b/src/plugins/data/common/es_query/es_query/from_kuery.ts index afedaae45872b..3eccfd8776113 100644 --- a/src/plugins/data/common/es_query/es_query/from_kuery.ts +++ b/src/plugins/data/common/es_query/es_query/from_kuery.ts @@ -7,11 +7,11 @@ */ import { fromKueryExpression, toElasticsearchQuery, nodeTypes, KueryNode } from '../kuery'; -import { IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; import { Query } from '../../query/types'; export function buildQueryFromKuery( - indexPattern: IIndexPattern | undefined, + indexPattern: IndexPatternBase | undefined, queries: Query[] = [], allowLeadingWildcards: boolean = false, dateFormatTZ?: string @@ -24,7 +24,7 @@ export function buildQueryFromKuery( } function buildQuery( - indexPattern: IIndexPattern | undefined, + indexPattern: IndexPatternBase | undefined, queryASTs: KueryNode[], config: Record = {} ) { diff --git a/src/plugins/data/common/es_query/es_query/handle_nested_filter.test.ts b/src/plugins/data/common/es_query/es_query/handle_nested_filter.test.ts index ee5305132042a..d312d034df564 100644 --- a/src/plugins/data/common/es_query/es_query/handle_nested_filter.test.ts +++ b/src/plugins/data/common/es_query/es_query/handle_nested_filter.test.ts @@ -9,13 +9,14 @@ import { handleNestedFilter } from './handle_nested_filter'; import { fields } from '../../index_patterns/mocks'; import { buildPhraseFilter, buildQueryFilter } from '../filters'; -import { IFieldType, IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; +import { IFieldType } from '../../index_patterns'; describe('handleNestedFilter', function () { - const indexPattern: IIndexPattern = ({ + const indexPattern: IndexPatternBase = { id: 'logstash-*', fields, - } as unknown) as IIndexPattern; + }; it("should return the filter's query wrapped in nested query if the target field is nested", () => { const field = getField('nestedField.child'); diff --git a/src/plugins/data/common/es_query/es_query/handle_nested_filter.ts b/src/plugins/data/common/es_query/es_query/handle_nested_filter.ts index 93927d81565ef..60e92769503fb 100644 --- a/src/plugins/data/common/es_query/es_query/handle_nested_filter.ts +++ b/src/plugins/data/common/es_query/es_query/handle_nested_filter.ts @@ -7,9 +7,9 @@ */ import { getFilterField, cleanFilter, Filter } from '../filters'; -import { IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; -export const handleNestedFilter = (filter: Filter, indexPattern?: IIndexPattern) => { +export const handleNestedFilter = (filter: Filter, indexPattern?: IndexPatternBase) => { if (!indexPattern) return filter; const fieldName = getFilterField(filter); diff --git a/src/plugins/data/common/es_query/es_query/index.ts b/src/plugins/data/common/es_query/es_query/index.ts index 31529480c8ac9..c10ea5846ae3f 100644 --- a/src/plugins/data/common/es_query/es_query/index.ts +++ b/src/plugins/data/common/es_query/es_query/index.ts @@ -11,3 +11,4 @@ export { buildQueryFromFilters } from './from_filters'; export { luceneStringToDsl } from './lucene_string_to_dsl'; export { decorateQuery } from './decorate_query'; export { getEsQueryConfig } from './get_es_query_config'; +export { IndexPatternBase } from './types'; diff --git a/src/plugins/data/common/es_query/es_query/migrate_filter.ts b/src/plugins/data/common/es_query/es_query/migrate_filter.ts index c7c44d019a31c..9bd78b092fc18 100644 --- a/src/plugins/data/common/es_query/es_query/migrate_filter.ts +++ b/src/plugins/data/common/es_query/es_query/migrate_filter.ts @@ -9,7 +9,7 @@ import { get, omit } from 'lodash'; import { getConvertedValueForField } from '../filters'; import { Filter } from '../filters'; -import { IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; export interface DeprecatedMatchPhraseFilter extends Filter { query: { @@ -28,7 +28,7 @@ function isDeprecatedMatchPhraseFilter(filter: any): filter is DeprecatedMatchPh return Boolean(fieldName && get(filter, ['query', 'match', fieldName, 'type']) === 'phrase'); } -export function migrateFilter(filter: Filter, indexPattern?: IIndexPattern) { +export function migrateFilter(filter: Filter, indexPattern?: IndexPatternBase) { if (isDeprecatedMatchPhraseFilter(filter)) { const fieldName = Object.keys(filter.query.match)[0]; const params: Record = get(filter, ['query', 'match', fieldName]); diff --git a/src/plugins/data/common/es_query/es_query/types.ts b/src/plugins/data/common/es_query/es_query/types.ts new file mode 100644 index 0000000000000..2133736516049 --- /dev/null +++ b/src/plugins/data/common/es_query/es_query/types.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IFieldType } from '../../index_patterns'; + +export interface IndexPatternBase { + fields: IFieldType[]; + id?: string; +} diff --git a/src/plugins/data/common/es_query/filters/build_filters.ts b/src/plugins/data/common/es_query/filters/build_filters.ts index ba1bd0a615493..369f9530fb92b 100644 --- a/src/plugins/data/common/es_query/filters/build_filters.ts +++ b/src/plugins/data/common/es_query/filters/build_filters.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { IIndexPattern, IFieldType } from '../..'; +import { IFieldType, IndexPatternBase } from '../..'; import { Filter, FILTERS, @@ -19,7 +19,7 @@ import { } from '.'; export function buildFilter( - indexPattern: IIndexPattern, + indexPattern: IndexPatternBase, field: IFieldType, type: FILTERS, negate: boolean, @@ -59,7 +59,7 @@ export function buildCustomFilter( } function buildBaseFilter( - indexPattern: IIndexPattern, + indexPattern: IndexPatternBase, field: IFieldType, type: FILTERS, params: any diff --git a/src/plugins/data/common/es_query/filters/exists_filter.ts b/src/plugins/data/common/es_query/filters/exists_filter.ts index 441a6bcb924b7..4836950c3bb27 100644 --- a/src/plugins/data/common/es_query/filters/exists_filter.ts +++ b/src/plugins/data/common/es_query/filters/exists_filter.ts @@ -7,7 +7,8 @@ */ import { Filter, FilterMeta } from './meta_filter'; -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; +import { IndexPatternBase } from '..'; export type ExistsFilterMeta = FilterMeta; @@ -26,7 +27,7 @@ export const getExistsFilterField = (filter: ExistsFilter) => { return filter.exists && filter.exists.field; }; -export const buildExistsFilter = (field: IFieldType, indexPattern: IIndexPattern) => { +export const buildExistsFilter = (field: IFieldType, indexPattern: IndexPatternBase) => { return { meta: { index: indexPattern.id, diff --git a/src/plugins/data/common/es_query/filters/index.ts b/src/plugins/data/common/es_query/filters/index.ts index 133f5cd232e6f..fe7cdadabaee3 100644 --- a/src/plugins/data/common/es_query/filters/index.ts +++ b/src/plugins/data/common/es_query/filters/index.ts @@ -14,10 +14,8 @@ export * from './custom_filter'; export * from './exists_filter'; export * from './geo_bounding_box_filter'; export * from './geo_polygon_filter'; -export * from './get_display_value'; export * from './get_filter_field'; export * from './get_filter_params'; -export * from './get_index_pattern_from_filter'; export * from './match_all_filter'; export * from './meta_filter'; export * from './missing_filter'; diff --git a/src/plugins/data/common/es_query/filters/phrase_filter.ts b/src/plugins/data/common/es_query/filters/phrase_filter.ts index 85562435e68d0..27c1e85562097 100644 --- a/src/plugins/data/common/es_query/filters/phrase_filter.ts +++ b/src/plugins/data/common/es_query/filters/phrase_filter.ts @@ -8,7 +8,8 @@ import type { estypes } from '@elastic/elasticsearch'; import { get, isPlainObject } from 'lodash'; import { Filter, FilterMeta } from './meta_filter'; -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; +import { IndexPatternBase } from '..'; export type PhraseFilterMeta = FilterMeta & { params?: { @@ -60,7 +61,7 @@ export const getPhraseFilterValue = (filter: PhraseFilter): PhraseFilterValue => export const buildPhraseFilter = ( field: IFieldType, value: any, - indexPattern: IIndexPattern + indexPattern: IndexPatternBase ): PhraseFilter => { const convertedValue = getConvertedValueForField(field, value); diff --git a/src/plugins/data/common/es_query/filters/phrases_filter.ts b/src/plugins/data/common/es_query/filters/phrases_filter.ts index 849c1b3faef2a..8a79472154493 100644 --- a/src/plugins/data/common/es_query/filters/phrases_filter.ts +++ b/src/plugins/data/common/es_query/filters/phrases_filter.ts @@ -9,7 +9,8 @@ import { Filter, FilterMeta } from './meta_filter'; import { getPhraseScript } from './phrase_filter'; import { FILTERS } from './index'; -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; +import { IndexPatternBase } from '../es_query'; export type PhrasesFilterMeta = FilterMeta & { params: string[]; // The unformatted values @@ -34,7 +35,7 @@ export const getPhrasesFilterField = (filter: PhrasesFilter) => { export const buildPhrasesFilter = ( field: IFieldType, params: any[], - indexPattern: IIndexPattern + indexPattern: IndexPatternBase ) => { const index = indexPattern.id; const type = FILTERS.PHRASES; diff --git a/src/plugins/data/common/es_query/filters/range_filter.ts b/src/plugins/data/common/es_query/filters/range_filter.ts index a082b93c0a79a..7bc7a8cff7487 100644 --- a/src/plugins/data/common/es_query/filters/range_filter.ts +++ b/src/plugins/data/common/es_query/filters/range_filter.ts @@ -8,7 +8,8 @@ import type { estypes } from '@elastic/elasticsearch'; import { map, reduce, mapValues, get, keys, pickBy } from 'lodash'; import { Filter, FilterMeta } from './meta_filter'; -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; +import { IndexPatternBase } from '..'; const OPERANDS_IN_RANGE = 2; @@ -93,7 +94,7 @@ const format = (field: IFieldType, value: any) => export const buildRangeFilter = ( field: IFieldType, params: RangeFilterParams, - indexPattern: IIndexPattern, + indexPattern: IndexPatternBase, formattedValue?: string ): RangeFilter => { const filter: any = { meta: { index: indexPattern.id, params: {} } }; diff --git a/src/plugins/data/common/es_query/kuery/ast/ast.ts b/src/plugins/data/common/es_query/kuery/ast/ast.ts index be82128969968..3e7b25897cab7 100644 --- a/src/plugins/data/common/es_query/kuery/ast/ast.ts +++ b/src/plugins/data/common/es_query/kuery/ast/ast.ts @@ -10,10 +10,10 @@ import { JsonObject } from '@kbn/common-utils'; import { nodeTypes } from '../node_types/index'; import { KQLSyntaxError } from '../kuery_syntax_error'; import { KueryNode, DslQuery, KueryParseOptions } from '../types'; -import { IIndexPattern } from '../../../index_patterns/types'; // @ts-ignore import { parse as parseKuery } from './_generated_/kuery'; +import { IndexPatternBase } from '../..'; const fromExpression = ( expression: string | DslQuery, @@ -65,7 +65,7 @@ export const fromKueryExpression = ( */ export const toElasticsearchQuery = ( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config?: Record, context?: Record ): JsonObject => { diff --git a/src/plugins/data/common/es_query/kuery/functions/and.ts b/src/plugins/data/common/es_query/kuery/functions/and.ts index 1989704cb627e..ba7d5d1f6645b 100644 --- a/src/plugins/data/common/es_query/kuery/functions/and.ts +++ b/src/plugins/data/common/es_query/kuery/functions/and.ts @@ -7,7 +7,7 @@ */ import * as ast from '../ast'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; export function buildNodeParams(children: KueryNode[]) { return { @@ -17,7 +17,7 @@ export function buildNodeParams(children: KueryNode[]) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/exists.ts b/src/plugins/data/common/es_query/kuery/functions/exists.ts index 5238fb1d8ee7f..fa6c37e6ba18f 100644 --- a/src/plugins/data/common/es_query/kuery/functions/exists.ts +++ b/src/plugins/data/common/es_query/kuery/functions/exists.ts @@ -8,7 +8,7 @@ import { get } from 'lodash'; import * as literal from '../node_types/literal'; -import { IIndexPattern, KueryNode, IFieldType } from '../../..'; +import { KueryNode, IFieldType, IndexPatternBase } from '../../..'; export function buildNodeParams(fieldName: string) { return { @@ -18,7 +18,7 @@ export function buildNodeParams(fieldName: string) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/geo_bounding_box.ts b/src/plugins/data/common/es_query/kuery/functions/geo_bounding_box.ts index f2498f3ea2ad4..38a433b1b80ab 100644 --- a/src/plugins/data/common/es_query/kuery/functions/geo_bounding_box.ts +++ b/src/plugins/data/common/es_query/kuery/functions/geo_bounding_box.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import { nodeTypes } from '../node_types'; import * as ast from '../ast'; -import { IIndexPattern, KueryNode, IFieldType, LatLon } from '../../..'; +import { IndexPatternBase, KueryNode, IFieldType, LatLon } from '../../..'; export function buildNodeParams(fieldName: string, params: any) { params = _.pick(params, 'topLeft', 'bottomRight'); @@ -26,7 +26,7 @@ export function buildNodeParams(fieldName: string, params: any) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/geo_polygon.ts b/src/plugins/data/common/es_query/kuery/functions/geo_polygon.ts index 584a315930d9c..69de7248a7b38 100644 --- a/src/plugins/data/common/es_query/kuery/functions/geo_polygon.ts +++ b/src/plugins/data/common/es_query/kuery/functions/geo_polygon.ts @@ -8,7 +8,7 @@ import { nodeTypes } from '../node_types'; import * as ast from '../ast'; -import { IIndexPattern, KueryNode, IFieldType, LatLon } from '../../..'; +import { IndexPatternBase, KueryNode, IFieldType, LatLon } from '../../..'; import { LiteralTypeBuildNode } from '../node_types/types'; export function buildNodeParams(fieldName: string, points: LatLon[]) { @@ -25,7 +25,7 @@ export function buildNodeParams(fieldName: string, points: LatLon[]) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/is.ts b/src/plugins/data/common/es_query/kuery/functions/is.ts index a18ad230c3cae..55d036c2156f9 100644 --- a/src/plugins/data/common/es_query/kuery/functions/is.ts +++ b/src/plugins/data/common/es_query/kuery/functions/is.ts @@ -11,7 +11,7 @@ import { getPhraseScript } from '../../filters'; import { getFields } from './utils/get_fields'; import { getTimeZoneFromSettings } from '../../utils'; import { getFullFieldNameNode } from './utils/get_full_field_name_node'; -import { IIndexPattern, KueryNode, IFieldType } from '../../..'; +import { IndexPatternBase, KueryNode, IFieldType } from '../../..'; import * as ast from '../ast'; @@ -39,7 +39,7 @@ export function buildNodeParams(fieldName: string, value: any, isPhrase: boolean export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/nested.ts b/src/plugins/data/common/es_query/kuery/functions/nested.ts index bfd01ef39764c..46ceeaf3e5de6 100644 --- a/src/plugins/data/common/es_query/kuery/functions/nested.ts +++ b/src/plugins/data/common/es_query/kuery/functions/nested.ts @@ -8,7 +8,7 @@ import * as ast from '../ast'; import * as literal from '../node_types/literal'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; export function buildNodeParams(path: any, child: any) { const pathNode = @@ -20,7 +20,7 @@ export function buildNodeParams(path: any, child: any) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/not.ts b/src/plugins/data/common/es_query/kuery/functions/not.ts index ef4456897bcdd..f837cd261c814 100644 --- a/src/plugins/data/common/es_query/kuery/functions/not.ts +++ b/src/plugins/data/common/es_query/kuery/functions/not.ts @@ -7,7 +7,7 @@ */ import * as ast from '../ast'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; export function buildNodeParams(child: KueryNode) { return { @@ -17,7 +17,7 @@ export function buildNodeParams(child: KueryNode) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/or.ts b/src/plugins/data/common/es_query/kuery/functions/or.ts index 416687e7cde9c..7365cc39595e6 100644 --- a/src/plugins/data/common/es_query/kuery/functions/or.ts +++ b/src/plugins/data/common/es_query/kuery/functions/or.ts @@ -7,7 +7,7 @@ */ import * as ast from '../ast'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; export function buildNodeParams(children: KueryNode[]) { return { @@ -17,7 +17,7 @@ export function buildNodeParams(children: KueryNode[]) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/range.ts b/src/plugins/data/common/es_query/kuery/functions/range.ts index 06b345e5821c3..caefa7e5373ca 100644 --- a/src/plugins/data/common/es_query/kuery/functions/range.ts +++ b/src/plugins/data/common/es_query/kuery/functions/range.ts @@ -13,7 +13,7 @@ import { getRangeScript, RangeFilterParams } from '../../filters'; import { getFields } from './utils/get_fields'; import { getTimeZoneFromSettings } from '../../utils'; import { getFullFieldNameNode } from './utils/get_full_field_name_node'; -import { IIndexPattern, KueryNode, IFieldType } from '../../..'; +import { IndexPatternBase, KueryNode, IFieldType } from '../../..'; export function buildNodeParams(fieldName: string, params: RangeFilterParams) { const paramsToMap = _.pick(params, 'gt', 'lt', 'gte', 'lte', 'format'); @@ -33,7 +33,7 @@ export function buildNodeParams(fieldName: string, params: RangeFilterParams) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/utils/get_fields.ts b/src/plugins/data/common/es_query/kuery/functions/utils/get_fields.ts index 4002a36648f04..7dac1262d5062 100644 --- a/src/plugins/data/common/es_query/kuery/functions/utils/get_fields.ts +++ b/src/plugins/data/common/es_query/kuery/functions/utils/get_fields.ts @@ -8,10 +8,10 @@ import * as literal from '../../node_types/literal'; import * as wildcard from '../../node_types/wildcard'; -import { KueryNode, IIndexPattern } from '../../../..'; +import { KueryNode, IndexPatternBase } from '../../../..'; import { LiteralTypeBuildNode } from '../../node_types/types'; -export function getFields(node: KueryNode, indexPattern?: IIndexPattern) { +export function getFields(node: KueryNode, indexPattern?: IndexPatternBase) { if (!indexPattern) return []; if (node.type === 'literal') { const fieldName = literal.toElasticsearchQuery(node as LiteralTypeBuildNode); diff --git a/src/plugins/data/common/es_query/kuery/functions/utils/get_full_field_name_node.ts b/src/plugins/data/common/es_query/kuery/functions/utils/get_full_field_name_node.ts index e623579226861..644791637aa70 100644 --- a/src/plugins/data/common/es_query/kuery/functions/utils/get_full_field_name_node.ts +++ b/src/plugins/data/common/es_query/kuery/functions/utils/get_full_field_name_node.ts @@ -7,11 +7,11 @@ */ import { getFields } from './get_fields'; -import { IIndexPattern, IFieldType, KueryNode } from '../../../..'; +import { IndexPatternBase, IFieldType, KueryNode } from '../../../..'; export function getFullFieldNameNode( rootNameNode: any, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, nestedPath?: string ): KueryNode { const fullFieldNameNode = { diff --git a/src/plugins/data/common/es_query/kuery/node_types/function.ts b/src/plugins/data/common/es_query/kuery/node_types/function.ts index b9b7379dfb23d..642089a101f31 100644 --- a/src/plugins/data/common/es_query/kuery/node_types/function.ts +++ b/src/plugins/data/common/es_query/kuery/node_types/function.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import { functions } from '../functions'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; import { FunctionName, FunctionTypeBuildNode } from './types'; export function buildNode(functionName: FunctionName, ...args: any[]) { @@ -45,7 +45,7 @@ export function buildNodeWithArgumentNodes( export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config?: Record, context?: Record ) { diff --git a/src/plugins/data/common/es_query/kuery/node_types/types.ts b/src/plugins/data/common/es_query/kuery/node_types/types.ts index b3247a0ad8dc2..ea8eb5e8a0618 100644 --- a/src/plugins/data/common/es_query/kuery/node_types/types.ts +++ b/src/plugins/data/common/es_query/kuery/node_types/types.ts @@ -11,8 +11,8 @@ */ import { JsonValue } from '@kbn/common-utils'; -import { IIndexPattern } from '../../../index_patterns'; import { KueryNode } from '..'; +import { IndexPatternBase } from '../..'; export type FunctionName = | 'is' @@ -30,7 +30,7 @@ interface FunctionType { buildNodeWithArgumentNodes: (functionName: FunctionName, args: any[]) => FunctionTypeBuildNode; toElasticsearchQuery: ( node: any, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config?: Record, context?: Record ) => JsonValue; diff --git a/src/plugins/data/common/index_patterns/types.ts b/src/plugins/data/common/index_patterns/types.ts index 07aa8967b905e..a88f029c0c7cd 100644 --- a/src/plugins/data/common/index_patterns/types.ts +++ b/src/plugins/data/common/index_patterns/types.ts @@ -9,6 +9,7 @@ import type { estypes } from '@elastic/elasticsearch'; import { ToastInputFields, ErrorToastOptions } from 'src/core/public/notifications'; // eslint-disable-next-line import type { SavedObject } from 'src/core/server'; +import type { IndexPatternBase } from '../es_query'; import { IFieldType } from './fields'; import { RUNTIME_FIELD_TYPES } from './constants'; import { SerializedFieldFormat } from '../../../expressions/common'; @@ -29,10 +30,8 @@ export interface RuntimeField { * IIndexPattern allows for an IndexPattern OR an index pattern saved object * Use IndexPattern or IndexPatternSpec instead */ -export interface IIndexPattern { - fields: IFieldType[]; +export interface IIndexPattern extends IndexPatternBase { title: string; - id?: string; /** * Type is used for identifying rollup indices, otherwise left undefined */ diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 078dd3a9b7c5a..d7667f20d517e 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -23,7 +23,6 @@ import { disableFilter, FILTERS, FilterStateStore, - getDisplayValueFromFilter, getPhraseFilterField, getPhraseFilterValue, isExistsFilter, @@ -43,6 +42,7 @@ import { FilterLabel } from './ui'; import { FilterItem } from './ui/filter_bar'; import { + getDisplayValueFromFilter, generateFilters, onlyDisabledFiltersChanged, changeTimeFilter, diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 7a5f323e51459..2849b93b14483 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -808,11 +808,11 @@ export const esFilters: { FILTERS: typeof FILTERS; FilterStateStore: typeof FilterStateStore; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IIndexPattern) => import("../common").PhrasesFilter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IIndexPattern) => import("../common").ExistsFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IIndexPattern) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IndexPatternBase) => import("../common").PhrasesFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IndexPatternBase) => import("../common").ExistsFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IndexPatternBase) => import("../common").PhraseFilter; buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IndexPatternBase, formattedValue?: string | undefined) => import("../common").RangeFilter; isPhraseFilter: (filter: any) => filter is import("../common").PhraseFilter; isExistsFilter: (filter: any) => filter is import("../common").ExistsFilter; isPhrasesFilter: (filter: any) => filter is import("../common").PhrasesFilter; @@ -858,7 +858,7 @@ export const esFilters: { export const esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IndexPatternBase | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; }; // Warning: (ae-missing-release-tag) "esQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -867,7 +867,7 @@ export const esKuery: { export const esQuery: { buildEsQuery: typeof buildEsQuery; getEsQueryConfig: typeof getEsQueryConfig; - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IndexPatternBase | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; @@ -1286,22 +1286,19 @@ export interface IFieldType { visualizable?: boolean; } +// Warning: (ae-forgotten-export) The symbol "IndexPatternBase" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "IIndexPattern" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @deprecated (undocumented) -export interface IIndexPattern { +export interface IIndexPattern extends IndexPatternBase { // Warning: (ae-forgotten-export) The symbol "SerializedFieldFormat" needs to be exported by the entry point index.d.ts // // (undocumented) fieldFormatMap?: Record | undefined>; - // (undocumented) - fields: IFieldType[]; getFormatterForField?: (field: IndexPatternField | IndexPatternField['spec'] | IFieldType) => FieldFormat; // (undocumented) getTimeField?(): IFieldType | undefined; // (undocumented) - id?: string; - // (undocumented) timeFieldName?: string; // (undocumented) title: string; @@ -2731,13 +2728,13 @@ export interface WaitUntilNextSessionCompletesOptions { // Warnings were encountered during analysis: // -// src/plugins/data/common/es_query/filters/exists_filter.ts:19:3 - (ae-forgotten-export) The symbol "ExistsFilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/es_query/filters/exists_filter.ts:20:3 - (ae-forgotten-export) The symbol "FilterExistsProperty" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/exists_filter.ts:20:3 - (ae-forgotten-export) The symbol "ExistsFilterMeta" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/exists_filter.ts:21:3 - (ae-forgotten-export) The symbol "FilterExistsProperty" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/match_all_filter.ts:17:3 - (ae-forgotten-export) The symbol "MatchAllFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/meta_filter.ts:43:3 - (ae-forgotten-export) The symbol "FilterState" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/meta_filter.ts:44:3 - (ae-forgotten-export) The symbol "FilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/es_query/filters/phrase_filter.ts:22:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/es_query/filters/phrases_filter.ts:20:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/phrase_filter.ts:23:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/phrases_filter.ts:21:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:65:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:138:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:169:7 - (ae-forgotten-export) The symbol "RuntimeField" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/query/filter_manager/index.ts b/src/plugins/data/public/query/filter_manager/index.ts index 327b9763541ac..55dba640b07b6 100644 --- a/src/plugins/data/public/query/filter_manager/index.ts +++ b/src/plugins/data/public/query/filter_manager/index.ts @@ -11,3 +11,5 @@ export { FilterManager } from './filter_manager'; export { mapAndFlattenFilters } from './lib/map_and_flatten_filters'; export { onlyDisabledFiltersChanged } from './lib/only_disabled'; export { generateFilters } from './lib/generate_filters'; +export { getDisplayValueFromFilter } from './lib/get_display_value'; +export { getIndexPatternFromFilter } from './lib/get_index_pattern_from_filter'; diff --git a/src/plugins/data/common/es_query/filters/get_display_value.ts b/src/plugins/data/public/query/filter_manager/lib/get_display_value.ts similarity index 95% rename from src/plugins/data/common/es_query/filters/get_display_value.ts rename to src/plugins/data/public/query/filter_manager/lib/get_display_value.ts index ee719843ae879..45c6167f600bc 100644 --- a/src/plugins/data/common/es_query/filters/get_display_value.ts +++ b/src/plugins/data/public/query/filter_manager/lib/get_display_value.ts @@ -7,9 +7,8 @@ */ import { i18n } from '@kbn/i18n'; -import { IIndexPattern } from '../..'; +import { Filter, IIndexPattern } from '../../../../common'; import { getIndexPatternFromFilter } from './get_index_pattern_from_filter'; -import { Filter } from '../filters'; function getValueFormatter(indexPattern?: IIndexPattern, key?: string) { // checking getFormatterForField exists because there is at least once case where an index pattern diff --git a/src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.test.ts b/src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.test.ts similarity index 100% rename from src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.test.ts rename to src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.test.ts diff --git a/src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.ts b/src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.ts similarity index 88% rename from src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.ts rename to src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.ts index bceeb5f2793ec..7a2ce29102e51 100644 --- a/src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.ts +++ b/src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import { Filter } from '../filters'; -import { IIndexPattern } from '../..'; +import { Filter, IIndexPattern } from '../../../../common'; export function getIndexPatternFromFilter( filter: Filter, diff --git a/src/plugins/data/public/ui/apply_filters/apply_filter_popover_content.tsx b/src/plugins/data/public/ui/apply_filters/apply_filter_popover_content.tsx index 23de8327ce1f1..9cc9af04409f1 100644 --- a/src/plugins/data/public/ui/apply_filters/apply_filter_popover_content.tsx +++ b/src/plugins/data/public/ui/apply_filters/apply_filter_popover_content.tsx @@ -20,9 +20,9 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; import { IIndexPattern } from '../..'; -import { getDisplayValueFromFilter, Filter } from '../../../common'; +import { Filter } from '../../../common'; import { FilterLabel } from '../filter_bar'; -import { mapAndFlattenFilters } from '../../query'; +import { mapAndFlattenFilters, getDisplayValueFromFilter } from '../../query'; interface Props { filters: Filter[]; diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx index 2b8978a125bca..734161ea87232 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx @@ -37,10 +37,10 @@ import { Operator } from './lib/filter_operators'; import { PhraseValueInput } from './phrase_value_input'; import { PhrasesValuesInput } from './phrases_values_input'; import { RangeValueInput } from './range_value_input'; +import { getIndexPatternFromFilter } from '../../../query'; import { IIndexPattern, IFieldType } from '../../..'; import { Filter, - getIndexPatternFromFilter, FieldFilter, buildFilter, buildCustomFilter, diff --git a/src/plugins/data/public/ui/filter_bar/filter_item.tsx b/src/plugins/data/public/ui/filter_bar/filter_item.tsx index 9e5090f945182..09e0571c2a870 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_item.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_item.tsx @@ -14,14 +14,13 @@ import { IUiSettingsClient } from 'src/core/public'; import { FilterEditor } from './filter_editor'; import { FilterView } from './filter_view'; import { IIndexPattern } from '../..'; +import { getDisplayValueFromFilter, getIndexPatternFromFilter } from '../../query'; import { Filter, isFilterPinned, - getDisplayValueFromFilter, toggleFilterNegated, toggleFilterPinned, toggleFilterDisabled, - getIndexPatternFromFilter, } from '../../../common'; import { getIndexPatterns } from '../../services'; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 768c44d3e3e95..5ca19f9e1e509 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -447,11 +447,11 @@ export const esFilters: { buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; buildCustomFilter: typeof buildCustomFilter; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IIndexPattern) => import("../common").ExistsFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IndexPatternBase) => import("../common").ExistsFilter; buildFilter: typeof buildFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IIndexPattern) => import("../common").PhraseFilter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IIndexPattern) => import("../common").PhrasesFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IndexPatternBase) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IndexPatternBase) => import("../common").PhrasesFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IndexPatternBase, formattedValue?: string | undefined) => import("../common").RangeFilter; isFilterDisabled: (filter: import("../common").Filter) => boolean; }; @@ -461,14 +461,14 @@ export const esFilters: { export const esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IndexPatternBase | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; }; // Warning: (ae-missing-release-tag) "esQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export const esQuery: { - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IndexPatternBase | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; From 23666832091d0a30c00222fdd73d56af51224ff9 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 23 Jun 2021 14:43:17 -0500 Subject: [PATCH 11/95] [Enterprise Search] Add shared Users components and enable RBAC functionality (#102826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add RolesEmptyPrompt component * Move constants to shared Will be used in next commit so DRYing them out here * Add UserAddedInfo component * Add UsersEmptyPrompt component * Add UserInvitationCallout component * Add some shared types * Add UserSelector component * Fix imports from a previous commit Refactored these to shared but missed updating the implementation. See e2d3ec2ca4aba3cb6f7e8e2d2d2da96aa6bedf1b * Add UsersHeading component * Add UserFlyout component * Update UsersAndRolesRowActions with confirm modal Design calls for using a custom call out instead of window.confirm * Add pagination size and fix type - email can be null on bult-in elasticsearch users * Add UsersTable component * Remove window.confirm from logic files The UsersAndRolesRowActions component now uses an EUI prompt for this. Whitespace changes should be hidden for this commit * Add routes for enabling RBAC * Update App Search routes https://github.com/elastic/ent-search/pull/3862 added the ‘/as’ prefix to App Search role mappings routes * Add logic for enabling role-based access * Pass docsLink as a prop to the heading component * Add empty states to mappings landing pages * Fix a couple of missed i18ns * Remove unused translations * Remove EuiOverlayMask This was needed in ent-search because it uses an older EUI. The newer confirm modal has its own overlay * Update RoleMappingsTable to use new design Previously, we showed all engines/groups in the table but the new design calls for a truncated list with additional items so [‘foo’, ‘bar’, ‘baz’] would display as “foo, bar + 1” This is already in place for the users table * Lint fix * Another lint fix * Fix test name Co-authored-by: Jason Stoltzfus * Move test Co-authored-by: Jason Stoltzfus --- .../components/role_mappings/constants.ts | 8 - .../role_mappings/role_mappings.tsx | 22 +- .../role_mappings/role_mappings_logic.test.ts | 49 ++-- .../role_mappings/role_mappings_logic.ts | 37 ++- .../applications/shared/constants/index.ts | 1 + .../applications/shared/constants/labels.ts | 15 ++ .../__mocks__/elasticsearch_users.ts | 13 ++ .../shared/role_mapping/__mocks__/roles.ts | 19 ++ .../shared/role_mapping/constants.ts | 213 +++++++++++++++++- .../applications/shared/role_mapping/index.ts | 8 + .../role_mappings_heading.test.tsx | 8 +- .../role_mapping/role_mappings_heading.tsx | 8 +- .../role_mapping/role_mappings_table.test.tsx | 34 +-- .../role_mapping/role_mappings_table.tsx | 37 ++- .../role_mapping/roles_empty_prompt.test.tsx | 39 ++++ .../role_mapping/roles_empty_prompt.tsx | 48 ++++ .../role_mapping/user_added_info.test.tsx | 28 +++ .../shared/role_mapping/user_added_info.tsx | 40 ++++ .../shared/role_mapping/user_flyout.test.tsx | 70 ++++++ .../shared/role_mapping/user_flyout.tsx | 113 ++++++++++ .../user_invitation_callout.test.tsx | 46 ++++ .../role_mapping/user_invitation_callout.tsx | 47 ++++ .../role_mapping/user_selector.test.tsx | 112 +++++++++ .../shared/role_mapping/user_selector.tsx | 159 +++++++++++++ .../users_and_roles_row_actions.test.tsx | 22 +- .../users_and_roles_row_actions.tsx | 63 +++++- .../role_mapping/users_empty_prompt.test.tsx | 22 ++ .../role_mapping/users_empty_prompt.tsx | 43 ++++ .../role_mapping/users_heading.test.tsx | 32 +++ .../shared/role_mapping/users_heading.tsx | 37 +++ .../shared/role_mapping/users_table.test.tsx | 100 ++++++++ .../shared/role_mapping/users_table.tsx | 147 ++++++++++++ .../public/applications/shared/types.ts | 16 ++ .../groups/components/group_users_table.tsx | 18 +- .../views/role_mappings/constants.ts | 8 - .../views/role_mappings/role_mappings.tsx | 27 ++- .../role_mappings/role_mappings_logic.test.ts | 50 ++-- .../role_mappings/role_mappings_logic.ts | 37 ++- .../routes/app_search/role_mappings.test.ts | 37 ++- .../server/routes/app_search/role_mappings.ts | 24 +- .../workplace_search/role_mappings.test.ts | 29 ++- .../routes/workplace_search/role_mappings.ts | 16 ++ .../translations/translations/ja-JP.json | 9 +- .../translations/translations/zh-CN.json | 9 +- 44 files changed, 1748 insertions(+), 172 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/constants/labels.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/elasticsearch_users.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts index df1e19e264c75..cce18cbeffd0a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts @@ -9,14 +9,6 @@ import { i18n } from '@kbn/i18n'; import { AdvanceRoleType } from '../../types'; -export const DELETE_ROLE_MAPPING_MESSAGE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.deleteRoleMappingMessage', - { - defaultMessage: - 'Are you sure you want to permanently delete this mapping? This action is not reversible and some users might lose access.', - } -); - export const ROLE_MAPPING_DELETED_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.appSearch.roleMappingDeletedMessage', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx index db0e6e6dead11..03e2ae67eca9e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx @@ -10,16 +10,25 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; -import { RoleMappingsTable, RoleMappingsHeading } from '../../../shared/role_mapping'; +import { + RoleMappingsTable, + RoleMappingsHeading, + RolesEmptyPrompt, +} from '../../../shared/role_mapping'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; + +import { DOCS_PREFIX } from '../../routes'; import { AppSearchPageTemplate } from '../layout'; import { ROLE_MAPPINGS_ENGINE_ACCESS_HEADING } from './constants'; import { RoleMapping } from './role_mapping'; import { RoleMappingsLogic } from './role_mappings_logic'; +const ROLES_DOCS_LINK = `${DOCS_PREFIX}/security-and-users.html`; + export const RoleMappings: React.FC = () => { const { + enableRoleBasedAccess, initializeRoleMappings, initializeRoleMapping, handleDeleteMapping, @@ -37,10 +46,19 @@ export const RoleMappings: React.FC = () => { return resetState; }, []); + const rolesEmptyState = ( + + ); + const roleMappingsSection = (

initializeRoleMapping()} /> { pageChrome={[ROLE_MAPPINGS_TITLE]} pageHeader={{ pageTitle: ROLE_MAPPINGS_TITLE }} isLoading={dataLoading} + isEmptyState={roleMappings.length < 1} + emptyState={rolesEmptyState} > {roleMappingFlyoutOpen && } {roleMappingsSection} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts index 870e303a2930d..6985f213d1dd5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts @@ -87,6 +87,13 @@ describe('RoleMappingsLogic', () => { }); }); + it('setRoleMappings', () => { + RoleMappingsLogic.actions.setRoleMappings({ roleMappings: [asRoleMapping] }); + + expect(RoleMappingsLogic.values.roleMappings).toEqual([asRoleMapping]); + expect(RoleMappingsLogic.values.dataLoading).toEqual(false); + }); + it('handleRoleChange', () => { RoleMappingsLogic.actions.handleRoleChange('dev'); @@ -266,6 +273,30 @@ describe('RoleMappingsLogic', () => { }); describe('listeners', () => { + describe('enableRoleBasedAccess', () => { + it('calls API and sets values', async () => { + const setRoleMappingsSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappings'); + http.post.mockReturnValue(Promise.resolve(mappingsServerProps)); + RoleMappingsLogic.actions.enableRoleBasedAccess(); + + expect(RoleMappingsLogic.values.dataLoading).toEqual(true); + + expect(http.post).toHaveBeenCalledWith( + '/api/app_search/role_mappings/enable_role_based_access' + ); + await nextTick(); + expect(setRoleMappingsSpy).toHaveBeenCalledWith(mappingsServerProps); + }); + + it('handles error', async () => { + http.post.mockReturnValue(Promise.reject('this is an error')); + RoleMappingsLogic.actions.enableRoleBasedAccess(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + describe('initializeRoleMappings', () => { it('calls API and sets values', async () => { const setRoleMappingsDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappingsData'); @@ -400,18 +431,8 @@ describe('RoleMappingsLogic', () => { }); describe('handleDeleteMapping', () => { - let confirmSpy: any; const roleMappingId = 'r1'; - beforeEach(() => { - confirmSpy = jest.spyOn(window, 'confirm'); - confirmSpy.mockImplementation(jest.fn(() => true)); - }); - - afterEach(() => { - confirmSpy.mockRestore(); - }); - it('calls API and refreshes list', async () => { mount(mappingsServerProps); const initializeRoleMappingsSpy = jest.spyOn( @@ -436,14 +457,6 @@ describe('RoleMappingsLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); - - it('will do nothing if not confirmed', () => { - mount(mappingsServerProps); - jest.spyOn(window, 'confirm').mockReturnValueOnce(false); - RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); - - expect(http.delete).not.toHaveBeenCalled(); - }); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts index fc0a235b23c77..e2ef75897528c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts @@ -22,7 +22,6 @@ import { roleHasScopedEngines } from '../../utils/role/has_scoped_engines'; import { Engine } from '../engine/types'; import { - DELETE_ROLE_MAPPING_MESSAGE, ROLE_MAPPING_DELETED_MESSAGE, ROLE_MAPPING_CREATED_MESSAGE, ROLE_MAPPING_UPDATED_MESSAGE, @@ -59,10 +58,16 @@ interface RoleMappingsActions { initializeRoleMappings(): void; resetState(): void; setRoleMapping(roleMapping: ASRoleMapping): { roleMapping: ASRoleMapping }; + setRoleMappings({ + roleMappings, + }: { + roleMappings: ASRoleMapping[]; + }): { roleMappings: ASRoleMapping[] }; setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; openRoleMappingFlyout(): void; closeRoleMappingFlyout(): void; setRoleMappingErrors(errors: string[]): { errors: string[] }; + enableRoleBasedAccess(): void; } interface RoleMappingsValues { @@ -91,6 +96,7 @@ export const RoleMappingsLogic = kea data, setRoleMapping: (roleMapping: ASRoleMapping) => ({ roleMapping }), + setRoleMappings: ({ roleMappings }: { roleMappings: ASRoleMapping[] }) => ({ roleMappings }), setRoleMappingErrors: (errors: string[]) => ({ errors }), handleAuthProviderChange: (value: string) => ({ value }), handleRoleChange: (roleType: RoleTypes) => ({ roleType }), @@ -101,6 +107,7 @@ export const RoleMappingsLogic = kea ({ value }), handleAccessAllEnginesChange: (selected: boolean) => ({ selected }), + enableRoleBasedAccess: true, resetState: true, initializeRoleMappings: true, initializeRoleMapping: (roleMappingId) => ({ roleMappingId }), @@ -114,13 +121,16 @@ export const RoleMappingsLogic = kea false, + setRoleMappings: () => false, resetState: () => true, + enableRoleBasedAccess: () => true, }, ], roleMappings: [ [], { setRoleMappingsData: (_, { roleMappings }) => roleMappings, + setRoleMappings: (_, { roleMappings }) => roleMappings, resetState: () => [], }, ], @@ -267,6 +277,17 @@ export const RoleMappingsLogic = kea ({ + enableRoleBasedAccess: async () => { + const { http } = HttpLogic.values; + const route = '/api/app_search/role_mappings/enable_role_based_access'; + + try { + const response = await http.post(route); + actions.setRoleMappings(response); + } catch (e) { + flashAPIErrors(e); + } + }, initializeRoleMappings: async () => { const { http } = HttpLogic.values; const route = '/api/app_search/role_mappings'; @@ -286,14 +307,12 @@ export const RoleMappingsLogic = kea { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts index 70990727b8a62..b15bd9e1155cc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts @@ -6,4 +6,5 @@ */ export * from './actions'; +export * from './labels'; export { DEFAULT_META } from './default_meta'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/labels.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/labels.ts new file mode 100644 index 0000000000000..8e6159d2b5b2a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/labels.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const USERNAME_LABEL = i18n.translate('xpack.enterpriseSearch.usernameLabel', { + defaultMessage: 'Username', +}); +export const EMAIL_LABEL = i18n.translate('xpack.enterpriseSearch.emailLabel', { + defaultMessage: 'Email', +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/elasticsearch_users.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/elasticsearch_users.ts new file mode 100644 index 0000000000000..500f560675679 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/elasticsearch_users.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const elasticsearchUsers = [ + { + email: 'user1@user.com', + username: 'user1', + }, +]; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts index 15dec753351ba..486c1ba6c9af6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts @@ -9,6 +9,8 @@ import { engines } from '../../../app_search/__mocks__/engines.mock'; import { AttributeName } from '../../types'; +import { elasticsearchUsers } from './elasticsearch_users'; + export const asRoleMapping = { id: 'sdgfasdgadf123', attributeName: 'role' as AttributeName, @@ -70,3 +72,20 @@ export const wsRoleMapping = { }, ], }; + +export const invitation = { + email: 'foo@example.com', + code: '123fooqwe', +}; + +export const wsSingleUserRoleMapping = { + invitation, + elasticsearchUser: elasticsearchUsers[0], + roleMapping: wsRoleMapping, +}; + +export const asSingleUserRoleMapping = { + invitation, + elasticsearchUser: elasticsearchUsers[0], + roleMapping: asRoleMapping, +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts index 9f40844e52470..45cab32b67e08 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts @@ -50,10 +50,26 @@ export const ROLE_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.rol defaultMessage: 'Role', }); +export const USERNAME_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.usernameLabel', { + defaultMessage: 'Username', +}); + +export const EMAIL_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.emailLabel', { + defaultMessage: 'Email', +}); + export const ALL_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.allLabel', { defaultMessage: 'All', }); +export const GROUPS_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.groupsLabel', { + defaultMessage: 'Groups', +}); + +export const ENGINES_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.enginesLabel', { + defaultMessage: 'Engines', +}); + export const AUTH_PROVIDER_LABEL = i18n.translate( 'xpack.enterpriseSearch.roleMapping.authProviderLabel', { @@ -82,10 +98,10 @@ export const ATTRIBUTE_VALUE_ERROR = i18n.translate( } ); -export const DELETE_ROLE_MAPPING_TITLE = i18n.translate( - 'xpack.enterpriseSearch.roleMapping.deleteRoleMappingTitle', +export const REMOVE_ROLE_MAPPING_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.removeRoleMappingTitle', { - defaultMessage: 'Remove this role mapping', + defaultMessage: 'Remove role mapping', } ); @@ -96,10 +112,17 @@ export const DELETE_ROLE_MAPPING_DESCRIPTION = i18n.translate( } ); -export const DELETE_ROLE_MAPPING_BUTTON = i18n.translate( - 'xpack.enterpriseSearch.roleMapping.deleteRoleMappingButton', +export const REMOVE_ROLE_MAPPING_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.removeRoleMappingButton', + { + defaultMessage: 'Remove mapping', + } +); + +export const REMOVE_USER_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.removeUserButton', { - defaultMessage: 'Delete mapping', + defaultMessage: 'Remove user', } ); @@ -205,3 +228,181 @@ export const ROLE_MAPPINGS_NO_RESULTS_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.roleMapping.noResults.message', { defaultMessage: 'Create a new role mapping' } ); + +export const ROLES_DISABLED_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.rolesDisabledTitle', + { defaultMessage: 'Role-based access is disabled' } +); + +export const ROLES_DISABLED_DESCRIPTION = (productName: ProductName) => + i18n.translate('xpack.enterpriseSearch.roleMapping.rolesDisabledDescription', { + defaultMessage: + 'All users set for this deployment currently have full access to {productName}. To restrict access and manage permissions, you must enable role-based access for Enterprise Search.', + values: { productName }, + }); + +export const ROLES_DISABLED_NOTE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.rolesDisabledNote', + { + defaultMessage: + 'Note: enabling role-based access restricts access for both App Search and Workplace Search. Once enabled, review access management for both products, if applicable.', + } +); + +export const ENABLE_ROLES_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.enableRolesButton', + { defaultMessage: 'Enable role-based access' } +); + +export const ENABLE_ROLES_LINK = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.enableRolesLink', + { defaultMessage: 'Learn more about role-based access' } +); + +export const INVITATION_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.invitationDescription', + { + defaultMessage: + 'This URL can be shared with the user, allowing them to accept the Enterprise Search invitation and set a new password', + } +); + +export const NEW_INVITATION_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.newInvitationLabel', + { defaultMessage: 'Invitation URL' } +); + +export const EXISTING_INVITATION_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.existingInvitationLabel', + { defaultMessage: 'The user has not yet accepted the invitation.' } +); + +export const INVITATION_LINK = i18n.translate('xpack.enterpriseSearch.roleMapping.invitationLink', { + defaultMessage: 'Enterprise Search Invitation Link', +}); + +export const NO_USERS_TITLE = i18n.translate('xpack.enterpriseSearch.roleMapping.noUsersTitle', { + defaultMessage: 'No user added', +}); + +export const NO_USERS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.noUsersDescription', + { + defaultMessage: + 'Users can be added individually, for flexibility. Role mappings provide a broader interface for adding large number of users using user attributes.', + } +); + +export const ENABLE_USERS_LINK = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.enableUsersLink', + { defaultMessage: 'Learn more about user management' } +); + +export const NEW_USER_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.newUserLabel', { + defaultMessage: 'Create new user', +}); + +export const EXISTING_USER_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.existingUserLabel', + { defaultMessage: 'Add existing user' } +); + +export const USERNAME_NO_USERS_TEXT = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.usernameNoUsersText', + { defaultMessage: 'No existing user eligible for addition.' } +); + +export const REQUIRED_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.requiredLabel', { + defaultMessage: 'Required', +}); + +export const USERS_HEADING_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.usersHeadingTitle', + { defaultMessage: 'Users' } +); + +export const USERS_HEADING_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.usersHeadingDescription', + { + defaultMessage: + 'User management provides granular access for individual or special permission needs. Users from federated sources such as SAML are managed by role mappings, and excluded from this list.', + } +); + +export const USERS_HEADING_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.usersHeadingLabel', + { defaultMessage: 'Add a new user' } +); + +export const UPDATE_USER_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.updateUserLabel', + { + defaultMessage: 'Update user', + } +); + +export const ADD_USER_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.addUserLabel', { + defaultMessage: 'Add user', +}); + +export const USER_ADDED_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.userAddedLabel', + { + defaultMessage: 'User added', + } +); + +export const USER_UPDATED_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.userUpdatedLabel', + { + defaultMessage: 'User updated', + } +); + +export const NEW_USER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.newUserDescription', + { + defaultMessage: 'Provide granular access and permissions', + } +); + +export const UPDATE_USER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.updateUserDescription', + { + defaultMessage: 'Manage granular access and permissions', + } +); + +export const INVITATION_PENDING_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.invitationPendingLabel', + { + defaultMessage: 'Invitation pending', + } +); + +export const ROLE_MODAL_TEXT = i18n.translate('xpack.enterpriseSearch.roleMapping.roleModalText', { + defaultMessage: + 'Removing a role mapping revokes access to any user corresponding to the mapping attributes, but may not take effect immediately for SAML-governed roles. Users with an active SAML session will retain access until it expires.', +}); + +export const USER_MODAL_TITLE = (username: string) => + i18n.translate('xpack.enterpriseSearch.roleMapping.userModalTitle', { + defaultMessage: 'Remove {username}', + values: { username }, + }); + +export const USER_MODAL_TEXT = i18n.translate('xpack.enterpriseSearch.roleMapping.userModalText', { + defaultMessage: + 'Removing a user immediately revokes access to the experience, unless this user’s attributes also corresponds to a role mapping for native and SAML-governed authentication, in which case associated role mappings should also be reviewed and adjusted, as needed.', +}); + +export const FILTER_USERS_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.filterUsersLabel', + { + defaultMessage: 'Filter users', + } +); + +export const NO_USERS_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.noUsersLabel', { + defaultMessage: 'No matching users found', +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts index b0d10e9692714..8096b86939ff3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts @@ -6,9 +6,17 @@ */ export { AttributeSelector } from './attribute_selector'; +export { RolesEmptyPrompt } from './roles_empty_prompt'; export { RoleMappingsTable } from './role_mappings_table'; export { RoleOptionLabel } from './role_option_label'; export { RoleSelector } from './role_selector'; export { RoleMappingFlyout } from './role_mapping_flyout'; export { RoleMappingsHeading } from './role_mappings_heading'; +export { UserAddedInfo } from './user_added_info'; +export { UserFlyout } from './user_flyout'; +export { UsersHeading } from './users_heading'; +export { UserInvitationCallout } from './user_invitation_callout'; +export { UserSelector } from './user_selector'; +export { UsersTable } from './users_table'; export { UsersAndRolesRowActions } from './users_and_roles_row_actions'; +export { UsersEmptyPrompt } from './users_empty_prompt'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.test.tsx index f0bf86fb306c6..5a2958d60dc2c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.test.tsx @@ -15,7 +15,13 @@ import { RoleMappingsHeading } from './role_mappings_heading'; describe('RoleMappingsHeading', () => { it('renders ', () => { - const wrapper = shallow(); + const wrapper = shallow( + + ); expect(wrapper.find(EuiTitle)).toHaveLength(1); expect(wrapper.find(EuiText)).toHaveLength(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.tsx index eee8b180d3281..1984cc6c60a34 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.tsx @@ -28,13 +28,11 @@ import { interface Props { productName: ProductName; + docsLink: string; onClick(): void; } -// TODO: Replace EuiLink href with acutal docs link when available -const ROLE_MAPPINGS_DOCS_HREF = '#TODO'; - -export const RoleMappingsHeading: React.FC = ({ productName, onClick }) => ( +export const RoleMappingsHeading: React.FC = ({ productName, docsLink, onClick }) => ( diff --git a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx index 7f338a859e7b4..460770744d53a 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx @@ -16,10 +16,6 @@ import { httpServiceMock } from '../../../http/http_service.mock'; import { ChromeRecentlyAccessedHistoryItem } from '../../recently_accessed'; import { CollapsibleNav } from './collapsible_nav'; -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ - htmlIdGenerator: () => () => 'mockId', -})); - const { kibana, observability, security, management } = DEFAULT_APP_CATEGORIES; function mockLink({ title = 'discover', category }: Partial) { diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index fdbdde8556eeb..a3a0197b4017e 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -99,7 +99,7 @@ describe('Header', () => { act(() => isLocked$.next(true)); component.update(); - expect(component.find('nav[aria-label="Primary"]').exists()).toBeTruthy(); + expect(component.find('[data-test-subj="collapsibleNav"]').exists()).toBeTruthy(); expect(component).toMatchSnapshot(); act(() => diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 67cdd24aae848..246ca83ef5ade 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -87,6 +87,7 @@ export function Header({ const isVisible = useObservable(observables.isVisible$, false); const isLocked = useObservable(observables.isLocked$, false); const [isNavOpen, setIsNavOpen] = useState(false); + const [navId] = useState(htmlIdGenerator()()); const breadcrumbsAppendExtension = useObservable(breadcrumbsAppendExtension$); if (!isVisible) { @@ -99,7 +100,6 @@ export function Header({ } const toggleCollapsibleNavRef = createRef void }>(); - const navId = htmlIdGenerator()(); const className = classnames('hide-for-sharing', 'headerGlobalNav'); const Breadcrumbs = ( diff --git a/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap b/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap index f5a1c51ccbe15..fbd09f3096854 100644 --- a/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap +++ b/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap @@ -26,7 +26,7 @@ Array [ ] `; -exports[`FlyoutService openFlyout() renders a flyout to the DOM 2`] = `"
Flyout content
"`; +exports[`FlyoutService openFlyout() renders a flyout to the DOM 2`] = `"
Flyout content
"`; exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 1`] = ` Array [ @@ -59,4 +59,4 @@ Array [ ] `; -exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 2`] = `"
Flyout content 2
"`; +exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 2`] = `"
Flyout content 2
"`; diff --git a/src/core/public/styles/_base.scss b/src/core/public/styles/_base.scss index 3386fa73f328a..de138cdf402e6 100644 --- a/src/core/public/styles/_base.scss +++ b/src/core/public/styles/_base.scss @@ -26,7 +26,7 @@ } .euiBody--collapsibleNavIsDocked .euiBottomBar { - margin-left: $euiCollapsibleNavWidth; + margin-left: 320px; // Hard-coded for now -- @cchaos } // Temporary fix for EuiPageHeader with a bottom border but no tabs or padding diff --git a/src/plugins/console/public/application/components/welcome_panel.tsx b/src/plugins/console/public/application/components/welcome_panel.tsx index eb746e313d228..8514d41c04a51 100644 --- a/src/plugins/console/public/application/components/welcome_panel.tsx +++ b/src/plugins/console/public/application/components/welcome_panel.tsx @@ -27,7 +27,7 @@ interface Props { export function WelcomePanel(props: Props) { return ( - +

diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 9f56740fdac22..afe339f3f43a2 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -603,7 +603,7 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` } > -
-
+
@@ -950,7 +950,7 @@ exports[`DashboardEmptyScreen renders correctly with view mode 1`] = ` } > -
-
+
diff --git a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap index a0a7e54d27532..0ab3f8a4e3466 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap @@ -176,27 +176,27 @@ exports[`Inspector Data View component should render empty state 1`] = `
+ +

+ + No data available + +

+
- -

- - No data available - -

-
diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx index 60841799b1398..50be2473a441e 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx @@ -144,7 +144,9 @@ describe('Discover flyout', function () { expect(props.setExpandedDoc.mock.calls[0][0]._id).toBe('4'); }); - it('allows navigating with arrow keys through documents', () => { + // EuiFlyout is mocked in Jest environments. + // EUI team to reinstate `onKeyDown`: https://github.com/elastic/eui/issues/4883 + it.skip('allows navigating with arrow keys through documents', () => { const props = getProps(); const component = mountWithIntl(); findTestSubject(component, 'docTableDetailsFlyout').simulate('keydown', { key: 'ArrowRight' }); diff --git a/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap index f40dbbbae1f87..68786871825ac 100644 --- a/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap +++ b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap @@ -147,27 +147,27 @@ exports[`Source Viewer component renders error state 1`] = ` />
+ +

+ An Error Occurred +

+
- -

- An Error Occurred -

-
diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap index 40170c39942e5..79c1a11cfef84 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap @@ -153,7 +153,7 @@ exports[`UrlFormatEditor should render normally 1`] = ` class="euiFormControlLayout__childrenWrapper" > diff --git a/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap index 5ad8205365146..67d2cf72c5375 100644 --- a/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap +++ b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap @@ -329,6 +329,7 @@ exports[`InspectorPanel should render as expected 1`] = ` >
& { +export type KibanaPageTemplateSolutionNavProps = Partial> & { /** * Name of the solution, i.e. "Observability" */ diff --git a/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx b/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx index 5b424c7e95f18..1af85da983085 100644 --- a/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx +++ b/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx @@ -20,7 +20,6 @@ import { EuiFlexItem, EuiFlexGroup, EuiIcon, - EuiOverlayMask, } from '@elastic/eui'; import { SolutionName, ProjectStatus, ProjectID, Project, EnvironmentName } from '../../../common'; @@ -124,30 +123,32 @@ export const LabsFlyout = (props: Props) => { ); return ( - onClose()} headerZindexLocation="below"> - - - -

- - - - - {strings.getTitleLabel()} - -

-
- - -

{strings.getDescriptionMessage()}

-
-
- - - - {footer} -
-
+ + + +

+ + + + + {strings.getTitleLabel()} + +

+
+ + +

{strings.getDescriptionMessage()}

+
+
+ + + + {footer} +
); }; diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap index 5239a92543539..5a8cd06b8ecc0 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap @@ -47,20 +47,30 @@ exports[`Intro component renders correctly 1`] = `
-
- +
- Modifying objects is for advanced users only. Object properties are not validated and invalid objects could cause errors, data loss, or worse. Unless someone with intimate knowledge of the code told you to be in here, you probably shouldn’t be. - -
+
+ + Modifying objects is for advanced users only. Object properties are not validated and invalid objects could cause errors, data loss, or worse. Unless someone with intimate knowledge of the code told you to be in here, you probably shouldn’t be. + +
+
+
diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap index bddfe000008d4..f977c17df41d3 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap @@ -49,29 +49,39 @@ exports[`NotFoundErrors component renders correctly for index-pattern type 1`] =
-
- - The index pattern associated with this object no longer exists. - -
-
- +
- If you know what this error means, go ahead and fix it — otherwise click the delete button above. - -
+
+ + The index pattern associated with this object no longer exists. + +
+
+ + If you know what this error means, go ahead and fix it — otherwise click the delete button above. + +
+
+
@@ -128,29 +138,39 @@ exports[`NotFoundErrors component renders correctly for index-pattern-field type
-
- - A field associated with this object no longer exists in the index pattern. - -
-
- +
- If you know what this error means, go ahead and fix it — otherwise click the delete button above. - -
+
+ + A field associated with this object no longer exists in the index pattern. + +
+
+ + If you know what this error means, go ahead and fix it — otherwise click the delete button above. + +
+
+
@@ -207,29 +227,39 @@ exports[`NotFoundErrors component renders correctly for search type 1`] = `
-
- - The saved search associated with this object no longer exists. - -
-
- +
- If you know what this error means, go ahead and fix it — otherwise click the delete button above. - -
+
+ + The saved search associated with this object no longer exists. + +
+
+ + If you know what this error means, go ahead and fix it — otherwise click the delete button above. + +
+
+
@@ -286,21 +316,31 @@ exports[`NotFoundErrors component renders correctly for unknown type 1`] = `
-
-
- +
- If you know what this error means, go ahead and fix it — otherwise click the delete button above. - -
+
+
+ + If you know what this error means, go ahead and fix it — otherwise click the delete button above. + +
+
+
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index a68e8891b5ad1..bd97f2e6bffb1 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -2,6 +2,7 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = ` @@ -277,6 +278,7 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` exports[`Flyout legacy conflicts should allow conflict resolution 1`] = ` @@ -548,6 +550,7 @@ Array [ exports[`Flyout should render import step 1`] = ` diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx index 62e0cd0504e8e..f6c8d5fb69408 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx @@ -960,7 +960,7 @@ export class Flyout extends Component { } return ( - +

diff --git a/src/plugins/vis_type_timeseries/public/application/components/color_picker.test.tsx b/src/plugins/vis_type_timeseries/public/application/components/color_picker.test.tsx index 8e975f9904256..50d3e8c38e389 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/color_picker.test.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/color_picker.test.tsx @@ -36,7 +36,7 @@ describe('ColorPicker', () => { const props = { ...defaultProps, value: '#68BC00' }; component = mount(); component.find('.tvbColorPicker button').simulate('click'); - const input = findTestSubject(component, 'topColorPickerInput'); + const input = findTestSubject(component, 'euiColorPickerInput_top'); expect(input.props().value).toBe('#68BC00'); }); @@ -44,7 +44,7 @@ describe('ColorPicker', () => { const props = { ...defaultProps, value: 'rgba(85,66,177,1)' }; component = mount(); component.find('.tvbColorPicker button').simulate('click'); - const input = findTestSubject(component, 'topColorPickerInput'); + const input = findTestSubject(component, 'euiColorPickerInput_top'); expect(input.props().value).toBe('85,66,177,1'); }); diff --git a/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap b/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap index 25ec05c83a8c6..56e2cb1b60f3c 100644 --- a/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap +++ b/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap @@ -14,7 +14,7 @@ exports[`VisualizationNoResults should render according to snapshot 1`] = ` data-euiicon-type="visualizeApp" />
{ await PageObjects.settings.clickEditFieldFormat(); await a11y.testAppSnapshot(); + await PageObjects.settings.clickCloseEditFieldFormatFlyout(); }); it('Advanced settings', async () => { diff --git a/test/functional/apps/management/_import_objects.ts b/test/functional/apps/management/_import_objects.ts index 0278955c577a1..6ef0bfd5a09e8 100644 --- a/test/functional/apps/management/_import_objects.ts +++ b/test/functional/apps/management/_import_objects.ts @@ -419,14 +419,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'index-pattern-test-1' ); - await testSubjects.click('pagination-button-next'); + const flyout = await testSubjects.find('importSavedObjectsFlyout'); + + await (await flyout.findByTestSubject('pagination-button-next')).click(); await PageObjects.savedObjects.setOverriddenIndexPatternValue( 'missing-index-pattern-7', 'index-pattern-test-2' ); - await testSubjects.click('pagination-button-previous'); + await (await flyout.findByTestSubject('pagination-button-previous')).click(); const selectedIdForMissingIndexPattern1 = await testSubjects.getAttribute( 'managementChangeIndexSelection-missing-index-pattern-1', @@ -435,7 +437,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(selectedIdForMissingIndexPattern1).to.eql('f1e4c910-a2e6-11e7-bb30-233be9be6a20'); - await testSubjects.click('pagination-button-next'); + await (await flyout.findByTestSubject('pagination-button-next')).click(); const selectedIdForMissingIndexPattern7 = await testSubjects.getAttribute( 'managementChangeIndexSelection-missing-index-pattern-7', diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 88951bb04c956..cb8f198177017 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -739,6 +739,10 @@ export class SettingsPageObject extends FtrService { await this.testSubjects.click('editFieldFormat'); } + async clickCloseEditFieldFormatFlyout() { + await this.testSubjects.click('euiFlyoutCloseButton'); + } + async associateIndexPattern(oldIndexPatternId: string, newIndexPatternTitle: string) { await this.find.clickByCssSelector( `select[data-test-subj="managementChangeIndexSelection-${oldIndexPatternId}"] > diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index 6e263dd1cdbbf..7f1ea64bcd979 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -563,7 +563,7 @@ export class VisualBuilderPageObject extends FtrService { public async checkColorPickerPopUpIsPresent(): Promise { this.log.debug(`Check color picker popup is present`); - await this.testSubjects.existOrFail('colorPickerPopover', { timeout: 5000 }); + await this.testSubjects.existOrFail('euiColorPickerPopover', { timeout: 5000 }); } public async changePanelPreview(nth: number = 0): Promise { diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx index 8549f09bba248..09fbf07b8ecbd 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx @@ -5,10 +5,21 @@ * 2.0. */ +import { ReactNode } from 'react'; +import { StyledComponent } from 'styled-components'; import { EuiFlyout } from '@elastic/eui'; -import { euiStyled } from '../../../../../../../../../../src/plugins/kibana_react/common'; +import { + euiStyled, + EuiTheme, +} from '../../../../../../../../../../src/plugins/kibana_react/common'; -export const ResponsiveFlyout = euiStyled(EuiFlyout)` +// TODO: EUI team follow up on complex types and styled-components `styled` +// https://github.com/elastic/eui/issues/4855 +export const ResponsiveFlyout: StyledComponent< + typeof EuiFlyout, + EuiTheme, + { children?: ReactNode } +> = euiStyled(EuiFlyout)` width: 100%; @media (min-width: 800px) { diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset_manager.stories.storyshot b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset_manager.stories.storyshot index 34b6b333f3ef5..d567d3cf85f13 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset_manager.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset_manager.stories.storyshot @@ -116,20 +116,13 @@ exports[`Storyshots components/Assets/AssetManager no assets 1`] = ` size="xxl" />
- -

- Import your assets to get started -

-
- + Import your assets to get started +

diff --git a/x-pack/plugins/canvas/public/components/custom_element_modal/__stories__/__snapshots__/custom_element_modal.stories.storyshot b/x-pack/plugins/canvas/public/components/custom_element_modal/__stories__/__snapshots__/custom_element_modal.stories.storyshot index 18f86aca24302..dc66eef809050 100644 --- a/x-pack/plugins/canvas/public/components/custom_element_modal/__stories__/__snapshots__/custom_element_modal.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/custom_element_modal/__stories__/__snapshots__/custom_element_modal.stories.storyshot @@ -80,7 +80,7 @@ exports[`Storyshots components/Elements/CustomElementModal with description 1`] className="euiFormControlLayout__childrenWrapper" >
40 characters remaining
@@ -119,7 +119,7 @@ exports[`Storyshots components/Elements/CustomElementModal with description 1`] className="euiFormRow__fieldWrapper" >

@@ -45,7 +43,7 @@ export const RoleMappingsHeading: React.FC = ({ productName, onClick }) =

{ROLE_MAPPINGS_HEADING_DESCRIPTION(productName)}{' '} - + {ROLE_MAPPINGS_HEADING_DOCS_LINK}

diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx index 156b52a4016c3..81a7c06020165 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx @@ -13,7 +13,9 @@ import { mount } from 'enzyme'; import { EuiInMemoryTable, EuiTableHeaderCell } from '@elastic/eui'; -import { ALL_LABEL, ANY_AUTH_PROVIDER_OPTION_LABEL } from './constants'; +import { engines } from '../../app_search/__mocks__/engines.mock'; + +import { ANY_AUTH_PROVIDER_OPTION_LABEL } from './constants'; import { RoleMappingsTable } from './role_mappings_table'; import { UsersAndRolesRowActions } from './users_and_roles_row_actions'; @@ -78,28 +80,30 @@ describe('RoleMappingsTable', () => { expect(handleDeleteMapping).toHaveBeenCalled(); }); - it('shows default message when "accessAllEngines" is true', () => { + it('handles access items display for all items', () => { const wrapper = mount( ); - expect(wrapper.find('[data-test-subj="AccessItemsList"]').prop('children')).toEqual(ALL_LABEL); + expect(wrapper.find('[data-test-subj="AllItems"]')).toHaveLength(1); }); - it('handles display when no items present', () => { - const noItemsRoleMapping = { ...asRoleMapping, engines: [] }; - noItemsRoleMapping.accessAllEngines = false; - + it('handles access items display more than 2 items', () => { + const extraEngine = { + ...engines[0], + id: '3', + }; + + const roleMapping = { + ...asRoleMapping, + engines: [...engines, extraEngine], + accessAllEngines: false, + }; const wrapper = mount( - + ); - - expect(wrapper.find('[data-test-subj="AccessItemsList"]').children().children().text()).toEqual( - '—' + expect(wrapper.find('[data-test-subj="AccessItems"]').prop('children')).toEqual( + `${engines[0].name}, ${engines[1].name} + 1` ); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx index 7696cf03ed4b1..eb9621c7a242c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import React, { Fragment } from 'react'; +import React from 'react'; -import { EuiIconTip, EuiTextColor, EuiInMemoryTable, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiIconTip, EuiInMemoryTable, EuiBasicTableColumn } from '@elastic/eui'; import { ASRoleMapping } from '../../app_search/types'; import { WSRoleMapping } from '../../workplace_search/types'; @@ -46,8 +46,6 @@ interface Props { handleDeleteMapping(roleMappingId: string): void; } -const noItemsPlaceholder = ; - const getAuthProviderDisplayValue = (authProvider: string) => authProvider === ANY_AUTH_PROVIDER ? ANY_AUTH_PROVIDER_OPTION_LABEL : authProvider; @@ -90,24 +88,18 @@ export const RoleMappingsTable: React.FC = ({ const accessItemsCol: EuiBasicTableColumn = { field: 'accessItems', name: accessHeader, - render: (_, { accessAllEngines, accessItems }: SharedRoleMapping) => ( - - {accessAllEngines ? ( - ALL_LABEL - ) : ( - <> - {accessItems.length === 0 - ? noItemsPlaceholder - : accessItems.map(({ name }) => ( - - {name} -
-
- ))} - - )} -
- ), + render: (_, { accessAllEngines, accessItems }: SharedRoleMapping) => { + // Design calls for showing the first 2 items followed by a +x after those 2. + // ['foo', 'bar', 'baz'] would display as: "foo, bar + 1" + const numItems = accessItems.length; + if (accessAllEngines || numItems === 0) + return {ALL_LABEL}; + const additionalItems = numItems > 2 ? ` + ${numItems - 2}` : ''; + const names = accessItems.map((item) => item.name); + return ( + {names.slice(0, 2).join(', ') + additionalItems} + ); + }, }; const authProviderCol: EuiBasicTableColumn = { @@ -143,6 +135,7 @@ export const RoleMappingsTable: React.FC = ({ const pagination = { hidePerPageOptions: true, + pageSize: 10, }; const search = { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.test.tsx new file mode 100644 index 0000000000000..8331a45849e3a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.test.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButton, EuiLink, EuiEmptyPrompt } from '@elastic/eui'; + +import { RolesEmptyPrompt } from './roles_empty_prompt'; + +describe('RolesEmptyPrompt', () => { + const onEnable = jest.fn(); + + const props = { + productName: 'App Search', + docsLink: 'http://elastic.co', + onEnable, + }; + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(EuiEmptyPrompt).dive().find(EuiLink).prop('href')).toEqual(props.docsLink); + }); + + it('calls onEnable on change', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + prompt.find(EuiButton).simulate('click'); + + expect(onEnable).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.tsx new file mode 100644 index 0000000000000..11d50573c45f6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiEmptyPrompt, EuiButton, EuiLink, EuiSpacer } from '@elastic/eui'; + +import { ProductName } from '../types'; + +import { + ROLES_DISABLED_TITLE, + ROLES_DISABLED_DESCRIPTION, + ROLES_DISABLED_NOTE, + ENABLE_ROLES_BUTTON, + ENABLE_ROLES_LINK, +} from './constants'; + +interface Props { + productName: ProductName; + docsLink: string; + onEnable(): void; +} + +export const RolesEmptyPrompt: React.FC = ({ onEnable, docsLink, productName }) => ( + {ROLES_DISABLED_TITLE}} + body={ + <> +

{ROLES_DISABLED_DESCRIPTION(productName)}

+

{ROLES_DISABLED_NOTE}

+ + } + actions={[ + + {ENABLE_ROLES_BUTTON} + , + , + + {ENABLE_ROLES_LINK} + , + ]} + /> +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx new file mode 100644 index 0000000000000..30bdaa0010b58 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiText } from '@elastic/eui'; + +import { UserAddedInfo } from './'; + +describe('UserAddedInfo', () => { + const props = { + username: 'user1', + email: 'test@test.com', + roleType: 'user', + }; + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiText)).toHaveLength(6); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx new file mode 100644 index 0000000000000..a12eae66262a0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiSpacer, EuiText } from '@elastic/eui'; + +import { USERNAME_LABEL, EMAIL_LABEL } from '../constants'; + +import { ROLE_LABEL } from './constants'; + +interface Props { + username: string; + email: string; + roleType: string; +} + +export const UserAddedInfo: React.FC = ({ username, email, roleType }) => ( + <> + + {USERNAME_LABEL} + + {username} + + + {EMAIL_LABEL} + + {email} + + + {ROLE_LABEL} + + {roleType} + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.test.tsx new file mode 100644 index 0000000000000..43333fe048f23 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFlyout, EuiText, EuiIcon } from '@elastic/eui'; + +import { + USERS_HEADING_LABEL, + UPDATE_USER_LABEL, + USER_UPDATED_LABEL, + NEW_USER_DESCRIPTION, + UPDATE_USER_DESCRIPTION, +} from './constants'; + +import { UserFlyout } from './'; + +describe('UserFlyout', () => { + const closeUserFlyout = jest.fn(); + const handleSaveUser = jest.fn(); + + const props = { + children:
, + isNew: true, + isComplete: false, + disabled: false, + closeUserFlyout, + handleSaveUser, + }; + + it('renders for new user', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiFlyout)).toHaveLength(1); + expect(wrapper.find('h2').prop('children')).toEqual(USERS_HEADING_LABEL); + expect(wrapper.find(EuiText).prop('children')).toEqual(

{NEW_USER_DESCRIPTION}

); + }); + + it('renders for existing user', () => { + const wrapper = shallow(); + + expect(wrapper.find('h2').prop('children')).toEqual(UPDATE_USER_LABEL); + expect(wrapper.find(EuiText).prop('children')).toEqual(

{UPDATE_USER_DESCRIPTION}

); + }); + + it('renders icon and message for completed user', () => { + const wrapper = shallow(); + const icon = ( + + ); + const children = ( + + {USER_UPDATED_LABEL} {icon} + + ); + + expect(wrapper.find('h2').prop('children')).toEqual(children); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx new file mode 100644 index 0000000000000..e13a56a716929 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiIcon, + EuiText, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; + +interface Props { + children: React.ReactNode; + isNew: boolean; + isComplete: boolean; + disabled: boolean; + closeUserFlyout(): void; + handleSaveUser(): void; +} + +import { CANCEL_BUTTON_LABEL, CLOSE_BUTTON_LABEL } from '../constants'; + +import { + USERS_HEADING_LABEL, + UPDATE_USER_LABEL, + ADD_USER_LABEL, + USER_ADDED_LABEL, + USER_UPDATED_LABEL, + NEW_USER_DESCRIPTION, + UPDATE_USER_DESCRIPTION, +} from './constants'; + +export const UserFlyout: React.FC = ({ + children, + isNew, + isComplete, + disabled, + closeUserFlyout, + handleSaveUser, +}) => { + const savedIcon = ( + + ); + const IS_EDITING_HEADING = isNew ? USERS_HEADING_LABEL : UPDATE_USER_LABEL; + const IS_EDITING_DESCRIPTION = isNew ? NEW_USER_DESCRIPTION : UPDATE_USER_DESCRIPTION; + const USER_SAVED_HEADING = isNew ? USER_ADDED_LABEL : USER_UPDATED_LABEL; + const IS_COMPLETE_HEADING = ( + + {USER_SAVED_HEADING} {savedIcon} + + ); + + const editingFooterActions = ( + + + {CANCEL_BUTTON_LABEL} + + + + {isNew ? ADD_USER_LABEL : UPDATE_USER_LABEL} + + + + ); + + const completedFooterAction = ( + + + + {CLOSE_BUTTON_LABEL} + + + + ); + + return ( + + + +

{isComplete ? IS_COMPLETE_HEADING : IS_EDITING_HEADING}

+
+ {!isComplete && ( + +

{IS_EDITING_DESCRIPTION}

+
+ )} +
+ + {children} + + + {isComplete ? completedFooterAction : editingFooterActions} +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.test.tsx new file mode 100644 index 0000000000000..d5272a26715b6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiText, EuiButtonIcon, EuiCopy } from '@elastic/eui'; + +import { EXISTING_INVITATION_LABEL } from './constants'; + +import { UserInvitationCallout } from './'; + +describe('UserInvitationCallout', () => { + const props = { + isNew: true, + invitationCode: 'test@test.com', + urlPrefix: 'http://foo', + }; + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiText)).toHaveLength(2); + }); + + it('renders the copy button', () => { + const copyMock = jest.fn(); + const wrapper = shallow(); + + const copyEl = shallow(
{wrapper.find(EuiCopy).props().children(copyMock)}
); + expect(copyEl.find(EuiButtonIcon).props().onClick).toEqual(copyMock); + }); + + it('renders existing invitation label', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiText).first().prop('children')).toEqual( + {EXISTING_INVITATION_LABEL} + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx new file mode 100644 index 0000000000000..8310077ad6f2e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiCopy, EuiButtonIcon, EuiSpacer, EuiText, EuiLink } from '@elastic/eui'; + +import { + INVITATION_DESCRIPTION, + NEW_INVITATION_LABEL, + EXISTING_INVITATION_LABEL, + INVITATION_LINK, +} from './constants'; + +interface Props { + isNew: boolean; + invitationCode: string; + urlPrefix: string; +} + +export const UserInvitationCallout: React.FC = ({ isNew, invitationCode, urlPrefix }) => { + const link = urlPrefix + invitationCode; + const label = isNew ? NEW_INVITATION_LABEL : EXISTING_INVITATION_LABEL; + + return ( + <> + {!isNew && } + + {label} + + + {INVITATION_DESCRIPTION} + + + {INVITATION_LINK} + {' '} + + {(copy) => } + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx new file mode 100644 index 0000000000000..08ddc7ba5427f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { elasticsearchUsers } from './__mocks__/elasticsearch_users'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFormRow } from '@elastic/eui'; + +import { Role as ASRole } from '../../app_search/types'; + +import { REQUIRED_LABEL, USERNAME_NO_USERS_TEXT } from './constants'; + +import { UserSelector } from './'; + +const simulatedEvent = { + target: { value: 'foo' }, +}; + +describe('UserSelector', () => { + const setUserExisting = jest.fn(); + const setElasticsearchUsernameValue = jest.fn(); + const setElasticsearchEmailValue = jest.fn(); + const handleRoleChange = jest.fn(); + const handleUsernameSelectChange = jest.fn(); + + const roleType = ('user' as unknown) as ASRole; + + const props = { + isNewUser: true, + userFormUserIsExisting: true, + elasticsearchUsers, + elasticsearchUser: elasticsearchUsers[0], + roleTypes: [roleType], + roleType, + setUserExisting, + setElasticsearchUsernameValue, + setElasticsearchEmailValue, + handleRoleChange, + handleUsernameSelectChange, + }; + + it('renders Role select and calls method', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="RoleSelect"]').simulate('change', simulatedEvent); + + expect(handleRoleChange).toHaveBeenCalled(); + }); + + it('renders when updating user', () => { + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="UsernameInput"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="EmailInput"]')).toHaveLength(1); + }); + + it('renders Username select and calls method', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="UsernameSelect"]').simulate('change', simulatedEvent); + + expect(handleUsernameSelectChange).toHaveBeenCalled(); + }); + + it('renders Existing user radio and calls method', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="ExistingUserRadio"]').simulate('change'); + + expect(setUserExisting).toHaveBeenCalledWith(true); + }); + + it('renders Email input and calls method', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="EmailInput"]').simulate('change', simulatedEvent); + + expect(setElasticsearchEmailValue).toHaveBeenCalled(); + }); + + it('renders Username input and calls method', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="UsernameInput"]').simulate('change', simulatedEvent); + + expect(setElasticsearchUsernameValue).toHaveBeenCalled(); + }); + + it('renders New user radio and calls method', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="NewUserRadio"]').simulate('change'); + + expect(setUserExisting).toHaveBeenCalledWith(false); + }); + + it('renders helpText when values are empty', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiFormRow).at(0).prop('helpText')).toEqual(USERNAME_NO_USERS_TEXT); + expect(wrapper.find(EuiFormRow).at(1).prop('helpText')).toEqual(REQUIRED_LABEL); + expect(wrapper.find(EuiFormRow).at(2).prop('helpText')).toEqual(REQUIRED_LABEL); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx new file mode 100644 index 0000000000000..70348bf29894a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { + EuiFieldText, + EuiRadio, + EuiFormRow, + EuiSelect, + EuiSelectOption, + EuiSpacer, +} from '@elastic/eui'; + +import { Role as ASRole } from '../../app_search/types'; +import { ElasticsearchUser } from '../../shared/types'; +import { Role as WSRole } from '../../workplace_search/types'; + +import { USERNAME_LABEL, EMAIL_LABEL } from '../constants'; + +import { + NEW_USER_LABEL, + EXISTING_USER_LABEL, + USERNAME_NO_USERS_TEXT, + REQUIRED_LABEL, + ROLE_LABEL, +} from './constants'; + +type SharedRole = WSRole | ASRole; + +interface Props { + isNewUser: boolean; + userFormUserIsExisting: boolean; + elasticsearchUsers: ElasticsearchUser[]; + elasticsearchUser: ElasticsearchUser; + roleTypes: SharedRole[]; + roleType: SharedRole; + setUserExisting(userFormUserIsExisting: boolean): void; + setElasticsearchUsernameValue(username: string): void; + setElasticsearchEmailValue(email: string): void; + handleRoleChange(roleType: SharedRole): void; + handleUsernameSelectChange(username: string): void; +} + +export const UserSelector: React.FC = ({ + isNewUser, + userFormUserIsExisting, + elasticsearchUsers, + elasticsearchUser, + roleTypes, + roleType, + setUserExisting, + setElasticsearchUsernameValue, + setElasticsearchEmailValue, + handleRoleChange, + handleUsernameSelectChange, +}) => { + const roleOptions = roleTypes.map((role) => ({ id: role, text: role })); + const usernameOptions = elasticsearchUsers.map(({ username }) => ({ + id: username, + text: username, + })); + const hasElasticsearchUsers = elasticsearchUsers.length > 0; + const showNewUserExistingUserControls = userFormUserIsExisting && hasElasticsearchUsers; + + const roleSelect = ( + + handleRoleChange(e.target.value as SharedRole)} + /> + + ); + + const emailInput = ( + + setElasticsearchEmailValue(e.target.value)} + /> + + ); + + const usernameAndEmailControls = ( + <> + + setElasticsearchUsernameValue(e.target.value)} + /> + + {elasticsearchUser.email !== null && emailInput} + {roleSelect} + + ); + + const existingUserControls = ( + <> + + + handleUsernameSelectChange(e.target.value)} + /> + + {roleSelect} + + ); + + const newUserControls = ( + <> + + {usernameAndEmailControls} + + ); + + const createUserControls = ( + <> + + setUserExisting(true)} + disabled={!hasElasticsearchUsers} + /> + + + {showNewUserExistingUserControls && existingUserControls} + + setUserExisting(false)} + /> + {!showNewUserExistingUserControls && newUserControls} + + ); + + return isNewUser ? createUserControls : usernameAndEmailControls; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.test.tsx index dbb47b50d4066..5f1fefc688c77 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.test.tsx @@ -9,15 +9,23 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiButtonIcon } from '@elastic/eui'; +import { EuiButtonIcon, EuiConfirmModal } from '@elastic/eui'; + +import { + REMOVE_ROLE_MAPPING_TITLE, + REMOVE_ROLE_MAPPING_BUTTON, + ROLE_MODAL_TEXT, +} from './constants'; import { UsersAndRolesRowActions } from './users_and_roles_row_actions'; describe('UsersAndRolesRowActions', () => { const onManageClick = jest.fn(); const onDeleteClick = jest.fn(); + const username = 'foo'; const props = { + username, onManageClick, onDeleteClick, }; @@ -40,7 +48,19 @@ describe('UsersAndRolesRowActions', () => { const wrapper = shallow(); const button = wrapper.find(EuiButtonIcon).last(); button.simulate('click'); + wrapper.find(EuiConfirmModal).prop('onConfirm')!({} as any); expect(onDeleteClick).toHaveBeenCalled(); }); + + it('renders role mapping confirm modal text', () => { + const wrapper = shallow(); + const button = wrapper.find(EuiButtonIcon).last(); + button.simulate('click'); + const modal = wrapper.find(EuiConfirmModal); + + expect(modal.prop('title')).toEqual(REMOVE_ROLE_MAPPING_TITLE); + expect(modal.prop('children')).toEqual(

{ROLE_MODAL_TEXT}

); + expect(modal.prop('confirmButtonText')).toEqual(REMOVE_ROLE_MAPPING_BUTTON); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.tsx index 3d956c0aabd68..a3b0d24769bf6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.tsx @@ -5,20 +5,65 @@ * 2.0. */ -import React from 'react'; +import React, { useState } from 'react'; -import { EuiButtonIcon } from '@elastic/eui'; +import { EuiButtonIcon, EuiConfirmModal } from '@elastic/eui'; -import { MANAGE_BUTTON_LABEL, DELETE_BUTTON_LABEL } from '../constants'; +import { CANCEL_BUTTON_LABEL, MANAGE_BUTTON_LABEL, DELETE_BUTTON_LABEL } from '../constants'; + +import { + REMOVE_ROLE_MAPPING_TITLE, + REMOVE_ROLE_MAPPING_BUTTON, + REMOVE_USER_BUTTON, + ROLE_MODAL_TEXT, + USER_MODAL_TITLE, + USER_MODAL_TEXT, +} from './constants'; interface Props { + username?: string; onManageClick(): void; onDeleteClick(): void; } -export const UsersAndRolesRowActions: React.FC = ({ onManageClick, onDeleteClick }) => ( - <> - {' '} - - -); +export const UsersAndRolesRowActions: React.FC = ({ + onManageClick, + onDeleteClick, + username, +}) => { + const [deleteModalVisible, setVisible] = useState(false); + const showDeleteModal = () => setVisible(true); + const closeDeleteModal = () => setVisible(false); + const title = username ? USER_MODAL_TITLE(username) : REMOVE_ROLE_MAPPING_TITLE; + const text = username ? USER_MODAL_TEXT : ROLE_MODAL_TEXT; + const confirmButton = username ? REMOVE_USER_BUTTON : REMOVE_ROLE_MAPPING_BUTTON; + + const deleteModal = ( + { + onDeleteClick(); + closeDeleteModal(); + }} + cancelButtonText={CANCEL_BUTTON_LABEL} + confirmButtonText={confirmButton} + buttonColor="danger" + defaultFocusedButton="confirm" + > +

{text}

+
+ ); + + return ( + <> + {deleteModalVisible && deleteModal} + {' '} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.test.tsx new file mode 100644 index 0000000000000..9110c09827c49 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt } from '@elastic/eui'; + +import { UsersEmptyPrompt } from './'; + +describe('UsersEmptyPrompt', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.tsx new file mode 100644 index 0000000000000..42bf690c388c4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiEmptyPrompt, + EuiLink, + EuiSpacer, +} from '@elastic/eui'; + +import { docLinks } from '../doc_links'; + +import { NO_USERS_TITLE, NO_USERS_DESCRIPTION, ENABLE_USERS_LINK } from './constants'; + +const USERS_DOCS_URL = `${docLinks.enterpriseSearchBase}/users-access.html`; + +export const UsersEmptyPrompt: React.FC = () => ( + + + + + {NO_USERS_TITLE}} + body={

{NO_USERS_DESCRIPTION}

} + actions={ + + {ENABLE_USERS_LINK} + + } + /> +
+
+
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.test.tsx new file mode 100644 index 0000000000000..9bae93079e89f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButton, EuiText, EuiTitle } from '@elastic/eui'; + +import { UsersHeading } from './'; + +describe('UsersHeading', () => { + const onClick = jest.fn(); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiText)).toHaveLength(1); + expect(wrapper.find(EuiTitle)).toHaveLength(1); + }); + + it('handles button click', () => { + const wrapper = shallow(); + wrapper.find(EuiButton).simulate('click'); + + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.tsx new file mode 100644 index 0000000000000..8d097e21e9c3f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; + +import { USERS_HEADING_TITLE, USERS_HEADING_DESCRIPTION, USERS_HEADING_LABEL } from './constants'; + +interface Props { + onClick(): void; +} + +export const UsersHeading: React.FC = ({ onClick }) => ( + <> + + + +

{USERS_HEADING_TITLE}

+
+ +

{USERS_HEADING_DESCRIPTION}

+
+
+ + + {USERS_HEADING_LABEL} + + +
+ + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.test.tsx new file mode 100644 index 0000000000000..dc1a2713ced12 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { asSingleUserRoleMapping, wsSingleUserRoleMapping, asRoleMapping } from './__mocks__/roles'; + +import React from 'react'; + +import { shallow, mount } from 'enzyme'; + +import { EuiInMemoryTable, EuiTextColor } from '@elastic/eui'; + +import { engines } from '../../app_search/__mocks__/engines.mock'; + +import { UsersAndRolesRowActions } from './users_and_roles_row_actions'; + +import { UsersTable } from './'; + +describe('UsersTable', () => { + const initializeSingleUserRoleMapping = jest.fn(); + const handleDeleteMapping = jest.fn(); + const props = { + accessItemKey: 'groups' as 'groups' | 'engines', + singleUserRoleMappings: [wsSingleUserRoleMapping], + initializeSingleUserRoleMapping, + handleDeleteMapping, + }; + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiInMemoryTable)).toHaveLength(1); + }); + + it('handles manage click', () => { + const wrapper = mount(); + wrapper.find(UsersAndRolesRowActions).prop('onManageClick')(); + + expect(initializeSingleUserRoleMapping).toHaveBeenCalled(); + }); + + it('handles delete click', () => { + const wrapper = mount(); + wrapper.find(UsersAndRolesRowActions).prop('onDeleteClick')(); + + expect(handleDeleteMapping).toHaveBeenCalled(); + }); + + it('handles display when no email present', () => { + const userWithNoEmail = { + ...wsSingleUserRoleMapping, + elasticsearchUser: { + email: null, + username: 'foo', + }, + }; + const wrapper = mount(); + + expect(wrapper.find(EuiTextColor)).toHaveLength(1); + }); + + it('handles access items display for all items', () => { + const userWithAllItems = { + ...asSingleUserRoleMapping, + roleMapping: { + ...asRoleMapping, + engines: [], + }, + }; + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="AllItems"]')).toHaveLength(1); + }); + + it('handles access items display more than 2 items', () => { + const extraEngine = { + ...engines[0], + id: '3', + }; + const userWithAllItems = { + ...asSingleUserRoleMapping, + roleMapping: { + ...asRoleMapping, + engines: [...engines, extraEngine], + }, + }; + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="AccessItems"]').prop('children')).toEqual( + `${engines[0].name}, ${engines[1].name} + 1` + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx new file mode 100644 index 0000000000000..86dc2c2626229 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiBadge, EuiBasicTableColumn, EuiInMemoryTable, EuiTextColor } from '@elastic/eui'; + +import { ASRoleMapping } from '../../app_search/types'; +import { SingleUserRoleMapping } from '../../shared/types'; +import { WSRoleMapping } from '../../workplace_search/types'; + +import { + INVITATION_PENDING_LABEL, + ALL_LABEL, + FILTER_USERS_LABEL, + NO_USERS_LABEL, + ROLE_LABEL, + USERNAME_LABEL, + EMAIL_LABEL, + GROUPS_LABEL, + ENGINES_LABEL, +} from './constants'; + +import { UsersAndRolesRowActions } from './'; + +interface AccessItem { + name: string; +} + +interface SharedUser extends SingleUserRoleMapping { + accessItems: AccessItem[]; + username: string; + email: string | null; + roleType: string; + id: string; +} + +interface SharedRoleMapping extends ASRoleMapping, WSRoleMapping { + accessItems: AccessItem[]; +} + +interface Props { + accessItemKey: 'groups' | 'engines'; + singleUserRoleMappings: Array>; + initializeSingleUserRoleMapping(roleId: string): string; + handleDeleteMapping(roleId: string): string; +} + +const noItemsPlaceholder = ; +const invitationBadge = {INVITATION_PENDING_LABEL}; + +export const UsersTable: React.FC = ({ + accessItemKey, + singleUserRoleMappings, + initializeSingleUserRoleMapping, + handleDeleteMapping, +}) => { + // 'accessItems' is needed because App Search has `engines` and Workplace Search has `groups`. + const users = ((singleUserRoleMappings as SharedUser[]).map((user) => ({ + username: user.elasticsearchUser.username, + email: user.elasticsearchUser.email, + roleType: user.roleMapping.roleType, + id: user.roleMapping.id, + accessItems: (user.roleMapping as SharedRoleMapping)[accessItemKey], + invitation: user.invitation, + })) as unknown) as Array>; + + const columns: Array> = [ + { + field: 'username', + name: USERNAME_LABEL, + render: (_, { username }: SharedUser) => username, + }, + { + field: 'email', + name: EMAIL_LABEL, + render: (_, { email, invitation }: SharedUser) => { + if (!email) return noItemsPlaceholder; + return ( +
+ {email} {invitation && invitationBadge} +
+ ); + }, + }, + { + field: 'roleType', + name: ROLE_LABEL, + render: (_, user: SharedUser) => user.roleType, + }, + { + field: 'accessItems', + name: accessItemKey === 'groups' ? GROUPS_LABEL : ENGINES_LABEL, + render: (_, { accessItems }: SharedUser) => { + // Design calls for showing the first 2 items followed by a +x after those 2. + // ['foo', 'bar', 'baz'] would display as: "foo, bar + 1" + const numItems = accessItems.length; + if (numItems === 0) return {ALL_LABEL}; + const additionalItems = numItems > 2 ? ` + ${numItems - 2}` : ''; + const names = accessItems.map((item) => item.name); + return ( + {names.slice(0, 2).join(', ') + additionalItems} + ); + }, + }, + { + field: 'id', + name: '', + render: (_, { id, username }: SharedUser) => ( + initializeSingleUserRoleMapping(id)} + onDeleteClick={() => handleDeleteMapping(id)} + /> + ), + }, + ]; + + const pagination = { + hidePerPageOptions: true, + pageSize: 10, + }; + + const search = { + box: { + incremental: true, + fullWidth: false, + placeholder: FILTER_USERS_LABEL, + 'data-test-subj': 'UsersTableSearchInput', + }, + }; + + return ( + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts index 67208c63ddf4c..e6d2c67d1baf8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -40,3 +40,19 @@ export interface RoleMapping { const productNames = [APP_SEARCH_PLUGIN.NAME, WORKPLACE_SEARCH_PLUGIN.NAME] as const; export type ProductName = typeof productNames[number]; + +export interface Invitation { + email: string; + code: string; +} + +export interface ElasticsearchUser { + email: string | null; + username: string; +} + +export interface SingleUserRoleMapping { + invitation: Invitation; + elasticsearchUser: ElasticsearchUser; + roleMapping: T; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx index a4eb228eff92f..050aaf1dadf89 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx @@ -11,8 +11,8 @@ import { useValues } from 'kea'; import { EuiTable, EuiTableBody, EuiTablePagination } from '@elastic/eui'; import { Pager } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { USERNAME_LABEL, EMAIL_LABEL } from '../../../../shared/constants'; import { TableHeader } from '../../../../shared/table_header'; import { AppLogic } from '../../../app_logic'; import { UserRow } from '../../../components/shared/user_row'; @@ -20,27 +20,15 @@ import { User } from '../../../types'; import { GroupLogic } from '../group_logic'; const USERS_PER_PAGE = 10; -const USERNAME_TABLE_HEADER = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.usernameTableHeader', - { - defaultMessage: 'Username', - } -); -const EMAIL_TABLE_HEADER = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.emailTableHeader', - { - defaultMessage: 'Email', - } -); export const GroupUsersTable: React.FC = () => { const { isFederatedAuth } = useValues(AppLogic); const { group: { users }, } = useValues(GroupLogic); - const headerItems = [USERNAME_TABLE_HEADER]; + const headerItems = [USERNAME_LABEL]; if (!isFederatedAuth) { - headerItems.push(EMAIL_TABLE_HEADER); + headerItems.push(EMAIL_LABEL); } const [firstItem, setFirstItem] = useState(0); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts index 92c8b7827b9b6..809b631c78391 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts @@ -7,14 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const DELETE_ROLE_MAPPING_MESSAGE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.roleMapping.deleteRoleMappingButtonMessage', - { - defaultMessage: - 'Are you sure you want to permanently delete this mapping? This action is not reversible and some users might lose access.', - } -); - export const ROLE_MAPPING_DELETED_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.roleMappingDeletedMessage', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx index b153d01224193..01d32bec14ebd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx @@ -10,9 +10,14 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; -import { RoleMappingsTable, RoleMappingsHeading } from '../../../shared/role_mapping'; +import { + RoleMappingsTable, + RoleMappingsHeading, + RolesEmptyPrompt, +} from '../../../shared/role_mapping'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; import { WorkplaceSearchPageTemplate } from '../../components/layout'; +import { SECURITY_DOCS_URL } from '../../routes'; import { ROLE_MAPPINGS_TABLE_HEADER } from './constants'; @@ -20,9 +25,12 @@ import { RoleMapping } from './role_mapping'; import { RoleMappingsLogic } from './role_mappings_logic'; export const RoleMappings: React.FC = () => { - const { initializeRoleMappings, initializeRoleMapping, handleDeleteMapping } = useActions( - RoleMappingsLogic - ); + const { + enableRoleBasedAccess, + initializeRoleMappings, + initializeRoleMapping, + handleDeleteMapping, + } = useActions(RoleMappingsLogic); const { roleMappings, @@ -35,10 +43,19 @@ export const RoleMappings: React.FC = () => { initializeRoleMappings(); }, []); + const rolesEmptyState = ( + + ); + const roleMappingsSection = (
initializeRoleMapping()} /> { pageChrome={[ROLE_MAPPINGS_TITLE]} pageHeader={{ pageTitle: ROLE_MAPPINGS_TITLE }} isLoading={dataLoading} + isEmptyState={roleMappings.length < 1} + emptyState={rolesEmptyState} > {roleMappingFlyoutOpen && } {roleMappingsSection} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts index 4ee530870284e..a4bbddbd23b49 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts @@ -90,6 +90,13 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values.selectedGroups).toEqual(new Set([defaultGroup.id])); }); + it('setRoleMappings', () => { + RoleMappingsLogic.actions.setRoleMappings({ roleMappings: [wsRoleMapping] }); + + expect(RoleMappingsLogic.values.roleMappings).toEqual([wsRoleMapping]); + expect(RoleMappingsLogic.values.dataLoading).toEqual(false); + }); + it('handleRoleChange', () => { RoleMappingsLogic.actions.handleRoleChange('user'); @@ -234,6 +241,30 @@ describe('RoleMappingsLogic', () => { }); describe('listeners', () => { + describe('enableRoleBasedAccess', () => { + it('calls API and sets values', async () => { + const setRoleMappingsSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappings'); + http.post.mockReturnValue(Promise.resolve(mappingsServerProps)); + RoleMappingsLogic.actions.enableRoleBasedAccess(); + + expect(RoleMappingsLogic.values.dataLoading).toEqual(true); + + expect(http.post).toHaveBeenCalledWith( + '/api/workplace_search/org/role_mappings/enable_role_based_access' + ); + await nextTick(); + expect(setRoleMappingsSpy).toHaveBeenCalledWith(mappingsServerProps); + }); + + it('handles error', async () => { + http.post.mockReturnValue(Promise.reject('this is an error')); + RoleMappingsLogic.actions.enableRoleBasedAccess(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + describe('initializeRoleMappings', () => { it('calls API and sets values', async () => { const setRoleMappingsDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappingsData'); @@ -351,18 +382,8 @@ describe('RoleMappingsLogic', () => { }); describe('handleDeleteMapping', () => { - let confirmSpy: any; const roleMappingId = 'r1'; - beforeEach(() => { - confirmSpy = jest.spyOn(window, 'confirm'); - confirmSpy.mockImplementation(jest.fn(() => true)); - }); - - afterEach(() => { - confirmSpy.mockRestore(); - }); - it('calls API and refreshes list', async () => { const initializeRoleMappingsSpy = jest.spyOn( RoleMappingsLogic.actions, @@ -388,15 +409,6 @@ describe('RoleMappingsLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); - - it('will do nothing if not confirmed', async () => { - RoleMappingsLogic.actions.setRoleMapping(wsRoleMapping); - window.confirm = () => false; - RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); - - expect(http.delete).not.toHaveBeenCalled(); - await nextTick(); - }); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts index 361425b7a78a1..76b41b2f383eb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts @@ -20,7 +20,6 @@ import { AttributeName } from '../../../shared/types'; import { RoleGroup, WSRoleMapping, Role } from '../../types'; import { - DELETE_ROLE_MAPPING_MESSAGE, ROLE_MAPPING_DELETED_MESSAGE, ROLE_MAPPING_CREATED_MESSAGE, ROLE_MAPPING_UPDATED_MESSAGE, @@ -57,10 +56,16 @@ interface RoleMappingsActions { initializeRoleMappings(): void; resetState(): void; setRoleMapping(roleMapping: WSRoleMapping): { roleMapping: WSRoleMapping }; + setRoleMappings({ + roleMappings, + }: { + roleMappings: WSRoleMapping[]; + }): { roleMappings: WSRoleMapping[] }; setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; openRoleMappingFlyout(): void; closeRoleMappingFlyout(): void; setRoleMappingErrors(errors: string[]): { errors: string[] }; + enableRoleBasedAccess(): void; } interface RoleMappingsValues { @@ -88,6 +93,7 @@ export const RoleMappingsLogic = kea data, setRoleMapping: (roleMapping: WSRoleMapping) => ({ roleMapping }), + setRoleMappings: ({ roleMappings }: { roleMappings: WSRoleMapping[] }) => ({ roleMappings }), setRoleMappingErrors: (errors: string[]) => ({ errors }), handleAuthProviderChange: (value: string[]) => ({ value }), handleRoleChange: (roleType: Role) => ({ roleType }), @@ -98,6 +104,7 @@ export const RoleMappingsLogic = kea ({ value }), handleAllGroupsSelectionChange: (selected: boolean) => ({ selected }), + enableRoleBasedAccess: true, resetState: true, initializeRoleMappings: true, initializeRoleMapping: (roleMappingId?: string) => ({ roleMappingId }), @@ -111,13 +118,16 @@ export const RoleMappingsLogic = kea false, + setRoleMappings: () => false, resetState: () => true, + enableRoleBasedAccess: () => true, }, ], roleMappings: [ [], { setRoleMappingsData: (_, { roleMappings }) => roleMappings, + setRoleMappings: (_, { roleMappings }) => roleMappings, resetState: () => [], }, ], @@ -260,6 +270,17 @@ export const RoleMappingsLogic = kea ({ + enableRoleBasedAccess: async () => { + const { http } = HttpLogic.values; + const route = '/api/workplace_search/org/role_mappings/enable_role_based_access'; + + try { + const response = await http.post(route); + actions.setRoleMappings(response); + } catch (e) { + flashAPIErrors(e); + } + }, initializeRoleMappings: async () => { const { http } = HttpLogic.values; const route = '/api/workplace_search/org/role_mappings'; @@ -279,14 +300,12 @@ export const RoleMappingsLogic = kea { diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts index 718597c12e9c5..7d9f08627516b 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts @@ -7,7 +7,11 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; -import { registerRoleMappingsRoute, registerRoleMappingRoute } from './role_mappings'; +import { + registerEnableRoleMappingsRoute, + registerRoleMappingsRoute, + registerRoleMappingRoute, +} from './role_mappings'; const roleMappingBaseSchema = { rules: { username: 'user' }, @@ -18,6 +22,29 @@ const roleMappingBaseSchema = { }; describe('role mappings routes', () => { + describe('POST /api/app_search/role_mappings/enable_role_based_access', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/role_mappings/enable_role_based_access', + }); + + registerEnableRoleMappingsRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/role_mappings/enable_role_based_access', + }); + }); + }); + describe('GET /api/app_search/role_mappings', () => { let mockRouter: MockRouter; @@ -36,7 +63,7 @@ describe('role mappings routes', () => { it('creates a request handler', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/role_mappings', + path: '/as/role_mappings', }); }); }); @@ -59,7 +86,7 @@ describe('role mappings routes', () => { it('creates a request handler', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/role_mappings', + path: '/as/role_mappings', }); }); @@ -94,7 +121,7 @@ describe('role mappings routes', () => { it('creates a request handler', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/role_mappings/:id', + path: '/as/role_mappings/:id', }); }); @@ -129,7 +156,7 @@ describe('role mappings routes', () => { it('creates a request handler', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/role_mappings/:id', + path: '/as/role_mappings/:id', }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts index 75724a3344d6d..da620be2ea950 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts @@ -17,6 +17,21 @@ const roleMappingBaseSchema = { authProvider: schema.arrayOf(schema.string()), }; +export function registerEnableRoleMappingsRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.post( + { + path: '/api/app_search/role_mappings/enable_role_based_access', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/role_mappings/enable_role_based_access', + }) + ); +} + export function registerRoleMappingsRoute({ router, enterpriseSearchRequestHandler, @@ -27,7 +42,7 @@ export function registerRoleMappingsRoute({ validate: false, }, enterpriseSearchRequestHandler.createRequest({ - path: '/role_mappings', + path: '/as/role_mappings', }) ); @@ -39,7 +54,7 @@ export function registerRoleMappingsRoute({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/role_mappings', + path: '/as/role_mappings', }) ); } @@ -59,7 +74,7 @@ export function registerRoleMappingRoute({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/role_mappings/:id', + path: '/as/role_mappings/:id', }) ); @@ -73,12 +88,13 @@ export function registerRoleMappingRoute({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/role_mappings/:id', + path: '/as/role_mappings/:id', }) ); } export const registerRoleMappingsRoutes = (dependencies: RouteDependencies) => { + registerEnableRoleMappingsRoute(dependencies); registerRoleMappingsRoute(dependencies); registerRoleMappingRoute(dependencies); }; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts index a945866da5ef2..aa0e9983166c0 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts @@ -7,9 +7,36 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; -import { registerOrgRoleMappingsRoute, registerOrgRoleMappingRoute } from './role_mappings'; +import { + registerOrgEnableRoleMappingsRoute, + registerOrgRoleMappingsRoute, + registerOrgRoleMappingRoute, +} from './role_mappings'; describe('role mappings routes', () => { + describe('POST /api/workplace_search/org/role_mappings/enable_role_based_access', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/org/role_mappings/enable_role_based_access', + }); + + registerOrgEnableRoleMappingsRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/role_mappings/enable_role_based_access', + }); + }); + }); + describe('GET /api/workplace_search/org/role_mappings', () => { let mockRouter: MockRouter; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts index a0fcec63cbb27..cea7bcb311ce8 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts @@ -17,6 +17,21 @@ const roleMappingBaseSchema = { authProvider: schema.arrayOf(schema.string()), }; +export function registerOrgEnableRoleMappingsRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.post( + { + path: '/api/workplace_search/org/role_mappings/enable_role_based_access', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/role_mappings/enable_role_based_access', + }) + ); +} + export function registerOrgRoleMappingsRoute({ router, enterpriseSearchRequestHandler, @@ -79,6 +94,7 @@ export function registerOrgRoleMappingRoute({ } export const registerRoleMappingsRoutes = (dependencies: RouteDependencies) => { + registerOrgEnableRoleMappingsRoute(dependencies); registerOrgRoleMappingsRoute(dependencies); registerOrgRoleMappingRoute(dependencies); }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e246cd0681053..17c31b8cd115e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7521,7 +7521,6 @@ "xpack.enterpriseSearch.appSearch.credentials.title": "資格情報", "xpack.enterpriseSearch.appSearch.credentials.updateWarning": "既存の API キーはユーザー間で共有できます。このキーのアクセス権を変更すると、このキーにアクセスできるすべてのユーザーに影響します。", "xpack.enterpriseSearch.appSearch.credentials.updateWarningTitle": "十分ご注意ください!", - "xpack.enterpriseSearch.appSearch.deleteRoleMappingMessage": "このマッピングを完全に削除しますか?このアクションは元に戻せません。一部のユーザーがアクセスを失う可能性があります。", "xpack.enterpriseSearch.appSearch.DEV_ROLE_TYPE_DESCRIPTION": "開発者はエンジンのすべての要素を管理できます。", "xpack.enterpriseSearch.appSearch.documentCreation.api.description": "{documentsApiLink}を使用すると、新しいドキュメントをエンジンに追加できるほか、ドキュメントの更新、IDによるドキュメントの取得、ドキュメントの削除が可能です。基本操作を説明するさまざまな{clientLibrariesLink}があります。", "xpack.enterpriseSearch.appSearch.documentCreation.api.example": "実行中のAPIを表示するには、コマンドラインまたはクライアントライブラリを使用して、次の要求の例で実験することができます。", @@ -7906,6 +7905,7 @@ "xpack.enterpriseSearch.appSearch.tokens.search.description": "エンドポイントのみの検索では、公開検索キーが使用されます。", "xpack.enterpriseSearch.appSearch.tokens.search.name": "公開検索キー", "xpack.enterpriseSearch.appSearch.tokens.update": "正常に API キーを更新しました。", + "xpack.enterpriseSearch.emailLabel": "メール", "xpack.enterpriseSearch.enterpriseSearch.setupGuide.description": "場所を問わず、何でも検索。組織を支える多忙なチームのために、パワフルでモダンな検索エクスペリエンスを簡単に導入できます。Webサイトやアプリ、ワークプレイスに事前調整済みの検索をすばやく追加しましょう。何でもシンプルに検索できます。", "xpack.enterpriseSearch.enterpriseSearch.setupGuide.notConfigured": "エンタープライズサーチはまだKibanaインスタンスで構成されていません。", "xpack.enterpriseSearch.enterpriseSearch.setupGuide.videoAlt": "エンタープライズ サーチの基本操作", @@ -7948,15 +7948,14 @@ "xpack.enterpriseSearch.roleMapping.attributeSelectorTitle": "属性マッピング", "xpack.enterpriseSearch.roleMapping.attributeValueLabel": "属性値", "xpack.enterpriseSearch.roleMapping.authProviderLabel": "認証プロバイダー", - "xpack.enterpriseSearch.roleMapping.deleteRoleMappingButton": "マッピングを削除", "xpack.enterpriseSearch.roleMapping.deleteRoleMappingDescription": "マッピングの削除は永久的であり、元に戻すことはできません", - "xpack.enterpriseSearch.roleMapping.deleteRoleMappingTitle": "このロールマッピングを削除", "xpack.enterpriseSearch.roleMapping.externalAttributeLabel": "外部属性", "xpack.enterpriseSearch.roleMapping.filterRoleMappingsPlaceholder": "ロールをフィルタリング...", "xpack.enterpriseSearch.roleMapping.individualAuthProviderLabel": "個別の認証プロバイダーを選択", "xpack.enterpriseSearch.roleMapping.manageRoleMappingTitle": "ロールマッピングを管理", "xpack.enterpriseSearch.roleMapping.noResults.message": "の結果が見つかりません。", "xpack.enterpriseSearch.roleMapping.newRoleMappingTitle": "ロールマッピングを追加", + "xpack.enterpriseSearch.roleMapping.removeRoleMappingTitle": "このロールマッピングを削除", "xpack.enterpriseSearch.roleMapping.roleLabel": "ロール", "xpack.enterpriseSearch.roleMapping.roleMappingsTitle": "ユーザーとロール", "xpack.enterpriseSearch.roleMapping.saveRoleMappingButtonLabel": "ロールマッピングの保存", @@ -7993,6 +7992,7 @@ "xpack.enterpriseSearch.troubleshooting.differentEsClusters.title": "{productName}とKibanaは別のElasticsearchクラスターにあります", "xpack.enterpriseSearch.troubleshooting.standardAuth.description": "このプラグインは、{standardAuthLink}の{productName}を完全にはサポートしていません。{productName}で作成されたユーザーはKibanaアクセス権が必要です。Kibanaで作成されたユーザーは、ナビゲーションメニューに{productName}が表示されません。", "xpack.enterpriseSearch.troubleshooting.standardAuth.title": "標準認証の{productName}はサポートされていません", + "xpack.enterpriseSearch.usernameLabel": "ユーザー名", "xpack.enterpriseSearch.workplaceSearch.accountNav.account.link": "マイアカウント", "xpack.enterpriseSearch.workplaceSearch.accountNav.logout.link": "ログアウト", "xpack.enterpriseSearch.workplaceSearch.accountNav.orgDashboard.link": "組織ダッシュボードに移動", @@ -8163,8 +8163,6 @@ "xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.groupTableHeader": "グループ", "xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.sourcesTableHeader": "コンテンツソース", "xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.usersTableHeader": "ユーザー", - "xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.emailTableHeader": "メール", - "xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.usernameTableHeader": "ユーザー名", "xpack.enterpriseSearch.workplaceSearch.groups.groupUpdatedText": "前回更新日時{updatedAt}。", "xpack.enterpriseSearch.workplaceSearch.groups.groupUsersUpdated": "このグループのユーザーが正常に更新されました。", "xpack.enterpriseSearch.workplaceSearch.groups.heading": "グループを管理", @@ -8264,7 +8262,6 @@ "xpack.enterpriseSearch.workplaceSearch.reset.button": "リセット", "xpack.enterpriseSearch.workplaceSearch.roleMapping.adminRoleTypeDescription": "管理者は、コンテンツソース、グループ、ユーザー管理機能など、すべての組織レベルの設定に無制限にアクセスできます。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.defaultGroupName": "デフォルト", - "xpack.enterpriseSearch.workplaceSearch.roleMapping.deleteRoleMappingButtonMessage": "このマッピングを完全に削除しますか?このアクションは元に戻せません。一部のユーザーがアクセスを失う可能性があります。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentInvalidError": "1つ以上の割り当てられたグループが必要です。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.roleMappingsTableHeader": "グループアクセス", "xpack.enterpriseSearch.workplaceSearch.roleMapping.userRoleTypeDescription": "ユーザーの機能アクセスは検索インターフェースと個人設定管理に制限されます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6a96769e2da1e..055ccbdde6ae8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7580,7 +7580,6 @@ "xpack.enterpriseSearch.appSearch.credentials.title": "凭据", "xpack.enterpriseSearch.appSearch.credentials.updateWarning": "现有 API 密钥可在用户之间共享。更改此密钥的权限将影响有权访问此密钥的所有用户。", "xpack.enterpriseSearch.appSearch.credentials.updateWarningTitle": "谨慎操作!", - "xpack.enterpriseSearch.appSearch.deleteRoleMappingMessage": "确定要永久删除此映射?此操作不可逆转,且某些用户可能会失去访问权限。", "xpack.enterpriseSearch.appSearch.DEV_ROLE_TYPE_DESCRIPTION": "开发人员可以管理引擎的所有方面。", "xpack.enterpriseSearch.appSearch.documentCreation.api.description": "{documentsApiLink} 可用于将新文档添加到您的引擎、更新文档、按 ID 检索文档以及删除文档。有各种{clientLibrariesLink}可帮助您入门。", "xpack.enterpriseSearch.appSearch.documentCreation.api.example": "要了解如何使用 API,可以在下面通过命令行或客户端库试用示例请求。", @@ -7974,6 +7973,7 @@ "xpack.enterpriseSearch.appSearch.tokens.search.description": "公有搜索密钥仅用于搜索终端。", "xpack.enterpriseSearch.appSearch.tokens.search.name": "公有搜索密钥", "xpack.enterpriseSearch.appSearch.tokens.update": "成功更新 API 密钥。", + "xpack.enterpriseSearch.emailLabel": "电子邮件", "xpack.enterpriseSearch.enterpriseSearch.setupGuide.description": "随时随地进行全面搜索。为工作繁忙的团队轻松实现强大的现代搜索体验。将预先调整的搜索功能快速添加到您的网站、应用或工作区。全面搜索就是这么简单。", "xpack.enterpriseSearch.enterpriseSearch.setupGuide.notConfigured": "企业搜索尚未在您的 Kibana 实例中配置。", "xpack.enterpriseSearch.enterpriseSearch.setupGuide.videoAlt": "企业搜索入门", @@ -8016,9 +8016,7 @@ "xpack.enterpriseSearch.roleMapping.attributeSelectorTitle": "属性映射", "xpack.enterpriseSearch.roleMapping.attributeValueLabel": "属性值", "xpack.enterpriseSearch.roleMapping.authProviderLabel": "身份验证提供程序", - "xpack.enterpriseSearch.roleMapping.deleteRoleMappingButton": "删除映射", "xpack.enterpriseSearch.roleMapping.deleteRoleMappingDescription": "请注意,删除映射是永久性的,无法撤消", - "xpack.enterpriseSearch.roleMapping.deleteRoleMappingTitle": "移除此角色映射", "xpack.enterpriseSearch.roleMapping.externalAttributeLabel": "外部属性", "xpack.enterpriseSearch.roleMapping.filterRoleMappingsPlaceholder": "筛选角色......", "xpack.enterpriseSearch.roleMapping.individualAuthProviderLabel": "选择单个身份验证提供程序", @@ -8027,6 +8025,7 @@ "xpack.enterpriseSearch.roleMapping.newRoleMappingTitle": "添加角色映射", "xpack.enterpriseSearch.roleMapping.roleLabel": "角色", "xpack.enterpriseSearch.roleMapping.roleMappingsTitle": "用户和角色", + "xpack.enterpriseSearch.roleMapping.removeRoleMappingTitle": "移除此角色映射", "xpack.enterpriseSearch.roleMapping.saveRoleMappingButtonLabel": "保存角色映射", "xpack.enterpriseSearch.roleMapping.updateRoleMappingButtonLabel": "更新角色映射", "xpack.enterpriseSearch.schema.addFieldModal.fieldNameNote.correct": "字段名称只能包含小写字母、数字和下划线", @@ -8061,6 +8060,7 @@ "xpack.enterpriseSearch.troubleshooting.differentEsClusters.title": "{productName} 和 Kibana 在不同的 Elasticsearch 集群中", "xpack.enterpriseSearch.troubleshooting.standardAuth.description": "此插件不完全支持使用 {standardAuthLink} 的 {productName}。{productName} 中创建的用户必须具有 Kibana 访问权限。Kibana 中创建的用户在导航菜单中将看不到 {productName}。", "xpack.enterpriseSearch.troubleshooting.standardAuth.title": "不支持使用标准身份验证的 {productName}", + "xpack.enterpriseSearch.usernameLabel": "用户名", "xpack.enterpriseSearch.workplaceSearch.accountNav.account.link": "我的帐户", "xpack.enterpriseSearch.workplaceSearch.accountNav.logout.link": "注销", "xpack.enterpriseSearch.workplaceSearch.accountNav.orgDashboard.link": "前往组织仪表板", @@ -8231,8 +8231,6 @@ "xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.groupTableHeader": "组", "xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.sourcesTableHeader": "内容源", "xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.usersTableHeader": "用户", - "xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.emailTableHeader": "电子邮件", - "xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.usernameTableHeader": "用户名", "xpack.enterpriseSearch.workplaceSearch.groups.groupUpdatedText": "上次更新于 {updatedAt}。", "xpack.enterpriseSearch.workplaceSearch.groups.groupUsersUpdated": "已成功更新此组的用户", "xpack.enterpriseSearch.workplaceSearch.groups.heading": "管理组", @@ -8332,7 +8330,6 @@ "xpack.enterpriseSearch.workplaceSearch.reset.button": "重置", "xpack.enterpriseSearch.workplaceSearch.roleMapping.adminRoleTypeDescription": "管理员对所有组织范围设置 (包括内容源、组和用户管理功能) 具有完全权限。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.defaultGroupName": "默认", - "xpack.enterpriseSearch.workplaceSearch.roleMapping.deleteRoleMappingButtonMessage": "确定要永久删除此映射?此操作不可逆转,且某些用户可能会失去访问权限。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentInvalidError": "至少需要一个分配的组。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.roleMappingsTableHeader": "组访问权限", "xpack.enterpriseSearch.workplaceSearch.roleMapping.userRoleTypeDescription": "用户的功能访问权限仅限于搜索界面和个人设置管理。", From 136d3617032526dcb396896da408791c1362cb39 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Wed, 23 Jun 2021 15:10:34 -0500 Subject: [PATCH 12/95] Upgrade EUI to v34.3.0 (#101334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * eui to v34.1.0 * styled-components types * src snapshot updates * x-pack snapshot updates * eui to v34.2.0 * styled-components todo * src snapshot updates * x-pack snapshot updates * jest test updates * collapsible_nav * Hard-code global nav width for bottom bar’s (for now) * Update to eui v34.3.0 * flyout unmock * src flyout snapshots * remove duplicate euioverlaymask * xpack flyout snapshots * remove unused import * sidenavprops * attr updates * trial: flyout ownfocus * remove unused * graph selector * jest * jest * flyout ownFocus * saved objects flyout * console welcome flyout * timeline flyout * clean up * visible * colorpicker data-test-subj * selectors * selector * ts * selector * snapshot * Fix `use_security_solution_navigation` TS error * cypress Co-authored-by: cchaos Co-authored-by: Chandler Prall --- package.json | 2 +- .../collapsible_nav.test.tsx.snap | 3451 ++++++++--------- .../header/__snapshots__/header.test.tsx.snap | 989 +++-- .../chrome/ui/header/collapsible_nav.test.tsx | 4 - .../public/chrome/ui/header/header.test.tsx | 2 +- src/core/public/chrome/ui/header/header.tsx | 2 +- .../flyout_service.test.tsx.snap | 4 +- src/core/public/styles/_base.scss | 2 +- .../application/components/welcome_panel.tsx | 2 +- .../dashboard_empty_screen.test.tsx.snap | 8 +- .../__snapshots__/data_view.test.tsx.snap | 30 +- .../discover_grid_flyout.test.tsx | 4 +- .../__snapshots__/source_viewer.test.tsx.snap | 22 +- .../url/__snapshots__/url.test.tsx.snap | 8 +- .../header/__snapshots__/header.test.tsx.snap | 2 +- .../warning_call_out.test.tsx.snap | 98 +- .../inspector_panel.test.tsx.snap | 1 + .../__snapshots__/solution_nav.test.tsx.snap | 18 + .../solution_nav/solution_nav.tsx | 2 +- .../public/components/labs/labs_flyout.tsx | 51 +- .../__snapshots__/intro.test.tsx.snap | 26 +- .../not_found_errors.test.tsx.snap | 160 +- .../__snapshots__/flyout.test.tsx.snap | 3 + .../objects_table/components/flyout.tsx | 2 +- .../components/color_picker.test.tsx | 4 +- .../visualization_noresults.test.js.snap | 2 +- test/accessibility/apps/management.ts | 1 + .../apps/management/_import_objects.ts | 8 +- test/functional/page_objects/settings_page.ts | 4 + .../page_objects/visual_builder_page.ts | 2 +- .../Waterfall/ResponsiveFlyout.tsx | 15 +- .../asset_manager.stories.storyshot | 17 +- .../custom_element_modal.stories.storyshot | 32 +- .../datasource_component.stories.storyshot | 10 +- .../keyboard_shortcuts_doc.stories.storyshot | 2145 +++++----- .../saved_elements_modal.stories.storyshot | 12 +- .../__snapshots__/pdf_panel.stories.storyshot | 4 +- .../__snapshots__/settings.test.tsx.snap | 10 +- .../autoplay_settings.stories.storyshot | 12 +- .../toolbar_settings.stories.storyshot | 12 +- .../filebeat_config_flyout.tsx | 2 +- .../private_sources_sidebar.tsx | 1 - .../components/create_agent_policy.tsx | 11 +- .../extend_index_management.test.tsx.snap | 142 +- .../__snapshots__/policy_table.test.tsx.snap | 15 +- .../components/table_basic.test.tsx | 22 +- .../upload_license.test.tsx.snap | 96 +- .../action_edit/edit_action_flyout.tsx | 327 +- .../__snapshots__/checker_errors.test.js.snap | 54 +- .../__snapshots__/no_data.test.js.snap | 8 +- .../__snapshots__/page_loading.test.js.snap | 4 +- .../app/cases/create/flyout.test.tsx | 2 +- .../components/app/cases/create/flyout.tsx | 10 +- .../shared/page_template/page_template.tsx | 10 +- ...screen_capture_panel_content.test.tsx.snap | 18 +- .../report_info_button.test.tsx.snap | 356 +- .../privilege_summary/privilege_summary.tsx | 61 +- .../privilege_space_form.tsx | 116 +- .../roles_grid_page.test.tsx.snap | 34 +- .../__snapshots__/prompt_page.test.tsx.snap | 4 +- .../unauthenticated_page.test.tsx.snap | 2 +- .../reset_session_page.test.tsx.snap | 2 +- .../timelines/data_providers.spec.ts | 4 +- .../integration/timelines/pagination.spec.ts | 6 +- .../cypress/screens/timeline.ts | 8 +- .../cases/components/create/flyout.test.tsx | 2 +- .../public/cases/components/create/flyout.tsx | 10 +- .../exceptions/add_exception_comments.tsx | 2 +- .../index.test.tsx | 4 +- .../endpoint_hosts/view/details/index.tsx | 1 + .../__snapshots__/index.test.tsx.snap | 11 +- .../timelines/components/flyout/index.tsx | 11 +- .../components/flyout/pane/index.tsx | 13 +- .../__snapshots__/index.test.tsx.snap | 827 ++-- .../timelines/components/side_panel/index.tsx | 10 +- .../edit_transform_flyout.tsx | 127 +- .../sections/alert_form/alert_add.tsx | 1 + .../__snapshots__/license_info.test.tsx.snap | 30 +- .../ml/__snapshots__/ml_flyout.test.tsx.snap | 205 +- .../__snapshots__/expanded_row.test.tsx.snap | 82 +- .../waterfall/waterfall_flyout.tsx | 10 +- .../test/functional/apps/lens/lens_tagging.ts | 2 +- .../functional/page_objects/graph_page.ts | 4 +- .../test/functional/page_objects/lens_page.ts | 4 +- .../page_objects/space_selector_page.ts | 4 +- .../page_objects/tag_management_page.ts | 5 +- .../functional/tests/dashboard_integration.ts | 2 +- .../functional/tests/maps_integration.ts | 2 +- .../functional/tests/visualize_integration.ts | 2 +- yarn.lock | 25 +- 90 files changed, 4774 insertions(+), 5120 deletions(-) diff --git a/package.json b/package.json index 26465133569cd..f99eb86a43cec 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.13", "@elastic/ems-client": "7.14.0", - "@elastic/eui": "33.0.0", + "@elastic/eui": "34.3.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", "@elastic/maki": "6.3.0", diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 3668829a6888c..0b10209bc13e5 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -370,54 +370,62 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` isOpen={true} onClose={[Function]} > - - - - } - /> - - - + + +
+
+ +
-
-
-
- - - -
+ data-euiicon-type="home" + /> + + + Home + + + + + +
-
-
- - + +
+
+ + + + +

+ Recently viewed +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-recentlyViewed" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="recentlyViewed" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Recently viewed" + paddingSize="none" > - - - -

- Recently viewed -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ recent 2 + + + + + +
- -
+
+
-
-
- -
-
- + + + +
+
+ +
-
- + + + + + +

+ Analytics +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-kibana" - iconType="logoKibana" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="kibana" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Analytics" + paddingSize="none" > - - - - - - -

- Analytics -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ dashboard + + + + + +
- -
+
+
-
-
- + + + + + + + + + +

+ Observability +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-observability" - iconType="logoObservability" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="observability" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Observability" + paddingSize="none" > - - - - - - -

- Observability -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ logs + + + + + +
- -
+
+
-
-
- + + + + + + + + + +

+ Security +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-securitySolution" - iconType="logoSecurity" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="securitySolution" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Security" + paddingSize="none" > - - - - - - -

- Security -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ siem + + + + + +
- -
+
+
-
-
- + + + + + + + + + +

+ Management +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-management" - iconType="managementApp" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="management" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Management" + paddingSize="none" > - - - - - - -

- Management -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ monitoring + + + + + +
- -
+
+
-
-
- + + + +
-
- - - -
+ canvas + + + + + +
- - - +
+
+ + +
-
- -
    - - - - Dock navigation - - , - } - } - color="subdued" - data-test-subj="collapsible-nav-lock" - iconType="lockOpen" - label="Dock navigation" - onClick={[Function]} - size="xs" - > -
  • - -
  • -
    -
-
-
+ , + } + } + color="subdued" + data-test-subj="collapsible-nav-lock" + iconType="lockOpen" + label="Dock navigation" + onClick={[Function]} + size="xs" + > +
  • + +
  • + + +
    - - -
    - - - - - - - -
    - +
    + + +
    + + + `; @@ -2770,42 +2706,57 @@ exports[`CollapsibleNav renders the default nav 3`] = ` isOpen={false} onClose={[Function]} > - - -
    -
    + + + + + +

    + Recently viewed +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-recentlyViewed" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="recentlyViewed" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Recently viewed" + paddingSize="none" > - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" +
    -
    - -
    -
    + +
    + +
    + + + +
    +
    + - -
    +
    +
    -
    - -
    - -
    -

    - No recently viewed items -

    -
    -
    -
    -
    -
    +

    + No recently viewed items +

    +
    + +
    +
    -
    -
    + + -
    -
    - -
    -
    - + + + +
    +
    + +
    -
    - - + +
    -
    - -
      - - - - Undock navigation - - , - } - } - color="subdued" - data-test-subj="collapsible-nav-lock" - iconType="lock" - label="Undock navigation" - onClick={[Function]} - size="xs" - > -
    • - -
    • -
      -
    -
    -
    + , + } + } + color="subdued" + data-test-subj="collapsible-nav-lock" + iconType="lock" + label="Undock navigation" + onClick={[Function]} + size="xs" + > +
  • + +
  • + + +
    - - -
    - - - - - - - -
    - +
    + + + +
    + + `; diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 6ad1e2d3a1cc6..5aee9ca1b7c08 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -4947,42 +4947,57 @@ exports[`Header renders 1`] = ` isOpen={false} onClose={[Function]} > - - -
    -
    +
    + +
    +
    + +
    -
    -
    -
    - - - -
    + data-euiicon-type="home" + /> + + + Home + + + + + +
    -
    -
    - - + +
    +
    + + + + +

    + Recently viewed +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-recentlyViewed" + id="mockId" initialIsOpen={true} - isCollapsible={true} - key="recentlyViewed" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Recently viewed" + paddingSize="none" > - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" +
    -
    - -
    -
    + +
    + +
    + + + +
    +
    + - -
    +
    +
    -
    - - - -
    + dashboard + + + + + +
    - -
    +
    +
    -
    -
    - -
    -
    - + + + +
    +
    + +
    -
    - +
    + +
      + +
    • + +
    • +
      +
    +
    +
    +
    + + +
    + + + Undock navigation + + , + } + } + color="subdued" + data-test-subj="collapsible-nav-lock" + iconType="lock" + label="Undock navigation" onClick={[Function]} - size="s" + size="xs" >
  • @@ -5445,163 +5540,11 @@ exports[`Header renders 1`] = `
    - - -
    -
    - -
      - - - - Undock navigation - - , - } - } - color="subdued" - data-test-subj="collapsible-nav-lock" - iconType="lock" - label="Undock navigation" - onClick={[Function]} - size="xs" - > -
    • - -
    • -
      -
    -
    -
    -
    -
    -
    -
    -
    - - - - - - - -
    + + + + +