diff --git a/.ci/Jenkinsfile_baseline_capture b/.ci/Jenkinsfile_baseline_capture index 3f90a6bc05af0..b0d3591821642 100644 --- a/.ci/Jenkinsfile_baseline_capture +++ b/.ci/Jenkinsfile_baseline_capture @@ -4,23 +4,25 @@ library 'kibana-pipeline-library' kibanaLibrary.load() kibanaPipeline(timeoutMinutes: 120) { - ciStats.trackBuild { - catchError { - parallel([ - 'oss-visualRegression': { - workers.ci(name: 'oss-visualRegression', size: 's-highmem', ramDisk: true) { - kibanaPipeline.functionalTestProcess('oss-visualRegression', './test/scripts/jenkins_visual_regression.sh')(1) - } - }, - 'xpack-visualRegression': { - workers.ci(name: 'xpack-visualRegression', size: 's-highmem', ramDisk: true) { - kibanaPipeline.functionalTestProcess('xpack-visualRegression', './test/scripts/jenkins_xpack_visual_regression.sh')(1) - } - }, - ]) - } + githubCommitStatus.trackBuild(params.commit, 'kibana-ci-baseline') { + ciStats.trackBuild { + catchError { + parallel([ + 'oss-visualRegression': { + workers.ci(name: 'oss-visualRegression', size: 's-highmem', ramDisk: true) { + kibanaPipeline.functionalTestProcess('oss-visualRegression', './test/scripts/jenkins_visual_regression.sh')(1) + } + }, + 'xpack-visualRegression': { + workers.ci(name: 'xpack-visualRegression', size: 's-highmem', ramDisk: true) { + kibanaPipeline.functionalTestProcess('xpack-visualRegression', './test/scripts/jenkins_xpack_visual_regression.sh')(1) + } + }, + ]) + } - kibanaPipeline.sendMail() - slackNotifications.onFailure() + kibanaPipeline.sendMail() + slackNotifications.onFailure() + } } } diff --git a/.ci/end2end.groovy b/.ci/end2end.groovy index 97099c6f87448..ee117d362d59b 100644 --- a/.ci/end2end.groovy +++ b/.ci/end2end.groovy @@ -110,6 +110,9 @@ pipeline { archiveArtifacts(allowEmptyArchive: true, artifacts: "${E2E_DIR}/kibana.log") } } + cleanup { + notifyBuildResult(notifyPRComment: false, analyzeFlakey: false, shouldNotify: false) + } } } diff --git a/.ci/pipeline-library/src/test/githubCommitStatus.groovy b/.ci/pipeline-library/src/test/githubCommitStatus.groovy index 17878624b73cf..c770d5596f9cb 100644 --- a/.ci/pipeline-library/src/test/githubCommitStatus.groovy +++ b/.ci/pipeline-library/src/test/githubCommitStatus.groovy @@ -12,6 +12,7 @@ class GithubCommitStatusTest extends KibanaBasePipelineTest { interface BuildState { Object get(String key) + Object has(String key) } interface GithubApi { @@ -25,6 +26,7 @@ class GithubCommitStatusTest extends KibanaBasePipelineTest { buildStateMock = mock(BuildState) githubApiMock = mock(GithubApi) + when(buildStateMock.has('checkoutInfo')).thenReturn(true) when(buildStateMock.get('checkoutInfo')).thenReturn([ commit: 'COMMIT_HASH', ]) when(githubApiMock.post(any(), any())).thenReturn(null) diff --git a/.eslintignore b/.eslintignore index d983c4bedfaab..9263b483b8de9 100644 --- a/.eslintignore +++ b/.eslintignore @@ -33,7 +33,7 @@ target /x-pack/plugins/canvas/canvas_plugin /x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts /x-pack/plugins/canvas/shareable_runtime/build -/x-pack/plugins/canvas/storybook +/x-pack/plugins/canvas/storybook/build /x-pack/plugins/monitoring/public/lib/jquery_flot /x-pack/plugins/reporting/server/export_types/printable_pdf/server/lib/pdf/assets/** /x-pack/legacy/plugins/infra/common/graphql/types.ts diff --git a/.eslintrc.js b/.eslintrc.js index a9ffe2850aa72..e2674e8d7b407 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1222,6 +1222,12 @@ module.exports = { ], }, }, + { + files: ['x-pack/plugins/canvas/storybook/**'], + rules: { + 'import/no-extraneous-dependencies': 0, + }, + }, { files: ['x-pack/plugins/canvas/canvas_plugin_src/**/*.js'], globals: { canvas: true, $: true }, diff --git a/.sass-lint.yml b/.sass-lint.yml index 50cbe81cc7da2..d6eaaf391de1a 100644 --- a/.sass-lint.yml +++ b/.sass-lint.yml @@ -3,6 +3,7 @@ files: - 'src/legacy/core_plugins/metrics/**/*.s+(a|c)ss' - 'src/plugins/timelion/**/*.s+(a|c)ss' - 'src/plugins/vis_type_vislib/**/*.s+(a|c)ss' + - 'src/plugins/vis_type_vega/**/*.s+(a|c)ss' - 'src/plugins/vis_type_xy/**/*.s+(a|c)ss' - 'x-pack/plugins/canvas/**/*.s+(a|c)ss' - 'x-pack/plugins/triggers_actions_ui/**/*.s+(a|c)ss' diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md index eff53b7b75fa5..41b82f428948a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md @@ -14,5 +14,5 @@ registerOnPostAuth: (handler: OnPostAuthHandler) => void; ## Remarks -The auth state is available at stage via http.auth.get(..) Can register any number of registerOnPreRouting, which are called in sequence (from the first registered to the last). See [OnPostAuthHandler](./kibana-plugin-core-server.onpostauthhandler.md). +The auth state is available at stage via http.auth.get(..) Can register any number of registerOnPostAuth, which are called in sequence (from the first registered to the last). See [OnPostAuthHandler](./kibana-plugin-core-server.onpostauthhandler.md). diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md index ce4cacb1c8749..57b1833df5e03 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md @@ -14,5 +14,5 @@ registerOnPreAuth: (handler: OnPreAuthHandler) => void; ## Remarks -Can register any number of registerOnPostAuth, which are called in sequence (from the first registered to the last). See [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md). +Can register any number of registerOnPreAuth, which are called in sequence (from the first registered to the last). See [OnPreAuthHandler](./kibana-plugin-core-server.onpreauthhandler.md). diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index a665327454c1a..61ffc532f0de5 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -122,7 +122,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | | [OnPreResponseExtensions](./kibana-plugin-core-server.onpreresponseextensions.md) | Additional data to extend a response. | | [OnPreResponseInfo](./kibana-plugin-core-server.onpreresponseinfo.md) | Response status code. | -| [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreRouting interceptor for incoming request. | +| [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreResponse interceptor for incoming request. | | [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) | A tool set defining an outcome of OnPreRouting interceptor for incoming request. | | [OpsMetrics](./kibana-plugin-core-server.opsmetrics.md) | Regroups metrics gathered by all the collectors. This contains metrics about the os/runtime, the kibana process and the http server. | | [OpsOsMetrics](./kibana-plugin-core-server.opsosmetrics.md) | OS related metrics | diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md index 306c375ba4a3c..44da09d0cc68e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md @@ -4,7 +4,7 @@ ## OnPreResponseToolkit interface -A tool set defining an outcome of OnPreRouting interceptor for incoming request. +A tool set defining an outcome of OnPreResponse interceptor for incoming request. Signature: diff --git a/package.json b/package.json index ceb3ac4cca937..2f3f95854df04 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@elastic/apm-rum": "^5.2.0", "@elastic/charts": "19.8.1", "@elastic/datemath": "5.0.3", - "@elastic/elasticsearch": "7.9.0-rc.1", + "@elastic/elasticsearch": "7.9.0-rc.2", "@elastic/ems-client": "7.9.3", "@elastic/eui": "26.3.1", "@elastic/filesaver": "1.1.2", diff --git a/src/core/server/elasticsearch/client/errors.test.ts b/src/core/server/elasticsearch/client/errors.test.ts new file mode 100644 index 0000000000000..35ad4ca71f48c --- /dev/null +++ b/src/core/server/elasticsearch/client/errors.test.ts @@ -0,0 +1,82 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + ResponseError, + ConnectionError, + ConfigurationError, +} from '@elastic/elasticsearch/lib/errors'; +import { ApiResponse } from '@elastic/elasticsearch'; +import { isResponseError, isUnauthorizedError } from './errors'; + +const createApiResponseError = ({ + statusCode = 200, + headers = {}, + body = {}, +}: { + statusCode?: number; + headers?: Record; + body?: Record; +} = {}): ApiResponse => { + return { + body, + statusCode, + headers, + warnings: [], + meta: {} as any, + }; +}; + +describe('isResponseError', () => { + it('returns `true` when the input is a `ResponseError`', () => { + expect(isResponseError(new ResponseError(createApiResponseError()))).toBe(true); + }); + + it('returns `false` when the input is not a `ResponseError`', () => { + expect(isResponseError(new Error('foo'))).toBe(false); + expect(isResponseError(new ConnectionError('error', createApiResponseError()))).toBe(false); + expect(isResponseError(new ConfigurationError('foo'))).toBe(false); + }); +}); + +describe('isUnauthorizedError', () => { + it('returns true when the input is a `ResponseError` and statusCode === 401', () => { + expect( + isUnauthorizedError(new ResponseError(createApiResponseError({ statusCode: 401 }))) + ).toBe(true); + }); + + it('returns false when the input is a `ResponseError` and statusCode !== 401', () => { + expect( + isUnauthorizedError(new ResponseError(createApiResponseError({ statusCode: 200 }))) + ).toBe(false); + expect( + isUnauthorizedError(new ResponseError(createApiResponseError({ statusCode: 403 }))) + ).toBe(false); + expect( + isUnauthorizedError(new ResponseError(createApiResponseError({ statusCode: 500 }))) + ).toBe(false); + }); + + it('returns `false` when the input is not a `ResponseError`', () => { + expect(isUnauthorizedError(new Error('foo'))).toBe(false); + expect(isUnauthorizedError(new ConnectionError('error', createApiResponseError()))).toBe(false); + expect(isUnauthorizedError(new ConfigurationError('foo'))).toBe(false); + }); +}); diff --git a/src/core/server/elasticsearch/client/errors.ts b/src/core/server/elasticsearch/client/errors.ts new file mode 100644 index 0000000000000..31a27170e1155 --- /dev/null +++ b/src/core/server/elasticsearch/client/errors.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; + +export type UnauthorizedError = ResponseError & { + statusCode: 401; +}; + +export function isResponseError(error: any): error is ResponseError { + return Boolean(error.body && error.statusCode && error.headers); +} + +export function isUnauthorizedError(error: any): error is UnauthorizedError { + return isResponseError(error) && error.statusCode === 401; +} diff --git a/src/core/server/elasticsearch/client/mocks.test.ts b/src/core/server/elasticsearch/client/mocks.test.ts index b882f8d0c5d79..a6ce95155331e 100644 --- a/src/core/server/elasticsearch/client/mocks.test.ts +++ b/src/core/server/elasticsearch/client/mocks.test.ts @@ -49,6 +49,12 @@ describe('Mocked client', () => { expectMocked(client.close); }); + it('used EventEmitter functions should be mocked', () => { + expectMocked(client.on); + expectMocked(client.off); + expectMocked(client.once); + }); + it('`child` should be mocked and return a mocked Client', () => { expectMocked(client.child); diff --git a/src/core/server/elasticsearch/client/mocks.ts b/src/core/server/elasticsearch/client/mocks.ts index 34e83922d4d86..ec2885dfdf922 100644 --- a/src/core/server/elasticsearch/client/mocks.ts +++ b/src/core/server/elasticsearch/client/mocks.ts @@ -54,13 +54,20 @@ const createInternalClientMock = (): DeeplyMockedKeys => { mockify(client, omittedProps); - client.transport = { + // client got some read-only (getter) properties + // so we need to extend it to override the getter-only props. + const mock: any = { ...client }; + + mock.transport = { request: jest.fn(), }; - client.close = jest.fn().mockReturnValue(Promise.resolve()); - client.child = jest.fn().mockImplementation(() => createInternalClientMock()); + mock.close = jest.fn().mockReturnValue(Promise.resolve()); + mock.child = jest.fn().mockImplementation(() => createInternalClientMock()); + mock.on = jest.fn(); + mock.off = jest.fn(); + mock.once = jest.fn(); - return (client as unknown) as DeeplyMockedKeys; + return (mock as unknown) as DeeplyMockedKeys; }; export type ElasticSearchClientMock = DeeplyMockedKeys; diff --git a/src/core/server/elasticsearch/client/types.ts b/src/core/server/elasticsearch/client/types.ts index 934120c330e92..7ce998aab7669 100644 --- a/src/core/server/elasticsearch/client/types.ts +++ b/src/core/server/elasticsearch/client/types.ts @@ -17,11 +17,12 @@ * under the License. */ -import type { Client } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import type { ApiResponse, TransportRequestOptions, TransportRequestParams, + TransportRequestPromise, } from '@elastic/elasticsearch/lib/Transport'; /** @@ -30,13 +31,13 @@ import type { * @public */ export type ElasticsearchClient = Omit< - Client, - 'connectionPool' | 'transport' | 'serializer' | 'extend' | 'helpers' | 'child' | 'close' + KibanaClient, + 'connectionPool' | 'transport' | 'serializer' | 'extend' | 'child' | 'close' > & { transport: { request( params: TransportRequestParams, options?: TransportRequestOptions - ): Promise; + ): TransportRequestPromise; }; }; diff --git a/src/core/server/http/integration_tests/core_service.test.mocks.ts b/src/core/server/http/integration_tests/core_service.test.mocks.ts index c23724b7d332f..515dad5383c01 100644 --- a/src/core/server/http/integration_tests/core_service.test.mocks.ts +++ b/src/core/server/http/integration_tests/core_service.test.mocks.ts @@ -18,10 +18,12 @@ */ import { elasticsearchServiceMock } from '../../elasticsearch/elasticsearch_service.mock'; -export const clusterClientMock = jest.fn(); -export const clusterClientInstanceMock = elasticsearchServiceMock.createLegacyScopedClusterClient(); +export const MockLegacyScopedClusterClient = jest.fn(); +export const legacyClusterClientInstanceMock = elasticsearchServiceMock.createLegacyScopedClusterClient(); jest.doMock('../../elasticsearch/legacy/scoped_cluster_client', () => ({ - LegacyScopedClusterClient: clusterClientMock.mockImplementation(() => clusterClientInstanceMock), + LegacyScopedClusterClient: MockLegacyScopedClusterClient.mockImplementation( + () => legacyClusterClientInstanceMock + ), })); jest.doMock('elasticsearch', () => { @@ -34,3 +36,12 @@ jest.doMock('elasticsearch', () => { }, }; }); + +export const MockElasticsearchClient = jest.fn(); +jest.doMock('@elastic/elasticsearch', () => { + const real = jest.requireActual('@elastic/elasticsearch'); + return { + ...real, + Client: MockElasticsearchClient, + }; +}); diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index 3c5f22500e5e0..6338326626d54 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -17,14 +17,21 @@ * under the License. */ -import { clusterClientMock, clusterClientInstanceMock } from './core_service.test.mocks'; +import { + MockLegacyScopedClusterClient, + MockElasticsearchClient, + legacyClusterClientInstanceMock, +} from './core_service.test.mocks'; import Boom from 'boom'; import { Request } from 'hapi'; import { errors as esErrors } from 'elasticsearch'; import { LegacyElasticsearchErrorHelpers } from '../../elasticsearch/legacy'; +import { elasticsearchClientMock } from '../../elasticsearch/client/mocks'; +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import * as kbnTestServer from '../../../../test_utils/kbn_server'; +import { InternalElasticsearchServiceStart } from '../../elasticsearch'; interface User { id: string; @@ -44,6 +51,17 @@ const cookieOptions = { }; describe('http service', () => { + let esClient: ReturnType; + + beforeEach(async () => { + esClient = elasticsearchClientMock.createInternalClient(); + MockElasticsearchClient.mockImplementation(() => esClient); + }, 30000); + + afterEach(async () => { + MockElasticsearchClient.mockClear(); + }); + describe('auth', () => { let root: ReturnType; beforeEach(async () => { @@ -200,7 +218,7 @@ describe('http service', () => { }, 30000); afterEach(async () => { - clusterClientMock.mockClear(); + MockLegacyScopedClusterClient.mockClear(); await root.shutdown(); }); @@ -363,7 +381,7 @@ describe('http service', () => { }, 30000); afterEach(async () => { - clusterClientMock.mockClear(); + MockLegacyScopedClusterClient.mockClear(); await root.shutdown(); }); @@ -386,7 +404,7 @@ describe('http service', () => { await kbnTestServer.request.get(root, '/new-platform/').expect(200); // client contains authHeaders for BWC with legacy platform. - const [client] = clusterClientMock.mock.calls; + const [client] = MockLegacyScopedClusterClient.mock.calls; const [, , clientHeaders] = client; expect(clientHeaders).toEqual(authHeaders); }); @@ -410,7 +428,7 @@ describe('http service', () => { .set('Authorization', authorizationHeader) .expect(200); - const [client] = clusterClientMock.mock.calls; + const [client] = MockLegacyScopedClusterClient.mock.calls; const [, , clientHeaders] = client; expect(clientHeaders).toEqual({ authorization: authorizationHeader }); }); @@ -426,7 +444,7 @@ describe('http service', () => { }) ); - clusterClientInstanceMock.callAsCurrentUser.mockRejectedValue(authenticationError); + legacyClusterClientInstanceMock.callAsCurrentUser.mockRejectedValue(authenticationError); const router = createRouter('/new-platform'); router.get({ path: '/', validate: false }, async (context, req, res) => { @@ -441,4 +459,91 @@ describe('http service', () => { expect(response.header['www-authenticate']).toEqual('authenticate header'); }); }); + + describe('elasticsearch client', () => { + let root: ReturnType; + + beforeEach(async () => { + root = kbnTestServer.createRoot({ plugins: { initialize: false } }); + }, 30000); + + afterEach(async () => { + MockElasticsearchClient.mockClear(); + await root.shutdown(); + }); + + it('forwards unauthorized errors from elasticsearch', async () => { + const { http } = await root.setup(); + const { createRouter } = http; + // eslint-disable-next-line prefer-const + let elasticsearch: InternalElasticsearchServiceStart; + + esClient.ping.mockImplementation(() => + elasticsearchClientMock.createClientError( + new ResponseError({ + statusCode: 401, + body: { + error: { + type: 'Unauthorized', + }, + }, + warnings: [], + headers: { + 'WWW-Authenticate': 'content', + }, + meta: {} as any, + }) + ) + ); + + const router = createRouter('/new-platform'); + router.get({ path: '/', validate: false }, async (context, req, res) => { + await elasticsearch.client.asScoped(req).asInternalUser.ping(); + return res.ok(); + }); + + const coreStart = await root.start(); + elasticsearch = coreStart.elasticsearch; + + const { header } = await kbnTestServer.request.get(root, '/new-platform/').expect(401); + + expect(header['www-authenticate']).toEqual('content'); + }); + + it('uses a default value for `www-authenticate` header when ES 401 does not specify it', async () => { + const { http } = await root.setup(); + const { createRouter } = http; + // eslint-disable-next-line prefer-const + let elasticsearch: InternalElasticsearchServiceStart; + + esClient.ping.mockImplementation(() => + elasticsearchClientMock.createClientError( + new ResponseError({ + statusCode: 401, + body: { + error: { + type: 'Unauthorized', + }, + }, + warnings: [], + headers: {}, + meta: {} as any, + }) + ) + ); + + const router = createRouter('/new-platform'); + router.get({ path: '/', validate: false }, async (context, req, res) => { + await elasticsearch.client.asScoped(req).asInternalUser.ping(); + return res.ok(); + }); + + const coreStart = await root.start(); + elasticsearch = coreStart.elasticsearch; + + const { header } = await kbnTestServer.request.get(root, '/new-platform/').expect(401); + + expect(header['www-authenticate']).toEqual('Basic realm="Authorization Required"'); + }); + }); }); diff --git a/src/core/server/http/router/router.ts b/src/core/server/http/router/router.ts index 35eec746163ce..cc5279a396163 100644 --- a/src/core/server/http/router/router.ts +++ b/src/core/server/http/router/router.ts @@ -23,8 +23,17 @@ import Boom from 'boom'; import { isConfigSchema } from '@kbn/config-schema'; import { Logger } from '../../logging'; import { LegacyElasticsearchErrorHelpers } from '../../elasticsearch/legacy/errors'; +import { + isUnauthorizedError as isElasticsearchUnauthorizedError, + UnauthorizedError as EsNotAuthorizedError, +} from '../../elasticsearch/client/errors'; import { KibanaRequest } from './request'; -import { KibanaResponseFactory, kibanaResponseFactory, IKibanaResponse } from './response'; +import { + KibanaResponseFactory, + kibanaResponseFactory, + IKibanaResponse, + ErrorHttpResponseOptions, +} from './response'; import { RouteConfig, RouteConfigOptions, RouteMethod, validBodyOutput } from './route'; import { HapiResponseAdapter } from './response_adapter'; import { RequestHandlerContext } from '../../../server'; @@ -264,7 +273,13 @@ export class Router implements IRouter { return hapiResponseAdapter.handle(kibanaResponse); } catch (e) { this.log.error(e); - // forward 401 (boom) error from ES + // forward 401 errors from ES client + if (isElasticsearchUnauthorizedError(e)) { + return hapiResponseAdapter.handle( + kibanaResponseFactory.unauthorized(convertEsUnauthorized(e)) + ); + } + // forward 401 (boom) errors from legacy ES client if (LegacyElasticsearchErrorHelpers.isNotAuthorizedError(e)) { return e; } @@ -273,6 +288,21 @@ export class Router implements IRouter { } } +const convertEsUnauthorized = (e: EsNotAuthorizedError): ErrorHttpResponseOptions => { + const getAuthenticateHeaderValue = () => { + const header = Object.entries(e.headers).find( + ([key]) => key.toLowerCase() === 'www-authenticate' + ); + return header ? header[1] : 'Basic realm="Authorization Required"'; + }; + return { + body: e.message, + headers: { + 'www-authenticate': getAuthenticateHeaderValue(), + }, + }; +}; + type WithoutHeadArgument = T extends (first: any, ...rest: infer Params) => infer Return ? (...rest: Params) => Return : never; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index a0e16602ba4bf..4b6bcbc8ad7a0 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -22,7 +22,6 @@ import { CatTasksParams } from 'elasticsearch'; import { CatThreadPoolParams } from 'elasticsearch'; import { ClearScrollParams } from 'elasticsearch'; import { Client } from 'elasticsearch'; -import { Client as Client_2 } from '@elastic/elasticsearch'; import { ClientOptions } from '@elastic/elasticsearch'; import { ClusterAllocationExplainParams } from 'elasticsearch'; import { ClusterGetSettingsParams } from 'elasticsearch'; @@ -93,6 +92,7 @@ import { IngestDeletePipelineParams } from 'elasticsearch'; import { IngestGetPipelineParams } from 'elasticsearch'; import { IngestPutPipelineParams } from 'elasticsearch'; import { IngestSimulateParams } from 'elasticsearch'; +import { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { KibanaConfigType } from 'src/core/server/kibana_config'; import { MGetParams } from 'elasticsearch'; import { MGetResponse } from 'elasticsearch'; @@ -143,6 +143,7 @@ import { TasksListParams } from 'elasticsearch'; import { TermvectorsParams } from 'elasticsearch'; import { TransportRequestOptions } from '@elastic/elasticsearch/lib/Transport'; import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport'; +import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; import { UpdateDocumentByQueryParams } from 'elasticsearch'; diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index 745a3d1f0c830..0913d4ba4e83a 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -174,6 +174,8 @@ kibana_vars=( xpack.infra.sources.default.fields.timestamp xpack.infra.sources.default.logAlias xpack.infra.sources.default.metricAlias + xpack.ingestManager.fleet.tlsCheckDisabled + xpack.ingestManager.registryUrl xpack.license_management.enabled xpack.ml.enabled xpack.reporting.capture.browser.autoDownload diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 929de8c6701d4..1e4f048be8ea4 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -51,7 +51,7 @@ export const IGNORE_FILE_GLOBS = [ '.ci/pipeline-library/**/*', // Files in this directory must match a pre-determined name in some cases. - 'x-pack/plugins/canvas/.storybook/*', + 'x-pack/plugins/canvas/storybook/*', // filename must match language code which requires capital letters '**/translations/*.json', diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 38e0416233e25..a8868c07061c3 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -52,7 +52,6 @@ import { EuiButtonEmptyProps } from '@elastic/eui'; import { EuiComboBoxProps } from '@elastic/eui'; import { EuiConfirmModalProps } from '@elastic/eui'; import { EuiGlobalToastListToast } from '@elastic/eui'; -import { EventEmitter } from 'events'; import { ExclusiveUnion } from '@elastic/eui'; import { ExistsParams } from 'elasticsearch'; import { ExplainParams } from 'elasticsearch'; @@ -148,7 +147,7 @@ import { ReindexRethrottleParams } from 'elasticsearch'; import { RenderSearchTemplateParams } from 'elasticsearch'; import { Reporter } from '@kbn/analytics'; import { RequestAdapter } from 'src/plugins/inspector/common'; -import { RequestStatistics as RequestStatistics_2 } from 'src/plugins/inspector/common'; +import { RequestStatistics } from 'src/plugins/inspector/common'; import { Required } from '@kbn/utility-types'; import * as Rx from 'rxjs'; import { SavedObject } from 'src/core/server'; diff --git a/src/plugins/data/public/search/aggs/buckets/terms.ts b/src/plugins/data/public/search/aggs/buckets/terms.ts index d3acd33d73d01..5c8483cf21369 100644 --- a/src/plugins/data/public/search/aggs/buckets/terms.ts +++ b/src/plugins/data/public/search/aggs/buckets/terms.ts @@ -129,7 +129,7 @@ export const getTermsBucketAgg = () => const response = await nestedSearchSource.fetch({ abortSignal }); request - .stats(getResponseInspectorStats(nestedSearchSource, response)) + .stats(getResponseInspectorStats(response, nestedSearchSource)) .ok({ json: response }); resp = mergeOtherBucketAggResponse(aggConfigs, resp, response, aggConfig, filterAgg()); } diff --git a/src/plugins/data/public/search/expressions/esaggs.ts b/src/plugins/data/public/search/expressions/esaggs.ts index b01f17762b2be..690f6b1df11c3 100644 --- a/src/plugins/data/public/search/expressions/esaggs.ts +++ b/src/plugins/data/public/search/expressions/esaggs.ts @@ -160,7 +160,7 @@ const handleCourierRequest = async ({ (searchSource as any).lastQuery = queryHash; - request.stats(getResponseInspectorStats(searchSource, response)).ok({ json: response }); + request.stats(getResponseInspectorStats(response, searchSource)).ok({ json: response }); (searchSource as any).rawResponse = response; } catch (e) { diff --git a/src/plugins/data/public/search/expressions/utils/courier_inspector_stats.ts b/src/plugins/data/public/search/expressions/utils/courier_inspector_stats.ts index 96d0aaa16f6ba..c933e8cd3e961 100644 --- a/src/plugins/data/public/search/expressions/utils/courier_inspector_stats.ts +++ b/src/plugins/data/public/search/expressions/utils/courier_inspector_stats.ts @@ -61,10 +61,11 @@ export function getRequestInspectorStats(searchSource: ISearchSource) { /** @public */ export function getResponseInspectorStats( - searchSource: ISearchSource, - resp: SearchResponse + resp: SearchResponse, + searchSource?: ISearchSource ) { - const lastRequest = searchSource.history && searchSource.history[searchSource.history.length - 1]; + const lastRequest = + searchSource?.history && searchSource.history[searchSource.history.length - 1]; const stats: RequestStatistics = {}; if (resp && resp.took) { diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index c5d19fef9531e..99a77ff9aeb10 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -39,7 +39,6 @@ import { DeleteTemplateParams } from 'elasticsearch'; import { DetailedPeerCertificate } from 'tls'; import { Duration } from 'moment'; import { ErrorToastOptions } from 'src/core/public/notifications'; -import { EventEmitter } from 'events'; import { ExistsParams } from 'elasticsearch'; import { ExplainParams } from 'elasticsearch'; import { FieldStatsParams } from 'elasticsearch'; diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 9b8b32b51cfd8..c791bdd850151 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -874,7 +874,7 @@ function discoverController( } function onResults(resp) { - inspectorRequest.stats(getResponseInspectorStats($scope.searchSource, resp)).ok({ json: resp }); + inspectorRequest.stats(getResponseInspectorStats(resp, $scope.searchSource)).ok({ json: resp }); if (getTimeField()) { const tabifiedData = tabifyAggResponse($scope.vis.data.aggs, resp); diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index 9a3dd0d310ff7..b621017677c58 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -307,7 +307,7 @@ export class SearchEmbeddable extends Embeddable this.updateOutput({ loading: false, error: undefined }); // Log response to inspector - inspectorRequest.stats(getResponseInspectorStats(searchSource, resp)).ok({ json: resp }); + inspectorRequest.stats(getResponseInspectorStats(resp, searchSource)).ok({ json: resp }); // Apply the changes to the angular scope this.searchScope.$apply(() => { diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts index f42ee18965309..3533500a2fbc5 100644 --- a/src/plugins/expressions/common/execution/execution.ts +++ b/src/plugins/expressions/common/execution/execution.ts @@ -23,7 +23,7 @@ import { createExecutionContainer, ExecutionContainer } from './container'; import { createError } from '../util'; import { Defer, now } from '../../../kibana_utils/common'; import { toPromise } from '../../../data/common/utils/abort_utils'; -import { RequestAdapter, DataAdapter } from '../../../inspector/common'; +import { RequestAdapter, DataAdapter, Adapters } from '../../../inspector/common'; import { isExpressionValueError, ExpressionValueError } from '../expression_types/specs/error'; import { ExpressionAstExpression, @@ -70,7 +70,7 @@ export class Execution< ExtraContext extends Record = Record, Input = unknown, Output = unknown, - InspectorAdapters = ExtraContext['inspectorAdapters'] extends object + InspectorAdapters extends Adapters = ExtraContext['inspectorAdapters'] extends object ? ExtraContext['inspectorAdapters'] : DefaultInspectorAdapters > { diff --git a/src/plugins/expressions/common/execution/types.ts b/src/plugins/expressions/common/execution/types.ts index 51538394cd125..7c26e586fb790 100644 --- a/src/plugins/expressions/common/execution/types.ts +++ b/src/plugins/expressions/common/execution/types.ts @@ -18,7 +18,7 @@ */ import { ExpressionType } from '../expression_types'; -import { DataAdapter, RequestAdapter } from '../../../inspector/common'; +import { Adapters, DataAdapter, RequestAdapter } from '../../../inspector/common'; import { TimeRange, Query, Filter } from '../../../data/common'; import { SavedObject, SavedObjectAttributes } from '../../../../core/public'; @@ -26,7 +26,7 @@ import { SavedObject, SavedObjectAttributes } from '../../../../core/public'; * `ExecutionContext` is an object available to all functions during a single execution; * it provides various methods to perform side-effects. */ -export interface ExecutionContext { +export interface ExecutionContext { /** * Get initial input with which execution started. */ @@ -75,7 +75,7 @@ export interface ExecutionContext { /** * Adapters used to open the inspector. */ - adapters: Adapters; + adapters: TAdapters; /** * The title that the inspector is currently using e.g. a visualization name. */ 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 0e9560cbd7962..9ed4e60cac519 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 @@ -306,9 +306,11 @@ exports[`InspectorPanel should render as expected 1`] = ` - +
div { + display: flex; + flex-direction: column; + + > div { + flex-grow: 1; + } + } +} diff --git a/src/plugins/inspector/public/ui/inspector_panel.test.tsx b/src/plugins/inspector/public/ui/inspector_panel.test.tsx index c482b6fa8033b..23f698c23793b 100644 --- a/src/plugins/inspector/public/ui/inspector_panel.test.tsx +++ b/src/plugins/inspector/public/ui/inspector_panel.test.tsx @@ -20,7 +20,8 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { InspectorPanel } from './inspector_panel'; -import { Adapters, InspectorViewDescription } from '../types'; +import { InspectorViewDescription } from '../types'; +import { Adapters } from '../../common'; describe('InspectorPanel', () => { let adapters: Adapters; diff --git a/src/plugins/inspector/public/ui/inspector_panel.tsx b/src/plugins/inspector/public/ui/inspector_panel.tsx index 85705b6b74f55..37a51257112d6 100644 --- a/src/plugins/inspector/public/ui/inspector_panel.tsx +++ b/src/plugins/inspector/public/ui/inspector_panel.tsx @@ -17,11 +17,13 @@ * under the License. */ +import './inspector_panel.scss'; import { i18n } from '@kbn/i18n'; import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { EuiFlexGroup, EuiFlexItem, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui'; -import { Adapters, InspectorViewDescription } from '../types'; +import { InspectorViewDescription } from '../types'; +import { Adapters } from '../../common'; import { InspectorViewChooser } from './inspector_view_chooser'; function hasAdaptersChanged(oldAdapters: Adapters, newAdapters: Adapters) { @@ -122,7 +124,9 @@ export class InspectorPanel extends Component - {this.renderSelectedPanel()} + + {this.renderSelectedPanel()} + ); } diff --git a/src/plugins/inspector/public/view_registry.test.ts b/src/plugins/inspector/public/view_registry.test.ts index 542328d4f48da..13e109f50243c 100644 --- a/src/plugins/inspector/public/view_registry.test.ts +++ b/src/plugins/inspector/public/view_registry.test.ts @@ -20,7 +20,7 @@ import { InspectorViewRegistry } from './view_registry'; import { InspectorViewDescription } from './types'; -import { Adapters } from './types'; +import { Adapters } from '../common'; function createMockView( params: { diff --git a/src/plugins/inspector/public/view_registry.ts b/src/plugins/inspector/public/view_registry.ts index 800d917af28ca..be84a62a11712 100644 --- a/src/plugins/inspector/public/view_registry.ts +++ b/src/plugins/inspector/public/view_registry.ts @@ -18,7 +18,8 @@ */ import { EventEmitter } from 'events'; -import { Adapters, InspectorViewDescription } from './types'; +import { InspectorViewDescription } from './types'; +import { Adapters } from '../common'; /** * @callback viewShouldShowFunc diff --git a/src/plugins/inspector/public/views/data/components/data_view.tsx b/src/plugins/inspector/public/views/data/components/data_view.tsx index e03c165d96a27..1a2b6f9922d2d 100644 --- a/src/plugins/inspector/public/views/data/components/data_view.tsx +++ b/src/plugins/inspector/public/views/data/components/data_view.tsx @@ -30,7 +30,8 @@ import { } from '@elastic/eui'; import { DataTableFormat } from './data_table'; -import { InspectorViewProps, Adapters } from '../../../types'; +import { InspectorViewProps } from '../../../types'; +import { Adapters } from '../../../../common'; import { TabularLoaderOptions, TabularData, diff --git a/src/plugins/inspector/public/views/data/index.tsx b/src/plugins/inspector/public/views/data/index.tsx index 0cd88442bf8f8..b02e02bbe6b6b 100644 --- a/src/plugins/inspector/public/views/data/index.tsx +++ b/src/plugins/inspector/public/views/data/index.tsx @@ -20,7 +20,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { DataViewComponent } from './components/data_view'; -import { Adapters, InspectorViewDescription, InspectorViewProps } from '../../types'; +import { InspectorViewDescription, InspectorViewProps } from '../../types'; +import { Adapters } from '../../../common'; import { IUiSettingsClient } from '../../../../../core/public'; export const getDataViewDescription = ( diff --git a/src/plugins/inspector/public/views/requests/index.ts b/src/plugins/inspector/public/views/requests/index.ts index 741da76872710..00a223e1e30fa 100644 --- a/src/plugins/inspector/public/views/requests/index.ts +++ b/src/plugins/inspector/public/views/requests/index.ts @@ -19,7 +19,8 @@ import { i18n } from '@kbn/i18n'; import { RequestsViewComponent } from './components/requests_view'; -import { Adapters, InspectorViewDescription } from '../../types'; +import { InspectorViewDescription } from '../../types'; +import { Adapters } from '../../../common'; export const getRequestsViewDescription = (): InspectorViewDescription => ({ title: i18n.translate('inspector.requests.requestsTitle', { diff --git a/src/plugins/vis_type_markdown/public/markdown_vis.ts b/src/plugins/vis_type_markdown/public/markdown_vis.ts index 3309330d7527c..089e00bb44937 100644 --- a/src/plugins/vis_type_markdown/public/markdown_vis.ts +++ b/src/plugins/vis_type_markdown/public/markdown_vis.ts @@ -66,4 +66,5 @@ export const markdownVisDefinition = { }, requestHandler: 'none', responseHandler: 'none', + inspectorAdapters: {}, }; diff --git a/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx b/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx index 52addb3c2d9d2..b4c90700b160f 100644 --- a/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx +++ b/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx @@ -62,6 +62,7 @@ export function getTimelionVisDefinition(dependencies: TimelionVisDependencies) }, requestHandler: timelionRequestHandler, responseHandler: 'none', + inspectorAdapters: {}, options: { showIndexSelection: false, showQueryBar: false, diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index 649ee765cc642..44b0334a37871 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -78,5 +78,6 @@ export const metricsVisDefinition = { showIndexSelection: false, }, requestHandler: metricsRequestHandler, + inspectorAdapters: {}, responseHandler: 'none', }; diff --git a/src/plugins/vis_type_vega/kibana.json b/src/plugins/vis_type_vega/kibana.json index d7a92de627a99..7ba5f23f10564 100644 --- a/src/plugins/vis_type_vega/kibana.json +++ b/src/plugins/vis_type_vega/kibana.json @@ -3,6 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data", "visualizations", "mapsLegacy", "expressions"], + "requiredPlugins": ["data", "visualizations", "mapsLegacy", "expressions", "inspector"], "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/vis_type_vega/public/_vega_vis.scss b/src/plugins/vis_type_vega/public/_vega_vis.scss index 4fc6fbc326ec1..f9468d677eeed 100644 --- a/src/plugins/vis_type_vega/public/_vega_vis.scss +++ b/src/plugins/vis_type_vega/public/_vega_vis.scss @@ -17,6 +17,7 @@ // BUG #23514: Make sure Vega doesn't display the controls in two places .vega-bindings { + // sass-lint:disable no-important display: none !important; } } @@ -47,7 +48,7 @@ width: $euiSizeM * 10 - $euiSize; } - input[type="range"] { + input[type='range'] { width: $euiSizeM * 10; display: inline-block; vertical-align: middle; @@ -74,7 +75,7 @@ top: 0; width: 100%; margin: auto; - opacity: 0.8; + opacity: .8; z-index: 1; list-style: none; } @@ -115,25 +116,30 @@ @include euiTextTruncate; padding-top: $euiSizeXS; padding-bottom: $euiSizeXS; - } - td.key { - color: $euiColorMediumShade; - max-width: $euiSize * 10; - text-align: right; - padding-right: $euiSizeXS; - } - td.value { - max-width: $euiSizeL * 10; - text-align: left; - } + &.key { + color: $euiColorMediumShade; + max-width: $euiSize * 10; + text-align: right; + padding-right: $euiSizeXS; + } - @media only screen and (max-width: map-get($euiBreakpoints, 'm')){ - td.key { - max-width: $euiSize * 6; + &.value { + max-width: $euiSizeL * 10; + text-align: left; } - td.value { - max-width: $euiSize * 10; + } + + + @media only screen and (max-width: map-get($euiBreakpoints, 'm')) { + td { + &.key { + max-width: $euiSize * 6; + } + + &.value { + max-width: $euiSize * 10; + } } } } diff --git a/src/plugins/vis_type_vega/public/data_model/search_api.ts b/src/plugins/vis_type_vega/public/data_model/search_api.ts index c2eecf13c2d51..18387a6ab0876 100644 --- a/src/plugins/vis_type_vega/public/data_model/search_api.ts +++ b/src/plugins/vis_type_vega/public/data_model/search_api.ts @@ -18,13 +18,17 @@ */ import { combineLatest } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { map, tap } from 'rxjs/operators'; import { CoreStart, IUiSettingsClient } from 'kibana/public'; import { getSearchParamsFromRequest, SearchRequest, DataPublicPluginStart, + IEsSearchResponse, } from '../../../data/public'; +import { search as dataPluginSearch } from '../../../data/public'; +import { VegaInspectorAdapters } from '../vega_inspector'; +import { RequestResponder } from '../../../inspector/public'; export interface SearchAPIDependencies { uiSettings: IUiSettingsClient; @@ -35,26 +39,52 @@ export interface SearchAPIDependencies { export class SearchAPI { constructor( private readonly dependencies: SearchAPIDependencies, - private readonly abortSignal?: AbortSignal + private readonly abortSignal?: AbortSignal, + public readonly inspectorAdapters?: VegaInspectorAdapters ) {} search(searchRequests: SearchRequest[]) { const { search } = this.dependencies.search; + const requestResponders: any = {}; return combineLatest( searchRequests.map((request, index) => { + const requestId: number = index; const params = getSearchParamsFromRequest(request, { uiSettings: this.dependencies.uiSettings, injectedMetadata: this.dependencies.injectedMetadata, }); + if (this.inspectorAdapters) { + requestResponders[requestId] = this.inspectorAdapters.requests.start( + `#${requestId}`, + request + ); + requestResponders[requestId].json(params.body); + } + return search({ params }, { signal: this.abortSignal }).pipe( + tap((data) => this.inspectSearchResult(data, requestResponders[requestId])), map((data) => ({ - id: index, + id: requestId, rawResponse: data.rawResponse, })) ); }) ); } + + public resetSearchStats() { + if (this.inspectorAdapters) { + this.inspectorAdapters.requests.reset(); + } + } + + private inspectSearchResult(response: IEsSearchResponse, requestResponder: RequestResponder) { + if (requestResponder) { + requestResponder + .stats(dataPluginSearch.getResponseInspectorStats(response.rawResponse)) + .ok({ json: response.rawResponse }); + } + } } diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js b/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js index 51aa4313a97b5..e29e16e3212f4 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js @@ -97,6 +97,7 @@ describe('VegaParser._resolveEsQueries', () => { search: jest.fn(() => ({ toPromise: jest.fn(() => Promise.resolve(data)), })), + resetSearchStats: jest.fn(), }; }); diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts index 17166e1540755..c867523d2b3b3 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts @@ -79,6 +79,7 @@ export class VegaParser { paddingHeight?: number; containerDir?: ControlsLocation | ControlsDirection; controlsDir?: ControlsLocation; + searchAPI: SearchAPI; constructor( spec: VegaSpec | string, @@ -92,10 +93,11 @@ export class VegaParser { this.error = undefined; this.warnings = []; + this.searchAPI = searchAPI; const onWarn = this._onWarning.bind(this); this._urlParsers = { - elasticsearch: new EsQueryParser(timeCache, searchAPI, filters, onWarn), + elasticsearch: new EsQueryParser(timeCache, this.searchAPI, filters, onWarn), emsfile: new EmsFileParser(serviceSettings), url: new UrlParser(onWarn), }; @@ -541,6 +543,8 @@ export class VegaParser { async _resolveDataUrls() { const pending: PendingType = {}; + this.searchAPI.resetSearchStats(); + this._findObjectDataUrls(this.spec!, (obj: Data) => { const url = obj.url; delete obj.url; diff --git a/src/plugins/vis_type_vega/public/plugin.ts b/src/plugins/vis_type_vega/public/plugin.ts index c20a104736291..00c6b2e3c8d5b 100644 --- a/src/plugins/vis_type_vega/public/plugin.ts +++ b/src/plugins/vis_type_vega/public/plugin.ts @@ -18,8 +18,10 @@ */ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public'; import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; -import { Plugin as DataPublicPlugin } from '../../data/public'; +import { DataPublicPluginSetup, DataPublicPluginStart } from '../../data/public'; import { VisualizationsSetup } from '../../visualizations/public'; +import { Setup as InspectorSetup } from '../../inspector/public'; + import { setNotifications, setData, @@ -37,11 +39,13 @@ import { IServiceSettings } from '../../maps_legacy/public'; import './index.scss'; import { ConfigSchema } from '../config'; +import { getVegaInspectorView } from './vega_inspector'; + /** @internal */ export interface VegaVisualizationDependencies { core: CoreSetup; plugins: { - data: ReturnType; + data: DataPublicPluginSetup; }; serviceSettings: IServiceSettings; } @@ -50,13 +54,14 @@ export interface VegaVisualizationDependencies { export interface VegaPluginSetupDependencies { expressions: ReturnType; visualizations: VisualizationsSetup; - data: ReturnType; + inspector: InspectorSetup; + data: DataPublicPluginSetup; mapsLegacy: any; } /** @internal */ export interface VegaPluginStartDependencies { - data: ReturnType; + data: DataPublicPluginStart; } /** @internal */ @@ -69,7 +74,7 @@ export class VegaPlugin implements Plugin, void> { public async setup( core: CoreSetup, - { data, expressions, visualizations, mapsLegacy }: VegaPluginSetupDependencies + { inspector, data, expressions, visualizations, mapsLegacy }: VegaPluginSetupDependencies ) { setInjectedVars({ enableExternalUrls: this.initializerContext.config.get().enableExternalUrls, @@ -88,6 +93,8 @@ export class VegaPlugin implements Plugin, void> { serviceSettings: mapsLegacy.serviceSettings, }; + inspector.registerView(getVegaInspectorView({ uiSettings: core.uiSettings })); + expressions.registerFunction(() => createVegaFn(visualizationDependencies)); visualizations.createBaseVisualization(createVegaTypeDefinition(visualizationDependencies)); diff --git a/src/plugins/vis_type_vega/public/vega_fn.ts b/src/plugins/vis_type_vega/public/vega_fn.ts index d077aa7aee004..c109bb3c6e90c 100644 --- a/src/plugins/vis_type_vega/public/vega_fn.ts +++ b/src/plugins/vis_type_vega/public/vega_fn.ts @@ -19,9 +19,15 @@ import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { ExpressionFunctionDefinition, KibanaContext, Render } from '../../expressions/public'; +import { + ExecutionContext, + ExpressionFunctionDefinition, + KibanaContext, + Render, +} from '../../expressions/public'; import { VegaVisualizationDependencies } from './plugin'; import { createVegaRequestHandler } from './vega_request_handler'; +import { VegaInspectorAdapters } from './vega_inspector/index'; import { TimeRange, Query } from '../../data/public'; import { VegaParser } from './data_model/vega_parser'; @@ -42,7 +48,13 @@ interface RenderValue { export const createVegaFn = ( dependencies: VegaVisualizationDependencies -): ExpressionFunctionDefinition<'vega', Input, Arguments, Output> => ({ +): ExpressionFunctionDefinition< + 'vega', + Input, + Arguments, + Output, + ExecutionContext +> => ({ name: 'vega', type: 'render', inputTypes: ['kibana_context', 'null'], @@ -57,7 +69,7 @@ export const createVegaFn = ( }, }, async fn(input, args, context) { - const vegaRequestHandler = createVegaRequestHandler(dependencies, context.abortSignal); + const vegaRequestHandler = createVegaRequestHandler(dependencies, context); const response = await vegaRequestHandler({ timeRange: get(input, 'timeRange') as TimeRange, diff --git a/src/plugins/vis_type_vega/public/vega_inspector/components/data_viewer.tsx b/src/plugins/vis_type_vega/public/vega_inspector/components/data_viewer.tsx new file mode 100644 index 0000000000000..9b09a09eb05e0 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_inspector/components/data_viewer.tsx @@ -0,0 +1,114 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useState, useCallback, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiComboBox, + EuiFlexGroup, + EuiComboBoxProps, + EuiFlexItem, + EuiSpacer, + CommonProps, +} from '@elastic/eui'; +import { VegaAdapter, InspectDataSets } from '../vega_adapter'; +import { InspectorDataGrid } from './inspector_data_grid'; + +interface DataViewerProps extends CommonProps { + vegaAdapter: VegaAdapter; +} + +const getDataGridArialabel = (view: InspectDataSets) => + i18n.translate('visTypeVega.inspector.dataViewer.gridAriaLabel', { + defaultMessage: '{name} data grid', + values: { + name: view.id, + }, + }); + +const dataSetAriaLabel = i18n.translate('visTypeVega.inspector.dataViewer.dataSetAriaLabel', { + defaultMessage: 'Data set', +}); + +export const DataViewer = ({ vegaAdapter, ...rest }: DataViewerProps) => { + const [inspectDataSets, setInspectDataSets] = useState([]); + const [selectedView, setSelectedView] = useState(); + const [dataGridAriaLabel, setDataGridAriaLabel] = useState(''); + + const onViewChange: EuiComboBoxProps['onChange'] = useCallback( + (selectedOptions) => { + const newView = inspectDataSets!.find((view) => view.id === selectedOptions[0].label); + + if (newView) { + setDataGridAriaLabel(getDataGridArialabel(newView)); + setSelectedView(newView); + } + }, + [inspectDataSets] + ); + + useEffect(() => { + const subscription = vegaAdapter.getDataSetsSubscription().subscribe((dataSets) => { + setInspectDataSets(dataSets); + }); + + return () => { + subscription.unsubscribe(); + }; + }, [vegaAdapter]); + + useEffect(() => { + if (inspectDataSets) { + if (!selectedView) { + setSelectedView(inspectDataSets[0]); + } else { + setDataGridAriaLabel(getDataGridArialabel(selectedView)); + } + } + }, [selectedView, inspectDataSets]); + + if (!selectedView) { + return null; + } + + return ( + + + + ({ + label: item.id, + }))} + aria-label={dataSetAriaLabel} + onChange={onViewChange} + isClearable={false} + singleSelection={{ asPlainText: true }} + selectedOptions={[{ label: selectedView.id }]} + /> + + + + + + ); +}; diff --git a/src/plugins/vis_type_vega/public/vega_inspector/components/index.ts b/src/plugins/vis_type_vega/public/vega_inspector/components/index.ts new file mode 100644 index 0000000000000..76e631f9ecd94 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_inspector/components/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { DataViewer } from './data_viewer'; +export { SignalViewer } from './signal_viewer'; +export { SpecViewer } from './spec_viewer'; diff --git a/src/plugins/vis_type_vega/public/vega_inspector/components/inspector_data_grid.tsx b/src/plugins/vis_type_vega/public/vega_inspector/components/inspector_data_grid.tsx new file mode 100644 index 0000000000000..00f24e03d8196 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_inspector/components/inspector_data_grid.tsx @@ -0,0 +1,144 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import { EuiDataGrid, EuiDataGridSorting, EuiDataGridProps } from '@elastic/eui'; +import { VegaRuntimeData } from '../vega_adapter'; + +const DEFAULT_PAGE_SIZE = 15; + +interface InspectorDataGridProps extends VegaRuntimeData { + dataGridAriaLabel: string; +} + +export const InspectorDataGrid = ({ columns, data, dataGridAriaLabel }: InspectorDataGridProps) => { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE }); + const onChangeItemsPerPage = useCallback( + (pageSize) => setPagination((p) => ({ ...p, pageSize, pageIndex: 0 })), + [setPagination] + ); + + const onChangePage = useCallback((pageIndex) => setPagination((p) => ({ ...p, pageIndex })), [ + setPagination, + ]); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState([]); + + useEffect( + () => { + setPagination({ + ...pagination, + pageIndex: 0, + }); + setVisibleColumns(columns.map((column) => column.id)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [dataGridAriaLabel] + ); + + // Sorting + const [sortingColumns, setSortingColumns] = useState([]); + + const onSort = useCallback( + (newSortingColumns: EuiDataGridSorting['columns']) => { + setSortingColumns(newSortingColumns); + }, + [setSortingColumns] + ); + + let gridData = useMemo(() => { + return [...data].sort((a, b) => { + for (let i = 0; i < sortingColumns.length; i++) { + const column = sortingColumns[i]; + const aValue = a[column.id]; + const bValue = b[column.id]; + + if (aValue < bValue) return column.direction === 'asc' ? -1 : 1; + if (aValue > bValue) return column.direction === 'asc' ? 1 : -1; + } + return 0; + }); + }, [data, sortingColumns]); + + const renderCellValue = useMemo(() => { + return (({ rowIndex, columnId }) => { + let adjustedRowIndex = rowIndex; + + // If we are doing the pagination (instead of leaving that to the grid) + // then the row index must be adjusted as `data` has already been pruned to the page size + adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize; + + return gridData.hasOwnProperty(adjustedRowIndex) + ? gridData[adjustedRowIndex][columnId] || null + : null; + }) as EuiDataGridProps['renderCellValue']; + }, [gridData, pagination.pageIndex, pagination.pageSize]); + + // Pagination + gridData = useMemo(() => { + const rowStart = pagination.pageIndex * pagination.pageSize; + const rowEnd = Math.min(rowStart + pagination.pageSize, gridData.length); + return gridData.slice(rowStart, rowEnd); + }, [gridData, pagination]); + + // Resize + const [columnsWidth, setColumnsWidth] = useState>({}); + + const onColumnResize: EuiDataGridProps['onColumnResize'] = useCallback( + ({ columnId, width }) => { + setColumnsWidth({ + ...columnsWidth, + [columnId]: width, + }); + }, + [columnsWidth] + ); + + return ( + { + if (columnsWidth[column.id]) { + return { + ...column, + initialWidth: columnsWidth[column.id], + }; + } + return column; + })} + columnVisibility={{ + visibleColumns, + setVisibleColumns, + }} + rowCount={data.length} + renderCellValue={renderCellValue} + sorting={{ columns: sortingColumns, onSort }} + toolbarVisibility={{ + showFullScreenSelector: false, + }} + onColumnResize={onColumnResize} + pagination={{ + ...pagination, + pageSizeOptions: [DEFAULT_PAGE_SIZE, 25, 50], + onChangeItemsPerPage, + onChangePage, + }} + /> + ); +}; diff --git a/src/plugins/vis_type_vega/public/vega_inspector/components/signal_viewer.tsx b/src/plugins/vis_type_vega/public/vega_inspector/components/signal_viewer.tsx new file mode 100644 index 0000000000000..39df004f327a4 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_inspector/components/signal_viewer.tsx @@ -0,0 +1,72 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useEffect, useState } from 'react'; + +import { EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { VegaAdapter, InspectSignalsSets } from '../vega_adapter'; +import { InspectorDataGrid } from './inspector_data_grid'; + +interface SignalViewerProps { + vegaAdapter: VegaAdapter; +} + +const initialSignalColumnWidth = 150; + +const signalDataGridAriaLabel = i18n.translate('visTypeVega.inspector.signalViewer.gridAriaLabel', { + defaultMessage: 'Signal values data grid', +}); + +export const SignalViewer = ({ vegaAdapter }: SignalViewerProps) => { + const [inspectSignalsSets, setInspectSignalsSets] = useState(); + + useEffect(() => { + const subscription = vegaAdapter.getSignalsSetsSubscription().subscribe((signalSets) => { + if (signalSets) { + setInspectSignalsSets(signalSets); + } + }); + return () => { + subscription.unsubscribe(); + }; + }, [vegaAdapter]); + + if (!inspectSignalsSets) { + return null; + } + + return ( +
+ + { + if (index === 0) { + return { + ...column, + initialWidth: initialSignalColumnWidth, + }; + } + return column; + })} + data={inspectSignalsSets.data} + dataGridAriaLabel={signalDataGridAriaLabel} + /> +
+ ); +}; diff --git a/src/plugins/vis_type_vega/public/vega_inspector/components/spec_viewer.tsx b/src/plugins/vis_type_vega/public/vega_inspector/components/spec_viewer.tsx new file mode 100644 index 0000000000000..54f7974960aa2 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_inspector/components/spec_viewer.tsx @@ -0,0 +1,97 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { + EuiFlexItem, + EuiFlexGroup, + EuiCopy, + EuiButtonEmpty, + EuiSpacer, + CommonProps, +} from '@elastic/eui'; +import { VegaAdapter } from '../vega_adapter'; +import { CodeEditor } from '../../../../kibana_react/public'; + +interface SpecViewerProps extends CommonProps { + vegaAdapter: VegaAdapter; +} + +const copyToClipboardLabel = i18n.translate( + 'visTypeVega.inspector.specViewer.copyToClipboardLabel', + { + defaultMessage: 'Copy to clipboard', + } +); + +export const SpecViewer = ({ vegaAdapter, ...rest }: SpecViewerProps) => { + const [spec, setSpec] = useState(); + + useEffect(() => { + const subscription = vegaAdapter.getSpecSubscription().subscribe((data) => { + if (data) { + setSpec(data); + } + }); + return () => { + subscription.unsubscribe(); + }; + }, [vegaAdapter]); + + if (!spec) { + return null; + } + + return ( + + + +
+ + {(copy) => ( + + {copyToClipboardLabel} + + )} + +
+
+ + {}} + options={{ + readOnly: true, + lineNumbers: 'off', + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + +
+ ); +}; diff --git a/src/plugins/vis_type_vega/public/vega_inspector/index.ts b/src/plugins/vis_type_vega/public/vega_inspector/index.ts new file mode 100644 index 0000000000000..24da27d2d742d --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_inspector/index.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + createInspectorAdapters, + getVegaInspectorView, + VegaInspectorAdapters, +} from './vega_inspector'; diff --git a/src/plugins/vis_type_vega/public/vega_inspector/vega_adapter.ts b/src/plugins/vis_type_vega/public/vega_inspector/vega_adapter.ts new file mode 100644 index 0000000000000..e4c536af40591 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_inspector/vega_adapter.ts @@ -0,0 +1,148 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Observable, ReplaySubject, fromEventPattern, merge, timer } from 'rxjs'; +import { map, switchMap, filter, debounce } from 'rxjs/operators'; +import { View, Runtime, Spec } from 'vega'; +import { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; + +interface DebugValues { + view: View; + spec: Spec; +} + +export interface VegaRuntimeData { + columns: Array<{ + id: string; + }>; + data: Array>; +} + +export type InspectDataSets = Assign; +export type InspectSignalsSets = VegaRuntimeData; + +const vegaAdapterSignalLabel = i18n.translate('visTypeVega.inspector.vegaAdapter.signal', { + defaultMessage: 'Signal', +}); + +const vegaAdapterValueLabel = i18n.translate('visTypeVega.inspector.vegaAdapter.value', { + defaultMessage: 'Value', +}); + +/** Get Runtime Scope for Vega View + * @link https://vega.github.io/vega/docs/api/debugging/#scope + **/ +const getVegaRuntimeScope = (debugValues: DebugValues) => + (debugValues.view as any)._runtime as Runtime; + +const serializeColumns = (item: Record, columns: string[]) => { + const nonSerializableFieldLabel = '(..)'; + + return columns.reduce((row: Record, column) => { + try { + const cell = item[column]; + row[column] = typeof cell === 'object' ? JSON.stringify(cell) : `${cell}`; + } catch (e) { + row[column] = nonSerializableFieldLabel; + } + return row; + }, {}); +}; + +export class VegaAdapter { + private debugValuesSubject = new ReplaySubject(); + + bindInspectValues(debugValues: DebugValues) { + this.debugValuesSubject.next(debugValues); + } + + getDataSetsSubscription(): Observable { + return this.debugValuesSubject.pipe( + filter((debugValues) => Boolean(debugValues)), + map((debugValues) => { + const runtimeScope = getVegaRuntimeScope(debugValues); + + return Object.keys(runtimeScope.data || []).reduce((acc: InspectDataSets[], key) => { + const value = runtimeScope.data[key].values.value; + + if (value && value[0]) { + const columns = Object.keys(value[0]); + acc.push({ + id: key, + columns: columns.map((column) => ({ id: column, schema: 'json' })), + data: value.map((item: Record) => serializeColumns(item, columns)), + }); + } + return acc; + }, []); + }) + ); + } + + getSignalsSetsSubscription(): Observable { + const signalsListener = this.debugValuesSubject.pipe( + filter((debugValues) => Boolean(debugValues)), + switchMap((debugValues) => { + const runtimeScope = getVegaRuntimeScope(debugValues); + + return merge( + ...Object.keys(runtimeScope.signals).map((key: string) => + fromEventPattern( + (handler) => debugValues.view.addSignalListener(key, handler), + (handler) => debugValues.view.removeSignalListener(key, handler) + ) + ) + ).pipe( + debounce((val) => timer(350)), + map(() => debugValues) + ); + }) + ); + + return merge(this.debugValuesSubject, signalsListener).pipe( + filter((debugValues) => Boolean(debugValues)), + map((debugValues) => { + const runtimeScope = getVegaRuntimeScope(debugValues); + + return { + columns: [ + { id: vegaAdapterSignalLabel, schema: 'text' }, + { id: vegaAdapterValueLabel, schema: 'json' }, + ], + data: Object.keys(runtimeScope.signals).map((key: string) => + serializeColumns( + { + [vegaAdapterSignalLabel]: key, + [vegaAdapterValueLabel]: runtimeScope.signals[key].value, + }, + [vegaAdapterSignalLabel, vegaAdapterValueLabel] + ) + ), + }; + }) + ); + } + + getSpecSubscription(): Observable { + return this.debugValuesSubject.pipe( + filter((debugValues) => Boolean(debugValues)), + map((debugValues) => JSON.stringify(debugValues.spec, null, 2)) + ); + } +} diff --git a/src/plugins/vis_type_vega/public/vega_inspector/vega_data_inspector.scss b/src/plugins/vis_type_vega/public/vega_inspector/vega_data_inspector.scss new file mode 100644 index 0000000000000..487f505657d3b --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_inspector/vega_data_inspector.scss @@ -0,0 +1,18 @@ +.vgaVegaDataInspector, +.vgaVegaDataInspector__specViewer { + height: 100%; +} + +.vgaVegaDataInspector { + // TODO: EUI needs to provide props to pass down from EuiTabbedContent to tabs and content + display: flex; + flex-direction: column; + + [role='tablist'] { + flex-shrink: 0; + } + + [role='tabpanel'] { + flex-grow: 1; + } +} diff --git a/src/plugins/vis_type_vega/public/vega_inspector/vega_data_inspector.tsx b/src/plugins/vis_type_vega/public/vega_inspector/vega_data_inspector.tsx new file mode 100644 index 0000000000000..3b9427c96e62a --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_inspector/vega_data_inspector.tsx @@ -0,0 +1,74 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import './vega_data_inspector.scss'; + +import React from 'react'; +import { EuiTabbedContent } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { VegaInspectorAdapters } from './vega_inspector'; +import { DataViewer, SignalViewer, SpecViewer } from './components'; +import { InspectorViewProps } from '../../../inspector/public'; + +export type VegaDataInspectorProps = InspectorViewProps; + +const dataSetsLabel = i18n.translate('visTypeVega.inspector.dataSetsLabel', { + defaultMessage: 'Data sets', +}); + +const signalValuesLabel = i18n.translate('visTypeVega.inspector.signalValuesLabel', { + defaultMessage: 'Signal values', +}); + +const specLabel = i18n.translate('visTypeVega.inspector.specLabel', { + defaultMessage: 'Spec', +}); + +export const VegaDataInspector = ({ adapters }: VegaDataInspectorProps) => { + const tabs = [ + { + id: 'data-viewer--id', + name: dataSetsLabel, + content: , + }, + { + id: 'signal-viewer--id', + name: signalValuesLabel, + content: , + }, + { + id: 'spec-viewer--id', + name: specLabel, + content: ( + + ), + }, + ]; + + return ( + + ); +}; diff --git a/src/plugins/vis_type_vega/public/vega_inspector/vega_inspector.tsx b/src/plugins/vis_type_vega/public/vega_inspector/vega_inspector.tsx new file mode 100644 index 0000000000000..83d9e467646a6 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_inspector/vega_inspector.tsx @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { IUiSettingsClient } from 'kibana/public'; +import { VegaAdapter } from './vega_adapter'; +import { VegaDataInspector, VegaDataInspectorProps } from './vega_data_inspector'; +import { KibanaContextProvider } from '../../../kibana_react/public'; +import { Adapters, RequestAdapter, InspectorViewDescription } from '../../../inspector/public'; + +export interface VegaInspectorAdapters extends Adapters { + requests: RequestAdapter; + vega: VegaAdapter; +} + +const vegaDebugLabel = i18n.translate('visTypeVega.inspector.vegaDebugLabel', { + defaultMessage: 'Vega debug', +}); + +interface VegaInspectorViewDependencies { + uiSettings: IUiSettingsClient; +} + +export const getVegaInspectorView = (dependencies: VegaInspectorViewDependencies) => + ({ + title: vegaDebugLabel, + shouldShow(adapters) { + return Boolean(adapters.vega); + }, + component: (props) => ( + + + + ), + } as InspectorViewDescription); + +export const createInspectorAdapters = (): VegaInspectorAdapters => ({ + requests: new RequestAdapter(), + vega: new VegaAdapter(), +}); diff --git a/src/plugins/vis_type_vega/public/vega_request_handler.ts b/src/plugins/vis_type_vega/public/vega_request_handler.ts index 997b1982d749a..c09a9466df602 100644 --- a/src/plugins/vis_type_vega/public/vega_request_handler.ts +++ b/src/plugins/vis_type_vega/public/vega_request_handler.ts @@ -25,6 +25,7 @@ import { TimeCache } from './data_model/time_cache'; import { VegaVisualizationDependencies } from './plugin'; import { VisParams } from './vega_fn'; import { getData, getInjectedMetadata } from './services'; +import { VegaInspectorAdapters } from './vega_inspector'; interface VegaRequestHandlerParams { query: Query; @@ -33,9 +34,14 @@ interface VegaRequestHandlerParams { visParams: VisParams; } +interface VegaRequestHandlerContext { + abortSignal?: AbortSignal; + inspectorAdapters?: VegaInspectorAdapters; +} + export function createVegaRequestHandler( { plugins: { data }, core: { uiSettings }, serviceSettings }: VegaVisualizationDependencies, - abortSignal?: AbortSignal + context: VegaRequestHandlerContext = {} ) { let searchAPI: SearchAPI; const { timefilter } = data.query.timefilter; @@ -54,7 +60,8 @@ export function createVegaRequestHandler( search: getData().search, injectedMetadata: getInjectedMetadata(), }, - abortSignal + context.abortSignal, + context.inspectorAdapters ); } diff --git a/src/plugins/vis_type_vega/public/vega_type.ts b/src/plugins/vis_type_vega/public/vega_type.ts index 5825661f9001c..d69eb3cfba282 100644 --- a/src/plugins/vis_type_vega/public/vega_type.ts +++ b/src/plugins/vis_type_vega/public/vega_type.ts @@ -23,9 +23,10 @@ import { VegaVisualizationDependencies } from './plugin'; import { VegaVisEditor } from './components'; import { createVegaRequestHandler } from './vega_request_handler'; -// @ts-ignore +// @ts-expect-error import { createVegaVisualization } from './vega_visualization'; import { getDefaultSpec } from './default_spec'; +import { createInspectorAdapters } from './vega_inspector'; export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependencies) => { const requestHandler = createVegaRequestHandler(dependencies); @@ -54,5 +55,6 @@ export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependen showFilterBar: true, }, stage: 'experimental', + inspectorAdapters: createInspectorAdapters, }; }; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js index 55c3606bf5e45..8f88d5c5b2056 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js @@ -364,6 +364,11 @@ export class VegaBaseView { * Set global debug variable to simplify vega debugging in console. Show info message first time */ setDebugValues(view, spec, vlspec) { + this._parser.searchAPI.inspectorAdapters?.vega.bindInspectValues({ + view, + spec: vlspec || spec, + }); + if (window) { if (window.VEGA_DEBUG === undefined && console) { console.log('%cWelcome to Kibana Vega Plugin!', 'font-size: 16px; font-weight: bold;'); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_map_view.js index 6908fd13a9ca1..78ae2efdbdda5 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view.js @@ -142,7 +142,7 @@ export class VegaMapView extends VegaBaseView { }); const vegaView = vegaMapLayer.getVegaView(); - this.setDebugValues(vegaView, this._parser.spec, this._parser.vlspec); await this.setView(vegaView); + this.setDebugValues(vegaView, this._parser.spec, this._parser.vlspec); } } diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_view.js index e3455b97b7fe2..98c972ef84ccb 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_view.js @@ -26,7 +26,6 @@ export class VegaView extends VegaBaseView { if (!this._$container) return; const view = new vega.View(vega.parse(this._parser.spec), this._vegaViewConfig); - this.setDebugValues(view, this._parser.spec, this._parser.vlspec); view.warn = this.onWarn.bind(this); view.error = this.onError.bind(this); @@ -36,5 +35,6 @@ export class VegaView extends VegaBaseView { if (this._parser.useHover) view.hover(); await this.setView(view); + this.setDebugValues(view, this._parser.spec, this._parser.vlspec); } } diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index 2f9cda32fccdc..749926e1abd00 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -34,6 +34,7 @@ import { EmbeddableOutput, Embeddable, IContainer, + Adapters, } from '../../../../plugins/embeddable/public'; import { dispatchRenderComplete } from '../../../../plugins/kibana_utils/public'; import { @@ -78,8 +79,6 @@ export interface VisualizeOutput extends EmbeddableOutput { type ExpressionLoader = InstanceType; -const visTypesWithoutInspector = ['markdown', 'input_control_vis', 'metrics', 'vega', 'timelion']; - export class VisualizeEmbeddable extends Embeddable { private handler?: ExpressionLoader; private timefilter: TimefilterContract; @@ -96,6 +95,7 @@ export class VisualizeEmbeddable extends Embeddable { - if (!this.handler || visTypesWithoutInspector.includes(this.vis.type.name)) { + if (!this.handler || (this.inspectorAdapters && !Object.keys(this.inspectorAdapters).length)) { return undefined; } return this.handler.inspect(); @@ -349,6 +356,7 @@ export class VisualizeEmbeddable extends Embeddable Adapters); } export class BaseVisType { @@ -63,6 +65,7 @@ export class BaseVisType { hierarchicalData: boolean | unknown; setup?: unknown; useCustomNoDataScreen: boolean; + inspectorAdapters?: Adapters | (() => Adapters); constructor(opts: BaseVisTypeOptions) { if (!opts.icon && !opts.image) { @@ -98,6 +101,7 @@ export class BaseVisType { this.requiresSearch = this.requestHandler !== 'none'; this.hierarchicalData = opts.hierarchicalData || false; this.useCustomNoDataScreen = opts.useCustomNoDataScreen || false; + this.inspectorAdapters = opts.inspectorAdapters; } public get schemas() { diff --git a/tasks/test.js b/tasks/test.js index 96ec4d91db325..09821b97fe2e8 100644 --- a/tasks/test.js +++ b/tasks/test.js @@ -48,7 +48,7 @@ module.exports = function (grunt) { grunt.task.run(['run:karmaTestServer', ...ciShardTasks]); }); - grunt.registerTask('test:coverage', ['run:testCoverageServer', 'karma:coverage']); + grunt.registerTask('test:coverage', ['run:karmaTestCoverageServer', 'karma:coverage']); grunt.registerTask('test:quick', [ 'checkPlugins', diff --git a/test/functional/apps/discover/_discover_histogram.js b/test/functional/apps/discover/_discover_histogram.ts similarity index 61% rename from test/functional/apps/discover/_discover_histogram.js rename to test/functional/apps/discover/_discover_histogram.ts index e53c953f1514e..5c78bfccbb966 100644 --- a/test/functional/apps/discover/_discover_histogram.js +++ b/test/functional/apps/discover/_discover_histogram.ts @@ -18,14 +18,12 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { - const log = getService('log'); +export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const browser = getService('browser'); const elasticChart = getService('elasticChart'); const kibanaServer = getService('kibanaServer'); - const security = getService('security'); const PageObjects = getPageObjects(['settings', 'common', 'discover', 'header', 'timePicker']); const defaultSettings = { defaultIndex: 'long-window-logstash-*', @@ -33,63 +31,43 @@ export default function ({ getService, getPageObjects }) { }; describe('discover histogram', function describeIndexTests() { - before(async function () { - log.debug('load kibana index with default index pattern'); - await PageObjects.common.navigateToApp('settings'); - await security.testUser.setRoles([ - 'kibana_admin', - 'test_logstash_reader', - 'long_window_logstash', - ]); + before(async () => { await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.load('long_window_logstash'); - await esArchiver.load('visualize'); - await esArchiver.load('discover'); - - log.debug('create long_window_logstash index pattern'); - // NOTE: long_window_logstash load does NOT create index pattern - await PageObjects.settings.createIndexPattern('long-window-logstash-*'); + await esArchiver.load('long_window_logstash_index_pattern'); await kibanaServer.uiSettings.replace(defaultSettings); - await browser.refresh(); - - log.debug('discover'); await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.selectIndexPattern('long-window-logstash-*'); - // NOTE: For some reason without setting this relative time, the abs times will not fetch data. - await PageObjects.timePicker.setCommonlyUsedTime('Last_1 year'); }); after(async () => { await esArchiver.unload('long_window_logstash'); - await esArchiver.unload('visualize'); - await esArchiver.unload('discover'); - await security.testUser.restoreDefaults(); + await esArchiver.unload('long_window_logstash_index_pattern'); }); + async function prepareTest(fromTime: string, toTime: string, interval: string) { + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.setChartInterval(interval); + await PageObjects.header.waitUntilLoadingHasFinished(); + } + it('should visualize monthly data with different day intervals', async () => { const fromTime = 'Nov 01, 2017 @ 00:00:00.000'; const toTime = 'Mar 21, 2018 @ 00:00:00.000'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - await PageObjects.discover.setChartInterval('Month'); - await PageObjects.header.waitUntilLoadingHasFinished(); + await prepareTest(fromTime, toTime, 'Month'); const chartCanvasExist = await elasticChart.canvasExists(); expect(chartCanvasExist).to.be(true); }); it('should visualize weekly data with within DST changes', async () => { const fromTime = 'Mar 01, 2018 @ 00:00:00.000'; const toTime = 'May 01, 2018 @ 00:00:00.000'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - await PageObjects.discover.setChartInterval('Week'); - await PageObjects.header.waitUntilLoadingHasFinished(); + await prepareTest(fromTime, toTime, 'Week'); const chartCanvasExist = await elasticChart.canvasExists(); expect(chartCanvasExist).to.be(true); }); - it('should visualize monthly data with different years Scaled to 30 days', async () => { + it('should visualize monthly data with different years scaled to 30 days', async () => { const fromTime = 'Jan 01, 2010 @ 00:00:00.000'; const toTime = 'Mar 21, 2019 @ 00:00:00.000'; - - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - await PageObjects.discover.setChartInterval('Day'); - await PageObjects.header.waitUntilLoadingHasFinished(); + await prepareTest(fromTime, toTime, 'Day'); const chartCanvasExist = await elasticChart.canvasExists(); expect(chartCanvasExist).to.be(true); const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon(); diff --git a/test/functional/apps/visualize/_vega_chart.js b/test/functional/apps/visualize/_vega_chart.js index 4442e1f969b4b..c530c6f823133 100644 --- a/test/functional/apps/visualize/_vega_chart.js +++ b/test/functional/apps/visualize/_vega_chart.js @@ -22,7 +22,6 @@ import expect from '@kbn/expect'; export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['timePicker', 'visualize', 'visChart', 'vegaChart']); const filterBar = getService('filterBar'); - const inspector = getService('inspector'); const log = getService('log'); describe('vega chart in visualize app', () => { @@ -35,10 +34,6 @@ export default function ({ getService, getPageObjects }) { describe('vega chart', () => { describe('initial render', () => { - it('should not have inspector enabled', async function () { - await inspector.expectIsNotEnabled(); - }); - it.skip('should have some initial vega spec text', async function () { const vegaSpec = await PageObjects.vegaChart.getSpec(); expect(vegaSpec).to.contain('{').and.to.contain('data'); diff --git a/test/functional/fixtures/es_archiver/long_window_logstash_index_pattern/data.json b/test/functional/fixtures/es_archiver/long_window_logstash_index_pattern/data.json new file mode 100644 index 0000000000000..75aa6f06bb11a --- /dev/null +++ b/test/functional/fixtures/es_archiver/long_window_logstash_index_pattern/data.json @@ -0,0 +1,17 @@ +{ + "type": "doc", + "value": { + "id": "index-pattern:long-window-logstash-*", + "index": ".kibana", + "source": { + "index-pattern": { + "fields": "[{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false}]", + "timeFieldName": "@timestamp", + "title": "long-window-logstash-*" + }, + "type": "index-pattern" + } + } +} + + diff --git a/test/functional/services/remote/webdriver.ts b/test/functional/services/remote/webdriver.ts index 0611c80f59b92..09fede7fe2546 100644 --- a/test/functional/services/remote/webdriver.ts +++ b/test/functional/services/remote/webdriver.ts @@ -21,10 +21,9 @@ import { resolve } from 'path'; import Fs from 'fs'; import * as Rx from 'rxjs'; -import { mergeMap, map, takeUntil } from 'rxjs/operators'; +import { mergeMap, map, takeUntil, catchError } from 'rxjs/operators'; import { Lifecycle } from '@kbn/test/src/functional_test_runner/lib/lifecycle'; import { ToolingLog } from '@kbn/dev-utils'; -import { delay } from 'bluebird'; import chromeDriver from 'chromedriver'; // @ts-ignore types not available import geckoDriver from 'geckodriver'; @@ -337,25 +336,33 @@ export async function initWebDriver( edgePaths = await installDriver(); } - return await Promise.race([ - (async () => { - await delay(2 * MINUTE); - throw new Error('remote failed to start within 2 minutes'); - })(), - - (async () => { - while (true) { - const command = await Promise.race([ - delay(30 * SECOND), - attemptToCreateCommand(log, browserType, lifecycle, config), - ]); + return await Rx.race( + Rx.timer(2 * MINUTE).pipe( + map(() => { + throw new Error('remote failed to start within 2 minutes'); + }) + ), + Rx.race( + Rx.defer(async () => { + const command = await attemptToCreateCommand(log, browserType, lifecycle, config); if (!command) { - continue; + throw new Error('remote creation aborted'); } - return command; - } - })(), - ]); + }), + Rx.timer(30 * SECOND).pipe( + map(() => { + throw new Error('remote failed to start within 30 seconds'); + }) + ) + ).pipe( + catchError((error, resubscribe) => { + log.warning('Failure while creating webdriver instance'); + log.warning(error); + log.warning('...retrying...'); + return resubscribe; + }) + ) + ).toPromise(); } diff --git a/vars/githubCommitStatus.groovy b/vars/githubCommitStatus.groovy index 17d3c234f6928..248d226169a61 100644 --- a/vars/githubCommitStatus.groovy +++ b/vars/githubCommitStatus.groovy @@ -1,39 +1,47 @@ -def shouldCreateStatuses() { - return !githubPr.isPr() && buildState.get('checkoutInfo') +def defaultCommit() { + if (buildState.has('checkoutInfo')) { + return buildState.get('checkoutInfo').commit + } } -def onStart() { +def onStart(commit = defaultCommit(), context = 'kibana-ci') { catchError { - if (!shouldCreateStatuses()) { + if (githubPr.isPr() || !commit) { return } - def checkoutInfo = buildState.get('checkoutInfo') - create(checkoutInfo.commit, 'pending', 'Build started.') + create(commit, 'pending', 'Build started.', context) } } -def onFinish() { +def onFinish(commit = defaultCommit(), context = 'kibana-ci') { catchError { - if (!shouldCreateStatuses()) { + if (githubPr.isPr() || !commit) { return } - def checkoutInfo = buildState.get('checkoutInfo') def status = buildUtils.getBuildStatus() if (status == 'SUCCESS' || status == 'UNSTABLE') { - create(checkoutInfo.commit, 'success', 'Build completed successfully.') + create(commit, 'success', 'Build completed successfully.', context) } else if(status == 'ABORTED') { - create(checkoutInfo.commit, 'error', 'Build aborted or timed out.') + create(commit, 'error', 'Build aborted or timed out.', context) } else { - create(checkoutInfo.commit, 'error', 'Build failed.') + create(commit, 'error', 'Build failed.', context) } } } +def trackBuild(commit, context, Closure closure) { + onStart(commit, context) + catchError { + closure() + } + onFinish(commit, context) +} + // state: error|failure|pending|success -def create(sha, state, description, context = 'kibana-ci') { +def create(sha, state, description, context) { withGithubCredentials { return githubApi.post("repos/elastic/kibana/statuses/${sha}", [ state: state, diff --git a/x-pack/plugins/actions/server/authorization/audit_logger.test.ts b/x-pack/plugins/actions/server/authorization/audit_logger.test.ts index 6d3e69b822c96..d700abdaa70ff 100644 --- a/x-pack/plugins/actions/server/authorization/audit_logger.test.ts +++ b/x-pack/plugins/actions/server/authorization/audit_logger.test.ts @@ -26,7 +26,7 @@ describe(`#constructor`, () => { }); describe(`#actionsAuthorizationFailure`, () => { - test('logs auth failure with consumer scope', () => { + test('logs auth failure', () => { const auditLogger = createMockAuditLogger(); const actionsAuditLogger = new ActionsAuthorizationAuditLogger(auditLogger); const username = 'foo-user'; @@ -47,56 +47,10 @@ describe(`#actionsAuthorizationFailure`, () => { ] `); }); - - test('logs auth failure with producer scope', () => { - const auditLogger = createMockAuditLogger(); - const actionsAuditLogger = new ActionsAuthorizationAuditLogger(auditLogger); - const username = 'foo-user'; - const actionTypeId = 'action-type-id'; - - const operation = 'create'; - - actionsAuditLogger.actionsAuthorizationFailure(username, operation, actionTypeId); - - expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "actions_authorization_failure", - "foo-user Unauthorized to create a \\"action-type-id\\" action", - Object { - "actionTypeId": "action-type-id", - "operation": "create", - "username": "foo-user", - }, - ] - `); - }); }); describe(`#savedObjectsAuthorizationSuccess`, () => { - test('logs auth success with consumer scope', () => { - const auditLogger = createMockAuditLogger(); - const actionsAuditLogger = new ActionsAuthorizationAuditLogger(auditLogger); - const username = 'foo-user'; - const actionTypeId = 'action-type-id'; - - const operation = 'create'; - - actionsAuditLogger.actionsAuthorizationSuccess(username, operation, actionTypeId); - - expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "actions_authorization_success", - "foo-user Authorized to create a \\"action-type-id\\" action", - Object { - "actionTypeId": "action-type-id", - "operation": "create", - "username": "foo-user", - }, - ] - `); - }); - - test('logs auth success with producer scope', () => { + test('logs auth success', () => { const auditLogger = createMockAuditLogger(); const actionsAuditLogger = new ActionsAuthorizationAuditLogger(auditLogger); const username = 'foo-user'; diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index c8e6669275e11..65fd0646c639e 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -9,15 +9,17 @@ import { schema } from '@kbn/config-schema'; import { ActionExecutor } from './action_executor'; import { actionTypeRegistryMock } from '../action_type_registry.mock'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; -import { loggingSystemMock, savedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/mocks'; import { spacesServiceMock } from '../../../spaces/server/spaces_service/spaces_service.mock'; import { ActionType } from '../types'; -import { actionsMock } from '../mocks'; +import { actionsMock, actionsClientMock } from '../mocks'; +import { pick } from 'lodash'; const actionExecutor = new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false }); const services = actionsMock.createServices(); -const savedObjectsClientWithHidden = savedObjectsClientMock.create(); + +const actionsClient = actionsClientMock.create(); const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); const actionTypeRegistry = actionTypeRegistryMock.create(); @@ -30,11 +32,12 @@ const executeParams = { }; const spacesMock = spacesServiceMock.createSetupContract(); +const getActionsClientWithRequest = jest.fn(); actionExecutor.initialize({ logger: loggingSystemMock.create().get(), spaces: spacesMock, getServices: () => services, - getScopedSavedObjectsClient: () => savedObjectsClientWithHidden, + getActionsClientWithRequest, actionTypeRegistry, encryptedSavedObjectsClient, eventLogger: eventLoggerMock.create(), @@ -44,6 +47,7 @@ actionExecutor.initialize({ beforeEach(() => { jest.resetAllMocks(); spacesMock.getSpaceId.mockReturnValue('some-namespace'); + getActionsClientWithRequest.mockResolvedValue(actionsClient); }); test('successfully executes', async () => { @@ -67,7 +71,13 @@ test('successfully executes', async () => { }, references: [], }; - savedObjectsClientWithHidden.get.mockResolvedValueOnce(actionSavedObject); + const actionResult = { + id: actionSavedObject.id, + name: actionSavedObject.id, + ...pick(actionSavedObject.attributes, 'actionTypeId', 'config'), + isPreconfigured: false, + }; + actionsClient.get.mockResolvedValueOnce(actionResult); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); await actionExecutor.execute(executeParams); @@ -108,7 +118,13 @@ test('provides empty config when config and / or secrets is empty', async () => }, references: [], }; - savedObjectsClientWithHidden.get.mockResolvedValueOnce(actionSavedObject); + const actionResult = { + id: actionSavedObject.id, + name: actionSavedObject.id, + actionTypeId: actionSavedObject.attributes.actionTypeId, + isPreconfigured: false, + }; + actionsClient.get.mockResolvedValueOnce(actionResult); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); await actionExecutor.execute(executeParams); @@ -138,7 +154,13 @@ test('throws an error when config is invalid', async () => { }, references: [], }; - savedObjectsClientWithHidden.get.mockResolvedValueOnce(actionSavedObject); + const actionResult = { + id: actionSavedObject.id, + name: actionSavedObject.id, + actionTypeId: actionSavedObject.attributes.actionTypeId, + isPreconfigured: false, + }; + actionsClient.get.mockResolvedValueOnce(actionResult); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); @@ -171,7 +193,13 @@ test('throws an error when params is invalid', async () => { }, references: [], }; - savedObjectsClientWithHidden.get.mockResolvedValueOnce(actionSavedObject); + const actionResult = { + id: actionSavedObject.id, + name: actionSavedObject.id, + actionTypeId: actionSavedObject.attributes.actionTypeId, + isPreconfigured: false, + }; + actionsClient.get.mockResolvedValueOnce(actionResult); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); @@ -185,7 +213,7 @@ test('throws an error when params is invalid', async () => { }); test('throws an error when failing to load action through savedObjectsClient', async () => { - savedObjectsClientWithHidden.get.mockRejectedValueOnce(new Error('No access')); + actionsClient.get.mockRejectedValueOnce(new Error('No access')); await expect(actionExecutor.execute(executeParams)).rejects.toThrowErrorMatchingInlineSnapshot( `"No access"` ); @@ -206,7 +234,13 @@ test('throws an error if actionType is not enabled', async () => { }, references: [], }; - savedObjectsClientWithHidden.get.mockResolvedValueOnce(actionSavedObject); + const actionResult = { + id: actionSavedObject.id, + name: actionSavedObject.id, + actionTypeId: actionSavedObject.attributes.actionTypeId, + isPreconfigured: false, + }; + actionsClient.get.mockResolvedValueOnce(actionResult); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); actionTypeRegistry.ensureActionTypeEnabled.mockImplementationOnce(() => { @@ -240,7 +274,13 @@ test('should not throws an error if actionType is preconfigured', async () => { }, references: [], }; - savedObjectsClientWithHidden.get.mockResolvedValueOnce(actionSavedObject); + const actionResult = { + id: actionSavedObject.id, + name: actionSavedObject.id, + ...pick(actionSavedObject.attributes, 'actionTypeId', 'config', 'secrets'), + isPreconfigured: false, + }; + actionsClient.get.mockResolvedValueOnce(actionResult); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); actionTypeRegistry.ensureActionTypeEnabled.mockImplementationOnce(() => { @@ -268,7 +308,7 @@ test('throws an error when passing isESOUsingEphemeralEncryptionKey with value o customActionExecutor.initialize({ logger: loggingSystemMock.create().get(), spaces: spacesMock, - getScopedSavedObjectsClient: () => savedObjectsClientWithHidden, + getActionsClientWithRequest, getServices: () => services, actionTypeRegistry, encryptedSavedObjectsClient, diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index 250bfc2752f1b..0e63cc8f5956e 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger, KibanaRequest, SavedObjectsClientContract } from '../../../../../src/core/server'; +import { Logger, KibanaRequest } from '../../../../../src/core/server'; import { validateParams, validateConfig, validateSecrets } from './validate_with_schema'; import { ActionTypeExecutorResult, @@ -15,14 +15,15 @@ import { } from '../types'; import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; import { SpacesServiceSetup } from '../../../spaces/server'; -import { EVENT_LOG_ACTIONS } from '../plugin'; +import { EVENT_LOG_ACTIONS, PluginStartContract } from '../plugin'; import { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; +import { ActionsClient } from '../actions_client'; export interface ActionExecutorContext { logger: Logger; spaces?: SpacesServiceSetup; getServices: GetServicesFunction; - getScopedSavedObjectsClient: (req: KibanaRequest) => SavedObjectsClientContract; + getActionsClientWithRequest: PluginStartContract['getActionsClientWithRequest']; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; actionTypeRegistry: ActionTypeRegistryContract; eventLogger: IEventLogger; @@ -76,7 +77,7 @@ export class ActionExecutor { actionTypeRegistry, eventLogger, preconfiguredActions, - getScopedSavedObjectsClient, + getActionsClientWithRequest, } = this.actionExecutorContext!; const services = getServices(request); @@ -84,7 +85,7 @@ export class ActionExecutor { const namespace = spaceId && spaceId !== 'default' ? { namespace: spaceId } : {}; const { actionTypeId, name, config, secrets } = await getActionInfo( - getScopedSavedObjectsClient(request), + await getActionsClientWithRequest(request), encryptedSavedObjectsClient, preconfiguredActions, actionId, @@ -196,7 +197,7 @@ interface ActionInfo { } async function getActionInfo( - savedObjectsClient: SavedObjectsClientContract, + actionsClient: PublicMethodsOf, encryptedSavedObjectsClient: EncryptedSavedObjectsClient, preconfiguredActions: PreConfiguredAction[], actionId: string, @@ -217,9 +218,7 @@ async function getActionInfo( // if not pre-configured action, should be a saved object // ensure user can read the action before processing - const { - attributes: { actionTypeId, config, name }, - } = await savedObjectsClient.get('action', actionId); + const { actionTypeId, config, name } = await actionsClient.get({ id: actionId }); const { attributes: { secrets }, diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index 06cb84ad79a89..78522682054e1 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -15,6 +15,7 @@ import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/serv import { savedObjectsClientMock, loggingSystemMock } from 'src/core/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/mocks'; import { ActionTypeDisabledError } from './errors'; +import { actionsClientMock } from '../mocks'; const spaceIdToNamespace = jest.fn(); const actionTypeRegistry = actionTypeRegistryMock.create(); @@ -59,7 +60,7 @@ const actionExecutorInitializerParams = { logger: loggingSystemMock.create().get(), getServices: jest.fn().mockReturnValue(services), actionTypeRegistry, - getScopedSavedObjectsClient: () => savedObjectsClientMock.create(), + getActionsClientWithRequest: jest.fn(async () => actionsClientMock.create()), encryptedSavedObjectsClient: mockedEncryptedSavedObjectsClient, eventLogger: eventLoggerMock.create(), preconfiguredActions: [], diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 62bd1058774de..3eedd69410d11 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -249,10 +249,32 @@ export class ActionsPlugin implements Plugin, Plugi includedHiddenTypes, }); - const getScopedSavedObjectsClient = (request: KibanaRequest) => - core.savedObjects.getScopedClient(request, { - includedHiddenTypes, + const getActionsClientWithRequest = async (request: KibanaRequest) => { + if (isESOUsingEphemeralEncryptionKey === true) { + throw new Error( + `Unable to create actions client due to the Encrypted Saved Objects plugin using an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml` + ); + } + return new ActionsClient({ + savedObjectsClient: core.savedObjects.getScopedClient(request, { + excludedWrappers: ['security'], + includedHiddenTypes, + }), + actionTypeRegistry: actionTypeRegistry!, + defaultKibanaIndex: await kibanaIndex, + scopedClusterClient: core.elasticsearch.legacy.client.asScoped(request), + preconfiguredActions, + request, + authorization: instantiateAuthorization(request), + actionExecutor: actionExecutor!, + executionEnqueuer: createExecutionEnqueuerFunction({ + taskManager: plugins.taskManager, + actionTypeRegistry: actionTypeRegistry!, + isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!, + preconfiguredActions, + }), }); + }; const getScopedSavedObjectsClientWithoutAccessToActions = (request: KibanaRequest) => core.savedObjects.getScopedClient(request); @@ -261,7 +283,7 @@ export class ActionsPlugin implements Plugin, Plugi logger, eventLogger: this.eventLogger!, spaces: this.spaces, - getScopedSavedObjectsClient, + getActionsClientWithRequest, getServices: this.getServicesFactory( getScopedSavedObjectsClientWithoutAccessToActions, core.elasticsearch @@ -277,7 +299,7 @@ export class ActionsPlugin implements Plugin, Plugi encryptedSavedObjectsClient, getBasePath: this.getBasePath, spaceIdToNamespace: this.spaceIdToNamespace, - getScopedSavedObjectsClient, + getScopedSavedObjectsClient: core.savedObjects.getScopedClient, }); scheduleActionsTelemetry(this.telemetryLogger, plugins.taskManager); @@ -292,29 +314,7 @@ export class ActionsPlugin implements Plugin, Plugi getActionsAuthorizationWithRequest(request: KibanaRequest) { return instantiateAuthorization(request); }, - async getActionsClientWithRequest(request: KibanaRequest) { - if (isESOUsingEphemeralEncryptionKey === true) { - throw new Error( - `Unable to create actions client due to the Encrypted Saved Objects plugin using an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml` - ); - } - return new ActionsClient({ - savedObjectsClient: getScopedSavedObjectsClient(request), - actionTypeRegistry: actionTypeRegistry!, - defaultKibanaIndex: await kibanaIndex, - scopedClusterClient: core.elasticsearch.legacy.client.asScoped(request), - preconfiguredActions, - request, - authorization: instantiateAuthorization(request), - actionExecutor: actionExecutor!, - executionEnqueuer: createExecutionEnqueuerFunction({ - taskManager: plugins.taskManager, - actionTypeRegistry: actionTypeRegistry!, - isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!, - preconfiguredActions, - }), - }); - }, + getActionsClientWithRequest, preconfiguredActions, }; } @@ -364,7 +364,10 @@ export class ActionsPlugin implements Plugin, Plugi ); } return new ActionsClient({ - savedObjectsClient: savedObjects.getScopedClient(request, { includedHiddenTypes }), + savedObjectsClient: savedObjects.getScopedClient(request, { + excludedWrappers: ['security'], + includedHiddenTypes, + }), actionTypeRegistry: actionTypeRegistry!, defaultKibanaIndex, scopedClusterClient: context.core.elasticsearch.legacy.client, diff --git a/x-pack/plugins/apm/common/anomaly_detection.ts b/x-pack/plugins/apm/common/anomaly_detection.ts index 1fd927d82f186..9e0a3e3d0d889 100644 --- a/x-pack/plugins/apm/common/anomaly_detection.ts +++ b/x-pack/plugins/apm/common/anomaly_detection.ts @@ -4,9 +4,59 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; + export interface ServiceAnomalyStats { transactionType?: string; anomalyScore?: number; actualValue?: number; jobId?: string; } + +export const MLErrorMessages: Record = { + INSUFFICIENT_LICENSE: i18n.translate( + 'xpack.apm.anomaly_detection.error.insufficient_license', + { + defaultMessage: + 'You must have a platinum license to use Anomaly Detection', + } + ), + MISSING_READ_PRIVILEGES: i18n.translate( + 'xpack.apm.anomaly_detection.error.missing_read_privileges', + { + defaultMessage: + 'You must have "read" privileges to Machine Learning in order to view Anomaly Detection jobs', + } + ), + MISSING_WRITE_PRIVILEGES: i18n.translate( + 'xpack.apm.anomaly_detection.error.missing_write_privileges', + { + defaultMessage: + 'You must have "write" privileges to Machine Learning and APM in order to create Anomaly Detection jobs', + } + ), + ML_NOT_AVAILABLE: i18n.translate( + 'xpack.apm.anomaly_detection.error.not_available', + { + defaultMessage: 'Machine learning is not available', + } + ), + ML_NOT_AVAILABLE_IN_SPACE: i18n.translate( + 'xpack.apm.anomaly_detection.error.not_available_in_space', + { + defaultMessage: 'Machine learning is not available in the selected space', + } + ), + UNEXPECTED: i18n.translate('xpack.apm.anomaly_detection.error.unexpected', { + defaultMessage: 'An unexpected error occurred', + }), +}; + +export enum ErrorCode { + INSUFFICIENT_LICENSE = 'INSUFFICIENT_LICENSE', + MISSING_READ_PRIVILEGES = 'MISSING_READ_PRIVILEGES', + MISSING_WRITE_PRIVILEGES = 'MISSING_WRITE_PRIVILEGES', + ML_NOT_AVAILABLE = 'ML_NOT_AVAILABLE', + ML_NOT_AVAILABLE_IN_SPACE = 'ML_NOT_AVAILABLE_IN_SPACE', + UNEXPECTED = 'UNEXPECTED', +} diff --git a/x-pack/plugins/apm/common/ml_job_constants.test.ts b/x-pack/plugins/apm/common/ml_job_constants.test.ts deleted file mode 100644 index 96e3ba826d201..0000000000000 --- a/x-pack/plugins/apm/common/ml_job_constants.test.ts +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getSeverity, severity } from './ml_job_constants'; - -describe('ml_job_constants', () => { - describe('getSeverity', () => { - describe('when score is undefined', () => { - it('returns undefined', () => { - expect(getSeverity(undefined)).toEqual(undefined); - }); - }); - - describe('when score < 25', () => { - it('returns warning', () => { - expect(getSeverity(10)).toEqual(severity.warning); - }); - }); - - describe('when score is between 25 and 50', () => { - it('returns minor', () => { - expect(getSeverity(40)).toEqual(severity.minor); - }); - }); - - describe('when score is between 50 and 75', () => { - it('returns major', () => { - expect(getSeverity(60)).toEqual(severity.major); - }); - }); - - describe('when score is 75 or more', () => { - it('returns critical', () => { - expect(getSeverity(100)).toEqual(severity.critical); - }); - }); - }); -}); diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx index aa95918939dfa..80c749e58c88c 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx @@ -12,7 +12,6 @@ import d3 from 'd3'; import { scaleUtc } from 'd3-scale'; import { mean } from 'lodash'; import React from 'react'; -import { px } from '../../../../style/variables'; import { asRelativeDateTimeRange } from '../../../../utils/formatters'; import { getTimezoneOffsetInMs } from '../../../shared/charts/CustomPlot/getTimezoneOffsetInMs'; // @ts-ignore @@ -89,7 +88,7 @@ export function ErrorDistribution({ distribution, title }: Props) { {title} bucket.x} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx index 410ba8b5027fb..b3d19e1aab2cc 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx @@ -19,9 +19,9 @@ import { fontSize, px } from '../../../../style/variables'; import { asInteger, asDuration } from '../../../../utils/formatters'; import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink'; import { getSeverityColor, popoverWidth } from '../cytoscapeOptions'; -import { getSeverity } from '../../../../../common/ml_job_constants'; import { TRANSACTION_REQUEST } from '../../../../../common/transaction_types'; import { ServiceAnomalyStats } from '../../../../../common/anomaly_detection'; +import { getSeverity } from './getSeverity'; const HealthStatusTitle = styled(EuiTitle)` display: inline; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.test.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.test.ts new file mode 100644 index 0000000000000..52b7d54236db6 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getSeverity, severity } from './getSeverity'; + +describe('getSeverity', () => { + describe('when score is undefined', () => { + it('returns undefined', () => { + expect(getSeverity(undefined)).toEqual(undefined); + }); + }); + + describe('when score < 25', () => { + it('returns warning', () => { + expect(getSeverity(10)).toEqual(severity.warning); + }); + }); + + describe('when score is between 25 and 50', () => { + it('returns minor', () => { + expect(getSeverity(40)).toEqual(severity.minor); + }); + }); + + describe('when score is between 50 and 75', () => { + it('returns major', () => { + expect(getSeverity(60)).toEqual(severity.major); + }); + }); + + describe('when score is 75 or more', () => { + it('returns critical', () => { + expect(getSeverity(100)).toEqual(severity.critical); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/ml_job_constants.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.ts similarity index 80% rename from x-pack/plugins/apm/common/ml_job_constants.ts rename to x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.ts index b8c2546bd0c84..f4eb2033e9231 100644 --- a/x-pack/plugins/apm/common/ml_job_constants.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.ts @@ -11,6 +11,8 @@ export enum severity { warning = 'warning', } +// TODO: Replace with `getSeverity` from: +// https://github.com/elastic/kibana/blob/0f964f66916480f2de1f4b633e5afafc08cf62a0/x-pack/plugins/ml/common/util/anomaly_utils.ts#L129 export function getSeverity(score?: number) { if (typeof score !== 'number') { return undefined; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts index e7d55cd570710..012256db3ab98 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getSeverity } from '../../../../../common/ml_job_constants'; +import { getSeverity } from '../Popover/getSeverity'; export function generateServiceMapElements(size: number): any[] { const services = range(size).map((i) => { diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index dfcfbee1806a4..4a271019e06db 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -10,9 +10,9 @@ import { SPAN_DESTINATION_SERVICE_RESOURCE, } from '../../../../common/elasticsearch_fieldnames'; import { EuiTheme } from '../../../../../observability/public'; -import { severity, getSeverity } from '../../../../common/ml_job_constants'; import { defaultIcon, iconForNode } from './icons'; import { ServiceAnomalyStats } from '../../../../common/anomaly_detection'; +import { severity, getSeverity } from './Popover/getSeverity'; export const popoverWidth = 280; diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx index 4c056d48f4b14..c9328c4988e5f 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx @@ -17,8 +17,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiEmptyPrompt, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { MLErrorMessages } from '../../../../../common/anomaly_detection'; import { useFetcher, FETCH_STATUS } from '../../../../hooks/useFetcher'; import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; import { createJobs } from './create_jobs'; @@ -34,7 +36,9 @@ export const AddEnvironments = ({ onCreateJobSuccess, onCancel, }: Props) => { - const { toasts } = useApmPluginContext().core.notifications; + const { notifications, application } = useApmPluginContext().core; + const canCreateJob = !!application.capabilities.ml.canCreateJob; + const { toasts } = notifications; const { data = [], status } = useFetcher( (callApmApi) => callApmApi({ @@ -56,6 +60,17 @@ export const AddEnvironments = ({ Array> >([]); + if (!canCreateJob) { + return ( + + {MLErrorMessages.MISSING_WRITE_PRIVILEGES}} + /> + + ); + } + const isLoading = status === FETCH_STATUS.PENDING || status === FETCH_STATUS.LOADING; return ( diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/create_jobs.ts b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/create_jobs.ts index 614632a5a3b09..acea38732b40a 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/create_jobs.ts +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/create_jobs.ts @@ -6,8 +6,19 @@ import { i18n } from '@kbn/i18n'; import { NotificationsStart } from 'kibana/public'; +import { MLErrorMessages } from '../../../../../common/anomaly_detection'; import { callApmApi } from '../../../../services/rest/createCallApmApi'; +const errorToastTitle = i18n.translate( + 'xpack.apm.anomalyDetection.createJobs.failed.title', + { defaultMessage: 'Anomaly detection jobs could not be created' } +); + +const successToastTitle = i18n.translate( + 'xpack.apm.anomalyDetection.createJobs.succeeded.title', + { defaultMessage: 'Anomaly detection jobs created' } +); + export async function createJobs({ environments, toasts, @@ -16,7 +27,7 @@ export async function createJobs({ toasts: NotificationsStart['toasts']; }) { try { - await callApmApi({ + const res = await callApmApi({ pathname: '/api/apm/settings/anomaly-detection/jobs', method: 'POST', params: { @@ -24,41 +35,50 @@ export async function createJobs({ }, }); + // a known error occurred + if (res?.errorCode) { + toasts.addDanger({ + title: errorToastTitle, + text: MLErrorMessages[res.errorCode], + }); + return false; + } + + // job created successfully toasts.addSuccess({ - title: i18n.translate( - 'xpack.apm.anomalyDetection.createJobs.succeeded.title', - { defaultMessage: 'Anomaly detection jobs created' } - ), - text: i18n.translate( - 'xpack.apm.anomalyDetection.createJobs.succeeded.text', - { - defaultMessage: - 'Anomaly detection jobs successfully created for APM service environments [{environments}]. It will take some time for machine learning to start analyzing traffic for anomalies.', - values: { environments: environments.join(', ') }, - } - ), + title: successToastTitle, + text: getSuccessToastMessage(environments), }); return true; + + // an unknown/unexpected error occurred } catch (error) { toasts.addDanger({ - title: i18n.translate( - 'xpack.apm.anomalyDetection.createJobs.failed.title', - { - defaultMessage: 'Anomaly detection jobs could not be created', - } - ), - text: i18n.translate( - 'xpack.apm.anomalyDetection.createJobs.failed.text', - { - defaultMessage: - 'Something went wrong when creating one ore more anomaly detection jobs for APM service environments [{environments}]. Error: "{errorMessage}"', - values: { - environments: environments.join(', '), - errorMessage: error.message, - }, - } - ), + title: errorToastTitle, + text: getErrorToastMessage(environments, error), }); return false; } } + +function getSuccessToastMessage(environments: string[]) { + return i18n.translate( + 'xpack.apm.anomalyDetection.createJobs.succeeded.text', + { + defaultMessage: + 'Anomaly detection jobs successfully created for APM service environments [{environments}]. It will take some time for machine learning to start analyzing traffic for anomalies.', + values: { environments: environments.join(', ') }, + } + ); +} + +function getErrorToastMessage(environments: string[], error: Error) { + return i18n.translate('xpack.apm.anomalyDetection.createJobs.failed.text', { + defaultMessage: + 'Something went wrong when creating one ore more anomaly detection jobs for APM service environments [{environments}]. Error: "{errorMessage}"', + values: { + environments: environments.join(', '), + errorMessage: error.message, + }, + }); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx index f02350fafbabb..abbe1e2c83c7b 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx @@ -7,7 +7,9 @@ import React, { useState } from 'react'; import { EuiTitle, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { EuiPanel } from '@elastic/eui'; +import { EuiPanel, EuiEmptyPrompt } from '@elastic/eui'; +import { MLErrorMessages } from '../../../../../common/anomaly_detection'; +import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; import { JobsList } from './jobs_list'; import { AddEnvironments } from './add_environments'; import { useFetcher } from '../../../../hooks/useFetcher'; @@ -16,24 +18,31 @@ import { useLicense } from '../../../../hooks/useLicense'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; export type AnomalyDetectionApiResponse = APIReturnType< - '/api/apm/settings/anomaly-detection' + '/api/apm/settings/anomaly-detection', + 'GET' >; const DEFAULT_VALUE: AnomalyDetectionApiResponse = { jobs: [], hasLegacyJobs: false, + errorCode: undefined, }; export const AnomalyDetection = () => { + const plugin = useApmPluginContext(); + const canGetJobs = !!plugin.core.application.capabilities.ml.canGetJobs; const license = useLicense(); const hasValidLicense = license?.isActive && license?.hasAtLeast('platinum'); const [viewAddEnvironments, setViewAddEnvironments] = useState(false); const { refetch, data = DEFAULT_VALUE, status } = useFetcher( - (callApmApi) => - callApmApi({ pathname: `/api/apm/settings/anomaly-detection` }), - [], + (callApmApi) => { + if (canGetJobs) { + return callApmApi({ pathname: `/api/apm/settings/anomaly-detection` }); + } + }, + [canGetJobs], { preservePreviousData: false, showToastOnError: false } ); @@ -53,6 +62,17 @@ export const AnomalyDetection = () => { ); } + if (!canGetJobs) { + return ( + + {MLErrorMessages.MISSING_READ_PRIVILEGES}} + /> + + ); + } + return ( <> @@ -83,9 +103,8 @@ export const AnomalyDetection = () => { /> ) : ( { setViewAddEnvironments(true); }} diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx index 5954b82f3b9e7..67227f99cb5f1 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx @@ -16,6 +16,10 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { + MLErrorMessages, + ErrorCode, +} from '../../../../../common/anomaly_detection'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { ITableColumn, ManagedTable } from '../../../shared/ManagedTable'; import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; @@ -57,21 +61,12 @@ const columns: Array> = [ ]; interface Props { + data: AnomalyDetectionApiResponse; status: FETCH_STATUS; onAddEnvironments: () => void; - jobs: Jobs; - hasLegacyJobs: boolean; } -export const JobsList = ({ - status, - onAddEnvironments, - jobs, - hasLegacyJobs, -}: Props) => { - const isLoading = - status === FETCH_STATUS.PENDING || status === FETCH_STATUS.LOADING; - - const hasFetchFailure = status === FETCH_STATUS.FAILURE; +export const JobsList = ({ data, status, onAddEnvironments }: Props) => { + const { jobs, hasLegacyJobs, errorCode } = data; return ( @@ -120,15 +115,10 @@ export const JobsList = ({ - ) : hasFetchFailure ? ( - - ) : ( - - ) - } + noItemsMessage={getNoItemsMessage({ + status, + errorCode, + })} columns={columns} items={jobs} /> @@ -139,28 +129,36 @@ export const JobsList = ({ ); }; -function EmptyStatePrompt() { - return ( - <> - {i18n.translate( - 'xpack.apm.settings.anomalyDetection.jobList.emptyListText', - { - defaultMessage: 'No anomaly detection jobs.', - } - )} - - ); -} +function getNoItemsMessage({ + status, + errorCode, +}: { + status: FETCH_STATUS; + errorCode?: ErrorCode; +}) { + // loading state + const isLoading = + status === FETCH_STATUS.PENDING || status === FETCH_STATUS.LOADING; + if (isLoading) { + return ; + } -function FailureStatePrompt() { - return ( - <> - {i18n.translate( - 'xpack.apm.settings.anomalyDetection.jobList.failedFetchText', - { - defaultMessage: 'Unabled to fetch anomaly detection jobs.', - } - )} - + // A known error occured. Show specific error message + if (errorCode) { + return MLErrorMessages[errorCode]; + } + + // An unexpected error occurred. Show default error message + if (status === FETCH_STATUS.FAILURE) { + return i18n.translate( + 'xpack.apm.settings.anomalyDetection.jobList.failedFetchText', + { defaultMessage: 'Unabled to fetch anomaly detection jobs.' } + ); + } + + // no errors occurred + return i18n.translate( + 'xpack.apm.settings.anomalyDetection.jobList.emptyListText', + { defaultMessage: 'No anomaly detection jobs.' } ); } diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx index 268d8bd7ea823..2149cb676f0d8 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx @@ -6,37 +6,48 @@ import { showAlert } from './AnomalyDetectionSetupLink'; +const dataWithJobs = { + hasLegacyJobs: false, + jobs: [ + { job_id: 'job1', environment: 'staging' }, + { job_id: 'job2', environment: 'production' }, + ], +}; +const dataWithoutJobs = ({ jobs: [] } as unknown) as any; + describe('#showAlert', () => { describe('when an environment is selected', () => { it('should return true when there are no jobs', () => { - const result = showAlert([], 'testing'); + const result = showAlert(dataWithoutJobs, 'testing'); expect(result).toBe(true); }); it('should return true when environment is not included in the jobs', () => { - const result = showAlert( - [{ environment: 'staging' }, { environment: 'production' }], - 'testing' - ); + const result = showAlert(dataWithJobs, 'testing'); expect(result).toBe(true); }); it('should return false when environment is included in the jobs', () => { - const result = showAlert( - [{ environment: 'staging' }, { environment: 'production' }], - 'staging' - ); + const result = showAlert(dataWithJobs, 'staging'); expect(result).toBe(false); }); }); + describe('there is no environment selected (All)', () => { it('should return true when there are no jobs', () => { - const result = showAlert([], undefined); + const result = showAlert(dataWithoutJobs, undefined); expect(result).toBe(true); }); it('should return false when there are any number of jobs', () => { - const result = showAlert( - [{ environment: 'staging' }, { environment: 'production' }], - undefined - ); + const result = showAlert(dataWithJobs, undefined); + expect(result).toBe(false); + }); + }); + + describe('when a known error occurred', () => { + it('should return false', () => { + const data = ({ + errorCode: 'MISSING_READ_PRIVILEGES', + } as unknown) as any; + const result = showAlert(data, undefined); expect(result).toBe(false); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx index 6f3a5df480d7e..e989244d43148 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx @@ -6,16 +6,25 @@ import React from 'react'; import { EuiButtonEmpty, EuiToolTip, EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { ErrorCode } from '../../../../../common/anomaly_detection'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { APMLink } from './APMLink'; import { getEnvironmentLabel } from '../../../../../common/environment_filter_values'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { useFetcher, FETCH_STATUS } from '../../../../hooks/useFetcher'; +export type AnomalyDetectionApiResponse = APIReturnType< + '/api/apm/settings/anomaly-detection', + 'GET' +>; + +const DEFAULT_DATA = { jobs: [], hasLegacyJobs: false, errorCode: undefined }; + export function AnomalyDetectionSetupLink() { const { uiFilters } = useUrlParams(); const environment = uiFilters.environment; - const { data = { jobs: [], hasLegacyJobs: false }, status } = useFetcher( + const { data = DEFAULT_DATA, status } = useFetcher( (callApmApi) => callApmApi({ pathname: `/api/apm/settings/anomaly-detection` }), [], @@ -28,7 +37,7 @@ export function AnomalyDetectionSetupLink() { {ANOMALY_DETECTION_LINK_LABEL} - {isFetchSuccess && showAlert(data.jobs, environment) && ( + {isFetchSuccess && showAlert(data, environment) && ( @@ -59,9 +68,14 @@ const ANOMALY_DETECTION_LINK_LABEL = i18n.translate( ); export function showAlert( - jobs: Array<{ environment: string }> = [], + { jobs = [], errorCode }: AnomalyDetectionApiResponse, environment: string | undefined ) { + // don't show warning if the user is missing read privileges + if (errorCode === ErrorCode.MISSING_READ_PRIVILEGES) { + return false; + } + return ( // No job exists, or jobs.length === 0 || diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/anomaly_detection_error.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/anomaly_detection_error.ts new file mode 100644 index 0000000000000..993dcf4c5354b --- /dev/null +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/anomaly_detection_error.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ErrorCode, MLErrorMessages } from '../../../common/anomaly_detection'; + +export class AnomalyDetectionError extends Error { + constructor(public code: ErrorCode) { + super(MLErrorMessages[code]); + + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts index c387c5152b1c5..e5338ac9f5797 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts @@ -7,6 +7,7 @@ import { Logger } from 'kibana/server'; import uuid from 'uuid/v4'; import { snakeCase } from 'lodash'; +import { ErrorCode } from '../../../common/anomaly_detection'; import { PromiseReturnType } from '../../../../observability/typings/common'; import { Setup } from '../helpers/setup_request'; import { @@ -15,6 +16,7 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { APM_ML_JOB_GROUP, ML_MODULE_ID_APM_TRANSACTION } from './constants'; import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { AnomalyDetectionError } from './anomaly_detection_error'; export type CreateAnomalyDetectionJobsAPIResponse = PromiseReturnType< typeof createAnomalyDetectionJobs @@ -25,21 +27,20 @@ export async function createAnomalyDetectionJobs( logger: Logger ) { const { ml, indices } = setup; + if (!ml) { - logger.warn('Anomaly detection plugin is not available.'); - return []; + throw new AnomalyDetectionError(ErrorCode.ML_NOT_AVAILABLE); } + const mlCapabilities = await ml.mlSystem.mlCapabilities(); if (!mlCapabilities.mlFeatureEnabledInSpace) { - logger.warn('Anomaly detection feature is not enabled for the space.'); - return []; + throw new AnomalyDetectionError(ErrorCode.ML_NOT_AVAILABLE_IN_SPACE); } + if (!mlCapabilities.isPlatinumOrTrialLicense) { - logger.warn( - 'Unable to create anomaly detection jobs due to insufficient license.' - ); - return []; + throw new AnomalyDetectionError(ErrorCode.INSUFFICIENT_LICENSE); } + logger.info( `Creating ML anomaly detection jobs for environments: [${environments}].` ); @@ -59,9 +60,8 @@ export async function createAnomalyDetectionJobs( `Failed to create anomaly detection ML jobs for: [${failedJobIds}]:` ); failedJobs.forEach(({ error }) => logger.error(JSON.stringify(error))); - throw new Error( - `Failed to create anomaly detection ML jobs for: [${failedJobIds}].` - ); + + throw new AnomalyDetectionError(ErrorCode.UNEXPECTED); } return jobResponses; @@ -70,11 +70,11 @@ export async function createAnomalyDetectionJobs( async function createAnomalyDetectionJob({ ml, environment, - indexPatternName = 'apm-*-transaction-*', + indexPatternName, }: { ml: Required['ml']; environment: string; - indexPatternName?: string | undefined; + indexPatternName: string; }) { const randomToken = uuid().substr(-4); diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts index 13b30f159eed1..62d4243a06028 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts @@ -5,8 +5,10 @@ */ import { Logger } from 'kibana/server'; +import { ErrorCode } from '../../../common/anomaly_detection'; import { Setup } from '../helpers/setup_request'; import { getMlJobsWithAPMGroup } from './get_ml_jobs_with_apm_group'; +import { AnomalyDetectionError } from './anomaly_detection_error'; export async function getAnomalyDetectionJobs(setup: Setup, logger: Logger) { const { ml } = setup; @@ -15,14 +17,12 @@ export async function getAnomalyDetectionJobs(setup: Setup, logger: Logger) { } const mlCapabilities = await ml.mlSystem.mlCapabilities(); - if ( - !( - mlCapabilities.mlFeatureEnabledInSpace && - mlCapabilities.isPlatinumOrTrialLicense - ) - ) { - logger.warn('Anomaly detection integration is not availble for this user.'); - return []; + if (!mlCapabilities.mlFeatureEnabledInSpace) { + throw new AnomalyDetectionError(ErrorCode.ML_NOT_AVAILABLE_IN_SPACE); + } + + if (!mlCapabilities.isPlatinumOrTrialLicense) { + throw new AnomalyDetectionError(ErrorCode.INSUFFICIENT_LICENSE); } const response = await getMlJobsWithAPMGroup(ml); diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index 4d564b773e397..218d47fcf9bb4 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -5,12 +5,32 @@ */ import * as t from 'io-ts'; +import { ErrorCode } from '../../../common/anomaly_detection'; +import { PromiseReturnType } from '../../../typings/common'; +import { InsufficientMLCapabilities } from '../../../../ml/server'; import { createRoute } from '../create_route'; import { getAnomalyDetectionJobs } from '../../lib/anomaly_detection/get_anomaly_detection_jobs'; import { createAnomalyDetectionJobs } from '../../lib/anomaly_detection/create_anomaly_detection_jobs'; import { setupRequest } from '../../lib/helpers/setup_request'; import { getAllEnvironments } from '../../lib/environments/get_all_environments'; import { hasLegacyJobs } from '../../lib/anomaly_detection/has_legacy_jobs'; +import { AnomalyDetectionError } from '../../lib/anomaly_detection/anomaly_detection_error'; + +type Jobs = PromiseReturnType; + +function getMlErrorCode(e: Error) { + // Missing privileges + if (e instanceof InsufficientMLCapabilities) { + return ErrorCode.MISSING_READ_PRIVILEGES; + } + + if (e instanceof AnomalyDetectionError) { + return e.code; + } + + // unexpected error + return ErrorCode.UNEXPECTED; +} // get ML anomaly detection jobs for each environment export const anomalyDetectionJobsRoute = createRoute(() => ({ @@ -18,14 +38,25 @@ export const anomalyDetectionJobsRoute = createRoute(() => ({ path: '/api/apm/settings/anomaly-detection', handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - const [jobs, legacyJobs] = await Promise.all([ - getAnomalyDetectionJobs(setup, context.logger), - hasLegacyJobs(setup), - ]); - return { - jobs, - hasLegacyJobs: legacyJobs, - }; + + try { + const [jobs, legacyJobs] = await Promise.all([ + getAnomalyDetectionJobs(setup, context.logger), + hasLegacyJobs(setup), + ]); + return { + jobs, + hasLegacyJobs: legacyJobs, + }; + } catch (e) { + const mlErrorCode = getMlErrorCode(e); + context.logger.warn(`Error while retrieving ML jobs: "${e.message}"`); + return { + jobs: [] as Jobs, + hasLegacyJobs: false, + errorCode: mlErrorCode, + }; + } }, })); @@ -44,11 +75,16 @@ export const createAnomalyDetectionJobsRoute = createRoute(() => ({ handler: async ({ context, request }) => { const { environments } = context.params.body; const setup = await setupRequest(context, request); - return await createAnomalyDetectionJobs( - setup, - environments, - context.logger - ); + + try { + await createAnomalyDetectionJobs(setup, environments, context.logger); + } catch (e) { + const mlErrorCode = getMlErrorCode(e); + context.logger.warn(`Error while creating ML job: "${e.message}"`); + return { + errorCode: mlErrorCode, + }; + } }, })); diff --git a/x-pack/plugins/canvas/.gitignore b/x-pack/plugins/canvas/.gitignore index 1c6258670c59c..d47bd8acf6be2 100644 --- a/x-pack/plugins/canvas/.gitignore +++ b/x-pack/plugins/canvas/.gitignore @@ -57,4 +57,4 @@ canvas_plugin/* webpack_stats.json # Don't commit storybook builds -storybook +storybook/build diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/__examples__/__snapshots__/advanced_filter.examples.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/__examples__/__snapshots__/advanced_filter.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/__examples__/__snapshots__/advanced_filter.examples.storyshot rename to x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/__examples__/__snapshots__/advanced_filter.stories.storyshot diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/__examples__/advanced_filter.examples.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/__examples__/advanced_filter.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/__examples__/advanced_filter.examples.tsx rename to x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/__examples__/advanced_filter.stories.tsx diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/__examples__/__snapshots__/dropdown_filter.examples.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/__examples__/__snapshots__/dropdown_filter.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/__examples__/__snapshots__/dropdown_filter.examples.storyshot rename to x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/__examples__/__snapshots__/dropdown_filter.stories.storyshot diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/__examples__/dropdown_filter.examples.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/__examples__/dropdown_filter.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/__examples__/dropdown_filter.examples.tsx rename to x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/__examples__/dropdown_filter.stories.tsx diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/__examples__/__snapshots__/time_filter.examples.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/__examples__/__snapshots__/time_filter.examples.storyshot deleted file mode 100644 index d555fbbe0ce92..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/__examples__/__snapshots__/time_filter.examples.storyshot +++ /dev/null @@ -1,134 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Storyshots renderers/TimeFilter default 1`] = ` -
-
-
-
-
-
- -
-
-
-
-
-
- -
-
-
-
- → -
-
-
-
- -
-
-
-
-
-
-
-
-`; diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot rename to x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.stories.storyshot diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset_manager.examples.storyshot b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset_manager.examples.storyshot deleted file mode 100644 index 9f91668e602a4..0000000000000 --- a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset_manager.examples.storyshot +++ /dev/null @@ -1,37 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Storyshots components/Assets/AssetManager no assets 1`] = ` - -`; - -exports[`Storyshots components/Assets/AssetManager two assets 1`] = ` - -`; diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/asset.examples.tsx b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/asset.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/asset_manager/__examples__/asset.examples.tsx rename to x-pack/plugins/canvas/public/components/asset_manager/__examples__/asset.stories.tsx diff --git a/x-pack/plugins/canvas/public/components/color_picker/__examples__/color_picker.stories.tsx b/x-pack/plugins/canvas/public/components/color_picker/__examples__/color_picker.stories.tsx index 0a7ed75ee728e..c5d42be2d201e 100644 --- a/x-pack/plugins/canvas/public/components/color_picker/__examples__/color_picker.stories.tsx +++ b/x-pack/plugins/canvas/public/components/color_picker/__examples__/color_picker.stories.tsx @@ -5,7 +5,7 @@ */ import { action } from '@storybook/addon-actions'; -import { boolean, withKnobs } from '@storybook/addon-knobs'; +import { boolean } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/react'; import React from 'react'; import { ColorPicker } from '../color_picker'; @@ -54,7 +54,6 @@ class Interactive extends React.Component< } storiesOf('components/Color/ColorPicker', module) - .addDecorator(withKnobs) .addParameters({ info: { inline: true, diff --git a/x-pack/plugins/canvas/public/components/custom_element_modal/__examples__/__snapshots__/custom_element_modal.examples.storyshot b/x-pack/plugins/canvas/public/components/custom_element_modal/__examples__/__snapshots__/custom_element_modal.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/public/components/custom_element_modal/__examples__/__snapshots__/custom_element_modal.examples.storyshot rename to x-pack/plugins/canvas/public/components/custom_element_modal/__examples__/__snapshots__/custom_element_modal.stories.storyshot diff --git a/x-pack/plugins/canvas/public/components/custom_element_modal/__examples__/custom_element_modal.examples.tsx b/x-pack/plugins/canvas/public/components/custom_element_modal/__examples__/custom_element_modal.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/custom_element_modal/__examples__/custom_element_modal.examples.tsx rename to x-pack/plugins/canvas/public/components/custom_element_modal/__examples__/custom_element_modal.stories.tsx diff --git a/x-pack/plugins/canvas/public/components/debug/__examples__/__snapshots__/debug.examples.storyshot b/x-pack/plugins/canvas/public/components/debug/__examples__/__snapshots__/debug.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/public/components/debug/__examples__/__snapshots__/debug.examples.storyshot rename to x-pack/plugins/canvas/public/components/debug/__examples__/__snapshots__/debug.stories.storyshot diff --git a/x-pack/plugins/canvas/public/components/debug/__examples__/debug.examples.tsx b/x-pack/plugins/canvas/public/components/debug/__examples__/debug.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/debug/__examples__/debug.examples.tsx rename to x-pack/plugins/canvas/public/components/debug/__examples__/debug.stories.tsx diff --git a/x-pack/plugins/canvas/public/components/element_card/__examples__/__snapshots__/element_controls.examples.storyshot b/x-pack/plugins/canvas/public/components/element_card/__examples__/__snapshots__/element_controls.examples.storyshot deleted file mode 100644 index 5e076ba76a9c1..0000000000000 --- a/x-pack/plugins/canvas/public/components/element_card/__examples__/__snapshots__/element_controls.examples.storyshot +++ /dev/null @@ -1,98 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Storyshots components/ElementTypes/ElementControls has two buttons 1`] = ` -
-
-
- - - -
-
- - - -
-
-
-`; diff --git a/x-pack/plugins/canvas/public/components/expression_input/__examples__/__snapshots__/expression_input.examples.storyshot b/x-pack/plugins/canvas/public/components/expression_input/__examples__/__snapshots__/expression_input.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/public/components/expression_input/__examples__/__snapshots__/expression_input.examples.storyshot rename to x-pack/plugins/canvas/public/components/expression_input/__examples__/__snapshots__/expression_input.stories.storyshot diff --git a/x-pack/plugins/canvas/public/components/expression_input/__examples__/expression_input.examples.tsx b/x-pack/plugins/canvas/public/components/expression_input/__examples__/expression_input.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/expression_input/__examples__/expression_input.examples.tsx rename to x-pack/plugins/canvas/public/components/expression_input/__examples__/expression_input.stories.tsx diff --git a/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/__snapshots__/edit_menu.examples.storyshot b/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/__snapshots__/edit_menu.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/__snapshots__/edit_menu.examples.storyshot rename to x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/__snapshots__/edit_menu.stories.storyshot diff --git a/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/edit_menu.examples.tsx b/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/edit_menu.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/edit_menu.examples.tsx rename to x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/edit_menu.stories.tsx diff --git a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__examples__/__snapshots__/element_menu.examples.storyshot b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__examples__/__snapshots__/element_menu.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/public/components/workpad_header/element_menu/__examples__/__snapshots__/element_menu.examples.storyshot rename to x-pack/plugins/canvas/public/components/workpad_header/element_menu/__examples__/__snapshots__/element_menu.stories.storyshot diff --git a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__examples__/element_menu.examples.tsx b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__examples__/element_menu.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/workpad_header/element_menu/__examples__/element_menu.examples.tsx rename to x-pack/plugins/canvas/public/components/workpad_header/element_menu/__examples__/element_menu.stories.tsx diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__examples__/__snapshots__/share_menu.examples.storyshot b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__examples__/__snapshots__/share_menu.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/public/components/workpad_header/share_menu/__examples__/__snapshots__/share_menu.examples.storyshot rename to x-pack/plugins/canvas/public/components/workpad_header/share_menu/__examples__/__snapshots__/share_menu.stories.storyshot diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__examples__/share_menu.examples.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__examples__/share_menu.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/workpad_header/share_menu/__examples__/share_menu.examples.tsx rename to x-pack/plugins/canvas/public/components/workpad_header/share_menu/__examples__/share_menu.stories.tsx diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/extended_template.examples.storyshot b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/extended_template.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/extended_template.examples.storyshot rename to x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/extended_template.stories.storyshot diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/simple_template.examples.storyshot b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/simple_template.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/simple_template.examples.storyshot rename to x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/simple_template.stories.storyshot diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/extended_template.examples.tsx b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/extended_template.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/extended_template.examples.tsx rename to x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/extended_template.stories.tsx diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/simple_template.examples.tsx b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/simple_template.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/simple_template.examples.tsx rename to x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/simple_template.stories.tsx diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/extended_template.examples.storyshot b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/extended_template.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/extended_template.examples.storyshot rename to x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/extended_template.stories.storyshot diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/simple_template.examples.storyshot b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/simple_template.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/simple_template.examples.storyshot rename to x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/simple_template.stories.storyshot diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/extended_template.examples.tsx b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/extended_template.stories.tsx similarity index 95% rename from x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/extended_template.examples.tsx rename to x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/extended_template.stories.tsx index 4a300b3de8923..a831f35ad597e 100644 --- a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/extended_template.examples.tsx +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/extended_template.stories.tsx @@ -6,7 +6,7 @@ import { action } from '@storybook/addon-actions'; import { storiesOf } from '@storybook/react'; -import { withKnobs, array, radios, boolean } from '@storybook/addon-knobs'; +import { array, radios, boolean } from '@storybook/addon-knobs'; import React from 'react'; import { ExtendedTemplate } from '../extended_template'; @@ -64,7 +64,6 @@ storiesOf('arguments/SeriesStyle', module) .addDecorator((story) => (
{story()}
)) - .addDecorator(withKnobs) .add('extended', () => ); storiesOf('arguments/SeriesStyle/components', module) diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/simple_template.examples.tsx b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/simple_template.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/simple_template.examples.tsx rename to x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/simple_template.stories.tsx diff --git a/x-pack/plugins/canvas/scripts/jest.js b/x-pack/plugins/canvas/scripts/jest.js index b30cb02d2c99a..a91431a0141c5 100644 --- a/x-pack/plugins/canvas/scripts/jest.js +++ b/x-pack/plugins/canvas/scripts/jest.js @@ -60,7 +60,7 @@ run( if (all) { log.info('Running all available tests. This will take a while...'); } else if (storybook) { - path = 'plugins/canvas/.storybook'; + path = 'plugins/canvas/storybook'; log.info('Running Storybook Snapshot tests...'); } else { log.info('Running tests. This does not include Storybook Snapshots...'); diff --git a/x-pack/plugins/canvas/scripts/storybook.js b/x-pack/plugins/canvas/scripts/storybook.js index c9e6c6c65436c..beea1814b54d2 100644 --- a/x-pack/plugins/canvas/scripts/storybook.js +++ b/x-pack/plugins/canvas/scripts/storybook.js @@ -10,7 +10,7 @@ const del = require('del'); const { run } = require('@kbn/dev-utils'); const storybook = require('@storybook/react/standalone'); const execa = require('execa'); -const { DLL_OUTPUT } = require('./../.storybook/constants'); +const { DLL_OUTPUT } = require('./../storybook/constants'); const options = { stdio: ['ignore', 'inherit', 'inherit'], @@ -18,7 +18,7 @@ const options = { }; const storybookOptions = { - configDir: path.resolve(__dirname, './../.storybook'), + configDir: path.resolve(__dirname, './../storybook'), mode: 'dev', }; @@ -51,7 +51,7 @@ run( [ 'webpack', '--config', - 'x-pack/plugins/canvas/.storybook/webpack.dll.config.js', + 'x-pack/plugins/canvas/storybook/webpack.dll.config.js', '--progress', '--hide-modules', '--display-entrypoints', diff --git a/x-pack/plugins/canvas/shareable_runtime/components/__examples__/__snapshots__/canvas.examples.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/__examples__/__snapshots__/canvas.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/__examples__/__snapshots__/canvas.examples.storyshot rename to x-pack/plugins/canvas/shareable_runtime/components/__examples__/__snapshots__/canvas.stories.storyshot diff --git a/x-pack/plugins/canvas/shareable_runtime/components/__examples__/__snapshots__/page.examples.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/__examples__/__snapshots__/page.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/__examples__/__snapshots__/page.examples.storyshot rename to x-pack/plugins/canvas/shareable_runtime/components/__examples__/__snapshots__/page.stories.storyshot diff --git a/x-pack/plugins/canvas/shareable_runtime/components/__examples__/__snapshots__/rendered_element.examples.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/__examples__/__snapshots__/rendered_element.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/__examples__/__snapshots__/rendered_element.examples.storyshot rename to x-pack/plugins/canvas/shareable_runtime/components/__examples__/__snapshots__/rendered_element.stories.storyshot diff --git a/x-pack/plugins/canvas/shareable_runtime/components/__examples__/canvas.examples.tsx b/x-pack/plugins/canvas/shareable_runtime/components/__examples__/canvas.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/__examples__/canvas.examples.tsx rename to x-pack/plugins/canvas/shareable_runtime/components/__examples__/canvas.stories.tsx diff --git a/x-pack/plugins/canvas/shareable_runtime/components/__examples__/page.examples.tsx b/x-pack/plugins/canvas/shareable_runtime/components/__examples__/page.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/__examples__/page.examples.tsx rename to x-pack/plugins/canvas/shareable_runtime/components/__examples__/page.stories.tsx diff --git a/x-pack/plugins/canvas/shareable_runtime/components/__examples__/rendered_element.examples.tsx b/x-pack/plugins/canvas/shareable_runtime/components/__examples__/rendered_element.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/__examples__/rendered_element.examples.tsx rename to x-pack/plugins/canvas/shareable_runtime/components/__examples__/rendered_element.stories.tsx diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/footer.components.examples.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/footer.components.examples.storyshot deleted file mode 100644 index 6d783a26d8424..0000000000000 --- a/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/footer.components.examples.storyshot +++ /dev/null @@ -1,140 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Storyshots shareables/Footer/components PageControls 1`] = ` -
-
- -
-
- -
-
- -
-
-`; - -exports[`Storyshots shareables/Footer/components Title 1`] = ` -
-
-
- -
-
-
-
-
- This is a test title. -
-
-
-
-
-
-`; diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/footer.examples.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/footer.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/footer.examples.storyshot rename to x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/footer.stories.storyshot diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/page_controls.examples.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/page_controls.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/page_controls.examples.storyshot rename to x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/page_controls.stories.storyshot diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/scrubber.examples.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/scrubber.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/scrubber.examples.storyshot rename to x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/scrubber.stories.storyshot diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/title.examples.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/title.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/title.examples.storyshot rename to x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/__snapshots__/title.stories.storyshot diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/footer.examples.tsx b/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/footer.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/footer.examples.tsx rename to x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/footer.stories.tsx diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/page_controls.examples.tsx b/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/page_controls.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/page_controls.examples.tsx rename to x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/page_controls.stories.tsx diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/scrubber.examples.tsx b/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/scrubber.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/scrubber.examples.tsx rename to x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/scrubber.stories.tsx diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/title.examples.tsx b/x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/title.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/title.examples.tsx rename to x-pack/plugins/canvas/shareable_runtime/components/footer/__examples__/title.stories.tsx diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/autoplay_settings.examples.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/autoplay_settings.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/autoplay_settings.examples.storyshot rename to x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/autoplay_settings.stories.storyshot diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/settings.components.examples.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/settings.components.examples.storyshot deleted file mode 100644 index 1922bd84b174c..0000000000000 --- a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/settings.components.examples.storyshot +++ /dev/null @@ -1,479 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Storyshots shareables/Settings/components AutoplaySettings, autoplay disabled 1`] = ` -
-
- - - - - - - - - -
-
-
-
-
-
-
- -
-
-
-
- -
-
-
- Use shorthand notation, like 30s, 10m, or 1h -
-
-
-
-
-
-
- -
-
- -
-
-
-
-
-
-`; - -exports[`Storyshots shareables/Settings/components AutoplaySettings, autoplay enabled 1`] = ` -
-
- - - - - - - - - -
-
-
-
-
-
-
- -
-
-
-
- -
-
-
- Use shorthand notation, like 30s, 10m, or 1h -
-
-
-
-
-
-
- -
-
- -
-
-
-
-
-
-`; - -exports[`Storyshots shareables/Settings/components ToolbarSettings, autohide disabled 1`] = ` -
-
-
-
- - - - - - - - - -
-
- Hide the toolbar when the mouse is not within the Canvas? -
-
-
-
-`; - -exports[`Storyshots shareables/Settings/components ToolbarSettings, autohide enabled 1`] = ` -
-
-
-
- - - - - - - - - -
-
- Hide the toolbar when the mouse is not within the Canvas? -
-
-
-
-`; diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/settings.examples.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/settings.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/settings.examples.storyshot rename to x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/settings.stories.storyshot diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/toolbar_settings.examples.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/toolbar_settings.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/toolbar_settings.examples.storyshot rename to x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/__snapshots__/toolbar_settings.stories.storyshot diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/autoplay_settings.examples.tsx b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/autoplay_settings.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/autoplay_settings.examples.tsx rename to x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/autoplay_settings.stories.tsx diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/settings.examples.tsx b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/settings.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/settings.examples.tsx rename to x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/settings.stories.tsx diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/toolbar_settings.examples.tsx b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/toolbar_settings.stories.tsx similarity index 100% rename from x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/toolbar_settings.examples.tsx rename to x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__examples__/toolbar_settings.stories.tsx diff --git a/x-pack/plugins/canvas/.storybook/.babelrc b/x-pack/plugins/canvas/storybook/.babelrc similarity index 100% rename from x-pack/plugins/canvas/.storybook/.babelrc rename to x-pack/plugins/canvas/storybook/.babelrc diff --git a/x-pack/plugins/canvas/.storybook/addons.js b/x-pack/plugins/canvas/storybook/addons.js similarity index 100% rename from x-pack/plugins/canvas/.storybook/addons.js rename to x-pack/plugins/canvas/storybook/addons.js diff --git a/x-pack/plugins/canvas/.storybook/config.js b/x-pack/plugins/canvas/storybook/config.js similarity index 67% rename from x-pack/plugins/canvas/.storybook/config.js rename to x-pack/plugins/canvas/storybook/config.js index 04b4e2a8e7b4b..f349f9b7ccf98 100644 --- a/x-pack/plugins/canvas/.storybook/config.js +++ b/x-pack/plugins/canvas/storybook/config.js @@ -4,13 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; import { configure, addDecorator, addParameters } from '@storybook/react'; -import { withKnobs } from '@storybook/addon-knobs/react'; import { withInfo } from '@storybook/addon-info'; import { create } from '@storybook/theming'; -import { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public'; +import { addDecorators } from './decorators'; // If we're running Storyshots, be sure to register the require context hook. // Otherwise, add the other decorators. @@ -31,18 +29,9 @@ if (process.env.NODE_ENV === 'test') { }, }) ); - - // Add optional knobs to customize each story. - addDecorator(withKnobs); } -// Add New Platform Context for any stories that need it -const settings = new Map(); -settings.set('darkMode', true); -const platform = { - uiSettings: settings, -}; -addDecorator(fn => {fn()}); +addDecorators(); function loadStories() { require('./dll_contexts'); @@ -54,14 +43,14 @@ function loadStories() { true, /plugins\/(?=canvas).*light\.css/ ); - css.keys().forEach(filename => css(filename)); + css.keys().forEach((filename) => css(filename)); - // Find all files ending in *.examples.ts - const req = require.context('./..', true, /.(stories|examples).tsx$/); - req.keys().forEach(filename => req(filename)); + // Find all files ending in *.stories.tsx + const req = require.context('./..', true, /.(stories).tsx$/); + req.keys().forEach((filename) => req(filename)); // Import Canvas CSS - require('../public/style/index.scss') + require('../public/style/index.scss'); } // Set up the Storybook environment with custom settings. diff --git a/x-pack/plugins/canvas/.storybook/constants.js b/x-pack/plugins/canvas/storybook/constants.js similarity index 100% rename from x-pack/plugins/canvas/.storybook/constants.js rename to x-pack/plugins/canvas/storybook/constants.js diff --git a/x-pack/plugins/canvas/storybook/decorators/index.ts b/x-pack/plugins/canvas/storybook/decorators/index.ts new file mode 100644 index 0000000000000..aa1e958a410f5 --- /dev/null +++ b/x-pack/plugins/canvas/storybook/decorators/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { addDecorator } from '@storybook/react'; +import { withKnobs } from '@storybook/addon-knobs'; +// @ts-expect-error +import { withInfo } from '@storybook/addon-info'; + +import { routerContextDecorator } from './router_decorator'; +import { kibanaContextDecorator } from './kibana_decorator'; + +export const addDecorators = () => { + addDecorator(withKnobs); + addDecorator(kibanaContextDecorator); + addDecorator(routerContextDecorator); +}; diff --git a/x-pack/plugins/canvas/storybook/decorators/kibana_decorator.tsx b/x-pack/plugins/canvas/storybook/decorators/kibana_decorator.tsx new file mode 100644 index 0000000000000..a55efba5a5183 --- /dev/null +++ b/x-pack/plugins/canvas/storybook/decorators/kibana_decorator.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; + +const settings = new Map(); +settings.set('darkMode', true); +const platform = { + http: { + basePath: { + get: () => '', + prepend: () => '', + remove: () => '', + serverBasePath: '', + }, + }, + uiSettings: settings, +}; + +export const kibanaContextDecorator = (story: Function) => ( + {story()} +); diff --git a/x-pack/plugins/canvas/storybook/decorators/router_decorator.tsx b/x-pack/plugins/canvas/storybook/decorators/router_decorator.tsx new file mode 100644 index 0000000000000..43b0da6473f23 --- /dev/null +++ b/x-pack/plugins/canvas/storybook/decorators/router_decorator.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +class RouterContext extends React.Component { + static childContextTypes = { + router: PropTypes.object.isRequired, + }; + + getChildContext() { + return { + router: { + getFullPath: () => 'path', + create: () => '', + }, + }; + } + render() { + return <>{this.props.children}; + } +} + +export function routerContextDecorator(story: Function) { + return {story()}; +} diff --git a/x-pack/plugins/canvas/.storybook/dll_contexts.js b/x-pack/plugins/canvas/storybook/dll_contexts.js similarity index 88% rename from x-pack/plugins/canvas/.storybook/dll_contexts.js rename to x-pack/plugins/canvas/storybook/dll_contexts.js index 529fd8aa5c791..85f2ab66cab88 100644 --- a/x-pack/plugins/canvas/.storybook/dll_contexts.js +++ b/x-pack/plugins/canvas/storybook/dll_contexts.js @@ -15,7 +15,7 @@ const css = require.context( true, /\.\/plugins\/(?!canvas).*light\.css/ ); -css.keys().forEach(filename => { +css.keys().forEach((filename) => { css(filename); }); @@ -25,7 +25,7 @@ const uiStyles = require.context( false, /[\/\\](?!mixins|variables|_|\.|bootstrap_(light|dark))[^\/\\]+\.less/ ); -uiStyles.keys().forEach(key => uiStyles(key)); +uiStyles.keys().forEach((key) => uiStyles(key)); const json = require.context('../shareable_runtime/test/workpads', false, /\.json$/); -json.keys().forEach(key => json(key)); +json.keys().forEach((key) => json(key)); diff --git a/x-pack/plugins/canvas/.storybook/middleware.js b/x-pack/plugins/canvas/storybook/middleware.js similarity index 72% rename from x-pack/plugins/canvas/.storybook/middleware.js rename to x-pack/plugins/canvas/storybook/middleware.js index 8bbd2b6c1a22f..baa524aefa709 100644 --- a/x-pack/plugins/canvas/.storybook/middleware.js +++ b/x-pack/plugins/canvas/storybook/middleware.js @@ -4,10 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -const serve = require('serve-static'); const path = require('path'); +const serve = require('serve-static'); // Extend the Storybook Middleware to include a route to access Legacy UI assets -module.exports = function(router) { - router.get('/ui', serve(path.resolve(__dirname, '../../../../../src/core/server/core_app/assets'))); +module.exports = function (router) { + router.get( + '/ui', + serve(path.resolve(__dirname, '../../../../../src/core/server/core_app/assets')) + ); }; diff --git a/x-pack/plugins/canvas/.storybook/preview-head.html b/x-pack/plugins/canvas/storybook/preview-head.html similarity index 100% rename from x-pack/plugins/canvas/.storybook/preview-head.html rename to x-pack/plugins/canvas/storybook/preview-head.html diff --git a/x-pack/plugins/canvas/.storybook/storyshots.test.js b/x-pack/plugins/canvas/storybook/storyshots.test.js similarity index 95% rename from x-pack/plugins/canvas/.storybook/storyshots.test.js rename to x-pack/plugins/canvas/storybook/storyshots.test.js index 7195b97712464..ba4013f7cc816 100644 --- a/x-pack/plugins/canvas/.storybook/storyshots.test.js +++ b/x-pack/plugins/canvas/storybook/storyshots.test.js @@ -53,7 +53,7 @@ jest.mock('@elastic/eui/packages/react-datepicker', () => { }); // Mock React Portal for components that use modals, tooltips, etc -ReactDOM.createPortal = jest.fn(element => { +ReactDOM.createPortal = jest.fn((element) => { return element; }); @@ -67,7 +67,7 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { // https://github.com/elastic/eui/issues/3712 jest.mock('@elastic/eui/lib/components/overlay_mask/overlay_mask', () => { return { - EuiOverlayMask: ({children}) => children, + EuiOverlayMask: ({ children }) => children, }; }); @@ -79,19 +79,19 @@ jest.mock( } ); +import { EuiObserver } from '@elastic/eui/test-env/components/observer/observer'; +jest.mock('@elastic/eui/test-env/components/observer/observer'); +EuiObserver.mockImplementation(() => 'EuiObserver'); + // This element uses a `ref` and cannot be rendered by Jest snapshots. import { RenderedElement } from '../shareable_runtime/components/rendered_element'; jest.mock('../shareable_runtime/components/rendered_element'); RenderedElement.mockImplementation(() => 'RenderedElement'); -import { EuiObserver } from '@elastic/eui/test-env/components/observer/observer'; -jest.mock('@elastic/eui/test-env/components/observer/observer'); -EuiObserver.mockImplementation(() => 'EuiObserver'); - addSerializer(styleSheetSerializer); // Initialize Storyshots and build the Jest Snapshots initStoryshots({ - configPath: path.resolve(__dirname, './../.storybook'), + configPath: path.resolve(__dirname, './../storybook'), test: multiSnapshotWithOptions({}), }); diff --git a/x-pack/plugins/canvas/.storybook/webpack.config.js b/x-pack/plugins/canvas/storybook/webpack.config.js similarity index 88% rename from x-pack/plugins/canvas/.storybook/webpack.config.js rename to x-pack/plugins/canvas/storybook/webpack.config.js index 3148a6742f76a..1e0e36f796128 100644 --- a/x-pack/plugins/canvas/.storybook/webpack.config.js +++ b/x-pack/plugins/canvas/storybook/webpack.config.js @@ -14,7 +14,7 @@ const { DLL_OUTPUT, KIBANA_ROOT } = require('./constants'); module.exports = async ({ config }) => { // Find and alter the CSS rule to replace the Kibana public path string with a path // to the route we've added in middleware.js - const cssRule = config.module.rules.find(rule => rule.test.source.includes('.css$')); + const cssRule = config.module.rules.find((rule) => rule.test.source.includes('.css$')); cssRule.use.push({ loader: 'string-replace-loader', options: { @@ -153,7 +153,7 @@ module.exports = async ({ config }) => { config.plugins.push( // replace imports for `uiExports/*` modules with a synthetic module // created by create_ui_exports_module.js - new webpack.NormalModuleReplacementPlugin(/^uiExports\//, resource => { + new webpack.NormalModuleReplacementPlugin(/^uiExports\//, (resource) => { // uiExports used by Canvas const extensions = { hacks: [], @@ -179,10 +179,22 @@ module.exports = async ({ config }) => { }), // Mock out libs used by a few componets to avoid loading in kibana_legacy and platform - new webpack.NormalModuleReplacementPlugin(/(lib)?\/notify/, path.resolve(__dirname, '../tasks/mocks/uiNotify')), - new webpack.NormalModuleReplacementPlugin(/lib\/download_workpad/, path.resolve(__dirname, '../tasks/mocks/downloadWorkpad')), - new webpack.NormalModuleReplacementPlugin(/(lib)?\/custom_element_service/, path.resolve(__dirname, '../tasks/mocks/customElementService')), - new webpack.NormalModuleReplacementPlugin(/(lib)?\/ui_metric/, path.resolve(__dirname, '../tasks/mocks/uiMetric')), + new webpack.NormalModuleReplacementPlugin( + /(lib)?\/notify/, + path.resolve(__dirname, '../tasks/mocks/uiNotify') + ), + new webpack.NormalModuleReplacementPlugin( + /lib\/download_workpad/, + path.resolve(__dirname, '../tasks/mocks/downloadWorkpad') + ), + new webpack.NormalModuleReplacementPlugin( + /(lib)?\/custom_element_service/, + path.resolve(__dirname, '../tasks/mocks/customElementService') + ), + new webpack.NormalModuleReplacementPlugin( + /(lib)?\/ui_metric/, + path.resolve(__dirname, '../tasks/mocks/uiMetric') + ) ); // Tell Webpack about relevant extensions @@ -196,7 +208,10 @@ module.exports = async ({ config }) => { '../tasks/mocks/uiNotifyFormatMsg' ); config.resolve.alias['ui/notify'] = path.resolve(__dirname, '../tasks/mocks/uiNotify'); - config.resolve.alias['ui/url/absolute_to_parsed_url'] = path.resolve(__dirname, '../tasks/mocks/uiAbsoluteToParsedUrl'); + config.resolve.alias['ui/url/absolute_to_parsed_url'] = path.resolve( + __dirname, + '../tasks/mocks/uiAbsoluteToParsedUrl' + ); config.resolve.alias['ui/chrome'] = path.resolve(__dirname, '../tasks/mocks/uiChrome'); config.resolve.alias.ui = path.resolve(KIBANA_ROOT, 'src/legacy/ui/public'); config.resolve.alias.ng_mock$ = path.resolve(KIBANA_ROOT, 'src/test_utils/public/ng_mock'); diff --git a/x-pack/plugins/canvas/.storybook/webpack.dll.config.js b/x-pack/plugins/canvas/storybook/webpack.dll.config.js similarity index 100% rename from x-pack/plugins/canvas/.storybook/webpack.dll.config.js rename to x-pack/plugins/canvas/storybook/webpack.dll.config.js index 5fdc4519f3bd7..0e9371e4cb5e4 100644 --- a/x-pack/plugins/canvas/.storybook/webpack.dll.config.js +++ b/x-pack/plugins/canvas/storybook/webpack.dll.config.js @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -const webpack = require('webpack'); const path = require('path'); +const webpack = require('webpack'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const { DLL_NAME, DLL_OUTPUT, KIBANA_ROOT } = require('./constants'); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/setup_environment.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/setup_environment.ts index b3205a9523c62..325d8193de5fd 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/setup_environment.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/setup_environment.ts @@ -29,6 +29,8 @@ export const setupEnvironment = () => { ); mockHttpClient.interceptors.response.use(({ data }) => data); + // This expects HttpSetup but we're giving it AxiosInstance. + // @ts-ignore initHttp(mockHttpClient); const { server, httpRequestsMockHelpers } = initHttpRequests(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts index 065fb3bcebca7..30c341baa6194 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts @@ -5,7 +5,6 @@ */ import { METRIC_TYPE } from '@kbn/analytics'; -import { trackUiMetric } from './ui_metric'; import { UIM_POLICY_DELETE, @@ -15,8 +14,13 @@ import { UIM_INDEX_RETRY_STEP, } from '../constants'; +import { trackUiMetric } from './ui_metric'; import { sendGet, sendPost, sendDelete, useRequest } from './http'; +interface GenericObject { + [key: string]: any; +} + export async function loadNodes() { return await sendGet(`nodes/list`); } @@ -33,7 +37,7 @@ export async function loadPolicies(withIndices: boolean) { return await sendGet('policies', { withIndices }); } -export async function savePolicy(policy: any) { +export async function savePolicy(policy: GenericObject) { return await sendPost(`policies`, policy); } @@ -58,14 +62,14 @@ export const removeLifecycleForIndex = async (indexNames: string[]) => { return response; }; -export const addLifecyclePolicyToIndex = async (body: any) => { +export const addLifecyclePolicyToIndex = async (body: GenericObject) => { const response = await sendPost(`index/add`, body); // Only track successful actions. trackUiMetric(METRIC_TYPE.COUNT, UIM_POLICY_ATTACH_INDEX); return response; }; -export const addLifecyclePolicyToTemplate = async (body: any) => { +export const addLifecyclePolicyToTemplate = async (body: GenericObject) => { const response = await sendPost(`template`, body); // Only track successful actions. trackUiMetric(METRIC_TYPE.COUNT, UIM_POLICY_ATTACH_INDEX_TEMPLATE); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/api_errors.js b/x-pack/plugins/index_lifecycle_management/public/application/services/api_errors.ts similarity index 73% rename from x-pack/plugins/index_lifecycle_management/public/application/services/api_errors.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/api_errors.ts index af107b5cff4b1..7b8d48acced33 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/api_errors.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/api_errors.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { IHttpFetchError } from 'src/core/public'; import { fatalErrors, toasts } from './notification'; -function createToastConfig(error, errorTitle) { +function createToastConfig(error: IHttpFetchError, errorTitle: string) { if (error && error.body) { + // Error body shape is defined by the API. const { error: errorString, statusCode, message } = error.body; return { @@ -17,7 +19,7 @@ function createToastConfig(error, errorTitle) { } } -export function showApiWarning(error, errorTitle) { +export function showApiWarning(error: IHttpFetchError, errorTitle: string) { const toastConfig = createToastConfig(error, errorTitle); if (toastConfig) { @@ -26,10 +28,10 @@ export function showApiWarning(error, errorTitle) { // This error isn't an HTTP error, so let the fatal error screen tell the user something // unexpected happened. - return fatalErrors(error, errorTitle); + return fatalErrors.add(error, errorTitle); } -export function showApiError(error, errorTitle) { +export function showApiError(error: IHttpFetchError, errorTitle: string) { const toastConfig = createToastConfig(error, errorTitle); if (toastConfig) { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts index c54ee15fd69bf..0b5f39a52c13f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts @@ -4,15 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { HttpSetup } from 'src/core/public'; import { UseRequestConfig, useRequest as _useRequest, Error, } from '../../../../../../src/plugins/es_ui_shared/public'; -let _httpClient: any; +interface GenericObject { + [key: string]: any; +} + +let _httpClient: HttpSetup; -export function init(httpClient: any): void { +export function init(httpClient: HttpSetup): void { _httpClient = httpClient; } @@ -26,15 +31,15 @@ function getFullPath(path: string): string { return apiPrefix; } -export function sendPost(path: string, payload: any): any { +export function sendPost(path: string, payload: GenericObject) { return _httpClient.post(getFullPath(path), { body: JSON.stringify(payload) }); } -export function sendGet(path: string, query?: any): any { +export function sendGet(path: string, query?: GenericObject): any { return _httpClient.get(getFullPath(path), { query }); } -export function sendDelete(path: string): any { +export function sendDelete(path: string) { return _httpClient.delete(getFullPath(path)); } diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.tsx similarity index 95% rename from x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.js rename to x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.tsx index 048ed44bd58b2..6057522885b1d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.js +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.tsx @@ -13,16 +13,13 @@ import { removeLifecycleForIndex } from '../../application/services/api'; import { showApiError } from '../../application/services/api_errors'; import { toasts } from '../../application/services/notification'; -export class RemoveLifecyclePolicyConfirmModal extends Component { - constructor(props) { - super(props); - this.state = { - policies: [], - selectedPolicyName: null, - selectedAlias: null, - }; - } +interface Props { + indexNames: string[]; + closeModal: () => void; + reloadIndices: () => void; +} +export class RemoveLifecyclePolicyConfirmModal extends Component { removePolicy = async () => { const { indexNames, closeModal, reloadIndices } = this.props; diff --git a/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx b/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx index 0e0e23ef73a3a..f3136ca155c78 100644 --- a/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx +++ b/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx @@ -14,13 +14,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiCallOut, - EuiOverlayMask, - EuiModal, - EuiModalHeader, - EuiModalHeaderTitle, - EuiModalBody, + EuiAccordion, EuiCodeBlock, - EuiLink, + EuiText, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -61,9 +57,6 @@ export const AlertPreview: React.FC = (props) => { const [previewResult, setPreviewResult] = useState< (AlertPreviewSuccessResponsePayload & Record) | null >(null); - const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); - const onOpenModal = useCallback(() => setIsErrorModalVisible(true), [setIsErrorModalVisible]); - const onCloseModal = useCallback(() => setIsErrorModalVisible(false), [setIsErrorModalVisible]); const onSelectPreviewLookbackInterval = useCallback((e) => { setPreviewLookbackInterval(e.target.value); @@ -271,33 +264,32 @@ export const AlertPreview: React.FC = (props) => { iconType="alert" > {previewError.body && ( - view the error, - }} - /> + <> + + + + + + + + } + > + + {previewError.body.message} + + )} )} - {isErrorModalVisible && ( - - - - - - - - - {previewError.body.message} - - - - )} )} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_stream.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_stream.tsx index 11a9df276485b..04a0f4e6dbb74 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_stream.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_stream.tsx @@ -5,6 +5,7 @@ */ import React, { useState, Fragment, memo, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; +import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexGrid, @@ -23,6 +24,10 @@ import { } from '../services'; import { PackageConfigInputVarField } from './package_config_input_var_field'; +const FlexItemWithMaxWidth = styled(EuiFlexItem)` + max-width: calc(50% - ${(props) => props.theme.eui.euiSizeL}); +`; + export const PackageConfigInputStreamConfig: React.FunctionComponent<{ packageInputStream: RegistryStream; packageConfigInputStream: PackageConfigInputStream; @@ -91,7 +96,7 @@ export const PackageConfigInputStreamConfig: React.FunctionComponent<{ - + {requiredVars.map((varDef) => { const { name: varName, type: varType } = varDef; @@ -178,7 +183,7 @@ export const PackageConfigInputStreamConfig: React.FunctionComponent<{ ) : null} - + ); } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/assets_facet_group.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/assets_facet_group.tsx index 24b4baeaa092b..b8fab92e40da8 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/assets_facet_group.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/assets_facet_group.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import React, { Fragment } from 'react'; import { EuiFacetButton, EuiFacetGroup, @@ -14,8 +14,8 @@ import { EuiTextColor, EuiTitle, } from '@elastic/eui'; -import React, { Fragment } from 'react'; import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n/react'; import { AssetsGroupedByServiceByType, AssetTypeToParts, @@ -43,8 +43,15 @@ const FacetGroup = styled(EuiFacetGroup)` `; const FacetButton = styled(EuiFacetButton)` - padding: '${(props) => props.theme.eui.paddingSizes.xs} 0'; - height: 'unset'; + &&& { + .euiFacetButton__icon, + .euiFacetButton__quantity { + opacity: 1; + } + .euiFacetButton__text { + color: ${(props) => props.theme.eui.euiTextColor}; + } + } `; export function AssetsFacetGroup({ assets }: { assets: AssetsGroupedByServiceByType }) { @@ -70,7 +77,15 @@ export function AssetsFacetGroup({ assets }: { assets: AssetsGroupedByServiceByT -

{ServiceTitleMap[service]} Assets

+

+ +

@@ -83,13 +98,7 @@ export function AssetsFacetGroup({ assets }: { assets: AssetsGroupedByServiceByT const iconType = type in AssetIcons && AssetIcons[type]; const iconNode = iconType ? : ''; return ( - {}} - > + {AssetTitleMap[type]} ); diff --git a/x-pack/plugins/maps/public/actions/data_request_actions.ts b/x-pack/plugins/maps/public/actions/data_request_actions.ts index a0c484a82e530..5919feadfcc2a 100644 --- a/x-pack/plugins/maps/public/actions/data_request_actions.ts +++ b/x-pack/plugins/maps/public/actions/data_request_actions.ts @@ -37,8 +37,12 @@ import { UPDATE_SOURCE_DATA_REQUEST, } from './map_action_constants'; import { ILayer } from '../classes/layers/layer'; +import { IVectorLayer } from '../classes/layers/vector_layer/vector_layer'; import { DataMeta, MapExtent, MapFilters } from '../../common/descriptor_types'; import { DataRequestAbortError } from '../classes/util/data_request'; +import { scaleBounds } from '../elasticsearch_geo_utils'; + +const FIT_TO_BOUNDS_SCALE_FACTOR = 0.1; export type DataRequestContext = { startLoading(dataId: string, requestToken: symbol, meta: DataMeta): void; @@ -122,13 +126,26 @@ function getDataRequestContext( export function syncDataForAllLayers() { return async (dispatch: Dispatch, getState: () => MapStoreState) => { - const syncPromises = getLayerList(getState()).map(async (layer) => { + const syncPromises = getLayerList(getState()).map((layer) => { return dispatch(syncDataForLayer(layer)); }); await Promise.all(syncPromises); }; } +export function syncDataForAllJoinLayers() { + return async (dispatch: Dispatch, getState: () => MapStoreState) => { + const syncPromises = getLayerList(getState()) + .filter((layer) => { + return 'hasJoins' in layer ? (layer as IVectorLayer).hasJoins() : false; + }) + .map((layer) => { + return dispatch(syncDataForLayer(layer)); + }); + await Promise.all(syncPromises); + }; +} + export function syncDataForLayer(layer: ILayer) { return async (dispatch: Dispatch, getState: () => MapStoreState) => { const dataRequestContext = getDataRequestContext(dispatch, getState, layer.getId()); @@ -284,7 +301,7 @@ export function fitToLayerExtent(layerId: string) { getDataRequestContext(dispatch, getState, layerId) ); if (bounds) { - await dispatch(setGotoWithBounds(bounds)); + await dispatch(setGotoWithBounds(scaleBounds(bounds, FIT_TO_BOUNDS_SCALE_FACTOR))); } } catch (error) { if (!(error instanceof DataRequestAbortError)) { @@ -359,7 +376,7 @@ export function fitToDataBounds() { maxLat: turfUnionBbox[3], }; - dispatch(setGotoWithBounds(dataBounds)); + dispatch(setGotoWithBounds(scaleBounds(dataBounds, FIT_TO_BOUNDS_SCALE_FACTOR))); }; } diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 75df8689a670e..ef0cfdf0b4742 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -8,11 +8,13 @@ import { Dispatch } from 'redux'; // @ts-ignore import turf from 'turf'; +import uuid from 'uuid/v4'; import turfBooleanContains from '@turf/boolean-contains'; import { Filter, Query, TimeRange } from 'src/plugins/data/public'; import { MapStoreState } from '../reducers/store'; import { getDataFilters, + getMapSettings, getWaitingForMapReadyLayerListRaw, getQuery, } from '../selectors/map_selectors'; @@ -42,7 +44,11 @@ import { UPDATE_DRAW_STATE, UPDATE_MAP_SETTING, } from './map_action_constants'; -import { syncDataForAllLayers } from './data_request_actions'; +import { + fitToDataBounds, + syncDataForAllJoinLayers, + syncDataForAllLayers, +} from './data_request_actions'; import { addLayer } from './layer_actions'; import { MapSettings } from '../reducers/map'; import { @@ -51,6 +57,7 @@ import { MapExtent, MapRefreshConfig, } from '../../common/descriptor_types'; +import { scaleBounds } from '../elasticsearch_geo_utils'; export function setMapInitError(errorMessage: string) { return { @@ -134,15 +141,7 @@ export function mapExtentChanged(newMapConstants: { zoom: number; extent: MapExt } if (!doesBufferContainExtent || currentZoom !== newZoom) { - const scaleFactor = 0.5; // TODO put scale factor in store and fetch with selector - const width = extent.maxLon - extent.minLon; - const height = extent.maxLat - extent.minLat; - dataFilters.buffer = { - minLon: extent.minLon - width * scaleFactor, - minLat: extent.minLat - height * scaleFactor, - maxLon: extent.maxLon + width * scaleFactor, - maxLat: extent.maxLat + height * scaleFactor, - }; + dataFilters.buffer = scaleBounds(extent, 0.5); } } @@ -197,6 +196,7 @@ function generateQueryTimestamp() { return new Date().toISOString(); } +let lastSetQueryCallId: string = ''; export function setQuery({ query, timeFilters, @@ -226,7 +226,22 @@ export function setQuery({ filters, }); - await dispatch(syncDataForAllLayers()); + if (getMapSettings(getState()).autoFitToDataBounds) { + // Joins are performed on the client. + // As a result, bounds for join layers must also be performed on the client. + // Therefore join layers need to fetch data prior to auto fitting bounds. + const localSetQueryCallId = uuid(); + lastSetQueryCallId = localSetQueryCallId; + await dispatch(syncDataForAllJoinLayers()); + + // setQuery can be triggered before async data fetching completes + // Only continue execution path if setQuery has not been re-triggered. + if (localSetQueryCallId === lastSetQueryCallId) { + dispatch(fitToDataBounds()); + } + } else { + await dispatch(syncDataForAllLayers()); + } }; } diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index aefa2beede7d1..c0b9c4553d01e 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -237,6 +237,10 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { return []; } + hasJoins() { + return false; + } + getSource() { return this._isClustered ? this._clusterSource : this._documentSource; } diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts index 77daf9c9af570..e6cb212daddae 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts @@ -35,6 +35,7 @@ export interface IVectorLayer extends ILayer { getStyle(): IVectorStyle; getFeatureById(id: string | number): Feature | null; getPropertiesForTooltip(properties: GeoJsonProperties): Promise; + hasJoins(): boolean; } export class VectorLayer extends AbstractLayer implements IVectorLayer { @@ -81,4 +82,5 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer { getStyle(): IVectorStyle; getFeatureById(id: string | number): Feature | null; getPropertiesForTooltip(properties: GeoJsonProperties): Promise; + hasJoins(): boolean; } diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js index 0a4fcfc23060c..23889bdca2dd7 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js @@ -85,7 +85,7 @@ export class VectorLayer extends AbstractLayer { }); } - _hasJoins() { + hasJoins() { return this.getValidJoins().length > 0; } @@ -159,7 +159,7 @@ export class VectorLayer extends AbstractLayer { async getBounds({ startLoading, stopLoading, registerCancelCallback, dataFilters }) { const isStaticLayer = !this.getSource().isBoundsAware(); if (isStaticLayer) { - return getFeatureCollectionBounds(this._getSourceFeatureCollection(), this._hasJoins()); + return getFeatureCollectionBounds(this._getSourceFeatureCollection(), this.hasJoins()); } const requestToken = Symbol(`${SOURCE_BOUNDS_DATA_REQUEST_ID}-${this.getId()}`); @@ -193,6 +193,11 @@ export class VectorLayer extends AbstractLayer { return bounds; } + isLoadingBounds() { + const boundsDataRequest = this.getDataRequest(SOURCE_BOUNDS_DATA_REQUEST_ID); + return !!boundsDataRequest && boundsDataRequest.isLoading(); + } + async getLeftJoinFields() { return await this.getSource().getLeftJoinFields(); } @@ -583,7 +588,7 @@ export class VectorLayer extends AbstractLayer { } async syncData(syncContext) { - this._syncData(syncContext, this.getSource(), this.getCurrentStyle()); + await this._syncData(syncContext, this.getSource(), this.getCurrentStyle()); } // TLDR: Do not call getSource or getCurrentStyle in syncData flow. Use 'source' and 'style' arguments instead. @@ -597,13 +602,16 @@ export class VectorLayer extends AbstractLayer { // Given 2 above, which source/style to use can not be pulled from data request state. // Therefore, source and style are provided as arugments and must be used instead of calling getSource or getCurrentStyle. async _syncData(syncContext, source, style) { + if (this.isLoadingBounds()) { + return; + } await this._syncSourceStyleMeta(syncContext, source, style); await this._syncSourceFormatters(syncContext, source, style); const sourceResult = await this._syncSource(syncContext, source, style); if ( !sourceResult.featureCollection || !sourceResult.featureCollection.features.length || - !this._hasJoins() + !this.hasJoins() ) { return; } @@ -711,7 +719,7 @@ export class VectorLayer extends AbstractLayer { mbMap.addLayer(mbLayer); } - const filterExpr = getPointFilterExpression(this._hasJoins()); + const filterExpr = getPointFilterExpression(this.hasJoins()); if (filterExpr !== mbMap.getFilter(pointLayerId)) { mbMap.setFilter(pointLayerId, filterExpr); mbMap.setFilter(textLayerId, filterExpr); @@ -747,7 +755,7 @@ export class VectorLayer extends AbstractLayer { mbMap.addLayer(mbLayer); } - const filterExpr = getPointFilterExpression(this._hasJoins()); + const filterExpr = getPointFilterExpression(this.hasJoins()); if (filterExpr !== mbMap.getFilter(symbolLayerId)) { mbMap.setFilter(symbolLayerId, filterExpr); } @@ -769,7 +777,7 @@ export class VectorLayer extends AbstractLayer { const sourceId = this.getId(); const fillLayerId = this._getMbPolygonLayerId(); const lineLayerId = this._getMbLineLayerId(); - const hasJoins = this._hasJoins(); + const hasJoins = this.hasJoins(); if (!mbMap.getLayer(fillLayerId)) { const mbLayer = { id: fillLayerId, diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap index 641dd20a1a44a..18e30d9446e05 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap @@ -16,6 +16,25 @@ exports[`should render 1`] = ` + + + + + + + + + + + + + + + { + updateMapSetting('autoFitToDataBounds', event.target.checked); + }; + const onZoomChange = (value: Value) => { const minZoom = Math.max(MIN_ZOOM, parseInt(value[0] as string, 10)); const maxZoom = Math.min(MAX_ZOOM, parseInt(value[1] as string, 10)); @@ -207,6 +213,19 @@ export function NavigationPanel({ center, settings, updateMapSetting, zoom }: Pr
+ + + + + + { expect(bbox).toEqual({ bottom_right: [-170, -89], top_left: [-175, 89] }); }); }); + +describe('scaleBounds', () => { + it('Should scale bounds', () => { + const bounds = { + maxLat: 10, + maxLon: 100, + minLat: 5, + minLon: 95, + }; + expect(scaleBounds(bounds, 0.5)).toEqual({ + maxLat: 12.5, + maxLon: 102.5, + minLat: 2.5, + minLon: 92.5, + }); + }); +}); diff --git a/x-pack/plugins/maps/public/kibana_services.js b/x-pack/plugins/maps/public/kibana_services.js index 53e128f94dfb6..89d578f27b118 100644 --- a/x-pack/plugins/maps/public/kibana_services.js +++ b/x-pack/plugins/maps/public/kibana_services.js @@ -80,7 +80,7 @@ export async function fetchSearchSourceAndRecordWithInspector({ inspectorRequest.json(body); }); resp = await searchSource.fetch({ abortSignal }); - inspectorRequest.stats(getResponseInspectorStats(searchSource, resp)).ok({ json: resp }); + inspectorRequest.stats(getResponseInspectorStats(resp, searchSource)).ok({ json: resp }); } catch (error) { inspectorRequest.error({ error }); throw error; diff --git a/x-pack/plugins/maps/public/reducers/default_map_settings.ts b/x-pack/plugins/maps/public/reducers/default_map_settings.ts index 9c9b814ae6add..896ac11e36782 100644 --- a/x-pack/plugins/maps/public/reducers/default_map_settings.ts +++ b/x-pack/plugins/maps/public/reducers/default_map_settings.ts @@ -9,6 +9,7 @@ import { MapSettings } from './map'; export function getDefaultMapSettings(): MapSettings { return { + autoFitToDataBounds: false, initialLocation: INITIAL_LOCATION.LAST_SAVED_LOCATION, fixedLocation: { lat: 0, lon: 0, zoom: 2 }, browserLocation: { zoom: 2 }, diff --git a/x-pack/plugins/maps/public/reducers/map.d.ts b/x-pack/plugins/maps/public/reducers/map.d.ts index 33794fcf8657d..aca75334032d9 100644 --- a/x-pack/plugins/maps/public/reducers/map.d.ts +++ b/x-pack/plugins/maps/public/reducers/map.d.ts @@ -42,6 +42,7 @@ export type MapContext = { }; export type MapSettings = { + autoFitToDataBounds: boolean; initialLocation: INITIAL_LOCATION; fixedLocation: { lat: number; diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js index 86398a57c3a45..69f7635a66032 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js @@ -256,7 +256,7 @@ export class AnnotationsTable extends Component { // if the annotation is at the series level // then pass the partitioning field(s) and detector index to the Single Metric Viewer if (_.has(annotation, 'detector_index')) { - mlTimeSeriesExplorer.detector_index = annotation.detector_index; + mlTimeSeriesExplorer.detectorIndex = annotation.detector_index; } if (_.has(annotation, 'partition_field_value')) { entityCondition[annotation.partition_field_name] = annotation.partition_field_value; @@ -523,10 +523,26 @@ export class AnnotationsTable extends Component { const aggregations = this.props.aggregations ?? this.state.aggregations; if (aggregations) { const buckets = aggregations.event.buckets; - const foundUser = buckets.findIndex((d) => d.key === ANNOTATION_EVENT_USER) > -1; - filterOptions = foundUser - ? buckets - : [{ key: ANNOTATION_EVENT_USER, doc_count: 0 }, ...buckets]; + let foundUser = false; + let foundDelayedData = false; + + buckets.forEach((bucket) => { + if (bucket.key === ANNOTATION_EVENT_USER) { + foundUser = true; + } + if (bucket.key === ANNOTATION_EVENT_DELAYED_DATA) { + foundDelayedData = true; + } + }); + const adjustedBuckets = []; + if (!foundUser) { + adjustedBuckets.push({ key: ANNOTATION_EVENT_USER, doc_count: 0 }); + } + if (!foundDelayedData) { + adjustedBuckets.push({ key: ANNOTATION_EVENT_DELAYED_DATA, doc_count: 0 }); + } + + filterOptions = [...adjustedBuckets, ...buckets]; } const filters = [ { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/job_config_error_callout/job_config_error_callout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/job_config_error_callout/job_config_error_callout.tsx index 945d6654067c0..9b9e1258db503 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/job_config_error_callout/job_config_error_callout.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/job_config_error_callout/job_config_error_callout.tsx @@ -6,7 +6,7 @@ import React, { FC } from 'react'; -import { EuiCallOut, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiLink, EuiPanel, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -31,6 +31,23 @@ export const JobConfigErrorCallout: FC = ({ jobConfigErrorMessage, title, }) => { + const containsIndexPatternLink = + typeof jobCapsServiceErrorMessage === 'string' && + jobCapsServiceErrorMessage.includes('locate that index-pattern') && + jobCapsServiceErrorMessage.includes('click here to re-create'); + + const message = ( +

{jobConfigErrorMessage ? jobConfigErrorMessage : jobCapsServiceErrorMessage}

+ ); + + const calloutBody = containsIndexPatternLink ? ( + + {message} + + ) : ( + message + ); + return ( @@ -40,7 +57,7 @@ export const JobConfigErrorCallout: FC = ({ color="danger" iconType="cross" > -

{jobConfigErrorMessage ? jobConfigErrorMessage : jobCapsServiceErrorMessage}

+ {calloutBody}
); diff --git a/x-pack/plugins/rollup/public/crud_app/services/api_errors.ts b/x-pack/plugins/rollup/public/crud_app/services/api_errors.ts index af9e1a16e4cc5..bea21d119e7fd 100644 --- a/x-pack/plugins/rollup/public/crud_app/services/api_errors.ts +++ b/x-pack/plugins/rollup/public/crud_app/services/api_errors.ts @@ -4,12 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { IHttpFetchError } from 'src/core/public'; import { getNotifications, getFatalErrors } from '../../kibana_services'; -function createToastConfig(error: any, errorTitle: string) { - // Expect an error in the shape provided by http service. +function createToastConfig(error: IHttpFetchError, errorTitle: string) { if (error && error.body) { + // Error body shape is defined by the API. const { error: errorString, statusCode, message } = error.body; + return { title: errorTitle, text: `${statusCode}: ${errorString}. ${message}`, @@ -17,7 +19,7 @@ function createToastConfig(error: any, errorTitle: string) { } } -export function showApiWarning(error: any, errorTitle: string) { +export function showApiWarning(error: IHttpFetchError, errorTitle: string) { const toastConfig = createToastConfig(error, errorTitle); if (toastConfig) { @@ -29,7 +31,7 @@ export function showApiWarning(error: any, errorTitle: string) { return getFatalErrors().add(error, errorTitle); } -export function showApiError(error: any, errorTitle: string) { +export function showApiError(error: IHttpFetchError, errorTitle: string) { const toastConfig = createToastConfig(error, errorTitle); if (toastConfig) { diff --git a/x-pack/plugins/security_solution/common/endpoint/index_data.ts b/x-pack/plugins/security_solution/common/endpoint/index_data.ts index 00b8f0b057afd..9a61738cd84b4 100644 --- a/x-pack/plugins/security_solution/common/endpoint/index_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/index_data.ts @@ -94,6 +94,6 @@ async function indexAlerts( }, [] ); - await client.bulk({ body, refresh: 'true' }); + await client.bulk({ body, refresh: true }); } } diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx index 5da75033d17fa..88969c3ae5fb3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx @@ -15,7 +15,9 @@ import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; import { usePostComment } from '../../containers/use_post_comment'; import { useForm } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'; -import { wait } from '../../../common/lib/helpers'; + +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; jest.mock( '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form' @@ -84,10 +86,11 @@ describe('AddComment ', () => { expect(wrapper.find(`[data-test-subj="loading-spinner"]`).exists()).toBeFalsy(); wrapper.find(`[data-test-subj="submit-comment"]`).first().simulate('click'); - await wait(); - expect(onCommentSaving).toBeCalled(); - expect(postComment).toBeCalledWith(sampleData, onCommentPosted); - expect(formHookMock.reset).toBeCalled(); + await waitFor(() => { + expect(onCommentSaving).toBeCalled(); + expect(postComment).toBeCalledWith(sampleData, onCommentPosted); + expect(formHookMock.reset).toBeCalled(); + }); }); it('should render spinner and disable submit when loading', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index 4e29db4022e65..278b972ada970 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -15,7 +15,9 @@ import { TestProviders } from '../../../common/mock'; import { useUpdateCase } from '../../containers/use_update_case'; import { useGetCase } from '../../containers/use_get_case'; import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; -import { wait } from '../../../common/lib/helpers'; + +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { useConnectors } from '../../containers/configure/use_connectors'; import { connectorsMock } from '../../containers/configure/mock'; @@ -108,30 +110,33 @@ describe('CaseView ', () => { ); - await wait(); - expect(wrapper.find(`[data-test-subj="case-view-title"]`).first().prop('title')).toEqual( - data.title - ); - expect(wrapper.find(`[data-test-subj="case-view-status"]`).first().text()).toEqual(data.status); - expect( - wrapper - .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag"]`) - .first() - .text() - ).toEqual(data.tags[0]); - expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual( - data.createdBy.username - ); - expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false); - expect(wrapper.find(`[data-test-subj="case-view-createdAt"]`).first().prop('value')).toEqual( - data.createdAt - ); - expect( - wrapper - .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`) - .first() - .prop('raw') - ).toEqual(data.description); + await waitFor(() => { + expect(wrapper.find(`[data-test-subj="case-view-title"]`).first().prop('title')).toEqual( + data.title + ); + expect(wrapper.find(`[data-test-subj="case-view-status"]`).first().text()).toEqual( + data.status + ); + expect( + wrapper + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag"]`) + .first() + .text() + ).toEqual(data.tags[0]); + expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual( + data.createdBy.username + ); + expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false); + expect(wrapper.find(`[data-test-subj="case-view-createdAt"]`).first().prop('value')).toEqual( + data.createdAt + ); + expect( + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`) + .first() + .prop('raw') + ).toEqual(data.description); + }); }); it('should show closed indicators in header when case is closed', async () => { @@ -146,14 +151,15 @@ describe('CaseView ', () => { ); - await wait(); - expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); - expect(wrapper.find(`[data-test-subj="case-view-closedAt"]`).first().prop('value')).toEqual( - basicCaseClosed.closedAt - ); - expect(wrapper.find(`[data-test-subj="case-view-status"]`).first().text()).toEqual( - basicCaseClosed.status - ); + await waitFor(() => { + expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); + expect(wrapper.find(`[data-test-subj="case-view-closedAt"]`).first().prop('value')).toEqual( + basicCaseClosed.closedAt + ); + expect(wrapper.find(`[data-test-subj="case-view-status"]`).first().text()).toEqual( + basicCaseClosed.status + ); + }); }); it('should dispatch update state when button is toggled', async () => { @@ -164,11 +170,12 @@ describe('CaseView ', () => { ); - await wait(); - wrapper - .find('input[data-test-subj="toggle-case-status"]') - .simulate('change', { target: { checked: true } }); - expect(updateCaseProperty).toHaveBeenCalled(); + await waitFor(() => { + wrapper + .find('input[data-test-subj="toggle-case-status"]') + .simulate('change', { target: { checked: true } }); + expect(updateCaseProperty).toHaveBeenCalled(); + }); }); it('should display EditableTitle isLoading', () => { @@ -296,17 +303,17 @@ describe('CaseView ', () => { ); - await wait(); + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="has-data-to-push-button"]').first().exists() + ).toBeTruthy(); - expect( - wrapper.find('[data-test-subj="has-data-to-push-button"]').first().exists() - ).toBeTruthy(); + wrapper.find('[data-test-subj="push-to-external-service"]').first().simulate('click'); - wrapper.find('[data-test-subj="push-to-external-service"]').first().simulate('click'); + wrapper.update(); - wrapper.update(); - - expect(postPushToService).toHaveBeenCalled(); + expect(postPushToService).toHaveBeenCalled(); + }); }); it('should return null if error', () => { @@ -424,17 +431,19 @@ describe('CaseView ', () => { ); - await wait(); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); - wrapper.update(); - wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click'); - wrapper.update(); - await wait(); - wrapper.update(); - expect( - wrapper.find('[data-test-subj="dropdown-connectors"]').at(0).prop('valueOfSelected') - ).toBe('servicenow-1'); + await waitFor(() => { + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); + wrapper.update(); + wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click'); + wrapper.update(); + }); + await waitFor(() => { + wrapper.update(); + expect( + wrapper.find('[data-test-subj="dropdown-connectors"]').at(0).prop('valueOfSelected') + ).toBe('servicenow-1'); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx index 25c12a53f2f5b..aefb196e0678d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx @@ -19,7 +19,9 @@ import { useGetTags } from '../../containers/use_get_tags'; jest.mock('../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'); jest.mock('../../containers/use_post_case'); import { useForm } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'; -import { wait } from '../../../common/lib/helpers'; + +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; jest.mock( '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form' @@ -97,8 +99,7 @@ describe('Create case', () => { ); wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - await wait(); - expect(postCase).toBeCalledWith(sampleData); + await waitFor(() => expect(postCase).toBeCalledWith(sampleData)); }); it('should redirect to all cases on cancel click', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.test.tsx index 564ce2e19df00..e531b71e8c90c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.test.tsx @@ -11,7 +11,8 @@ import { EditConnector } from './index'; import { getFormMock, useFormMock } from '../__mock__/form'; import { TestProviders } from '../../../common/mock'; import { connectorsMock } from '../../containers/configure/mock'; -import { wait } from '../../../common/lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; jest.mock( '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form' @@ -68,8 +69,7 @@ describe('EditConnector ', () => { await act(async () => { wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click'); - await wait(); - expect(onSubmit.mock.calls[0][0]).toBe(sampleConnector); + await waitFor(() => expect(onSubmit.mock.calls[0][0]).toBe(sampleConnector)); }); }); @@ -92,10 +92,11 @@ describe('EditConnector ', () => { await act(async () => { wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click'); - await wait(); - wrapper.update(); + await waitFor(() => { + wrapper.update(); + expect(formHookMock.setFieldValue).toHaveBeenCalledWith('connector', 'none'); + }); }); - expect(formHookMock.setFieldValue).toHaveBeenCalledWith('connector', 'none'); }); it('Resets selector on cancel', async () => { @@ -115,12 +116,13 @@ describe('EditConnector ', () => { await act(async () => { wrapper.find(`[data-test-subj="edit-connectors-cancel"]`).last().simulate('click'); - await wait(); - wrapper.update(); - expect(formHookMock.setFieldValue).toBeCalledWith( - 'connector', - defaultProps.selectedConnector - ); + await waitFor(() => { + wrapper.update(); + expect(formHookMock.setFieldValue).toBeCalledWith( + 'connector', + defaultProps.selectedConnector + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx index 950dd6f377945..939ddfde8b9dc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx @@ -11,7 +11,8 @@ import { act } from 'react-dom/test-utils'; import { TagList } from '.'; import { getFormMock } from '../__mock__/form'; import { TestProviders } from '../../../common/mock'; -import { wait } from '../../../common/lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { useForm } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'; import { useGetTags } from '../../containers/use_get_tags'; @@ -77,8 +78,7 @@ describe('TagList ', () => { wrapper.find(`[data-test-subj="tag-list-edit-button"]`).last().simulate('click'); await act(async () => { wrapper.find(`[data-test-subj="edit-tags-submit"]`).last().simulate('click'); - await wait(); - expect(onSubmit).toBeCalledWith(sampleTags); + await waitFor(() => expect(onSubmit).toBeCalledWith(sampleTags)); }); }); it('Tag options render with new tags added', () => { @@ -107,9 +107,10 @@ describe('TagList ', () => { await act(async () => { expect(wrapper.find(`[data-test-subj="case-tag"]`).last().exists()).toBeFalsy(); wrapper.find(`[data-test-subj="edit-tags-cancel"]`).last().simulate('click'); - await wait(); - wrapper.update(); - expect(wrapper.find(`[data-test-subj="case-tag"]`).last().exists()).toBeTruthy(); + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="case-tag"]`).last().exists()).toBeTruthy(); + }); }); }); it('Renders disabled button', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx index 23f1fb222a841..9cf13b6f9930a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx @@ -13,7 +13,8 @@ import { useUpdateComment } from '../../containers/use_update_comment'; import { basicCase, basicPush, getUserAction } from '../../containers/mock'; import { UserActionTree } from '.'; import { TestProviders } from '../../../common/mock'; -import { wait } from '../../../common/lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; const fetchUserActions = jest.fn(); @@ -225,22 +226,23 @@ describe('UserActionTree ', () => { .first() .simulate('click'); await act(async () => { - await wait(); - wrapper.update(); - expect( - wrapper - .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` - ) - .exists() - ).toEqual(false); - expect(patchComment).toBeCalledWith({ - commentUpdate: sampleData.content, - caseId: props.data.id, - commentId: props.data.comments[0].id, - fetchUserActions, - updateCase, - version: props.data.comments[0].version, + await waitFor(() => { + wrapper.update(); + expect( + wrapper + .find( + `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); + expect(patchComment).toBeCalledWith({ + commentUpdate: sampleData.content, + caseId: props.data.id, + commentId: props.data.comments[0].id, + fetchUserActions, + updateCase, + version: props.data.comments[0].version, + }); }); }); }); @@ -269,15 +271,16 @@ describe('UserActionTree ', () => { .first() .simulate('click'); await act(async () => { - await wait(); - expect( - wrapper - .find( - `[data-test-subj="user-action-${props.data.id}"] [data-test-subj="user-action-markdown-form"]` - ) - .exists() - ).toEqual(false); - expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); + await waitFor(() => { + expect( + wrapper + .find( + `[data-test-subj="user-action-${props.data.id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); + expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx index 7734344d193b8..1ff5d770521f3 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx @@ -8,14 +8,27 @@ import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; -import { AutocompleteFieldListsComponent } from './field_value_lists'; +import { ListSchema } from '../../../lists_plugin_deps'; import { getFoundListSchemaMock } from '../../../../../lists/common/schemas/response/found_list_schema.mock'; +import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; +import { DATE_NOW } from '../../../../../lists/common/constants.mock'; + +import { AutocompleteFieldListsComponent } from './field_value_lists'; -const mockStart = jest.fn(); -const mockResult = getFoundListSchemaMock(); jest.mock('../../../common/lib/kibana'); +const mockStart = jest.fn(); +const mockKeywordList: ListSchema = { + ...getListResponseMock(), + id: 'keyword_list', + type: 'keyword', + name: 'keyword list', +}; +const mockResult = { ...getFoundListSchemaMock() }; +mockResult.data = [...mockResult.data, mockKeywordList]; jest.mock('../../../lists_plugin_deps', () => { const originalModule = jest.requireActual('../../../lists_plugin_deps'); @@ -31,7 +44,7 @@ jest.mock('../../../lists_plugin_deps', () => { }); describe('AutocompleteFieldListsComponent', () => { - test('it renders disabled if "isDisabled" is true', () => { + test('it renders disabled if "isDisabled" is true', async () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { ); - expect( - wrapper - .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] input`) - .prop('disabled') - ).toBeTruthy(); + await waitFor(() => { + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] input`) + .prop('disabled') + ).toBeTruthy(); + }); }); - test('it renders loading if "isLoading" is true', () => { + test('it renders loading if "isLoading" is true', async () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { /> ); - wrapper - .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] button`) - .at(0) - .simulate('click'); - expect( + + await waitFor(() => { wrapper - .find( - `EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox listsComboxBox-optionsList"]` - ) - .prop('isLoading') - ).toBeTruthy(); + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] button`) + .at(0) + .simulate('click'); + expect( + wrapper + .find( + `EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox listsComboxBox-optionsList"]` + ) + .prop('isLoading') + ).toBeTruthy(); + }); }); - test('it allows user to clear values if "isClearable" is true', () => { + test('it allows user to clear values if "isClearable" is true', async () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { /> ); - expect( wrapper .find(`[data-test-subj="comboBoxInput"]`) @@ -102,7 +119,55 @@ describe('AutocompleteFieldListsComponent', () => { ).toBeTruthy(); }); - test('it correctly displays selected list', () => { + test('it correctly displays lists that match the selected "keyword" field esType', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="comboBoxToggleListButton"] button').simulate('click'); + + expect( + wrapper + .find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]') + .prop('options') + ).toEqual([{ label: 'keyword list' }]); + }); + + test('it correctly displays lists that match the selected "ip" field esType', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="comboBoxToggleListButton"] button').simulate('click'); + + expect( + wrapper + .find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]') + .prop('options') + ).toEqual([{ label: 'some name' }]); + }); + + test('it correctly displays selected list', async () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { }).onChange([{ label: 'some name' }]); expect(mockOnChange).toHaveBeenCalledWith({ - created_at: '2020-04-20T15:25:31.830Z', + created_at: DATE_NOW, created_by: 'some user', description: 'some description', id: 'some-list-id', @@ -154,7 +219,7 @@ describe('AutocompleteFieldListsComponent', () => { name: 'some name', tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e', type: 'ip', - updated_at: '2020-04-20T15:25:31.830Z', + updated_at: DATE_NOW, updated_by: 'some user', }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx index d8ce27e97874d..a9d85452651b5 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx @@ -36,8 +36,12 @@ export const AutocompleteFieldListsComponent: React.FC name, []); const optionsMemo = useMemo(() => { - if (selectedField != null) { - return lists.filter(({ type }) => type === selectedField.type); + if ( + selectedField != null && + selectedField.esTypes != null && + selectedField.esTypes.length > 0 + ) { + return lists.filter(({ type }) => selectedField.esTypes?.includes(type)); } else { return []; } diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx index 32a82af114bae..a082811920f88 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx @@ -79,10 +79,10 @@ export const AutocompleteFieldMatchComponent: React.FC validateParams(selectedValue, selectedField ? selectedField.type : ''), - [selectedField, selectedValue] - ); + const isValid = useMemo((): boolean => validateParams(selectedValue, selectedField), [ + selectedField, + selectedValue, + ]); return ( { const areAnyInvalid = selectedComboOptions.filter( - ({ label }) => !validateParams(label, selectedField ? selectedField.type : '') + ({ label }) => !validateParams(label, selectedField) ); return areAnyInvalid.length === 0; }, [selectedComboOptions, selectedField]); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts index cfe23b9391ec0..cb07d99913107 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts @@ -55,49 +55,25 @@ describe('helpers', () => { describe('#validateParams', () => { test('returns true if value is undefined', () => { - const isValid = validateParams(undefined, 'date'); + const isValid = validateParams(undefined, getField('@timestamp')); expect(isValid).toBeTruthy(); }); test('returns true if value is empty string', () => { - const isValid = validateParams('', 'date'); + const isValid = validateParams('', getField('@timestamp')); expect(isValid).toBeTruthy(); }); test('returns true if type is "date" and value is valid', () => { - const isValid = validateParams('1994-11-05T08:15:30-05:00', 'date'); + const isValid = validateParams('1994-11-05T08:15:30-05:00', getField('@timestamp')); expect(isValid).toBeTruthy(); }); test('returns false if type is "date" and value is not valid', () => { - const isValid = validateParams('1593478826', 'date'); - - expect(isValid).toBeFalsy(); - }); - - test('returns true if type is "ip" and value is valid', () => { - const isValid = validateParams('126.45.211.34', 'ip'); - - expect(isValid).toBeTruthy(); - }); - - test('returns false if type is "ip" and value is not valid', () => { - const isValid = validateParams('hellooo', 'ip'); - - expect(isValid).toBeFalsy(); - }); - - test('returns true if type is "number" and value is valid', () => { - const isValid = validateParams('123', 'number'); - - expect(isValid).toBeTruthy(); - }); - - test('returns false if type is "number" and value is not valid', () => { - const isValid = validateParams('not a number', 'number'); + const isValid = validateParams('1593478826', getField('@timestamp')); expect(isValid).toBeFalsy(); }); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts index 483ca5d6d332e..16659593784db 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts @@ -7,7 +7,7 @@ import dateMath from '@elastic/datemath'; import { EuiComboBoxOptionOption } from '@elastic/eui'; -import { IFieldType, Ipv4Address } from '../../../../../../../src/plugins/data/common'; +import { IFieldType } from '../../../../../../../src/plugins/data/common'; import { EXCEPTION_OPERATORS, @@ -30,29 +30,27 @@ export const getOperators = (field: IFieldType | undefined): OperatorOption[] => } }; -export function validateParams(params: string | undefined, type: string) { +export const validateParams = ( + params: string | undefined, + field: IFieldType | undefined +): boolean => { // Box would show error state if empty otherwise if (params == null || params === '') { return true; } - switch (type) { - case 'date': - const moment = dateMath.parse(params); - return Boolean(moment && moment.isValid()); - case 'ip': - try { - return Boolean(new Ipv4Address(params)); - } catch (e) { - return false; - } - case 'number': - const val = parseFloat(params); - return typeof val === 'number' && !isNaN(val); - default: - return true; - } -} + const types = field != null && field.esTypes != null ? field.esTypes : []; + + return types.reduce((acc, type) => { + switch (type) { + case 'date': + const moment = dateMath.parse(params); + return Boolean(moment && moment.isValid()); + default: + return acc; + } + }, true); +}; export function getGenericComboBoxProps({ options, diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 2a7cbff5ee149..049953e21febd 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -10,7 +10,8 @@ import useResizeObserver from 'use-resize-observer/polyfilled'; import '../../mock/match_media'; import { mockIndexPattern, TestProviders } from '../../mock'; -import { wait } from '../../lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { mockEventViewerResponse } from './mock'; import { StatefulEventsViewer } from '.'; @@ -60,12 +61,13 @@ describe('EventsViewer', () => { ); - await wait(); - wrapper.update(); + await waitFor(() => { + wrapper.update(); - expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().text()).toEqual( - 'Showing: 12 events' - ); + expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().text()).toEqual( + 'Showing: 12 events' + ); + }); }); test('it does NOT render fetch index pattern is loading', async () => { @@ -84,10 +86,13 @@ describe('EventsViewer', () => { ); - await wait(); - wrapper.update(); + await waitFor(() => { + wrapper.update(); - expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe(false); + expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe( + false + ); + }); }); test('it does NOT render when start is empty', async () => { @@ -106,10 +111,13 @@ describe('EventsViewer', () => { ); - await wait(); - wrapper.update(); + await waitFor(() => { + wrapper.update(); - expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe(false); + expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe( + false + ); + }); }); test('it does NOT render when end is empty', async () => { @@ -128,10 +136,13 @@ describe('EventsViewer', () => { ); - await wait(); - wrapper.update(); + await waitFor(() => { + wrapper.update(); - expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe(false); + expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe( + false + ); + }); }); test('it renders the Fields Browser as a settings gear', async () => { @@ -148,10 +159,11 @@ describe('EventsViewer', () => { ); - await wait(); - wrapper.update(); + await waitFor(() => { + wrapper.update(); - expect(wrapper.find(`[data-test-subj="show-field-browser"]`).first().exists()).toBe(true); + expect(wrapper.find(`[data-test-subj="show-field-browser"]`).first().exists()).toBe(true); + }); }); test('it renders the footer containing the Load More button', async () => { @@ -168,10 +180,11 @@ describe('EventsViewer', () => { ); - await wait(); - wrapper.update(); + await waitFor(() => { + wrapper.update(); - expect(wrapper.find(`[data-test-subj="TimelineMoreButton"]`).first().exists()).toBe(true); + expect(wrapper.find(`[data-test-subj="TimelineMoreButton"]`).first().exists()).toBe(true); + }); }); defaultHeaders.forEach((header) => { @@ -189,14 +202,15 @@ describe('EventsViewer', () => { ); - await wait(); - wrapper.update(); + await waitFor(() => { + wrapper.update(); - defaultHeaders.forEach((h) => - expect(wrapper.find(`[data-test-subj="header-text-${header.id}"]`).first().exists()).toBe( - true - ) - ); + defaultHeaders.forEach((h) => + expect(wrapper.find(`[data-test-subj="header-text-${header.id}"]`).first().exists()).toBe( + true + ) + ); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx index 1ab390a85ec50..4509d01e82e25 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx @@ -9,7 +9,8 @@ import { MockedProvider } from 'react-apollo/test-utils'; import useResizeObserver from 'use-resize-observer/polyfilled'; import '../../mock/match_media'; -import { wait } from '../../lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { mockIndexPattern, TestProviders } from '../../mock'; import { useMountAppended } from '../../utils/use_mount_appended'; @@ -54,10 +55,11 @@ describe('StatefulEventsViewer', () => { ); - await wait(); - wrapper.update(); + await waitFor(() => { + wrapper.update(); - expect(wrapper.find('[data-test-subj="events-viewer-panel"]').first().exists()).toBe(true); + expect(wrapper.find('[data-test-subj="events-viewer-panel"]').first().exists()).toBe(true); + }); }); // InspectButtonContainer controls displaying InspectButton components @@ -75,9 +77,10 @@ describe('StatefulEventsViewer', () => { ); - await wait(); - wrapper.update(); + await waitFor(() => { + wrapper.update(); - expect(wrapper.find(`InspectButtonContainer`).exists()).toBe(true); + expect(wrapper.find(`InspectButtonContainer`).exists()).toBe(true); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 79383676266f5..e630645ef8c4e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -21,7 +21,7 @@ import { EuiCallOut, EuiText, } from '@elastic/eui'; -import { alertsIndexPattern } from '../../../../../common/endpoint/constants'; +import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import { ExceptionListItemSchema, CreateExceptionListItemSchema, @@ -47,26 +47,21 @@ import { } from '../helpers'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules'; -export interface AddExceptionOnClick { +export interface AddExceptionModalBaseProps { ruleName: string; ruleId: string; exceptionListType: ExceptionListType; + ruleIndices: string[]; alertData?: { ecsData: Ecs; nonEcsData: TimelineNonEcsData[]; }; } -interface AddExceptionModalProps { - ruleName: string; - ruleId: string; - exceptionListType: ExceptionListType; - alertData?: { - ecsData: Ecs; - nonEcsData: TimelineNonEcsData[]; - }; +export interface AddExceptionModalProps extends AddExceptionModalBaseProps { onCancel: () => void; onConfirm: (didCloseAlert: boolean) => void; + alertStatus?: Status; } const Modal = styled(EuiModal)` @@ -76,10 +71,8 @@ const Modal = styled(EuiModal)` `; const ModalHeader = styled(EuiModalHeader)` - ${({ theme }) => css` - flex-direction: column; - align-items: flex-start; - `} + flex-direction: column; + align-items: flex-start; `; const ModalHeaderSubtitle = styled.div` @@ -101,10 +94,12 @@ const ModalBodySection = styled.section` export const AddExceptionModal = memo(function AddExceptionModal({ ruleName, ruleId, + ruleIndices, exceptionListType, alertData, onCancel, onConfirm, + alertStatus, }: AddExceptionModalProps) { const { http } = useKibana().services; const [comment, setComment] = useState(''); @@ -117,10 +112,11 @@ export const AddExceptionModal = memo(function AddExceptionModal({ const [fetchOrCreateListError, setFetchOrCreateListError] = useState(false); const { addError, addSuccess } = useAppToasts(); const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); + const [ + { isLoading: isSignalIndexPatternLoading, indexPatterns: signalIndexPatterns }, + ] = useFetchIndexPatterns(signalIndexName !== null ? [signalIndexName] : []); - const [{ isLoading: indexPatternLoading, indexPatterns }] = useFetchIndexPatterns( - signalIndexName !== null ? [signalIndexName] : [] - ); + const [{ isLoading: isIndexPatternLoading, indexPatterns }] = useFetchIndexPatterns(ruleIndices); const onError = useCallback( (error: Error) => { @@ -180,18 +176,19 @@ export const AddExceptionModal = memo(function AddExceptionModal({ }, [alertData, exceptionListType, ruleExceptionList, ruleName]); useEffect(() => { - if (indexPatternLoading === false && isSignalIndexLoading === false) { + if (isSignalIndexPatternLoading === false && isSignalIndexLoading === false) { setShouldDisableBulkClose( entryHasListType(exceptionItemsToAdd) || - entryHasNonEcsType(exceptionItemsToAdd, indexPatterns) + entryHasNonEcsType(exceptionItemsToAdd, signalIndexPatterns) || + exceptionItemsToAdd.length === 0 ); } }, [ setShouldDisableBulkClose, exceptionItemsToAdd, - indexPatternLoading, + isSignalIndexPatternLoading, isSignalIndexLoading, - indexPatterns, + signalIndexPatterns, ]); useEffect(() => { @@ -270,15 +267,8 @@ export const AddExceptionModal = memo(function AddExceptionModal({ [fetchOrCreateListError, exceptionItemsToAdd] ); - const indexPatternConfig = useCallback(() => { - if (exceptionListType === 'endpoint') { - return [alertsIndexPattern]; - } - return signalIndexName ? [signalIndexName] : []; - }, [exceptionListType, signalIndexName]); - return ( - + {i18n.ADD_EXCEPTION} @@ -297,8 +287,9 @@ export const AddExceptionModal = memo(function AddExceptionModal({ )} {fetchOrCreateListError === false && !isSignalIndexLoading && - !indexPatternLoading && + !isSignalIndexPatternLoading && !isLoadingExceptionList && + !isIndexPatternLoading && ruleExceptionList && ( <> @@ -310,8 +301,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ listId={ruleExceptionList.list_id} listNamespaceType={ruleExceptionList.namespace_type} ruleName={ruleName} - indexPatternConfig={indexPatternConfig()} - isLoading={false} + indexPatterns={indexPatterns} isOrDisabled={false} isAndDisabled={false} data-test-subj="alert-exception-builder" @@ -335,7 +325,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ - {alertData !== undefined && ( + {alertData !== undefined && alertStatus !== 'closed' && ( void; @@ -64,8 +62,7 @@ export const ExceptionBuilder = ({ listId, listNamespaceType, ruleName, - indexPatternConfig, - isLoading, + indexPatterns, isOrDisabled, isAndDisabled, onChange, @@ -75,9 +72,6 @@ export const ExceptionBuilder = ({ exceptionListItems ); const [exceptionsToDelete, setExceptionsToDelete] = useState([]); - const [{ isLoading: indexPatternLoading, indexPatterns }] = useFetchIndexPatterns( - indexPatternConfig ?? [] - ); const handleCheckAndLogic = (items: ExceptionsBuilderExceptionItem[]): void => { setAndLogicIncluded(items.filter(({ entries }) => entries.length > 1).length > 0); @@ -154,7 +148,7 @@ export const ExceptionBuilder = ({ }, [setExceptions, listType, listId, listNamespaceType, ruleName]); // Filters index pattern fields by exceptionable fields if list type is endpoint - const filterIndexPatterns = useCallback(() => { + const filterIndexPatterns = useMemo((): IIndexPattern => { if (listType === 'endpoint') { return { ...indexPatterns, @@ -196,9 +190,6 @@ export const ExceptionBuilder = ({ return ( - {(isLoading || indexPatternLoading) && ( - - )} {exceptions.map((exceptionListItem, index) => ( @@ -224,8 +215,8 @@ export const ExceptionBuilder = ({ key={getExceptionListItemId(exceptionListItem, index)} exceptionItem={exceptionListItem} exceptionId={getExceptionListItemId(exceptionListItem, index)} - indexPattern={filterIndexPatterns()} - isLoading={indexPatternLoading} + indexPattern={filterIndexPatterns} + isLoading={indexPatterns.fields.length === 0} exceptionItemIndex={index} andLogicIncluded={andLogicIncluded} isOnlyItem={exceptions.length === 1} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index dbc70dfe21dd0..d07a8b5f0d2f6 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -20,7 +20,6 @@ import { EuiFormRow, EuiText, } from '@elastic/eui'; -import { alertsIndexPattern } from '../../../../../common/endpoint/constants'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; import { @@ -41,9 +40,11 @@ import { entryHasListType, entryHasNonEcsType, } from '../helpers'; +import { Loader } from '../../loader'; interface EditExceptionModalProps { ruleName: string; + ruleIndices: string[]; exceptionItem: ExceptionListItemSchema; exceptionListType: ExceptionListType; onCancel: () => void; @@ -57,10 +58,8 @@ const Modal = styled(EuiModal)` `; const ModalHeader = styled(EuiModalHeader)` - ${({ theme }) => css` - flex-direction: column; - align-items: flex-start; - `} + flex-direction: column; + align-items: flex-start; `; const ModalHeaderSubtitle = styled.div` @@ -81,6 +80,7 @@ const ModalBodySection = styled.section` export const EditExceptionModal = memo(function EditExceptionModal({ ruleName, + ruleIndices, exceptionItem, exceptionListType, onCancel, @@ -95,10 +95,11 @@ export const EditExceptionModal = memo(function EditExceptionModal({ >([]); const { addError, addSuccess } = useAppToasts(); const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); + const [ + { isLoading: isSignalIndexPatternLoading, indexPatterns: signalIndexPatterns }, + ] = useFetchIndexPatterns(signalIndexName !== null ? [signalIndexName] : []); - const [{ isLoading: indexPatternLoading, indexPatterns }] = useFetchIndexPatterns( - signalIndexName !== null ? [signalIndexName] : [] - ); + const [{ isLoading: isIndexPatternLoading, indexPatterns }] = useFetchIndexPatterns(ruleIndices); const onError = useCallback( (error) => { @@ -121,18 +122,19 @@ export const EditExceptionModal = memo(function EditExceptionModal({ ); useEffect(() => { - if (indexPatternLoading === false && isSignalIndexLoading === false) { + if (isSignalIndexPatternLoading === false && isSignalIndexLoading === false) { setShouldDisableBulkClose( entryHasListType(exceptionItemsToAdd) || - entryHasNonEcsType(exceptionItemsToAdd, indexPatterns) + entryHasNonEcsType(exceptionItemsToAdd, signalIndexPatterns) || + exceptionItemsToAdd.length === 0 ); } }, [ setShouldDisableBulkClose, exceptionItemsToAdd, - indexPatternLoading, + isSignalIndexPatternLoading, isSignalIndexLoading, - indexPatterns, + signalIndexPatterns, ]); useEffect(() => { @@ -187,15 +189,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({ } }, [addOrUpdateExceptionItems, enrichExceptionItems, shouldBulkCloseAlert, signalIndexName]); - const indexPatternConfig = useCallback(() => { - if (exceptionListType === 'endpoint') { - return [alertsIndexPattern]; - } - return signalIndexName ? [signalIndexName] : []; - }, [exceptionListType, signalIndexName]); - return ( - + {i18n.EDIT_EXCEPTION_TITLE} @@ -204,7 +199,11 @@ export const EditExceptionModal = memo(function EditExceptionModal({ - {!isSignalIndexLoading && ( + {(addExceptionIsLoading || isIndexPatternLoading || isSignalIndexLoading) && ( + + )} + + {!isSignalIndexLoading && !addExceptionIsLoading && !isIndexPatternLoading && ( <> {i18n.EXCEPTION_BUILDER_INFO} @@ -215,13 +214,12 @@ export const EditExceptionModal = memo(function EditExceptionModal({ listId={exceptionItem.list_id} listNamespaceType={exceptionItem.namespace_type} ruleName={ruleName} - isLoading={false} isOrDisabled={false} isAndDisabled={false} data-test-subj="edit-exception-modal-builder" id-aria="edit-exception-modal-builder" onChange={handleBuilderOnChange} - indexPatternConfig={indexPatternConfig()} + indexPatterns={indexPatterns} /> diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx index f72008cbdffe1..986f27f6495ec 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx @@ -67,6 +67,7 @@ describe('ExceptionsViewer', () => { ({ eui: euiLightVars, darkMode: false })}> { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { + }: UseExceptionListSuccess): void => { dispatch({ type: 'setExceptions', lists: newLists, @@ -253,10 +255,11 @@ const ExceptionsViewerComponent = ({ return ( <> {currentModal === 'editModal' && - exceptionToEdit !== null && - exceptionListTypeToEdit !== null && ( + exceptionToEdit != null && + exceptionListTypeToEdit != null && ( { }).relativeTimeSearch.undefinedQuery, }); wrapper.update(); - await wait(); if (CONSTANTS.detectionsPage === page) { - expect(mockSetRelativeRangeDatePicker.mock.calls[3][0]).toEqual({ - from: '2020-01-01T00:00:00.000Z', - fromStr: 'now-1d/d', - kind: 'relative', - to: '2020-01-01T00:00:00.000Z', - toStr: 'now-1d/d', - id: 'global', - }); + await waitFor(() => { + expect(mockSetRelativeRangeDatePicker.mock.calls[3][0]).toEqual({ + from: '2020-01-01T00:00:00.000Z', + fromStr: 'now-1d/d', + kind: 'relative', + to: '2020-01-01T00:00:00.000Z', + toStr: 'now-1d/d', + id: 'global', + }); - expect(mockSetRelativeRangeDatePicker.mock.calls[2][0]).toEqual({ - from: 1558732849370, - fromStr: 'now-15m', - kind: 'relative', - to: 1558733749370, - toStr: 'now', - id: 'timeline', + expect(mockSetRelativeRangeDatePicker.mock.calls[2][0]).toEqual({ + from: 1558732849370, + fromStr: 'now-15m', + kind: 'relative', + to: 1558733749370, + toStr: 'now', + id: 'timeline', + }); }); } else { - // There is no change in url state, so that's expected we only have two actions - expect(mockSetRelativeRangeDatePicker.mock.calls.length).toEqual(2); + await waitFor(() => { + // There is no change in url state, so that's expected we only have two actions + expect(mockSetRelativeRangeDatePicker.mock.calls.length).toEqual(2); + }); } } ); diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.gql_query.ts b/x-pack/plugins/security_solution/public/common/containers/source/index.gql_query.ts index 1f9ba09167e1e..630515c5cbed4 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.gql_query.ts +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.gql_query.ts @@ -22,6 +22,8 @@ export const sourceQuery = gql` type aggregatable format + esTypes + subType } } } diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx index bbd00900105e8..54d49d7279d68 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx @@ -60,7 +60,7 @@ export const getIndexFields = memoizeOne( fields && fields.length > 0 ? { fields: fields.map((field) => - pick(['name', 'searchable', 'type', 'aggregatable'], field) + pick(['name', 'searchable', 'type', 'aggregatable', 'esTypes', 'subType'], field) ), title, } diff --git a/x-pack/plugins/security_solution/public/common/lib/helpers/index.tsx b/x-pack/plugins/security_solution/public/common/lib/helpers/index.tsx index 07e706ac2a9af..96b0343efdf72 100644 --- a/x-pack/plugins/security_solution/public/common/lib/helpers/index.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/helpers/index.tsx @@ -19,12 +19,6 @@ export type WrapArrayIfExitts = (value: Many) => T[] | undefined; export const asArrayIfExists: WrapArrayIfExitts = (value) => !isUndefined(value) ? castArray(value) : undefined; -export const wait = (delay = 0): Promise => { - return new Promise((resolve) => { - return setTimeout(resolve, delay); - }); -}; - /** * Creates a Union Type for all the values of an object */ diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 71cf5c10de764..a4ce6c0200eb3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -12,6 +12,7 @@ import { Dispatch } from 'redux'; import { EuiText } from '@elastic/eui'; import { RowRendererId } from '../../../../common/types/timeline'; +import { DEFAULT_INDEX_PATTERN } from '../../../../common/constants'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { Filter } from '../../../../../../../src/plugins/data/common/es_query'; import { @@ -38,7 +39,7 @@ import { UpdateTimelineLoading, } from './types'; import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; -import { AddExceptionOnClick } from '../../../common/components/exceptions/add_exception_modal'; +import { AddExceptionModalBaseProps } from '../../../common/components/exceptions/add_exception_modal'; import { getMappedNonEcsValue } from '../../../common/components/exceptions/helpers'; export const buildAlertStatusFilter = (status: Status): Filter[] => [ @@ -225,7 +226,7 @@ interface AlertActionArgs { alertData, ruleName, ruleId, - }: AddExceptionOnClick) => void; + }: AddExceptionModalBaseProps) => void; } export const getAlertActions = ({ @@ -346,10 +347,12 @@ export const getAlertActions = ({ onClick: ({ ecsData, data }: TimelineRowActionOnClick) => { const [ruleName] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); const [ruleId] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); + const ruleIndices = getMappedNonEcsValue({ data, fieldName: 'signal.rule.index' }); if (ruleId !== undefined) { openAddExceptionModal({ ruleName: ruleName ?? '', ruleId, + ruleIndices: ruleIndices.length > 0 ? ruleIndices : DEFAULT_INDEX_PATTERN, exceptionListType: 'endpoint', alertData: { ecsData, @@ -369,10 +372,12 @@ export const getAlertActions = ({ onClick: ({ ecsData, data }: TimelineRowActionOnClick) => { const [ruleName] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); const [ruleId] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); + const ruleIndices = getMappedNonEcsValue({ data, fieldName: 'signal.rule.index' }); if (ruleId !== undefined) { openAddExceptionModal({ ruleName: ruleName ?? '', ruleId, + ruleIndices: ruleIndices.length > 0 ? ruleIndices : DEFAULT_INDEX_PATTERN, exceptionListType: 'detection', alertData: { ecsData, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 30cfe2d02354f..1eda358fe5944 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -54,7 +54,7 @@ import { import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; import { AddExceptionModal, - AddExceptionOnClick, + AddExceptionModalBaseProps, } from '../../../common/components/exceptions/add_exception_modal'; interface OwnProps { @@ -73,9 +73,10 @@ interface OwnProps { type AlertsTableComponentProps = OwnProps & PropsFromRedux; -const addExceptionModalInitialState: AddExceptionOnClick = { +const addExceptionModalInitialState: AddExceptionModalBaseProps = { ruleName: '', ruleId: '', + ruleIndices: [], exceptionListType: 'detection', alertData: undefined, }; @@ -112,7 +113,7 @@ export const AlertsTableComponent: React.FC = ({ const [showClearSelectionAction, setShowClearSelectionAction] = useState(false); const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); const [shouldShowAddExceptionModal, setShouldShowAddExceptionModal] = useState(false); - const [addExceptionModalState, setAddExceptionModalState] = useState( + const [addExceptionModalState, setAddExceptionModalState] = useState( addExceptionModalInitialState ); const [{ browserFields, indexPatterns }] = useFetchIndexPatterns( @@ -216,12 +217,19 @@ export const AlertsTableComponent: React.FC = ({ ); const openAddExceptionModalCallback = useCallback( - ({ ruleName, ruleId, exceptionListType, alertData }: AddExceptionOnClick) => { + ({ + ruleName, + ruleIndices, + ruleId, + exceptionListType, + alertData, + }: AddExceptionModalBaseProps) => { if (alertData !== null && alertData !== undefined) { setShouldShowAddExceptionModal(true); setAddExceptionModalState({ ruleName, ruleId, + ruleIndices, exceptionListType, alertData, }); @@ -421,12 +429,9 @@ export const AlertsTableComponent: React.FC = ({ closeAddExceptionModal(); }, [closeAddExceptionModal]); - const onAddExceptionConfirm = useCallback( - (didCloseAlert: boolean) => { - closeAddExceptionModal(); - }, - [closeAddExceptionModal] - ); + const onAddExceptionConfirm = useCallback(() => closeAddExceptionModal(), [ + closeAddExceptionModal, + ]); if (loading || isEmpty(signalsIndex)) { return ( @@ -454,10 +459,12 @@ export const AlertsTableComponent: React.FC = ({ )} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/translations.tsx index 24e82a8f95a6b..c64a1e6891a44 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/translations.tsx @@ -51,7 +51,7 @@ export const RISK_SCORE_DESCRIPTION = i18n.translate( export const RISK_SCORE_MAPPING_DESCRIPTION = i18n.translate( 'xpack.securitySolution.alerts.riskScoreMapping.mappingDescriptionLabel', { - defaultMessage: 'Map a field from the source event (scaled 1-100) to risk score.', + defaultMessage: 'Use a source event value to override the default risk score.', } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/severity_mapping/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/severity_mapping/translations.tsx index f0bfc5f4637ab..12653ec5806bb 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/severity_mapping/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/severity_mapping/translations.tsx @@ -51,7 +51,7 @@ export const SEVERITY_DESCRIPTION = i18n.translate( export const SEVERITY_MAPPING_DESCRIPTION = i18n.translate( 'xpack.securitySolution.alerts.severityMapping.mappingDescriptionLabel', { - defaultMessage: 'Map a value from the source event to a specific severity.', + defaultMessage: 'Use source event values to override the default severity.', } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx index 9b2e0069f0ac0..a86c1b7ce1bea 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx @@ -13,7 +13,8 @@ import { StepAboutRule } from '.'; import { mockAboutStepRule } from '../../../pages/detection_engine/rules/all/__mocks__/mock'; import { StepRuleDescription } from '../description_step'; import { stepAboutDefaultValue } from './default_value'; -import { wait } from '@testing-library/react'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { AboutStepRule } from '../../../pages/detection_engine/rules/types'; const theme = () => ({ eui: euiDarkVars, darkMode: true }); @@ -162,31 +163,32 @@ describe('StepAboutRuleComponent', () => { .simulate('change', { target: { value: 'Test name text' } }); wrapper.find('button[data-test-subj="about-continue"]').first().simulate('click').update(); - await wait(); - const expected: Omit = { - author: [], - isAssociatedToEndpointList: false, - isBuildingBlock: false, - license: '', - ruleNameOverride: '', - timestampOverride: '', - description: 'Test description text', - falsePositives: [''], - name: 'Test name text', - note: '', - references: [''], - riskScore: { value: 50, mapping: [] }, - severity: { value: 'low', mapping: [] }, - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { id: 'none', name: 'none', reference: 'none' }, - technique: [], - }, - ], - }; - expect(stepDataMock.mock.calls[1][1]).toEqual(expected); + await waitFor(() => { + const expected: Omit = { + author: [], + isAssociatedToEndpointList: false, + isBuildingBlock: false, + license: '', + ruleNameOverride: '', + timestampOverride: '', + description: 'Test description text', + falsePositives: [''], + name: 'Test name text', + note: '', + references: [''], + riskScore: { value: 50, mapping: [] }, + severity: { value: 'low', mapping: [] }, + tags: [], + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { id: 'none', name: 'none', reference: 'none' }, + technique: [], + }, + ], + }; + expect(stepDataMock.mock.calls[1][1]).toEqual(expected); + }); }); test('it allows user to set the risk score as a number (and not a string)', async () => { @@ -221,30 +223,31 @@ describe('StepAboutRuleComponent', () => { .simulate('change', { target: { value: '80' } }); wrapper.find('[data-test-subj="about-continue"]').first().simulate('click').update(); - await wait(); - const expected: Omit = { - author: [], - isAssociatedToEndpointList: false, - isBuildingBlock: false, - license: '', - ruleNameOverride: '', - timestampOverride: '', - description: 'Test description text', - falsePositives: [''], - name: 'Test name text', - note: '', - references: [''], - riskScore: { value: 80, mapping: [] }, - severity: { value: 'low', mapping: [] }, - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { id: 'none', name: 'none', reference: 'none' }, - technique: [], - }, - ], - }; - expect(stepDataMock.mock.calls[1][1]).toEqual(expected); + await waitFor(() => { + const expected: Omit = { + author: [], + isAssociatedToEndpointList: false, + isBuildingBlock: false, + license: '', + ruleNameOverride: '', + timestampOverride: '', + description: 'Test description text', + falsePositives: [''], + name: 'Test name text', + note: '', + references: [''], + riskScore: { value: 80, mapping: [] }, + severity: { value: 'low', mapping: [] }, + tags: [], + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { id: 'none', name: 'none', reference: 'none' }, + technique: [], + }, + ], + }; + expect(stepDataMock.mock.calls[1][1]).toEqual(expected); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx index 58344e9e97534..b07caa754aec9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx @@ -11,7 +11,8 @@ import { act } from 'react-dom/test-utils'; import '../../../../../common/mock/match_media'; import { createKibanaContextProviderMock } from '../../../../../common/mock/kibana_react'; import { TestProviders } from '../../../../../common/mock'; -import { wait } from '../../../../../common/lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { AllRules } from './index'; jest.mock('react-router-dom', () => { @@ -202,10 +203,10 @@ describe('AllRules', () => { ); await act(async () => { - await wait(); - - expect(wrapper.exists('[data-test-subj="monitoring-table"]')).toBeFalsy(); - expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeTruthy(); + await waitFor(() => { + expect(wrapper.exists('[data-test-subj="monitoring-table"]')).toBeFalsy(); + expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeTruthy(); + }); }); }); @@ -234,11 +235,11 @@ describe('AllRules', () => { monitoringTab.simulate('click'); await act(async () => { - wrapper.update(); - await wait(); - - expect(wrapper.exists('[data-test-subj="monitoring-table"]')).toBeTruthy(); - expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeFalsy(); + await waitFor(() => { + wrapper.update(); + expect(wrapper.exists('[data-test-subj="monitoring-table"]')).toBeTruthy(); + expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeFalsy(); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 484c28b4b428c..f913cafec9a96 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -81,7 +81,7 @@ import { SecurityPageName } from '../../../../../app/types'; import { LinkButton } from '../../../../../common/components/links'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { ExceptionsViewer } from '../../../../../common/components/exceptions/viewer'; -import { FILTERS_GLOBAL_HEIGHT } from '../../../../../../common/constants'; +import { DEFAULT_INDEX_PATTERN, FILTERS_GLOBAL_HEIGHT } from '../../../../../../common/constants'; import { useFullScreen } from '../../../../../common/containers/use_full_screen'; import { Display } from '../../../../../hosts/pages/display'; import { ExceptionListTypeEnum, ExceptionIdentifiers } from '../../../../../lists_plugin_deps'; @@ -515,6 +515,7 @@ export const RuleDetailsPageComponent: FC = ({ ; format?: Maybe; + /** the elastic type as mapped in the index */ + esTypes?: Maybe; + + subType?: Maybe; } export interface AuthenticationsData { @@ -2780,6 +2788,10 @@ export namespace SourceQuery { aggregatable: boolean; format: Maybe; + + esTypes: Maybe; + + subType: Maybe; }; } diff --git a/x-pack/plugins/security_solution/public/hosts/components/first_last_seen_host/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/first_last_seen_host/index.test.tsx index 9715c1cb5c8b4..a2f53be721816 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/first_last_seen_host/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/first_last_seen_host/index.test.tsx @@ -7,10 +7,11 @@ import { cloneDeep } from 'lodash/fp'; import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; -import { render, act } from '@testing-library/react'; + +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { render, act, wait as waitFor } from '@testing-library/react'; import { mockFirstLastSeenHostQuery } from '../../containers/hosts/first_last_seen/mock'; -import { wait } from '../../../common/lib/helpers'; import { TestProviders } from '../../../common/mock'; import { FirstLastSeenHost, FirstLastSeenHostType } from '.'; @@ -51,10 +52,12 @@ describe('FirstLastSeen Component', () => { ); - await act(() => wait()); - - expect(container.innerHTML).toBe( - `
${firstSeen}
` + await act(() => + waitFor(() => { + expect(container.innerHTML).toBe( + `
${firstSeen}
` + ); + }) ); }); @@ -66,9 +69,12 @@ describe('FirstLastSeen Component', () => { ); - await act(() => wait()); - expect(container.innerHTML).toBe( - `
${lastSeen}
` + await act(() => + waitFor(() => { + expect(container.innerHTML).toBe( + `
${lastSeen}
` + ); + }) ); }); @@ -83,10 +89,12 @@ describe('FirstLastSeen Component', () => { ); - await act(() => wait()); - - expect(container.innerHTML).toBe( - `
${lastSeen}
` + await act(() => + waitFor(() => { + expect(container.innerHTML).toBe( + `
${lastSeen}
` + ); + }) ); }); @@ -101,10 +109,12 @@ describe('FirstLastSeen Component', () => { ); - await act(() => wait()); - - expect(container.innerHTML).toBe( - `
${firstSeen}
` + await act(() => + waitFor(() => { + expect(container.innerHTML).toBe( + `
${firstSeen}
` + ); + }) ); }); @@ -118,8 +128,11 @@ describe('FirstLastSeen Component', () => { ); - await act(() => wait()); - expect(container.textContent).toBe('something-invalid'); + await act(() => + waitFor(() => { + expect(container.textContent).toBe('something-invalid'); + }) + ); }); test('Last Seen With a bad date time string', async () => { @@ -132,7 +145,10 @@ describe('FirstLastSeen Component', () => { ); - await act(() => wait()); - expect(container.textContent).toBe('something-invalid'); + await act(() => + waitFor(() => { + expect(container.textContent).toBe('something-invalid'); + }) + ); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx index 63126da0b9bb5..f7f1fbc30aeb7 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx @@ -13,7 +13,8 @@ import { ThemeProvider } from 'styled-components'; import '../../../common/mock/match_media'; import { useQuery } from '../../../common/containers/matrix_histogram'; -import { wait } from '../../../common/lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { mockIndexPattern, TestProviders } from '../../../common/mock'; import { AlertsByCategory } from '.'; @@ -57,34 +58,45 @@ describe('Alerts by category', () => {
); - await wait(); - wrapper.update(); + await waitFor(() => { + wrapper.update(); + }); }); - test('it renders the expected title', () => { - expect(wrapper.find('[data-test-subj="header-section-title"]').text()).toEqual( - 'External alert trend' - ); + test('it renders the expected title', async () => { + await waitFor(() => { + expect(wrapper.find('[data-test-subj="header-section-title"]').text()).toEqual( + 'External alert trend' + ); + }); }); - test('it renders the subtitle (to prevent layout thrashing)', () => { - expect(wrapper.find('[data-test-subj="header-panel-subtitle"]').exists()).toBe(true); + test('it renders the subtitle (to prevent layout thrashing)', async () => { + await waitFor(() => { + expect(wrapper.find('[data-test-subj="header-panel-subtitle"]').exists()).toBe(true); + }); }); - test('it renders the expected filter fields', () => { - const expectedOptions = ['event.category', 'event.module']; + test('it renders the expected filter fields', async () => { + await waitFor(() => { + const expectedOptions = ['event.category', 'event.module']; - expectedOptions.forEach((option) => { - expect(wrapper.find(`option[value="${option}"]`).text()).toEqual(option); + expectedOptions.forEach((option) => { + expect(wrapper.find(`option[value="${option}"]`).text()).toEqual(option); + }); }); }); - test('it renders the `View alerts` button', () => { - expect(wrapper.find('[data-test-subj="view-alerts"]').exists()).toBe(true); + test('it renders the `View alerts` button', async () => { + await waitFor(() => { + expect(wrapper.find('[data-test-subj="view-alerts"]').exists()).toBe(true); + }); }); - test('it does NOT render the bar chart when data is not available', () => { - expect(wrapper.find(`.echChart`).exists()).toBe(false); + test('it does NOT render the bar chart when data is not available', async () => { + await waitFor(() => { + expect(wrapper.find(`.echChart`).exists()).toBe(false); + }); }); }); @@ -119,18 +131,21 @@ describe('Alerts by category', () => {
); - await wait(); wrapper.update(); }); - test('it renders the expected subtitle', () => { - expect(wrapper.find('[data-test-subj="header-panel-subtitle"]').text()).toEqual( - 'Showing: 6 external alerts' - ); + test('it renders the expected subtitle', async () => { + await waitFor(() => { + expect(wrapper.find('[data-test-subj="header-panel-subtitle"]').text()).toEqual( + 'Showing: 6 external alerts' + ); + }); }); - test('it renders the bar chart when data is available', () => { - expect(wrapper.find(`.echChart`).exists()).toBe(true); + test('it renders the bar chart when data is available', async () => { + await waitFor(() => { + expect(wrapper.find(`.echChart`).exists()).toBe(true); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx index 30874e8874760..5ff78c9b29cf5 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx @@ -24,7 +24,8 @@ import { createStore, State } from '../../../common/store'; import { overviewHostQuery } from '../../containers/overview_host/index.gql_query'; import { GetOverviewHostQuery } from '../../../graphql/types'; -import { wait } from '../../../common/lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/components/link_to'); @@ -147,11 +148,12 @@ describe('OverviewHost', () => { ); - await wait(); - wrapper.update(); + await waitFor(() => { + wrapper.update(); - expect(wrapper.find('[data-test-subj="header-panel-subtitle"]').first().text()).toEqual( - 'Showing: 16 events' - ); + expect(wrapper.find('[data-test-subj="header-panel-subtitle"]').first().text()).toEqual( + 'Showing: 16 events' + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx index 9ac4f7125f34d..0bb887b38a4b1 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx @@ -22,7 +22,8 @@ import { OverviewNetwork } from '.'; import { createStore, State } from '../../../common/store'; import { overviewNetworkQuery } from '../../containers/overview_network/index.gql_query'; import { GetOverviewHostQuery } from '../../../graphql/types'; -import { wait } from '../../../common/lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; jest.mock('../../../common/components/link_to'); const mockNavigateToApp = jest.fn(); @@ -155,12 +156,13 @@ describe('OverviewNetwork', () => { ); - await wait(); - wrapper.update(); + await waitFor(() => { + wrapper.update(); - expect(wrapper.find('[data-test-subj="header-panel-subtitle"]').first().text()).toEqual( - 'Showing: 9 events' - ); + expect(wrapper.find('[data-test-subj="header-panel-subtitle"]').first().text()).toEqual( + 'Showing: 9 events' + ); + }); }); it('it renders View Network', () => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx index 4dedafe55bb2c..55b5be21fb4a4 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx @@ -32,7 +32,7 @@ export interface CrumbInfo { } const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: string }>` - &.euiBreadcrumbs.euiBreadcrumbs--responsive { + &.euiBreadcrumbs { background-color: ${(props) => props.background}; color: ${(props) => props.text}; padding: 1em; diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.ts b/x-pack/plugins/security_solution/public/resolver/view/use_camera.ts index 6a1c35be57000..661e038d04e32 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.ts @@ -281,17 +281,61 @@ export function useCamera(): { * handle that. */ export function useAutoUpdatingClientRect(): [DOMRect | null, (node: Element | null) => void] { + // This hooks returns `rect`. const [rect, setRect] = useState(null); - // Using state as ref.current update does not trigger effect hook when reset + + const { ResizeObserver, requestAnimationFrame } = useContext(SideEffectContext); + + // Keep the current DOM node in state so that we can create a ResizeObserver for it via `useEffect`. const [currentNode, setCurrentNode] = useState(null); + // `ref` will be used with a react element. When the element is available, this function will be called. const ref = useCallback((node: Element | null) => { + // track the node in state setCurrentNode(node); - if (node !== null) { - setRect(node.getBoundingClientRect()); - } }, []); - const { ResizeObserver } = useContext(SideEffectContext); + + /** + * Any time the DOM node changes (to something other than `null`) recalculate the DOMRect and set it (which will cause it to be returned from the hook. + * This effect re-runs when the DOM node has changed. + */ + useEffect(() => { + if (currentNode !== null) { + // When the DOM node is received, immedaiately calculate its DOM Rect and return that + setRect(currentNode.getBoundingClientRect()); + } + }, [currentNode]); + + /** + * When scroll events occur, recalculate the DOMRect. DOMRect represents the position of an element relative to the viewport, so that may change during scroll (depending on the layout.) + * This effect re-runs when the DOM node has changed. + */ + useEffect(() => { + // the last scrollX and scrollY values that we handled + let previousX: number = window.scrollX; + let previousY: number = window.scrollY; + + const handleScroll = () => { + requestAnimationFrame(() => { + // synchronously read from the DOM + const currentX = window.scrollX; + const currentY = window.scrollY; + + if (currentNode !== null && (previousX !== currentX || previousY !== currentY)) { + setRect(currentNode.getBoundingClientRect()); + } + + previousX = currentX; + previousY = currentY; + }); + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, [currentNode, requestAnimationFrame]); + useEffect(() => { if (currentNode !== null) { const resizeObserver = new ResizeObserver((entries) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx index e671244d97b57..6c1c88f511edb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx @@ -8,7 +8,8 @@ import { mount } from 'enzyme'; import { MockedProvider } from 'react-apollo/test-utils'; import React from 'react'; -import { wait } from '../../../common/lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import '../../../common/mock/match_media'; import { TestProviders, apolloClient } from '../../../common/mock/test_providers'; import { mockOpenTimelineQueryResults } from '../../../common/mock/timeline_results'; @@ -119,15 +120,15 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - - wrapper - .find('[data-test-subj="search-bar"] input') - .simulate('keyup', { key: 'Enter', target: { value: ' abcd ' } }); + await waitFor(() => { + wrapper + .find('[data-test-subj="search-bar"] input') + .simulate('keyup', { key: 'Enter', target: { value: ' abcd ' } }); - expect(wrapper.find('[data-test-subj="query-message"]').first().text()).toContain( - 'Showing: 11 timelines with' - ); + expect(wrapper.find('[data-test-subj="query-message"]').first().text()).toContain( + 'Showing: 11 timelines with' + ); + }); }); test('echos (renders) the query when the user enters a query', async () => { @@ -144,15 +145,15 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - - wrapper - .find('[data-test-subj="search-bar"] input') - .simulate('keyup', { key: 'Enter', target: { value: ' abcd ' } }); + await waitFor(() => { + wrapper + .find('[data-test-subj="search-bar"] input') + .simulate('keyup', { key: 'Enter', target: { value: ' abcd ' } }); - expect(wrapper.find('[data-test-subj="selectable-query-text"]').first().text()).toEqual( - 'with "abcd"' - ); + expect(wrapper.find('[data-test-subj="selectable-query-text"]').first().text()).toEqual( + 'with "abcd"' + ); + }); }); }); @@ -171,12 +172,12 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - - expect( - wrapper.find(`.${OPEN_TIMELINE_CLASS_NAME} input`).first().getDOMNode().id === - document.activeElement!.id - ).toBe(true); + await waitFor(() => { + expect( + wrapper.find(`.${OPEN_TIMELINE_CLASS_NAME} input`).first().getDOMNode().id === + document.activeElement!.id + ).toBe(true); + }); }); }); @@ -198,26 +199,26 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - - wrapper - .find('.euiCheckbox__input') - .first() - .simulate('change', { target: { checked: true } }); - - wrapper.find('[data-test-subj="favorite-selected"]').first().simulate('click'); - - expect(addTimelinesToFavorites).toHaveBeenCalledWith([ - 'saved-timeline-11', - 'saved-timeline-10', - 'saved-timeline-9', - 'saved-timeline-8', - 'saved-timeline-6', - 'saved-timeline-5', - 'saved-timeline-4', - 'saved-timeline-3', - 'saved-timeline-2', - ]); + await waitFor(() => { + wrapper + .find('.euiCheckbox__input') + .first() + .simulate('change', { target: { checked: true } }); + + wrapper.find('[data-test-subj="favorite-selected"]').first().simulate('click'); + + expect(addTimelinesToFavorites).toHaveBeenCalledWith([ + 'saved-timeline-11', + 'saved-timeline-10', + 'saved-timeline-9', + 'saved-timeline-8', + 'saved-timeline-6', + 'saved-timeline-5', + 'saved-timeline-4', + 'saved-timeline-3', + 'saved-timeline-2', + ]); + }); }); }); @@ -239,26 +240,26 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - - wrapper - .find('.euiCheckbox__input') - .first() - .simulate('change', { target: { checked: true } }); - - wrapper.find('[data-test-subj="delete-selected"]').first().simulate('click'); - - expect(deleteTimelines).toHaveBeenCalledWith([ - 'saved-timeline-11', - 'saved-timeline-10', - 'saved-timeline-9', - 'saved-timeline-8', - 'saved-timeline-6', - 'saved-timeline-5', - 'saved-timeline-4', - 'saved-timeline-3', - 'saved-timeline-2', - ]); + await waitFor(() => { + wrapper + .find('.euiCheckbox__input') + .first() + .simulate('change', { target: { checked: true } }); + + wrapper.find('[data-test-subj="delete-selected"]').first().simulate('click'); + + expect(deleteTimelines).toHaveBeenCalledWith([ + 'saved-timeline-11', + 'saved-timeline-10', + 'saved-timeline-9', + 'saved-timeline-8', + 'saved-timeline-6', + 'saved-timeline-5', + 'saved-timeline-4', + 'saved-timeline-3', + 'saved-timeline-2', + ]); + }); }); }); @@ -278,19 +279,19 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - - wrapper - .find('.euiCheckbox__input') - .first() - .simulate('change', { target: { checked: true } }); + await waitFor(() => { + wrapper + .find('.euiCheckbox__input') + .first() + .simulate('change', { target: { checked: true } }); - const selectedItems: [] = wrapper - .find('[data-test-subj="open-timeline"]') - .last() - .prop('selectedItems'); + const selectedItems: [] = wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('selectedItems'); - expect(selectedItems.length).toEqual(13); // 13 because we did mock 13 timelines in the query + expect(selectedItems.length).toEqual(13); // 13 because we did mock 13 timelines in the query + }); }); }); @@ -366,29 +367,37 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - wrapper.update(); - - expect( - wrapper.find('[data-test-subj="open-timeline"]').last().prop('itemIdToExpandedNotesRowMap') - ).toEqual({}); - - wrapper.find('[data-test-subj="expand-notes"]').first().simulate('click'); - - expect( - wrapper.find('[data-test-subj="open-timeline"]').last().prop('itemIdToExpandedNotesRowMap') - ).toEqual({ - '10849df0-7b44-11e9-a608-ab3d811609': ( - ({ ...note, savedObjectId: note.noteId }) - ) - : [] - } - /> - ), + await waitFor(() => { + wrapper.update(); + + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('itemIdToExpandedNotesRowMap') + ).toEqual({}); + + wrapper.find('[data-test-subj="expand-notes"]').first().simulate('click'); + + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('itemIdToExpandedNotesRowMap') + ).toEqual({ + '10849df0-7b44-11e9-a608-ab3d811609': ( + ({ ...note, savedObjectId: note.noteId }) + ) + : [] + } + /> + ), + }); }); }); @@ -407,21 +416,21 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - - wrapper.update(); + await waitFor(() => { + wrapper.update(); - wrapper.find('[data-test-subj="expand-notes"]').first().simulate('click'); - expect(wrapper.find('[data-test-subj="note-previews-container"]').exists()).toEqual(true); - expect(wrapper.find('[data-test-subj="updated-by"]').exists()).toEqual(true); + wrapper.find('[data-test-subj="expand-notes"]').first().simulate('click'); + expect(wrapper.find('[data-test-subj="note-previews-container"]').exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="updated-by"]').exists()).toEqual(true); - expect( - wrapper - .find('[data-test-subj="note-previews-container"]') - .find('[data-test-subj="updated-by"]') - .first() - .text() - ).toEqual('elastic'); + expect( + wrapper + .find('[data-test-subj="note-previews-container"]') + .find('[data-test-subj="updated-by"]') + .first() + .text() + ).toEqual('elastic'); + }); }); /** @@ -442,11 +451,11 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - - expect(wrapper.find(`[data-test-subj="timeline-${TimelineTabsStyle.tab}"]`).exists()).toEqual( - true - ); + await waitFor(() => { + expect( + wrapper.find(`[data-test-subj="timeline-${TimelineTabsStyle.tab}"]`).exists() + ).toEqual(true); + }); }); }); @@ -467,13 +476,14 @@ describe('StatefulOpenTimeline', () => { ); const getSelectedItem = (): [] => wrapper.find('[data-test-subj="open-timeline"]').last().prop('selectedItems'); - await wait(); - expect(getSelectedItem().length).toEqual(0); - wrapper - .find('.euiCheckbox__input') - .first() - .simulate('change', { target: { checked: true } }); - expect(getSelectedItem().length).toEqual(13); + await waitFor(() => { + expect(getSelectedItem().length).toEqual(0); + wrapper + .find('.euiCheckbox__input') + .first() + .simulate('change', { target: { checked: true } }); + expect(getSelectedItem().length).toEqual(13); + }); }); }); @@ -492,13 +502,13 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - - wrapper.update(); + await waitFor(() => { + wrapper.update(); - expect(wrapper.find('[data-test-subj="query-message"]').first().text()).toContain( - 'Showing: 11 timelines ' - ); + expect(wrapper.find('[data-test-subj="query-message"]').first().text()).toContain( + 'Showing: 11 timelines ' + ); + }); }); // TODO - Have been skip because we need to re-implement the test as the component changed @@ -519,21 +529,21 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - - wrapper - .find( - `[data-test-subj="title-${ - mockOpenTimelineQueryResults[0].result.data!.getAllTimeline.timeline[0].savedObjectId - }"]` - ) - .first() - .simulate('click'); - - expect(onOpenTimeline).toHaveBeenCalledWith({ - duplicate: false, - timelineId: mockOpenTimelineQueryResults[0].result.data!.getAllTimeline.timeline[0] - .savedObjectId, + await waitFor(() => { + wrapper + .find( + `[data-test-subj="title-${ + mockOpenTimelineQueryResults[0].result.data!.getAllTimeline.timeline[0].savedObjectId + }"]` + ) + .first() + .simulate('click'); + + expect(onOpenTimeline).toHaveBeenCalledWith({ + duplicate: false, + timelineId: mockOpenTimelineQueryResults[0].result.data!.getAllTimeline.timeline[0] + .savedObjectId, + }); }); }); @@ -555,10 +565,10 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); + await waitFor(() => { + wrapper.find('[data-test-subj="open-duplicate"]').first().simulate('click'); - wrapper.find('[data-test-subj="open-duplicate"]').first().simulate('click'); - - expect(onOpenTimeline).toBeCalledWith({ duplicate: true, timelineId: 'saved-timeline-11' }); + expect(onOpenTimeline).toBeCalledWith({ duplicate: true, timelineId: 'saved-timeline-11' }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/index.test.tsx index 8382af6056ca7..3017f553d59d5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/index.test.tsx @@ -10,7 +10,8 @@ import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; import { ThemeProvider } from 'styled-components'; -import { wait } from '../../../../common/lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { TestProviderWithoutDragAndDrop } from '../../../../common/mock/test_providers'; import { mockOpenTimelineQueryResults } from '../../../../common/mock/timeline_results'; import { useGetAllTimeline, getAllTimeline } from '../../../containers/all'; @@ -64,10 +65,15 @@ describe('OpenTimelineModal', () => {
); - await wait(); + await waitFor( + () => { + wrapper.update(); - wrapper.update(); - - expect(wrapper.find('div[data-test-subj="open-timeline-modal"].euiModal').length).toEqual(1); - }); + expect(wrapper.find('div[data-test-subj="open-timeline-modal"].euiModal').length).toEqual( + 1 + ); + }, + { timeout: 10000 } + ); + }, 20000); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx index 80bca8096f615..a3f180ce84c58 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx @@ -10,7 +10,8 @@ import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; import { ThemeProvider } from 'styled-components'; -import { wait } from '../../../../common/lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { TestProviderWithoutDragAndDrop } from '../../../../common/mock/test_providers'; import { mockOpenTimelineQueryResults } from '../../../../common/mock/timeline_results'; import * as i18n from '../translations'; @@ -29,13 +30,13 @@ describe('OpenTimelineModalButton', () => { ); - await wait(); - - wrapper.update(); + await waitFor(() => { + wrapper.update(); - expect(wrapper.find('[data-test-subj="open-timeline-button"]').first().text()).toEqual( - i18n.OPEN_TIMELINE - ); + expect(wrapper.find('[data-test-subj="open-timeline-button"]').first().text()).toEqual( + i18n.OPEN_TIMELINE + ); + }); }); describe('onClick prop', () => { @@ -51,13 +52,13 @@ describe('OpenTimelineModalButton', () => {
); - await wait(); - - wrapper.find('[data-test-subj="open-timeline-button"]').first().simulate('click'); + await waitFor(() => { + wrapper.find('[data-test-subj="open-timeline-button"]').first().simulate('click'); - wrapper.update(); + wrapper.update(); - expect(onClick).toBeCalled(); + expect(onClick).toBeCalled(); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index b36f1dcc03261..5a98263cbd3fd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -16,7 +16,8 @@ import { TestProviders } from '../../../../common/mock/test_providers'; import { Body, BodyProps } from '.'; import { columnRenderers, rowRenderers } from './renderers'; import { Sort } from './sort'; -import { wait } from '../../../../common/lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; import { SELECTOR_TIMELINE_BODY_CLASS_NAME, TimelineBody } from '../styles'; import { TimelineType } from '../../../../../common/types/timeline'; @@ -130,16 +131,17 @@ describe('Body', () => { ); wrapper.update(); - await wait(); - wrapper.update(); - headersJustTimestamp.forEach(() => { - expect( - wrapper - .find('[data-test-subj="data-driven-columns"]') - .first() - .find('[data-test-subj="localized-date-tool-tip"]') - .exists() - ).toEqual(true); + await waitFor(() => { + wrapper.update(); + headersJustTimestamp.forEach(() => { + expect( + wrapper + .find('[data-test-subj="data-driven-columns"]') + .first() + .find('[data-test-subj="localized-date-tool-tip"]') + .exists() + ).toEqual(true); + }); }); }, 20000); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx index 8b75f8b398ac1..51edf7336c4e7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx @@ -16,7 +16,8 @@ import { ReturnSignalIndex, } from '../../../detections/containers/detection_engine/alerts/use_signal_index'; import { mocksSource } from '../../../common/containers/source/mock'; -import { wait } from '../../../common/lib/helpers'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; import { defaultHeaders, mockTimelineData, TestProviders } from '../../../common/mock'; import { Direction } from '../../../graphql/types'; import { timelineQuery } from '../../containers/index.gql_query'; @@ -124,12 +125,13 @@ describe('StatefulTimeline', () => { ); await act(async () => { - await wait(); - wrapper.update(); - const timeline = wrapper.find(Timeline); - expect(timeline.props().indexToAdd).toEqual([ - 'no-alert-index-049FC71A-4C2C-446F-9901-37XMC5024C51', - ]); + await waitFor(() => { + wrapper.update(); + const timeline = wrapper.find(Timeline); + expect(timeline.props().indexToAdd).toEqual([ + 'no-alert-index-049FC71A-4C2C-446F-9901-37XMC5024C51', + ]); + }); }); }); @@ -147,10 +149,11 @@ describe('StatefulTimeline', () => { ); await act(async () => { - await wait(); - wrapper.update(); - const timeline = wrapper.find(Timeline); - expect(timeline.props().indexToAdd).toEqual(['mock-siem-signals-index']); + await waitFor(() => { + wrapper.update(); + const timeline = wrapper.find(Timeline); + expect(timeline.props().indexToAdd).toEqual(['mock-siem-signals-index']); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index 1e0e85d4a48d9..06dd6f44bea94 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -7,6 +7,8 @@ import React from 'react'; import { shallow } from 'enzyme'; +// we don't have the types for waitFor just yet, so using "as waitFor" for when we do +import { wait as waitFor } from '@testing-library/react'; import '../../../common/mock/match_media'; import { mockGlobalState, @@ -44,10 +46,6 @@ import { TimelineStatus, TimelineType } from '../../../../common/types/timeline' jest.mock('../../containers/local_storage'); -const wait = (ms: number = 500): Promise => { - return new Promise((resolve) => setTimeout(resolve, ms)); -}; - const addTimelineInStorageMock = addTimelineInStorage as jest.Mock; describe('epicLocalStorage', () => { @@ -128,8 +126,7 @@ describe('epicLocalStorage', () => { ); store.dispatch(upsertColumn({ id: 'test', index: 1, column: defaultHeaders[0] })); - await wait(); - expect(addTimelineInStorageMock).toHaveBeenCalled(); + await waitFor(() => expect(addTimelineInStorageMock).toHaveBeenCalled()); }); it('persist timeline when removing a column ', async () => { @@ -139,8 +136,7 @@ describe('epicLocalStorage', () => { ); store.dispatch(removeColumn({ id: 'test', columnId: '@timestamp' })); - await wait(); - expect(addTimelineInStorageMock).toHaveBeenCalled(); + await waitFor(() => expect(addTimelineInStorageMock).toHaveBeenCalled()); }); it('persists resizing of a column', async () => { @@ -150,8 +146,7 @@ describe('epicLocalStorage', () => { ); store.dispatch(applyDeltaToColumnWidth({ id: 'test', columnId: '@timestamp', delta: 80 })); - await wait(); - expect(addTimelineInStorageMock).toHaveBeenCalled(); + await waitFor(() => expect(addTimelineInStorageMock).toHaveBeenCalled()); }); it('persist the resetting of the fields', async () => { @@ -161,8 +156,7 @@ describe('epicLocalStorage', () => { ); store.dispatch(updateColumns({ id: 'test', columns: defaultHeaders })); - await wait(); - expect(addTimelineInStorageMock).toHaveBeenCalled(); + await waitFor(() => expect(addTimelineInStorageMock).toHaveBeenCalled()); }); it('persist items per page', async () => { @@ -172,8 +166,7 @@ describe('epicLocalStorage', () => { ); store.dispatch(updateItemsPerPage({ id: 'test', itemsPerPage: 50 })); - await wait(); - expect(addTimelineInStorageMock).toHaveBeenCalled(); + await waitFor(() => expect(addTimelineInStorageMock).toHaveBeenCalled()); }); it('persist the sorting of a column', async () => { @@ -191,7 +184,6 @@ describe('epicLocalStorage', () => { }, }) ); - await wait(); - expect(addTimelineInStorageMock).toHaveBeenCalled(); + await waitFor(() => expect(addTimelineInStorageMock).toHaveBeenCalled()); }); }); diff --git a/x-pack/plugins/security_solution/server/graphql/ecs/resolvers.ts b/x-pack/plugins/security_solution/server/graphql/ecs/resolvers.ts index f30b7d192d05d..414e5b5d95bec 100644 --- a/x-pack/plugins/security_solution/server/graphql/ecs/resolvers.ts +++ b/x-pack/plugins/security_solution/server/graphql/ecs/resolvers.ts @@ -47,9 +47,41 @@ export const toStringArrayScalar = new GraphQLScalarType({ return null; }, }); - +export const toStringArrayNoNullableScalar = new GraphQLScalarType({ + name: 'StringArray', + description: 'Represents value in detail item from the timeline who wants to more than one type', + serialize(value): string[] | undefined { + if (value == null) { + return undefined; + } else if (Array.isArray(value)) { + return convertArrayToString(value) as string[]; + } else if (isBoolean(value) || isNumber(value) || isObject(value)) { + return [convertToString(value)]; + } + return [value]; + }, + parseValue(value) { + return value; + }, + parseLiteral(ast) { + switch (ast.kind) { + case Kind.INT: + return parseInt(ast.value, 10); + case Kind.FLOAT: + return parseFloat(ast.value); + case Kind.STRING: + return ast.value; + case Kind.LIST: + return ast.values; + case Kind.OBJECT: + return ast.fields; + } + return undefined; + }, +}); export const createScalarToStringArrayValueResolvers = () => ({ ToStringArray: toStringArrayScalar, + ToStringArrayNoNullable: toStringArrayNoNullableScalar, }); const convertToString = (value: object | number | boolean | string): string => { diff --git a/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts index 5b093a02b6514..bdc69f85d3542 100644 --- a/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts @@ -8,6 +8,7 @@ import gql from 'graphql-tag'; export const ecsSchema = gql` scalar ToStringArray + scalar ToStringArrayNoNullable type EventEcsFields { action: ToStringArray diff --git a/x-pack/plugins/security_solution/server/graphql/source_status/resolvers.ts b/x-pack/plugins/security_solution/server/graphql/source_status/resolvers.ts index 24589822f0250..8d55e645d6791 100644 --- a/x-pack/plugins/security_solution/server/graphql/source_status/resolvers.ts +++ b/x-pack/plugins/security_solution/server/graphql/source_status/resolvers.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { GraphQLScalarType, Kind } from 'graphql'; import { SourceStatusResolvers } from '../../graphql/types'; import { AppResolverOf, ChildResolverOf } from '../../lib/framework'; import { IndexFields } from '../../lib/index_fields'; import { SourceStatus } from '../../lib/source_status'; import { QuerySourceResolver } from '../sources/resolvers'; +import { IFieldSubType } from '../../../../../../src/plugins/data/common/index_patterns/types'; export type SourceStatusIndicesExistResolver = ChildResolverOf< AppResolverOf, @@ -50,3 +52,40 @@ export const createSourceStatusResolvers = (libs: { }, }, }); + +export const toIFieldSubTypeNonNullableScalar = new GraphQLScalarType({ + name: 'IFieldSubType', + description: 'Represents value in index pattern field item', + serialize(value): IFieldSubType | undefined { + if (value == null) { + return undefined; + } + + return { + multi: value.multi ?? undefined, + nested: value.nested ?? undefined, + }; + }, + parseValue(value) { + return value; + }, + parseLiteral(ast) { + switch (ast.kind) { + case Kind.INT: + return undefined; + case Kind.FLOAT: + return undefined; + case Kind.STRING: + return undefined; + case Kind.LIST: + return undefined; + case Kind.OBJECT: + return ast; + } + return undefined; + }, +}); + +export const createScalarToIFieldSubTypeNonNullableScalarResolvers = () => ({ + ToIFieldSubTypeNonNullable: toIFieldSubTypeNonNullableScalar, +}); diff --git a/x-pack/plugins/security_solution/server/graphql/source_status/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/source_status/schema.gql.ts index e484b60f8f364..3062113f1b635 100644 --- a/x-pack/plugins/security_solution/server/graphql/source_status/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/source_status/schema.gql.ts @@ -7,6 +7,8 @@ import gql from 'graphql-tag'; export const sourceStatusSchema = gql` + scalar ToIFieldSubTypeNonNullable + "A descriptor of a field in an index" type IndexField { "Where the field belong" @@ -26,6 +28,9 @@ export const sourceStatusSchema = gql` "Description of the field" description: String format: String + "the elastic type as mapped in the index" + esTypes: ToStringArrayNoNullable + subType: ToIFieldSubTypeNonNullable } extend type SourceStatus { diff --git a/x-pack/plugins/security_solution/server/graphql/types.ts b/x-pack/plugins/security_solution/server/graphql/types.ts index f8a614e86f28e..1e397a4e6bb6c 100644 --- a/x-pack/plugins/security_solution/server/graphql/types.ts +++ b/x-pack/plugins/security_solution/server/graphql/types.ts @@ -430,6 +430,10 @@ export enum FlowDirection { biDirectional = 'biDirectional', } +export type ToStringArrayNoNullable = any; + +export type ToIFieldSubTypeNonNullable = any; + export type ToStringArray = string[] | string; export type Date = string; @@ -629,6 +633,10 @@ export interface IndexField { description?: Maybe; format?: Maybe; + /** the elastic type as mapped in the index */ + esTypes?: Maybe; + + subType?: Maybe; } export interface AuthenticationsData { @@ -3579,6 +3587,10 @@ export namespace IndexFieldResolvers { description?: DescriptionResolver, TypeParent, TContext>; format?: FormatResolver, TypeParent, TContext>; + /** the elastic type as mapped in the index */ + esTypes?: EsTypesResolver, TypeParent, TContext>; + + subType?: SubTypeResolver, TypeParent, TContext>; } export type CategoryResolver = Resolver< @@ -3626,6 +3638,16 @@ export namespace IndexFieldResolvers { Parent = IndexField, TContext = SiemContext > = Resolver; + export type EsTypesResolver< + R = Maybe, + Parent = IndexField, + TContext = SiemContext + > = Resolver; + export type SubTypeResolver< + R = Maybe, + Parent = IndexField, + TContext = SiemContext + > = Resolver; } export namespace AuthenticationsDataResolvers { @@ -9317,6 +9339,14 @@ export interface DeprecatedDirectiveArgs { reason?: string; } +export interface ToStringArrayNoNullableScalarConfig + extends GraphQLScalarTypeConfig { + name: 'ToStringArrayNoNullable'; +} +export interface ToIFieldSubTypeNonNullableScalarConfig + extends GraphQLScalarTypeConfig { + name: 'ToIFieldSubTypeNonNullable'; +} export interface ToStringArrayScalarConfig extends GraphQLScalarTypeConfig { name: 'ToStringArray'; } @@ -9490,6 +9520,8 @@ export type IResolvers = { EventsTimelineData?: EventsTimelineDataResolvers.Resolvers; OsFields?: OsFieldsResolvers.Resolvers; HostFields?: HostFieldsResolvers.Resolvers; + ToStringArrayNoNullable?: GraphQLScalarType; + ToIFieldSubTypeNonNullable?: GraphQLScalarType; ToStringArray?: GraphQLScalarType; Date?: GraphQLScalarType; ToNumberArray?: GraphQLScalarType; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 17e05109b9a87..19fcf65ec0c5e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -56,11 +56,10 @@ export const sampleRuleAlertParams = ( exceptionsList: getListArrayMock(), }); -export const sampleDocNoSortId = (someUuid: string = sampleIdGuid): SignalSourceHit => ({ +export const sampleDocNoSortIdNoVersion = (someUuid: string = sampleIdGuid): SignalSourceHit => ({ _index: 'myFakeSignalIndex', _type: 'doc', _score: 100, - _version: 1, _id: someUuid, _source: { someKey: 'someValue', @@ -68,18 +67,26 @@ export const sampleDocNoSortId = (someUuid: string = sampleIdGuid): SignalSource }, }); -export const sampleDocNoSortIdNoVersion = (someUuid: string = sampleIdGuid): SignalSourceHit => ({ +export const sampleDocWithSortId = ( + someUuid: string = sampleIdGuid, + ip?: string +): SignalSourceHit => ({ _index: 'myFakeSignalIndex', _type: 'doc', _score: 100, + _version: 1, _id: someUuid, _source: { someKey: 'someValue', '@timestamp': '2020-04-20T21:27:45+0000', + source: { + ip: ip ?? '127.0.0.1', + }, }, + sort: ['1234567891111'], }); -export const sampleDocWithSortId = ( +export const sampleDocNoSortId = ( someUuid: string = sampleIdGuid, ip?: string ): SignalSourceHit => ({ @@ -95,7 +102,7 @@ export const sampleDocWithSortId = ( ip: ip ?? '127.0.0.1', }, }, - sort: ['1234567891111'], + sort: [], }); export const sampleEmptyDocSearchResults = (): SignalSearchResponse => ({ @@ -116,6 +123,8 @@ export const sampleEmptyDocSearchResults = (): SignalSearchResponse => ({ export const sampleDocWithAncestors = (): SignalSearchResponse => { const sampleDoc = sampleDocNoSortId(); + delete sampleDoc.sort; + delete sampleDoc._source.source; sampleDoc._source.signal = { parent: { rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', @@ -317,6 +326,29 @@ export const repeatedSearchResultsWithSortId = ( }, }); +export const repeatedSearchResultsWithNoSortId = ( + total: number, + pageSize: number, + guids: string[], + ips?: string[] +) => ({ + took: 10, + timed_out: false, + _shards: { + total: 10, + successful: 10, + failed: 0, + skipped: 0, + }, + hits: { + total, + max_score: 100, + hits: Array.from({ length: pageSize }).map((x, index) => ({ + ...sampleDocNoSortId(guids[index], ips ? ips[index] : '127.0.0.1'), + })), + }, +}); + export const sampleDocSearchResultsWithSortId = ( someUuid: string = sampleIdGuid ): SignalSearchResponse => ({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts index e840ae96cf3c1..fe2e0f2d96fd8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts @@ -21,8 +21,10 @@ describe('buildBulkBody', () => { test('bulk body builds well-defined body', () => { const sampleParams = sampleRuleAlertParams(); + const doc = sampleDocNoSortId(); + delete doc._source.source; const fakeSignalSourceHit = buildBulkBody({ - doc: sampleDocNoSortId(), + doc, ruleParams: sampleParams, id: sampleRuleGuid, name: 'rule-name', @@ -107,6 +109,7 @@ describe('buildBulkBody', () => { test('bulk body builds original_event if it exists on the event to begin with', () => { const sampleParams = sampleRuleAlertParams(); const doc = sampleDocNoSortId(); + delete doc._source.source; doc._source.event = { action: 'socket_opened', module: 'system', @@ -208,6 +211,7 @@ describe('buildBulkBody', () => { test('bulk body builds original_event if it exists on the event to begin with but no kind information', () => { const sampleParams = sampleRuleAlertParams(); const doc = sampleDocNoSortId(); + delete doc._source.source; doc._source.event = { action: 'socket_opened', module: 'system', @@ -307,6 +311,7 @@ describe('buildBulkBody', () => { test('bulk body builds original_event if it exists on the event to begin with with only kind information', () => { const sampleParams = sampleRuleAlertParams(); const doc = sampleDocNoSortId(); + delete doc._source.source; doc._source.event = { kind: 'event', }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 17935f64d5e14..3312191c3b41b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -11,6 +11,7 @@ import { sampleRuleGuid, mockLogger, repeatedSearchResultsWithSortId, + repeatedSearchResultsWithNoSortId, sampleDocSearchResultsNoSortIdNoHits, } from './__mocks__/es_results'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; @@ -356,6 +357,212 @@ describe('searchAfterAndBulkCreate', () => { expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); + test('should return success when all search results are in the allowlist and with sortId present', async () => { + listClient.getListItemByValues = jest + .fn() + .mockResolvedValue([{ value: '1.1.1.1' }, { value: '2.2.2.2' }, { value: '3.3.3.3' }]); + const sampleParams = sampleRuleAlertParams(30); + mockService.callCluster + .mockResolvedValueOnce( + repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ + '1.1.1.1', + '2.2.2.2', + '2.2.2.2', + '2.2.2.2', + ]) + ) + .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); + + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; + const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ + ruleParams: sampleParams, + gap: null, + previousStartedAt: new Date(), + listClient, + exceptionsList: [exceptionItem], + services: mockService, + logger: mockLogger, + id: sampleRuleGuid, + inputIndexPattern, + signalsIndex: DEFAULT_SIGNALS_INDEX, + name: 'rule-name', + actions: [], + createdAt: '2020-01-28T15:58:34.810Z', + updatedAt: '2020-01-28T15:59:14.004Z', + createdBy: 'elastic', + updatedBy: 'elastic', + interval: '5m', + enabled: true, + pageSize: 1, + filter: undefined, + refresh: false, + tags: ['some fake tag 1', 'some fake tag 2'], + throttle: 'no_actions', + buildRuleMessage, + }); + expect(success).toEqual(true); + expect(mockService.callCluster).toHaveBeenCalledTimes(2); + expect(createdSignalsCount).toEqual(0); // should not create any signals because all events were in the allowlist + expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); + }); + + test('should return success when all search results are in the allowlist and no sortId present', async () => { + listClient.getListItemByValues = jest + .fn() + .mockResolvedValue([{ value: '1.1.1.1' }, { value: '2.2.2.2' }, { value: '3.3.3.3' }]); + const sampleParams = sampleRuleAlertParams(30); + mockService.callCluster.mockResolvedValueOnce( + repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3), [ + '1.1.1.1', + '2.2.2.2', + '2.2.2.2', + '2.2.2.2', + ]) + ); + + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; + const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ + ruleParams: sampleParams, + gap: null, + previousStartedAt: new Date(), + listClient, + exceptionsList: [exceptionItem], + services: mockService, + logger: mockLogger, + id: sampleRuleGuid, + inputIndexPattern, + signalsIndex: DEFAULT_SIGNALS_INDEX, + name: 'rule-name', + actions: [], + createdAt: '2020-01-28T15:58:34.810Z', + updatedAt: '2020-01-28T15:59:14.004Z', + createdBy: 'elastic', + updatedBy: 'elastic', + interval: '5m', + enabled: true, + pageSize: 1, + filter: undefined, + refresh: false, + tags: ['some fake tag 1', 'some fake tag 2'], + throttle: 'no_actions', + buildRuleMessage, + }); + expect(success).toEqual(true); + expect(mockService.callCluster).toHaveBeenCalledTimes(1); + expect(createdSignalsCount).toEqual(0); // should not create any signals because all events were in the allowlist + expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); + // I don't like testing log statements since logs change but this is the best + // way I can think of to ensure this section is getting hit with this test case. + expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[7][0]).toContain( + 'sortIds was empty on searchResult' + ); + }); + + test('should return success when no sortId present but search results are in the allowlist', async () => { + const sampleParams = sampleRuleAlertParams(30); + mockService.callCluster + .mockResolvedValueOnce(repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + fakeItemValue: 'fakeItemKey', + }, + { + create: { + status: 201, + }, + }, + { + create: { + status: 201, + }, + }, + { + create: { + status: 201, + }, + }, + { + create: { + status: 201, + }, + }, + ], + }); + + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; + const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ + ruleParams: sampleParams, + gap: null, + previousStartedAt: new Date(), + listClient, + exceptionsList: [exceptionItem], + services: mockService, + logger: mockLogger, + id: sampleRuleGuid, + inputIndexPattern, + signalsIndex: DEFAULT_SIGNALS_INDEX, + name: 'rule-name', + actions: [], + createdAt: '2020-01-28T15:58:34.810Z', + updatedAt: '2020-01-28T15:59:14.004Z', + createdBy: 'elastic', + updatedBy: 'elastic', + interval: '5m', + enabled: true, + pageSize: 1, + filter: undefined, + refresh: false, + tags: ['some fake tag 1', 'some fake tag 2'], + throttle: 'no_actions', + buildRuleMessage, + }); + expect(success).toEqual(true); + expect(mockService.callCluster).toHaveBeenCalledTimes(2); + expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist + expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); + // I don't like testing log statements since logs change but this is the best + // way I can think of to ensure this section is getting hit with this test case. + expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[12][0]).toContain( + 'sortIds was empty on filteredEvents' + ); + }); + test('should return success when no exceptions list provided', async () => { const sampleParams = sampleRuleAlertParams(30); mockService.callCluster diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index 2a0e39cbbf237..e90e5996877f8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -90,23 +90,12 @@ export const searchAfterAndBulkCreate = async ({ createdSignalsCount: 0, }; - let sortId; // tells us where to start our next search_after query - let searchResultSize = 0; + // sortId tells us where to start our next consecutive search_after query + let sortId; - /* - The purpose of `maxResults` is to ensure we do not perform - extra search_after's. This will be reset on each - iteration, although it really only matters for the first - iteration of the loop. - e.g. if maxSignals = 100 but our search result only yields - 27 documents, there is no point in performing another search - since we know there are no more events that match our rule, - and thus, no more signals we could possibly generate. - However, if maxSignals = 500 and our search yields a total - of 3050 results we don't want to make 3050 signals, - we only want 500. So maxResults will help us control how - many times we perform a search_after - */ + // signalsCreatedCount keeps track of how many signals we have created, + // to ensure we don't exceed maxSignals + let signalsCreatedCount = 0; const totalToFromTuples = getSignalTimeTuples({ logger, @@ -118,7 +107,6 @@ export const searchAfterAndBulkCreate = async ({ interval, buildRuleMessage, }); - const useSortIds = totalToFromTuples.length <= 1; logger.debug(buildRuleMessage(`totalToFromTuples: ${totalToFromTuples.length}`)); while (totalToFromTuples.length > 0) { const tuple = totalToFromTuples.pop(); @@ -127,16 +115,18 @@ export const searchAfterAndBulkCreate = async ({ toReturn.success = false; return toReturn; } - searchResultSize = 0; - while (searchResultSize < tuple.maxSignals) { + signalsCreatedCount = 0; + while (signalsCreatedCount < tuple.maxSignals) { try { logger.debug(buildRuleMessage(`sortIds: ${sortId}`)); + + // perform search_after with optionally undefined sortId const { searchResult, searchDuration, }: { searchResult: SignalSearchResponse; searchDuration: string } = await singleSearchAfter( { - searchAfterSortId: useSortIds ? sortId : undefined, + searchAfterSortId: sortId, index: inputIndexPattern, from: tuple.from.toISOString(), to: tuple.to.toISOString(), @@ -149,6 +139,7 @@ export const searchAfterAndBulkCreate = async ({ ); toReturn.searchAfterTimes.push(searchDuration); + // determine if there are any candidate signals to be processed const totalHits = typeof searchResult.hits.total === 'number' ? searchResult.hits.total @@ -157,7 +148,23 @@ export const searchAfterAndBulkCreate = async ({ logger.debug( buildRuleMessage(`searchResult.hit.hits.length: ${searchResult.hits.hits.length}`) ); - if (totalHits === 0) { + + // search results yielded zero hits so exit + // with search_after, these two values can be different when + // searching with the last sortId of a consecutive search_after + // yields zero hits, but there were hits using the previous + // sortIds. + // e.g. totalHits was 156, index 50 of 100 results, do another search-after + // this time with a new sortId, index 22 of the remainding 56, get another sortId + // search with that sortId, total is still 156 but the hits.hits array is empty. + if (totalHits === 0 || searchResult.hits.hits.length === 0) { + logger.debug( + buildRuleMessage( + `${ + totalHits === 0 ? 'totalHits' : 'searchResult.hits.hits.length' + } was 0, exiting and moving on to next tuple` + ) + ); toReturn.success = true; break; } @@ -167,10 +174,10 @@ export const searchAfterAndBulkCreate = async ({ searchResult.hits.hits[searchResult.hits.hits.length - 1]?._source['@timestamp'] ) : null; - searchResultSize += searchResult.hits.hits.length; // filter out the search results that match with the values found in the list. - // the resulting set are valid signals that are not on the allowlist. + // the resulting set are signals to be indexed, given they are not duplicates + // of signals already present in the signals index. const filteredEvents: SignalSearchResponse = listClient != null ? await filterEventsAgainstList({ @@ -181,55 +188,79 @@ export const searchAfterAndBulkCreate = async ({ buildRuleMessage, }) : searchResult; - if (filteredEvents.hits.total === 0 || filteredEvents.hits.hits.length === 0) { - // everything in the events were allowed, so no need to generate signals - toReturn.success = true; - break; - } - const { - bulkCreateDuration: bulkDuration, - createdItemsCount: createdCount, - } = await singleBulkCreate({ - filteredEvents, - ruleParams, - services, - logger, - id, - signalsIndex, - actions, - name, - createdAt, - createdBy, - updatedAt, - updatedBy, - interval, - enabled, - refresh, - tags, - throttle, - }); - logger.debug(buildRuleMessage(`created ${createdCount} signals`)); - toReturn.createdSignalsCount += createdCount; - if (bulkDuration) { - toReturn.bulkCreateTimes.push(bulkDuration); - } + // only bulk create if there are filteredEvents leftover + // if there isn't anything after going through the value list filter + // skip the call to bulk create and proceed to the next search_after, + // if there is a sort id to continue the search_after with. + if (filteredEvents.hits.hits.length !== 0) { + // make sure we are not going to create more signals than maxSignals allows + if (signalsCreatedCount + filteredEvents.hits.hits.length > tuple.maxSignals) { + filteredEvents.hits.hits = filteredEvents.hits.hits.slice( + 0, + tuple.maxSignals - signalsCreatedCount + ); + } + const { + bulkCreateDuration: bulkDuration, + createdItemsCount: createdCount, + } = await singleBulkCreate({ + filteredEvents, + ruleParams, + services, + logger, + id, + signalsIndex, + actions, + name, + createdAt, + createdBy, + updatedAt, + updatedBy, + interval, + enabled, + refresh, + tags, + throttle, + }); + logger.debug(buildRuleMessage(`created ${createdCount} signals`)); + toReturn.createdSignalsCount += createdCount; + signalsCreatedCount += createdCount; + logger.debug(buildRuleMessage(`signalsCreatedCount: ${signalsCreatedCount}`)); + if (bulkDuration) { + toReturn.bulkCreateTimes.push(bulkDuration); + } - logger.debug( - buildRuleMessage(`filteredEvents.hits.hits: ${filteredEvents.hits.hits.length}`) - ); - if (useSortIds && filteredEvents.hits.hits[0].sort == null) { - logger.debug(buildRuleMessage('sortIds was empty on search')); - toReturn.success = true; - break; - } else if ( - useSortIds && - filteredEvents.hits.hits !== null && - filteredEvents.hits.hits[0].sort !== null - ) { - sortId = filteredEvents.hits.hits[0].sort - ? filteredEvents.hits.hits[0].sort[0] - : undefined; + logger.debug( + buildRuleMessage(`filteredEvents.hits.hits: ${filteredEvents.hits.hits.length}`) + ); + + if ( + filteredEvents.hits.hits[0].sort != null && + filteredEvents.hits.hits[0].sort.length !== 0 + ) { + sortId = filteredEvents.hits.hits[0].sort + ? filteredEvents.hits.hits[0].sort[0] + : undefined; + } else { + logger.debug(buildRuleMessage('sortIds was empty on filteredEvents')); + toReturn.success = true; + break; + } + } else { + // we are guaranteed to have searchResult hits at this point + // because we check before if the totalHits or + // searchResult.hits.hits.length is 0 + if ( + searchResult.hits.hits[0].sort != null && + searchResult.hits.hits[0].sort.length !== 0 + ) { + sortId = searchResult.hits.hits[0].sort ? searchResult.hits.hits[0].sort[0] : undefined; + } else { + logger.debug(buildRuleMessage('sortIds was empty on searchResult')); + toReturn.success = true; + break; + } } } catch (exc) { logger.error(buildRuleMessage(`[-] search_after and bulk threw an error ${exc}`)); diff --git a/x-pack/plugins/security_solution/server/lib/index_fields/types.ts b/x-pack/plugins/security_solution/server/lib/index_fields/types.ts index 0c894c6980a31..67b3c254007e2 100644 --- a/x-pack/plugins/security_solution/server/lib/index_fields/types.ts +++ b/x-pack/plugins/security_solution/server/lib/index_fields/types.ts @@ -6,6 +6,7 @@ import { IndexField } from '../../graphql/types'; import { FrameworkRequest } from '../framework'; +import { IFieldSubType } from '../../../../../../src/plugins/data/common'; export interface FieldsAdapter { getIndexFields(req: FrameworkRequest, indices: string[]): Promise; @@ -16,4 +17,6 @@ export interface IndexFieldDescriptor { type: string; searchable: boolean; aggregatable: boolean; + esTypes?: string[]; + subType?: IFieldSubType; } diff --git a/x-pack/test/api_integration/apis/fleet/agents/acks.ts b/x-pack/test/api_integration/apis/fleet/agents/acks.ts index e8381aa9d59ea..a040ef20081a8 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/acks.ts +++ b/x-pack/test/api_integration/apis/fleet/agents/acks.ts @@ -38,7 +38,7 @@ export default function (providerContext: FtrProviderContext) { await esClient.update({ index: '.kibana', id: 'fleet-agents:agent1', - refresh: 'true', + refresh: true, body: { doc: agentDoc, }, diff --git a/x-pack/test/api_integration/apis/fleet/agents/checkin.ts b/x-pack/test/api_integration/apis/fleet/agents/checkin.ts index 8942deafdd83c..70147f602e9c7 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/checkin.ts +++ b/x-pack/test/api_integration/apis/fleet/agents/checkin.ts @@ -38,7 +38,7 @@ export default function (providerContext: FtrProviderContext) { await esClient.update({ index: '.kibana', id: 'fleet-agents:agent1', - refresh: 'true', + refresh: true, body: { doc: agentDoc, }, diff --git a/x-pack/test/api_integration/apis/fleet/agents/enroll.ts b/x-pack/test/api_integration/apis/fleet/agents/enroll.ts index 8a21fbcf24c7d..58440a34457d0 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/enroll.ts +++ b/x-pack/test/api_integration/apis/fleet/agents/enroll.ts @@ -43,7 +43,7 @@ export default function (providerContext: FtrProviderContext) { await esClient.update({ index: '.kibana', id: 'fleet-enrollment-api-keys:ed22ca17-e178-4cfe-8b02-54ea29fbd6d0', - refresh: 'true', + refresh: true, body: { doc: enrollmentApiKeyDoc, }, diff --git a/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts b/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts index bc6c44e590cc4..bbbce3314e4cc 100644 --- a/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts +++ b/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts @@ -52,7 +52,7 @@ export default function (providerContext: FtrProviderContext) { await esClient.update({ index: '.kibana', id: 'fleet-agents:agent1', - refresh: 'true', + refresh: true, body: { doc: agentDoc, }, diff --git a/x-pack/test/api_integration/services/resolver.ts b/x-pack/test/api_integration/services/resolver.ts index 750d2f702fb84..7f568a2b00314 100644 --- a/x-pack/test/api_integration/services/resolver.ts +++ b/x-pack/test/api_integration/services/resolver.ts @@ -57,7 +57,7 @@ export function ResolverGeneratorProvider({ getService }: FtrProviderContext) { return array; }, []); // force a refresh here otherwise the documents might not be available when the tests search for them - await client.bulk({ body, refresh: 'true' }); + await client.bulk({ body, refresh: true }); allTrees.push(tree); } return { trees: allTrees, eventsIndex, alertsIndex }; diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts index 3340ac49b2d2d..a022b7c79c079 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts @@ -13,6 +13,7 @@ import { deleteAllAlerts, deleteAllTimelines, deleteSignalsIndex, + waitFor, } from '../../utils'; // eslint-disable-next-line import/no-default-export @@ -20,8 +21,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - // FLAKY: https://github.com/elastic/kibana/issues/71867 - describe.skip('add_prepackaged_rules', () => { + describe('add_prepackaged_rules', () => { describe('validation errors', () => { it('should give an error that the index must exist first if it does not exist before adding prepackaged rules', async () => { const { body } = await supertest @@ -91,6 +91,16 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(200); + // NOTE: I call the GET call until eventually it becomes consistent and that the number of rules to install are zero. + // This is to reduce flakiness where it can for a short period of time try to install the same rule twice. + await waitFor(async () => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + .set('kbn-xsrf', 'true') + .expect(200); + return body.rules_not_installed === 0; + }); + const { body } = await supertest .put(DETECTION_ENGINE_PREPACKAGED_URL) .set('kbn-xsrf', 'true') diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts index 7671b1bd49744..40456737b8761 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts @@ -13,6 +13,7 @@ import { deleteAllAlerts, deleteAllTimelines, deleteSignalsIndex, + waitFor, } from '../../utils'; // eslint-disable-next-line import/no-default-export @@ -20,8 +21,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - // FLAKY: https://github.com/elastic/kibana/issues/71814 - describe.skip('add_prepackaged_rules', () => { + describe('add_prepackaged_rules', () => { describe('validation errors', () => { it('should give an error that the index must exist first if it does not exist before adding prepackaged rules', async () => { const { body } = await supertest @@ -91,6 +91,16 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(200); + // NOTE: I call the GET call until eventually it becomes consistent and that the number of rules to install are zero. + // This is to reduce flakiness where it can for a short period of time try to install the same rule the same rule twice. + await waitFor(async () => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + .set('kbn-xsrf', 'true') + .expect(200); + return body.rules_not_installed === 0; + }); + const { body } = await supertest .put(DETECTION_ENGINE_PREPACKAGED_URL) .set('kbn-xsrf', 'true') diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts index b59fd1b744e97..52865e43be750 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts @@ -29,8 +29,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - // Failing ES promotion: https://github.com/elastic/kibana/issues/71612 - describe.skip('create_rules_bulk', () => { + describe('create_rules_bulk', () => { describe('validation errors', () => { it('should give a 200 even if the index does not exist as all bulks return a 200 but have an error of 409 bad request in the body', async () => { const { body } = await supertest diff --git a/x-pack/test/functional/apps/maps/auto_fit_to_bounds.js b/x-pack/test/functional/apps/maps/auto_fit_to_bounds.js new file mode 100644 index 0000000000000..64c07273c9ccf --- /dev/null +++ b/x-pack/test/functional/apps/maps/auto_fit_to_bounds.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default function ({ getPageObjects }) { + const PageObjects = getPageObjects(['maps']); + + describe('auto fit map to bounds', () => { + describe('without joins', () => { + before(async () => { + await PageObjects.maps.loadSavedMap('document example'); + await PageObjects.maps.enableAutoFitToBounds(); + }); + + it('should automatically fit to bounds when query is applied', async () => { + // Set view to other side of world so no matching results + await PageObjects.maps.setView(-15, -100, 6); + + // Setting query should trigger fit to bounds and move map + const origView = await PageObjects.maps.getView(); + await PageObjects.maps.setAndSubmitQuery('machine.os.raw : "ios"'); + await PageObjects.maps.waitForMapPanAndZoom(origView); + + const { lat, lon, zoom } = await PageObjects.maps.getView(); + expect(Math.round(lat)).to.equal(43); + expect(Math.round(lon)).to.equal(-102); + expect(Math.round(zoom)).to.equal(5); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/documents_source/search_hits.js b/x-pack/test/functional/apps/maps/documents_source/search_hits.js index 5d75679432c97..cc0f3a7df32de 100644 --- a/x-pack/test/functional/apps/maps/documents_source/search_hits.js +++ b/x-pack/test/functional/apps/maps/documents_source/search_hits.js @@ -103,7 +103,7 @@ export default function ({ getPageObjects, getService }) { await PageObjects.maps.setView(-15, -100, 6); await PageObjects.maps.clickFitToBounds('logstash'); const { lat, lon, zoom } = await PageObjects.maps.getView(); - expect(Math.round(lat)).to.equal(42); + expect(Math.round(lat)).to.equal(43); expect(Math.round(lon)).to.equal(-102); expect(Math.round(zoom)).to.equal(5); }); diff --git a/x-pack/test/functional/apps/maps/index.js b/x-pack/test/functional/apps/maps/index.js index 15928170972d9..d0735aecda78b 100644 --- a/x-pack/test/functional/apps/maps/index.js +++ b/x-pack/test/functional/apps/maps/index.js @@ -34,6 +34,7 @@ export default function ({ loadTestFile, getService }) { loadTestFile(require.resolve('./vector_styling')); loadTestFile(require.resolve('./saved_object_management')); loadTestFile(require.resolve('./sample_data')); + loadTestFile(require.resolve('./auto_fit_to_bounds')); loadTestFile(require.resolve('./feature_controls/maps_security')); loadTestFile(require.resolve('./feature_controls/maps_spaces')); loadTestFile(require.resolve('./full_screen_mode')); diff --git a/x-pack/test/functional/page_objects/gis_page.js b/x-pack/test/functional/page_objects/gis_page.js index ff50415d3066e..8a0b4aaefa888 100644 --- a/x-pack/test/functional/page_objects/gis_page.js +++ b/x-pack/test/functional/page_objects/gis_page.js @@ -656,6 +656,24 @@ export function GisPageProvider({ getService, getPageObjects }) { async getCategorySuggestions() { return await comboBox.getOptionsList(`colorStopInput1`); } + + async enableAutoFitToBounds() { + await testSubjects.click('openSettingsButton'); + const isEnabled = await testSubjects.getAttribute('autoFitToDataBoundsSwitch', 'checked'); + if (!isEnabled) { + await retry.try(async () => { + await testSubjects.click('autoFitToDataBoundsSwitch'); + const ensureEnabled = await testSubjects.getAttribute( + 'autoFitToDataBoundsSwitch', + 'checked' + ); + if (!ensureEnabled) { + throw new Error('autoFitToDataBoundsSwitch is not enabled'); + } + }); + } + await testSubjects.click('mapSettingSubmitButton'); + } } return new GisPage(); } diff --git a/yarn.lock b/yarn.lock index 3924655b5e43e..4cc802e328ab8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2183,6 +2183,17 @@ pump "^3.0.0" secure-json-parse "^2.1.0" +"@elastic/elasticsearch@7.9.0-rc.2": + version "7.9.0-rc.2" + resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-7.9.0-rc.2.tgz#cbc935f30940a15484b5ec3758c9b1ef119a5e5c" + integrity sha512-1FKCQJVr7s/LasKq6VbrmbWCI0LjoPcnjgmh2vKPzC+yyEEHVoYlmEfR5wBRchK1meATTXZtDhCVF95+Q9kVbA== + dependencies: + debug "^4.1.1" + decompress-response "^4.2.0" + ms "^2.1.1" + pump "^3.0.0" + secure-json-parse "^2.1.0" + "@elastic/ems-client@7.9.3": version "7.9.3" resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-7.9.3.tgz#71b79914f76e347f050ead8474ad65d761e94a8a"