From ac5e7aa81ee5cbe0074398500bf7c1fef2f5d23e Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 2 Mar 2020 11:08:19 +0100 Subject: [PATCH 01/36] [Upgrade Assistant] Remove "boom" from reindex service (#58715) * Removed Boom from reindex-service The reindex service had logic inside it for mapping errors to HTTP. This has been moved to the route handlers and also removed Boom. There is one more instance of Boom use inside of reindex-actions but that comes from Saved Objects which should probably be removed at a later stage. * Fix import path Specify the full relative import path to the kibana's core server folder * Remove unnecessary if statement Co-authored-by: Elastic Machine --- .../server/lib/reindexing/error.ts | 35 +++++++++++ .../server/lib/reindexing/error_symbols.ts | 15 +++++ .../server/lib/reindexing/reindex_service.ts | 24 ++++--- .../server/routes/reindex_indices.ts | 62 +++++++++++++------ 4 files changed, 106 insertions(+), 30 deletions(-) create mode 100644 x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts create mode 100644 x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts new file mode 100644 index 0000000000000..b7bc197fbd162 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts @@ -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 { + AccessForbidden, + IndexNotFound, + CannotCreateIndex, + ReindexTaskCannotBeDeleted, + ReindexTaskFailed, + ReindexAlreadyInProgress, + MultipleReindexJobsFound, +} from './error_symbols'; + +export class ReindexError extends Error { + constructor(message: string, public readonly symbol: symbol) { + super(message); + } +} + +export const createErrorFactory = (symbol: symbol) => (message: string) => { + return new ReindexError(message, symbol); +}; + +export const error = { + indexNotFound: createErrorFactory(IndexNotFound), + accessForbidden: createErrorFactory(AccessForbidden), + cannotCreateIndex: createErrorFactory(CannotCreateIndex), + reindexTaskFailed: createErrorFactory(ReindexTaskFailed), + reindexTaskCannotBeDeleted: createErrorFactory(ReindexTaskCannotBeDeleted), + reindexAlreadyInProgress: createErrorFactory(ReindexAlreadyInProgress), + multipleReindexJobsFound: createErrorFactory(MultipleReindexJobsFound), +}; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts new file mode 100644 index 0000000000000..9e49d280d1be2 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const AccessForbidden = Symbol('AccessForbidden'); +export const IndexNotFound = Symbol('IndexNotFound'); +export const CannotCreateIndex = Symbol('CannotCreateIndex'); + +export const ReindexTaskFailed = Symbol('ReindexTaskFailed'); +export const ReindexTaskCannotBeDeleted = Symbol('ReindexTaskCannotBeDeleted'); +export const ReindexAlreadyInProgress = Symbol('ReindexAlreadyInProgress'); + +export const MultipleReindexJobsFound = Symbol('MultipleReindexJobsFound'); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts index 8f1df5b34372b..b274743bdf279 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts @@ -3,8 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import Boom from 'boom'; import { APICaller, Logger } from 'src/core/server'; import { first } from 'rxjs/operators'; @@ -24,6 +22,8 @@ import { import { ReindexActions } from './reindex_actions'; import { LicensingPluginSetup } from '../../../../licensing/server'; +import { error } from './error'; + const VERSION_REGEX = new RegExp(/^([1-9]+)\.([0-9]+)\.([0-9]+)/); const ML_INDICES = ['.ml-state', '.ml-anomalies', '.ml-config']; const WATCHER_INDICES = ['.watches', '.triggered-watches']; @@ -284,7 +284,7 @@ export const reindexServiceFactory = ( const flatSettings = await actions.getFlatSettings(indexName); if (!flatSettings) { - throw Boom.notFound(`Index ${indexName} does not exist.`); + throw error.indexNotFound(`Index ${indexName} does not exist.`); } const { settings, mappings } = transformFlatSettings(flatSettings); @@ -298,7 +298,7 @@ export const reindexServiceFactory = ( }); if (!createIndex.acknowledged) { - throw Boom.badImplementation(`Index could not be created: ${newIndexName}`); + throw error.cannotCreateIndex(`Index could not be created: ${newIndexName}`); } return actions.updateReindexOp(reindexOp, { @@ -363,7 +363,7 @@ export const reindexServiceFactory = ( if (taskResponse.task.status.created < count) { // Include the entire task result in the error message. This should be guaranteed // to be JSON-serializable since it just came back from Elasticsearch. - throw Boom.badData(`Reindexing failed: ${JSON.stringify(taskResponse)}`); + throw error.reindexTaskFailed(`Reindexing failed: ${JSON.stringify(taskResponse)}`); } // Update the status @@ -380,7 +380,7 @@ export const reindexServiceFactory = ( }); if (deleteTaskResp.result !== 'deleted') { - throw Boom.badImplementation(`Could not delete reindexing task ${taskId}`); + throw error.reindexTaskCannotBeDeleted(`Could not delete reindexing task ${taskId}`); } return reindexOp; @@ -414,7 +414,7 @@ export const reindexServiceFactory = ( }); if (!aliasResponse.acknowledged) { - throw Boom.badImplementation(`Index aliases could not be created.`); + throw error.cannotCreateIndex(`Index aliases could not be created.`); } return actions.updateReindexOp(reindexOp, { @@ -520,7 +520,7 @@ export const reindexServiceFactory = ( async createReindexOperation(indexName: string) { const indexExists = await callAsUser('indices.exists', { index: indexName }); if (!indexExists) { - throw Boom.notFound(`Index ${indexName} does not exist in this cluster.`); + throw error.indexNotFound(`Index ${indexName} does not exist in this cluster.`); } const existingReindexOps = await actions.findReindexOperations(indexName); @@ -533,7 +533,9 @@ export const reindexServiceFactory = ( // Delete the existing one if it failed or was cancelled to give a chance to retry. await actions.deleteReindexOp(existingOp); } else { - throw Boom.badImplementation(`A reindex operation already in-progress for ${indexName}`); + throw error.reindexAlreadyInProgress( + `A reindex operation already in-progress for ${indexName}` + ); } } @@ -547,7 +549,9 @@ export const reindexServiceFactory = ( if (findResponse.total === 0) { return null; } else if (findResponse.total > 1) { - throw Boom.badImplementation(`More than one reindex operation found for ${indexName}`); + throw error.multipleReindexJobsFound( + `More than one reindex operation found for ${indexName}` + ); } return findResponse.saved_objects[0]; diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts index a910145474061..72c2f2c29b72e 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts @@ -5,7 +5,12 @@ */ import { schema } from '@kbn/config-schema'; -import { Logger, ElasticsearchServiceSetup, SavedObjectsClient } from 'src/core/server'; +import { + Logger, + ElasticsearchServiceSetup, + SavedObjectsClient, + kibanaResponseFactory, +} from '../../../../../src/core/server'; import { ReindexStatus } from '../../common/types'; import { versionCheckHandlerWrapper } from '../lib/es_version_precheck'; import { reindexServiceFactory, ReindexWorker } from '../lib/reindexing'; @@ -13,6 +18,16 @@ import { CredentialStore } from '../lib/reindexing/credential_store'; import { reindexActionsFactory } from '../lib/reindexing/reindex_actions'; import { RouteDependencies } from '../types'; import { LicensingPluginSetup } from '../../../licensing/server'; +import { ReindexError } from '../lib/reindexing/error'; +import { + AccessForbidden, + IndexNotFound, + CannotCreateIndex, + ReindexAlreadyInProgress, + ReindexTaskCannotBeDeleted, + ReindexTaskFailed, + MultipleReindexJobsFound, +} from '../lib/reindexing/error_symbols'; interface CreateReindexWorker { logger: Logger; @@ -33,6 +48,29 @@ export function createReindexWorker({ return new ReindexWorker(savedObjects, credentialStore, adminClient, logger, licensing); } +const mapAnyErrorToKibanaHttpResponse = (e: any) => { + if (e instanceof ReindexError) { + switch (e.symbol) { + case AccessForbidden: + return kibanaResponseFactory.forbidden({ body: e.message }); + case IndexNotFound: + return kibanaResponseFactory.notFound({ body: e.message }); + case CannotCreateIndex: + case ReindexTaskCannotBeDeleted: + return kibanaResponseFactory.internalError({ body: e.message }); + case ReindexTaskFailed: + // Bad data + return kibanaResponseFactory.customError({ body: e.message, statusCode: 422 }); + case ReindexAlreadyInProgress: + case MultipleReindexJobsFound: + return kibanaResponseFactory.badRequest({ body: e.message }); + default: + // nothing matched + } + } + return kibanaResponseFactory.internalError({ body: e }); +}; + export function registerReindexIndicesRoutes( { credentialStore, router, licensing, log }: RouteDependencies, getWorker: () => ReindexWorker @@ -94,7 +132,7 @@ export function registerReindexIndicesRoutes( return response.ok({ body: reindexOp.attributes }); } catch (e) { - return response.internalError({ body: e }); + return mapAnyErrorToKibanaHttpResponse(e); } } ) @@ -150,15 +188,7 @@ export function registerReindexIndicesRoutes( }, }); } catch (e) { - if (!e.isBoom) { - return response.internalError({ body: e }); - } - return response.customError({ - body: { - message: e.message, - }, - statusCode: e.statusCode, - }); + return mapAnyErrorToKibanaHttpResponse(e); } } ) @@ -201,15 +231,7 @@ export function registerReindexIndicesRoutes( return response.ok({ body: { acknowledged: true } }); } catch (e) { - if (!e.isBoom) { - return response.internalError({ body: e }); - } - return response.customError({ - body: { - message: e.message, - }, - statusCode: e.statusCode, - }); + return mapAnyErrorToKibanaHttpResponse(e); } } ) From d7601cc7431e1fdf9a1d4bc39640b10c897c6f3d Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Mon, 2 Mar 2020 13:15:29 +0100 Subject: [PATCH 02/36] Retry migration operations which fail due to snapshot in progress (#58884) Co-authored-by: Elastic Machine --- .../server/elasticsearch/retry_call_cluster.test.ts | 13 +++++++++++++ src/core/server/elasticsearch/retry_call_cluster.ts | 13 +++---------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/core/server/elasticsearch/retry_call_cluster.test.ts b/src/core/server/elasticsearch/retry_call_cluster.test.ts index b5a5185ab39d9..4f391f0aba34b 100644 --- a/src/core/server/elasticsearch/retry_call_cluster.test.ts +++ b/src/core/server/elasticsearch/retry_call_cluster.test.ts @@ -89,6 +89,19 @@ describe('migrationsRetryCallCluster', () => { }); }); + it('retries ES API calls that rejects with snapshot_in_progress_exception', () => { + expect.assertions(1); + const callEsApi = jest.fn(); + let i = 0; + callEsApi.mockImplementation(() => { + return i++ <= 2 + ? Promise.reject({ body: { error: { type: 'snapshot_in_progress_exception' } } }) + : Promise.resolve('success'); + }); + const retried = migrationsRetryCallCluster(callEsApi, mockLogger.get('mock log'), 1); + return expect(retried('endpoint')).resolves.toMatchInlineSnapshot(`"success"`); + }); + it('rejects when ES API calls reject with other errors', async () => { expect.assertions(3); const callEsApi = jest.fn(); diff --git a/src/core/server/elasticsearch/retry_call_cluster.ts b/src/core/server/elasticsearch/retry_call_cluster.ts index ea3cc0b90c077..901b801159cb6 100644 --- a/src/core/server/elasticsearch/retry_call_cluster.ts +++ b/src/core/server/elasticsearch/retry_call_cluster.ts @@ -64,7 +64,8 @@ export function migrationsRetryCallCluster( error instanceof esErrors.AuthenticationException || error instanceof esErrors.AuthorizationException || // @ts-ignore - error instanceof esErrors.Gone + error instanceof esErrors.Gone || + error?.body?.error?.type === 'snapshot_in_progress_exception' ); }, timer(delay), @@ -85,15 +86,7 @@ export function migrationsRetryCallCluster( * * @param apiCaller */ - -// TODO: Replace with APICaller from './scoped_cluster_client' once #46668 is merged -export function retryCallCluster( - apiCaller: ( - endpoint: string, - clientParams: Record, - options?: CallAPIOptions - ) => Promise -) { +export function retryCallCluster(apiCaller: APICaller) { return (endpoint: string, clientParams: Record = {}, options?: CallAPIOptions) => { return defer(() => apiCaller(endpoint, clientParams, options)) .pipe( From 810a6b474817a19e19e49d144f17014f16221c8d Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Mon, 2 Mar 2020 13:37:20 +0100 Subject: [PATCH 03/36] merge only plain objects (#59011) --- src/core/utils/merge.test.ts | 24 ++++++++++++++++++++++++ src/core/utils/merge.ts | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/core/utils/merge.test.ts b/src/core/utils/merge.test.ts index c857e980dec21..7ef07a83399ac 100644 --- a/src/core/utils/merge.test.ts +++ b/src/core/utils/merge.test.ts @@ -17,6 +17,7 @@ * under the License. */ +// eslint-disable-next-line max-classes-per-file import { merge } from './merge'; describe('merge', () => { @@ -62,6 +63,29 @@ describe('merge', () => { expect(merge({ a: 0 }, { a: 1 }, {})).toEqual({ a: 1 }); }); + test('does not merge class instances', () => { + class Folder { + constructor(public readonly path: string) {} + getPath() { + return this.path; + } + } + class File { + constructor(public readonly content: string) {} + getContent() { + return this.content; + } + } + const folder = new Folder('/etc'); + const file = new File('yolo'); + + const result = merge({}, { content: folder }, { content: file }); + expect(result).toStrictEqual({ + content: file, + }); + expect(result.content.getContent()).toBe('yolo'); + }); + test(`doesn't pollute prototypes`, () => { merge({}, JSON.parse('{ "__proto__": { "foo": "bar" } }')); merge({}, JSON.parse('{ "constructor": { "prototype": { "foo": "bar" } } }')); diff --git a/src/core/utils/merge.ts b/src/core/utils/merge.ts index 8e5d9f4860d95..43878c27b1e19 100644 --- a/src/core/utils/merge.ts +++ b/src/core/utils/merge.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ - +import { isPlainObject } from 'lodash'; /** * Deeply merges two objects, omitting undefined values, and not deeply merging Arrays. * @@ -60,7 +60,7 @@ export function merge>( ) as TReturn; } -const isMergable = (obj: any) => typeof obj === 'object' && obj !== null && !Array.isArray(obj); +const isMergable = (obj: any) => isPlainObject(obj); const mergeObjects = , U extends Record>( baseObj: T, From c9ebeb7cff9a8b1f25623ee4950074f7c7bc24c5 Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Mon, 2 Mar 2020 13:07:02 +0000 Subject: [PATCH 04/36] explicit ui exports from data plugin (#57764) * explicit ui exports * Fix imports in data plugin Co-authored-by: Elastic Machine --- src/plugins/data/public/index.ts | 15 ++++++++++++++- .../ui/query_string_input/query_string_input.tsx | 3 ++- .../data/public/ui/search_bar/search_bar.tsx | 4 +--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 5dcf51ecc81eb..a5f4ce2ce3c58 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -286,6 +286,19 @@ export { export { IRequestTypesMap, IResponseTypesMap } from './search'; export * from './search'; +/* + * UI components + */ + +export { + SearchBar, + SearchBarProps, + StatefulSearchBarProps, + FilterBar, + QueryStringInput, + IndexPatternSelect, +} from './ui'; + /** * Types to be shared externally * @public @@ -310,7 +323,7 @@ export { TimefilterContract, TimeHistoryContract, } from './query'; -export * from './ui'; + export { // kbn field types castEsToKbnFieldTypeName, diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index 018c2927031d0..a51362d0ba92e 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -34,13 +34,14 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { debounce, compact, isEqual } from 'lodash'; import { Toast } from 'src/core/public'; -import { IDataPluginServices, IIndexPattern, SuggestionsComponent, Query } from '../..'; +import { IDataPluginServices, IIndexPattern, Query } from '../..'; import { QuerySuggestion, QuerySuggestionTypes } from '../../autocomplete'; import { withKibana, KibanaReactContextValue, toMountPoint } from '../../../../kibana_react/public'; import { fetchIndexPatterns } from './fetch_index_patterns'; import { QueryLanguageSwitcher } from './language_switcher'; import { PersistedLog, getQueryLog, matchPairs, toUser, fromUser } from '../../query'; +import { SuggestionsComponent } from '..'; interface Props { kibana: KibanaReactContextValue; diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index 66ad4dfb12e97..5083a1e68c6dd 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -33,12 +33,10 @@ import { IIndexPattern, FilterBar, SavedQuery, - SavedQueryMeta, - SaveQueryForm, - SavedQueryManagementComponent, } from '../..'; import { QueryBarTopRow } from '../query_string_input/query_bar_top_row'; import { SavedQueryAttributes, TimeHistoryContract } from '../../query'; +import { SavedQueryMeta, SavedQueryManagementComponent, SaveQueryForm } from '..'; interface SearchBarInjectedDeps { kibana: KibanaReactContextValue; From b5dd99c4667e4ebf452073eee922c4bd34da6cce Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Mon, 2 Mar 2020 16:50:44 +0300 Subject: [PATCH 05/36] Converted terms_other_bucket_helper to TS. Migrated tests to jest. (#58143) * Converted terms_other_bucket_helper to TS. Migrated tests to jest. * Fixed some remarks * fix PR comments * Fixed tests * Fixed types Co-authored-by: Elastic Machine Co-authored-by: Alexey Antonov --- .../_terms_other_bucket_helper.test.ts} | 218 ++++++++++++------ ...elper.js => _terms_other_bucket_helper.ts} | 135 +++++++---- .../data/public/search/aggs/buckets/terms.ts | 1 - 3 files changed, 233 insertions(+), 121 deletions(-) rename src/legacy/core_plugins/data/public/search/aggs/{__tests__/buckets/_terms_other_bucket_helper.js => buckets/_terms_other_bucket_helper.test.ts} (54%) rename src/legacy/core_plugins/data/public/search/aggs/buckets/{_terms_other_bucket_helper.js => _terms_other_bucket_helper.ts} (65%) diff --git a/src/legacy/core_plugins/data/public/search/aggs/__tests__/buckets/_terms_other_bucket_helper.js b/src/legacy/core_plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.test.ts similarity index 54% rename from src/legacy/core_plugins/data/public/search/aggs/__tests__/buckets/_terms_other_bucket_helper.js rename to src/legacy/core_plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.test.ts index 749dad377f2e2..976ab57c00b63 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/__tests__/buckets/_terms_other_bucket_helper.js +++ b/src/legacy/core_plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.test.ts @@ -17,39 +17,73 @@ * under the License. */ -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; import { buildOtherBucketAgg, mergeOtherBucketAggResponse, updateMissingBucket, -} from '../../buckets/_terms_other_bucket_helper'; -import { start as visualizationsStart } from '../../../../../../../core_plugins/visualizations/public/np_ready/public/legacy'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +} from './_terms_other_bucket_helper'; +import { AggConfigs, CreateAggConfigParams } from '../agg_configs'; +import { BUCKET_TYPES } from './bucket_agg_types'; +import { IBucketAggConfig } from './_bucket_agg_type'; +import { mockDataServices, mockAggTypesRegistry } from '../test_helpers'; -const visConfigSingleTerm = { - type: 'pie', +const indexPattern = { + id: '1234', + title: 'logstash-*', + fields: [ + { + name: 'field', + }, + ], +} as any; + +const singleTerm = { aggs: [ { - type: 'terms', - schema: 'segment', - params: { field: 'machine.os.raw', otherBucket: true, missingBucket: true }, + id: '1', + type: BUCKET_TYPES.TERMS, + params: { + field: { + name: 'machine.os.raw', + indexPattern, + filterable: true, + }, + otherBucket: true, + missingBucket: true, + }, }, ], }; -const visConfigNestedTerm = { - type: 'pie', +const nestedTerm = { aggs: [ { - type: 'terms', - schema: 'segment', - params: { field: 'geo.src', size: 2, otherBucket: false, missingBucket: false }, + id: '1', + type: BUCKET_TYPES.TERMS, + params: { + field: { + name: 'geo.src', + indexPattern, + filterable: true, + }, + size: 2, + otherBucket: false, + missingBucket: false, + }, }, { - type: 'terms', - schema: 'segment', - params: { field: 'machine.os.raw', size: 2, otherBucket: true, missingBucket: true }, + id: '2', + type: BUCKET_TYPES.TERMS, + params: { + field: { + name: 'machine.os.raw', + indexPattern, + filterable: true, + }, + size: 2, + otherBucket: true, + missingBucket: true, + }, }, ], }; @@ -183,28 +217,36 @@ const nestedOtherResponse = { status: 200, }; -describe('Terms Agg Other bucket helper', () => { - let vis; +jest.mock('ui/new_platform'); - function init(aggConfig) { - ngMock.module('kibana'); - ngMock.inject(Private => { - const indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); +describe('Terms Agg Other bucket helper', () => { + const typesRegistry = mockAggTypesRegistry(); + const getAggConfigs = (aggs: CreateAggConfigParams[] = []) => { + return new AggConfigs(indexPattern, [...aggs], { typesRegistry }); + }; - vis = new visualizationsStart.Vis(indexPattern, aggConfig); - }); - } + beforeEach(() => { + mockDataServices(); + }); describe('buildOtherBucketAgg', () => { - it('returns a function', () => { - init(visConfigSingleTerm); - const agg = buildOtherBucketAgg(vis.aggs, vis.aggs.aggs[0], singleTermResponse); - expect(agg).to.be.a('function'); + test('returns a function', () => { + const aggConfigs = getAggConfigs(singleTerm.aggs); + const agg = buildOtherBucketAgg( + aggConfigs, + aggConfigs.aggs[0] as IBucketAggConfig, + singleTermResponse + ); + expect(typeof agg).toBe('function'); }); - it('correctly builds query with single terms agg', () => { - init(visConfigSingleTerm); - const agg = buildOtherBucketAgg(vis.aggs, vis.aggs.aggs[0], singleTermResponse)(); + test('correctly builds query with single terms agg', () => { + const aggConfigs = getAggConfigs(singleTerm.aggs); + const agg = buildOtherBucketAgg( + aggConfigs, + aggConfigs.aggs[0] as IBucketAggConfig, + singleTermResponse + ); const expectedResponse = { aggs: undefined, filters: { @@ -223,13 +265,19 @@ describe('Terms Agg Other bucket helper', () => { }, }, }; - - expect(agg['other-filter']).to.eql(expectedResponse); + expect(agg).toBeDefined(); + if (agg) { + expect(agg()['other-filter']).toEqual(expectedResponse); + } }); - it('correctly builds query for nested terms agg', () => { - init(visConfigNestedTerm); - const agg = buildOtherBucketAgg(vis.aggs, vis.aggs.aggs[1], nestedTermResponse)(); + test('correctly builds query for nested terms agg', () => { + const aggConfigs = getAggConfigs(nestedTerm.aggs); + const agg = buildOtherBucketAgg( + aggConfigs, + aggConfigs.aggs[1] as IBucketAggConfig, + nestedTermResponse + ); const expectedResponse = { 'other-filter': { aggs: undefined, @@ -267,54 +315,84 @@ describe('Terms Agg Other bucket helper', () => { }, }, }; - - expect(agg).to.eql(expectedResponse); + expect(agg).toBeDefined(); + if (agg) { + expect(agg()).toEqual(expectedResponse); + } }); - it('returns false when nested terms agg has no buckets', () => { - init(visConfigNestedTerm); - const agg = buildOtherBucketAgg(vis.aggs, vis.aggs.aggs[1], nestedTermResponseNoResults); - expect(agg).to.eql(false); + test('returns false when nested terms agg has no buckets', () => { + const aggConfigs = getAggConfigs(nestedTerm.aggs); + const agg = buildOtherBucketAgg( + aggConfigs, + aggConfigs.aggs[1] as IBucketAggConfig, + nestedTermResponseNoResults + ); + + expect(agg).toEqual(false); }); }); describe('mergeOtherBucketAggResponse', () => { - it('correctly merges other bucket with single terms agg', () => { - init(visConfigSingleTerm); - const otherAggConfig = buildOtherBucketAgg(vis.aggs, vis.aggs.aggs[0], singleTermResponse)(); - const mergedResponse = mergeOtherBucketAggResponse( - vis.aggs, - singleTermResponse, - singleOtherResponse, - vis.aggs.aggs[0], - otherAggConfig + test('correctly merges other bucket with single terms agg', () => { + const aggConfigs = getAggConfigs(singleTerm.aggs); + const otherAggConfig = buildOtherBucketAgg( + aggConfigs, + aggConfigs.aggs[0] as IBucketAggConfig, + singleTermResponse ); - expect(mergedResponse.aggregations['1'].buckets[3].key).to.equal('__other__'); + expect(otherAggConfig).toBeDefined(); + if (otherAggConfig) { + const mergedResponse = mergeOtherBucketAggResponse( + aggConfigs, + singleTermResponse, + singleOtherResponse, + aggConfigs.aggs[0] as IBucketAggConfig, + otherAggConfig() + ); + expect(mergedResponse.aggregations['1'].buckets[3].key).toEqual('__other__'); + } }); - it('correctly merges other bucket with nested terms agg', () => { - init(visConfigNestedTerm); - const otherAggConfig = buildOtherBucketAgg(vis.aggs, vis.aggs.aggs[1], nestedTermResponse)(); - const mergedResponse = mergeOtherBucketAggResponse( - vis.aggs, - nestedTermResponse, - nestedOtherResponse, - vis.aggs.aggs[1], - otherAggConfig + test('correctly merges other bucket with nested terms agg', () => { + const aggConfigs = getAggConfigs(nestedTerm.aggs); + const otherAggConfig = buildOtherBucketAgg( + aggConfigs, + aggConfigs.aggs[1] as IBucketAggConfig, + nestedTermResponse ); - expect(mergedResponse.aggregations['1'].buckets[1]['2'].buckets[3].key).to.equal('__other__'); + expect(otherAggConfig).toBeDefined(); + if (otherAggConfig) { + const mergedResponse = mergeOtherBucketAggResponse( + aggConfigs, + nestedTermResponse, + nestedOtherResponse, + aggConfigs.aggs[1] as IBucketAggConfig, + otherAggConfig() + ); + + expect(mergedResponse.aggregations['1'].buckets[1]['2'].buckets[3].key).toEqual( + '__other__' + ); + } }); }); describe('updateMissingBucket', () => { - it('correctly updates missing bucket key', () => { - init(visConfigNestedTerm); - const updatedResponse = updateMissingBucket(singleTermResponse, vis.aggs, vis.aggs.aggs[0]); + test('correctly updates missing bucket key', () => { + const aggConfigs = getAggConfigs(nestedTerm.aggs); + const updatedResponse = updateMissingBucket( + singleTermResponse, + aggConfigs, + aggConfigs.aggs[0] as IBucketAggConfig + ); expect( - updatedResponse.aggregations['1'].buckets.find(bucket => bucket.key === '__missing__') - ).to.not.be('undefined'); + updatedResponse.aggregations['1'].buckets.find( + (bucket: Record) => bucket.key === '__missing__' + ) + ).toBeDefined(); }); }); }); diff --git a/src/legacy/core_plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.js b/src/legacy/core_plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.ts similarity index 65% rename from src/legacy/core_plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.js rename to src/legacy/core_plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.ts index ddab360161744..42db37c81eadd 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.js +++ b/src/legacy/core_plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.ts @@ -17,21 +17,24 @@ * under the License. */ -import _ from 'lodash'; +import { isNumber, keys, values, find, each, cloneDeep, flatten } from 'lodash'; import { esFilters, esQuery } from '../../../../../../../plugins/data/public'; import { AggGroupNames } from '../agg_groups'; +import { IAggConfigs } from '../agg_configs'; +import { IBucketAggConfig } from './_bucket_agg_type'; /** * walks the aggregation DSL and returns DSL starting at aggregation with id of startFromAggId * @param aggNestedDsl: aggregation config DSL (top level) * @param startFromId: id of an aggregation from where we want to get the nested DSL */ -const getNestedAggDSL = (aggNestedDsl, startFromAggId) => { +const getNestedAggDSL = (aggNestedDsl: Record, startFromAggId: string): any => { if (aggNestedDsl[startFromAggId]) { return aggNestedDsl[startFromAggId]; } - const nestedAggs = _.values(aggNestedDsl); + const nestedAggs: Array> = values(aggNestedDsl); let aggs; + for (let i = 0; i < nestedAggs.length; i++) { if (nestedAggs[i].aggs && (aggs = getNestedAggDSL(nestedAggs[i].aggs, startFromAggId))) { return aggs; @@ -46,27 +49,34 @@ const getNestedAggDSL = (aggNestedDsl, startFromAggId) => { * @param aggWithOtherBucket: AggConfig of the aggregation with other bucket enabled * @param key: key from the other bucket request for a specific other bucket */ -const getAggResultBuckets = (aggConfigs, response, aggWithOtherBucket, key) => { +const getAggResultBuckets = ( + aggConfigs: IAggConfigs, + response: any, + aggWithOtherBucket: IBucketAggConfig, + key: string +) => { const keyParts = key.split('-'); let responseAgg = response; for (const i in keyParts) { if (keyParts[i]) { - const responseAggs = _.values(responseAgg); + const responseAggs: Array> = values(responseAgg); // If you have multi aggs, we cannot just assume the first one is the `other` bucket, // so we need to loop over each agg until we find it. for (let aggId = 0; aggId < responseAggs.length; aggId++) { - const agg = responseAggs[aggId]; - const aggKey = _.keys(responseAgg)[aggId]; - const aggConfig = _.find(aggConfigs.aggs, agg => agg.id === aggKey); - const bucket = _.find(agg.buckets, (bucket, bucketObjKey) => { - const bucketKey = aggConfig - .getKey(bucket, Number.isInteger(bucketObjKey) ? null : bucketObjKey) - .toString(); - return bucketKey === keyParts[i]; - }); - if (bucket) { - responseAgg = bucket; - break; + const aggById = responseAggs[aggId]; + const aggKey = keys(responseAgg)[aggId]; + const aggConfig = find(aggConfigs.aggs, agg => agg.id === aggKey); + if (aggConfig) { + const aggResultBucket = find(aggById.buckets, (bucket, bucketObjKey) => { + const bucketKey = aggConfig + .getKey(bucket, isNumber(bucketObjKey) ? undefined : bucketObjKey) + .toString(); + return bucketKey === keyParts[i]; + }); + if (aggResultBucket) { + responseAgg = aggResultBucket; + break; + } } } } @@ -82,21 +92,20 @@ const getAggResultBuckets = (aggConfigs, response, aggWithOtherBucket, key) => { * @param responseAggs: array of aggregations from response * @param aggId: id of the aggregation with missing bucket */ -const getAggConfigResultMissingBuckets = (responseAggs, aggId) => { +const getAggConfigResultMissingBuckets = (responseAggs: any, aggId: string) => { const missingKey = '__missing__'; - let resultBuckets = []; + let resultBuckets: Array> = []; if (responseAggs[aggId]) { - const matchingBucket = responseAggs[aggId].buckets.find(bucket => bucket.key === missingKey); + const matchingBucket = responseAggs[aggId].buckets.find( + (bucket: Record) => bucket.key === missingKey + ); if (matchingBucket) resultBuckets.push(matchingBucket); return resultBuckets; } - _.each(responseAggs, agg => { + each(responseAggs, agg => { if (agg.buckets) { - _.each(agg.buckets, bucket => { - resultBuckets = [ - ...resultBuckets, - ...getAggConfigResultMissingBuckets(bucket, aggId, missingKey), - ]; + each(agg.buckets, bucket => { + resultBuckets = [...resultBuckets, ...getAggConfigResultMissingBuckets(bucket, aggId)]; }); } }); @@ -110,13 +119,24 @@ const getAggConfigResultMissingBuckets = (responseAggs, aggId) => { * @param key: the key for this specific other bucket * @param otherAgg: AggConfig of the aggregation with other bucket */ -const getOtherAggTerms = (requestAgg, key, otherAgg) => { +const getOtherAggTerms = ( + requestAgg: Record, + key: string, + otherAgg: IBucketAggConfig +) => { return requestAgg['other-filter'].filters.filters[key].bool.must_not - .filter(filter => filter.match_phrase && filter.match_phrase[otherAgg.params.field.name]) - .map(filter => filter.match_phrase[otherAgg.params.field.name]); + .filter( + (filter: Record) => + filter.match_phrase && filter.match_phrase[otherAgg.params.field.name] + ) + .map((filter: Record) => filter.match_phrase[otherAgg.params.field.name]); }; -export const buildOtherBucketAgg = (aggConfigs, aggWithOtherBucket, response) => { +export const buildOtherBucketAgg = ( + aggConfigs: IAggConfigs, + aggWithOtherBucket: IBucketAggConfig, + response: any +) => { const bucketAggs = aggConfigs.aggs.filter(agg => agg.type.type === AggGroupNames.Buckets); const index = bucketAggs.findIndex(agg => agg.id === aggWithOtherBucket.id); const aggs = aggConfigs.toDsl(); @@ -130,6 +150,7 @@ export const buildOtherBucketAgg = (aggConfigs, aggWithOtherBucket, response) => params: { filters: [], }, + enabled: false, }, { addToAggConfigs: false, @@ -145,25 +166,31 @@ export const buildOtherBucketAgg = (aggConfigs, aggWithOtherBucket, response) => let noAggBucketResults = false; // recursively create filters for all parent aggregation buckets - const walkBucketTree = (aggIndex, aggs, aggId, filters, key) => { + const walkBucketTree = ( + aggIndex: number, + aggregations: any, + aggId: string, + filters: any[], + key: string + ) => { // make sure there are actually results for the buckets - if (aggs[aggId].buckets.length < 1) { + if (aggregations[aggId].buckets.length < 1) { noAggBucketResults = true; return; } - const agg = aggs[aggId]; + const agg = aggregations[aggId]; const newAggIndex = aggIndex + 1; const newAgg = bucketAggs[newAggIndex]; const currentAgg = bucketAggs[aggIndex]; if (aggIndex < index) { - _.each(agg.buckets, (bucket, bucketObjKey) => { + each(agg.buckets, (bucket: any, bucketObjKey) => { const bucketKey = currentAgg.getKey( bucket, - Number.isInteger(bucketObjKey) ? null : bucketObjKey + isNumber(bucketObjKey) ? undefined : bucketObjKey ); - const filter = _.cloneDeep(bucket.filters) || currentAgg.createFilter(bucketKey); - const newFilters = _.flatten([...filters, filter]); + const filter = cloneDeep(bucket.filters) || currentAgg.createFilter(bucketKey); + const newFilters = flatten([...filters, filter]); walkBucketTree( newAggIndex, bucket, @@ -177,7 +204,7 @@ export const buildOtherBucketAgg = (aggConfigs, aggWithOtherBucket, response) => if ( !aggWithOtherBucket.params.missingBucket || - agg.buckets.some(bucket => bucket.key === '__missing__') + agg.buckets.some((bucket: { key: string }) => bucket.key === '__missing__') ) { filters.push( esFilters.buildExistsFilter( @@ -188,7 +215,7 @@ export const buildOtherBucketAgg = (aggConfigs, aggWithOtherBucket, response) => } // create not filters for all the buckets - _.each(agg.buckets, bucket => { + each(agg.buckets, bucket => { if (bucket.key === '__missing__') return; const filter = currentAgg.createFilter(bucket.key); filter.meta.negate = true; @@ -214,15 +241,15 @@ export const buildOtherBucketAgg = (aggConfigs, aggWithOtherBucket, response) => }; export const mergeOtherBucketAggResponse = ( - aggsConfig, - response, - otherResponse, - otherAgg, - requestAgg + aggsConfig: IAggConfigs, + response: any, + otherResponse: any, + otherAgg: IBucketAggConfig, + requestAgg: Record ) => { - const updatedResponse = _.cloneDeep(response); - _.each(otherResponse.aggregations['other-filter'].buckets, (bucket, key) => { - if (!bucket.doc_count) return; + const updatedResponse = cloneDeep(response); + each(otherResponse.aggregations['other-filter'].buckets, (bucket, key) => { + if (!bucket.doc_count || key === undefined) return; const bucketKey = key.replace(/^-/, ''); const aggResultBuckets = getAggResultBuckets( aggsConfig, @@ -241,7 +268,11 @@ export const mergeOtherBucketAggResponse = ( bucket.filters = [phraseFilter]; bucket.key = '__other__'; - if (aggResultBuckets.some(bucket => bucket.key === '__missing__')) { + if ( + aggResultBuckets.some( + (aggResultBucket: Record) => aggResultBucket.key === '__missing__' + ) + ) { bucket.filters.push( esFilters.buildExistsFilter(otherAgg.params.field, otherAgg.params.field.indexPattern) ); @@ -251,8 +282,12 @@ export const mergeOtherBucketAggResponse = ( return updatedResponse; }; -export const updateMissingBucket = (response, aggConfigs, agg) => { - const updatedResponse = _.cloneDeep(response); +export const updateMissingBucket = ( + response: any, + aggConfigs: IAggConfigs, + agg: IBucketAggConfig +) => { + const updatedResponse = cloneDeep(response); const aggResultBuckets = getAggConfigResultMissingBuckets(updatedResponse.aggregations, agg.id); aggResultBuckets.forEach(bucket => { bucket.key = '__missing__'; diff --git a/src/legacy/core_plugins/data/public/search/aggs/buckets/terms.ts b/src/legacy/core_plugins/data/public/search/aggs/buckets/terms.ts index 0ed44aa876744..8fd95c86d8476 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/buckets/terms.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/buckets/terms.ts @@ -39,7 +39,6 @@ import { buildOtherBucketAgg, mergeOtherBucketAggResponse, updateMissingBucket, - // @ts-ignore } from './_terms_other_bucket_helper'; import { Schemas } from '../schemas'; import { AggGroupNames } from '../agg_groups'; From a3be4e22220df8724d7ceb5667f4af37647042ea Mon Sep 17 00:00:00 2001 From: kqualters-elastic <56408403+kqualters-elastic@users.noreply.github.com> Date: Mon, 2 Mar 2020 09:20:21 -0500 Subject: [PATCH 06/36] [Endpoint] add resolver middleware (#58288) * Add resolver middleware * Update types to match events, use sample events in useCamera tests * add predicate to convert alertdata to legacy endpoint event * Use mock event generator in tests * Guard against events not having agent or endpoint fields Co-authored-by: Robert Austin --- x-pack/plugins/endpoint/common/types.ts | 34 +++- .../public/applications/endpoint/index.tsx | 76 ++++--- .../store/alerts/mock_alert_result_list.ts | 1 + .../endpoint/store/alerts/selectors.ts | 23 ++- .../endpoint/view/alerts/index.test.tsx | 21 +- .../endpoint/view/alerts/index.tsx | 9 +- .../endpoint/view/alerts/resolver.tsx | 35 ++++ .../resolver/models/indexed_process_tree.ts | 20 +- .../resolver/models/process_event.test.ts | 10 +- .../resolver/models/process_event.ts | 20 +- .../models/process_event_test_helpers.ts | 43 ++-- .../embeddables/resolver/store/actions.ts | 31 ++- .../data/__snapshots__/graphing.test.ts.snap | 189 +++++++++--------- .../embeddables/resolver/store/data/action.ts | 4 +- .../resolver/store/data/graphing.test.ts | 73 +++---- .../resolver/store/data/reducer.ts | 10 +- .../resolver/store/data/selectors.ts | 14 +- .../embeddables/resolver/store/index.ts | 10 +- .../embeddables/resolver/store/methods.ts | 5 +- .../embeddables/resolver/store/middleware.ts | 45 +++++ .../embeddables/resolver/store/selectors.ts | 5 + .../public/embeddables/resolver/types.ts | 16 +- .../embeddables/resolver/view/index.tsx | 77 ++++--- .../embeddables/resolver/view/panel.tsx | 17 +- .../resolver/view/process_event_dot.tsx | 7 +- .../resolver/view/use_camera.test.tsx | 49 +++-- x-pack/plugins/endpoint/public/plugin.ts | 11 +- .../routes/resolver/queries/children.test.ts | 8 +- .../resolver/queries/related_events.test.ts | 8 +- .../routes/resolver/utils/pagination.ts | 8 +- 30 files changed, 578 insertions(+), 301 deletions(-) create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx create mode 100644 x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts index d3df972290759..6d904fda6f747 100644 --- a/x-pack/plugins/endpoint/common/types.ts +++ b/x-pack/plugins/endpoint/common/types.ts @@ -115,6 +115,10 @@ export type AlertEvent = Immutable<{ score: number; }; }; + process?: { + unique_pid: number; + pid: number; + }; host: { hostname: string; ip: string; @@ -122,10 +126,9 @@ export type AlertEvent = Immutable<{ name: string; }; }; - process: { - pid: number; - }; thread: {}; + endpoint?: {}; + endgame?: {}; }>; /** @@ -186,22 +189,34 @@ export interface ESTotal { export type AlertHits = SearchResponse['hits']['hits']; export interface LegacyEndpointEvent { - '@timestamp': Date; + '@timestamp': number; endgame: { - event_type_full: string; - event_subtype_full: string; + pid?: number; + ppid?: number; + event_type_full?: string; + event_subtype_full?: string; + event_timestamp?: number; + event_type?: number; unique_pid: number; - unique_ppid: number; - serial_event_id: number; + unique_ppid?: number; + machine_id?: string; + process_name?: string; + process_path?: string; + timestamp_utc?: string; + serial_event_id?: number; }; agent: { id: string; type: string; + version: string; }; + process?: object; + rule?: object; + user?: object; } export interface EndpointEvent { - '@timestamp': Date; + '@timestamp': number; event: { category: string; type: string; @@ -216,6 +231,7 @@ export interface EndpointEvent { }; }; agent: { + id: string; type: string; }; } diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx index 7ab66817a0888..296587706e6ac 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx @@ -11,6 +11,7 @@ import { I18nProvider, FormattedMessage } from '@kbn/i18n/react'; import { Route, Switch, BrowserRouter } from 'react-router-dom'; import { Provider } from 'react-redux'; import { Store } from 'redux'; +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { RouteCapture } from './view/route_capture'; import { appStoreFactory } from './store'; import { AlertIndex } from './view/alerts'; @@ -24,9 +25,7 @@ import { HeaderNavigation } from './components/header_nav'; export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMountParameters) { coreStart.http.get('/api/endpoint/hello-world'); const store = appStoreFactory(coreStart); - - ReactDOM.render(, element); - + ReactDOM.render(, element); return () => { ReactDOM.unmountComponentAtNode(element); }; @@ -35,35 +34,46 @@ export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMou interface RouterProps { basename: string; store: Store; + coreStart: CoreStart; } -const AppRoot: React.FunctionComponent = React.memo(({ basename, store }) => ( - - - - - - - ( -

- -

- )} - /> - - } /> - - ( - - )} - /> -
-
-
-
-
-)); +const AppRoot: React.FunctionComponent = React.memo( + ({ basename, store, coreStart: { http } }) => ( + + + + + + + + ( +

+ +

+ )} + /> + + + + ( + + )} + /> +
+
+
+
+
+
+ ) +); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts index b90f897ea2229..8eadb3e7fb3df 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts @@ -43,6 +43,7 @@ export const mockAlertResultList: (options?: { }, process: { pid: 107, + unique_pid: 1, }, host: { hostname: 'HD-c15-bc09190a', diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts index 54add85f0fe04..f217e3cda9191 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts @@ -9,13 +9,13 @@ import { createSelector, createStructuredSelector as createStructuredSelectorWithBadType, } from 'reselect'; -import { Immutable } from '../../../../../common/types'; import { AlertListState, AlertingIndexUIQueryParams, AlertsAPIQueryParams, CreateStructuredSelector, } from '../../types'; +import { Immutable, LegacyEndpointEvent } from '../../../../../common/types'; const createStructuredSelector: CreateStructuredSelector = createStructuredSelectorWithBadType; /** @@ -92,3 +92,24 @@ export const hasSelectedAlert: (state: AlertListState) => boolean = createSelect uiQueryParams, ({ selected_alert: selectedAlert }) => selectedAlert !== undefined ); + +/** + * Determine if the alert event is most likely compatible with LegacyEndpointEvent. + */ +function isAlertEventLegacyEndpointEvent(event: { endgame?: {} }): event is LegacyEndpointEvent { + return event.endgame !== undefined && 'unique_pid' in event.endgame; +} + +export const selectedEvent: ( + state: AlertListState +) => LegacyEndpointEvent | undefined = createSelector( + uiQueryParams, + alertListData, + ({ selected_alert: selectedAlert }, alertList) => { + const found = alertList.find(alert => alert.event.id === selectedAlert); + if (!found) { + return found; + } + return isAlertEventLegacyEndpointEvent(found) ? found : undefined; + } +); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx index 37847553d512a..fe362f21a178e 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx @@ -11,6 +11,7 @@ import { I18nProvider } from '@kbn/i18n/react'; import { AlertIndex } from './index'; import { appStoreFactory } from '../../store'; import { coreMock } from 'src/core/public/mocks'; +import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public'; import { fireEvent, waitForElement, act } from '@testing-library/react'; import { RouteCapture } from '../route_capture'; import { createMemoryHistory, MemoryHistory } from 'history'; @@ -44,6 +45,7 @@ describe('when on the alerting page', () => { * Create a store, with the middleware disabled. We don't want side effects being created by our code in this test. */ store = appStoreFactory(coreMock.createStart(), true); + /** * Render the test component, use this after setting up anything in `beforeEach`. */ @@ -56,13 +58,15 @@ describe('when on the alerting page', () => { */ return reactTestingLibrary.render( - - - - - - - + + + + + + + + + ); }; @@ -136,6 +140,9 @@ describe('when on the alerting page', () => { it('should show the flyout', async () => { await render().findByTestId('alertDetailFlyout'); }); + it('should render resolver', async () => { + await render().findByTestId('alertResolver'); + }); describe('when the user clicks the close button on the flyout', () => { let renderResult: reactTestingLibrary.RenderResult; beforeEach(async () => { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx index 6f88727575557..3c229484ede4e 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx @@ -25,6 +25,7 @@ import { urlFromQueryParams } from './url_from_query_params'; import { AlertData } from '../../../../../common/types'; import * as selectors from '../../store/alerts/selectors'; import { useAlertListSelector } from './hooks/use_alerts_selector'; +import { AlertDetailResolver } from './resolver'; export const AlertIndex = memo(() => { const history = useHistory(); @@ -86,6 +87,7 @@ export const AlertIndex = memo(() => { const alertListData = useAlertListSelector(selectors.alertListData); const hasSelectedAlert = useAlertListSelector(selectors.hasSelectedAlert); const queryParams = useAlertListSelector(selectors.uiQueryParams); + const selectedEvent = useAlertListSelector(selectors.selectedEvent); const onChangeItemsPerPage = useCallback( newPageSize => { @@ -132,12 +134,11 @@ export const AlertIndex = memo(() => { } const row = alertListData[rowIndex % pageSize]; - if (columnId === 'alert_type') { return ( {i18n.translate( 'xpack.endpoint.application.endpoint.alerts.alertType.maliciousFileDescription', @@ -213,7 +214,9 @@ export const AlertIndex = memo(() => { - + + + )} diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx new file mode 100644 index 0000000000000..c7ef7f73dfe05 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx @@ -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 React from 'react'; +import styled from 'styled-components'; +import { Provider } from 'react-redux'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { Resolver } from '../../../../embeddables/resolver/view'; +import { EndpointPluginServices } from '../../../../plugin'; +import { LegacyEndpointEvent } from '../../../../../common/types'; +import { storeFactory } from '../../../../embeddables/resolver/store'; + +export const AlertDetailResolver = styled( + React.memo( + ({ className, selectedEvent }: { className?: string; selectedEvent?: LegacyEndpointEvent }) => { + const context = useKibana(); + const { store } = storeFactory(context); + return ( +
+ + + +
+ ); + } + ) +)` + height: 100%; + width: 100%; + display: flex; + flex-grow: 1; +`; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts index 0eb3505096b4a..6892bf11ecff2 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts @@ -5,15 +5,16 @@ */ import { uniquePidForProcess, uniqueParentPidForProcess } from './process_event'; -import { IndexedProcessTree, ProcessEvent } from '../types'; +import { IndexedProcessTree } from '../types'; +import { LegacyEndpointEvent } from '../../../../common/types'; import { levelOrder as baseLevelOrder } from '../lib/tree_sequencers'; /** * Create a new IndexedProcessTree from an array of ProcessEvents */ -export function factory(processes: ProcessEvent[]): IndexedProcessTree { - const idToChildren = new Map(); - const idToValue = new Map(); +export function factory(processes: LegacyEndpointEvent[]): IndexedProcessTree { + const idToChildren = new Map(); + const idToValue = new Map(); for (const process of processes) { idToValue.set(uniquePidForProcess(process), process); @@ -35,7 +36,10 @@ export function factory(processes: ProcessEvent[]): IndexedProcessTree { /** * Returns an array with any children `ProcessEvent`s of the passed in `process` */ -export function children(tree: IndexedProcessTree, process: ProcessEvent): ProcessEvent[] { +export function children( + tree: IndexedProcessTree, + process: LegacyEndpointEvent +): LegacyEndpointEvent[] { const id = uniquePidForProcess(process); const processChildren = tree.idToChildren.get(id); return processChildren === undefined ? [] : processChildren; @@ -46,8 +50,8 @@ export function children(tree: IndexedProcessTree, process: ProcessEvent): Proce */ export function parent( tree: IndexedProcessTree, - childProcess: ProcessEvent -): ProcessEvent | undefined { + childProcess: LegacyEndpointEvent +): LegacyEndpointEvent | undefined { const uniqueParentPid = uniqueParentPidForProcess(childProcess); if (uniqueParentPid === undefined) { return undefined; @@ -70,7 +74,7 @@ export function root(tree: IndexedProcessTree) { if (size(tree) === 0) { return null; } - let current: ProcessEvent = tree.idToProcess.values().next().value; + let current: LegacyEndpointEvent = tree.idToProcess.values().next().value; while (parent(tree, current) !== undefined) { current = parent(tree, current)!; } diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event.test.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event.test.ts index 3177671a30001..3916396f7402c 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event.test.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event.test.ts @@ -4,22 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ import { eventType } from './process_event'; -import { ProcessEvent } from '../types'; +import { LegacyEndpointEvent } from '../../../../common/types'; import { mockProcessEvent } from './process_event_test_helpers'; describe('process event', () => { describe('eventType', () => { - let event: ProcessEvent; + let event: LegacyEndpointEvent; beforeEach(() => { event = mockProcessEvent({ - data_buffer: { - node_id: 1, + endgame: { + unique_pid: 1, event_type_full: 'process_event', }, }); }); it("returns the right value when the subType is 'creation_event'", () => { - event.data_buffer.event_subtype_full = 'creation_event'; + event.endgame.event_subtype_full = 'creation_event'; expect(eventType(event)).toEqual('processCreated'); }); }); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event.ts index c8496b8e6e7a5..876168d2ed96a 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event.ts @@ -4,23 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ProcessEvent } from '../types'; +import { LegacyEndpointEvent } from '../../../../common/types'; /** * Returns true if the process's eventType is either 'processCreated' or 'processRan'. * Resolver will only render 'graphable' process events. */ -export function isGraphableProcess(event: ProcessEvent) { - return eventType(event) === 'processCreated' || eventType(event) === 'processRan'; +export function isGraphableProcess(passedEvent: LegacyEndpointEvent) { + return eventType(passedEvent) === 'processCreated' || eventType(passedEvent) === 'processRan'; } /** * Returns a custom event type for a process event based on the event's metadata. */ -export function eventType(event: ProcessEvent) { +export function eventType(passedEvent: LegacyEndpointEvent) { const { - data_buffer: { event_type_full: type, event_subtype_full: subType }, - } = event; + endgame: { event_type_full: type, event_subtype_full: subType }, + } = passedEvent; if (type === 'process_event') { if (subType === 'creation_event' || subType === 'fork_event' || subType === 'exec_event') { @@ -41,13 +41,13 @@ export function eventType(event: ProcessEvent) { /** * Returns the process event's pid */ -export function uniquePidForProcess(event: ProcessEvent) { - return event.data_buffer.node_id; +export function uniquePidForProcess(event: LegacyEndpointEvent) { + return event.endgame.unique_pid; } /** * Returns the process event's parent pid */ -export function uniqueParentPidForProcess(event: ProcessEvent) { - return event.data_buffer.source_id; +export function uniqueParentPidForProcess(event: LegacyEndpointEvent) { + return event.endgame.unique_ppid; } diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event_test_helpers.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event_test_helpers.ts index 9a6f19adcc101..e88837d325108 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event_test_helpers.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event_test_helpers.ts @@ -4,33 +4,46 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ProcessEvent } from '../types'; -type DeepPartial = { [K in keyof T]?: DeepPartial }; +import { LegacyEndpointEvent } from '../../../../common/types'; +type DeepPartial = { [K in keyof T]?: DeepPartial }; /** * Creates a mock process event given the 'parts' argument, which can * include all or some process event fields as determined by the ProcessEvent type. * The only field that must be provided is the event's 'node_id' field. * The other fields are populated by the function unless provided in 'parts' */ -export function mockProcessEvent( - parts: { - data_buffer: { node_id: ProcessEvent['data_buffer']['node_id'] }; - } & DeepPartial -): ProcessEvent { - const { data_buffer: dataBuffer } = parts; +export function mockProcessEvent(parts: { + endgame: { + unique_pid: LegacyEndpointEvent['endgame']['unique_pid']; + unique_ppid?: LegacyEndpointEvent['endgame']['unique_ppid']; + process_name?: LegacyEndpointEvent['endgame']['process_name']; + event_subtype_full?: LegacyEndpointEvent['endgame']['event_subtype_full']; + event_type_full?: LegacyEndpointEvent['endgame']['event_type_full']; + } & DeepPartial; +}): LegacyEndpointEvent { + const { endgame: dataBuffer } = parts; return { - event_timestamp: 1, - event_type: 1, - machine_id: '', - ...parts, - data_buffer: { - timestamp_utc: '2019-09-24 01:47:47Z', + endgame: { + ...dataBuffer, + event_timestamp: 1, + event_type: 1, + unique_ppid: 0, + unique_pid: 1, + machine_id: '', event_subtype_full: 'creation_event', event_type_full: 'process_event', process_name: '', process_path: '', - ...dataBuffer, + timestamp_utc: '', + serial_event_id: 1, + }, + '@timestamp': 1582233383000, + agent: { + type: '', + id: '', + version: '', }, + ...parts, }; } diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts index 25f196c76a290..ecba0ec404d44 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts @@ -3,9 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ProcessEvent } from '../types'; import { CameraAction } from './camera'; import { DataAction } from './data'; +import { LegacyEndpointEvent } from '../../../../common/types'; /** * When the user wants to bring a process node front-and-center on the map. @@ -16,7 +16,7 @@ interface UserBroughtProcessIntoView { /** * Used to identify the process node that should be brought into view. */ - readonly process: ProcessEvent; + readonly process: LegacyEndpointEvent; /** * The time (since epoch in milliseconds) when the action was dispatched. */ @@ -24,4 +24,29 @@ interface UserBroughtProcessIntoView { }; } -export type ResolverAction = CameraAction | DataAction | UserBroughtProcessIntoView; +/** + * Used when the alert list selects an alert and the flyout shows resolver. + */ +interface UserChangedSelectedEvent { + readonly type: 'userChangedSelectedEvent'; + readonly payload: { + /** + * Optional because they could have unselected the event. + */ + selectedEvent?: LegacyEndpointEvent; + }; +} + +/** + * Triggered by middleware when the data for resolver needs to be loaded. Used to set state in redux to 'loading'. + */ +interface AppRequestedResolverData { + readonly type: 'appRequestedResolverData'; +} + +export type ResolverAction = + | CameraAction + | DataAction + | UserBroughtProcessIntoView + | UserChangedSelectedEvent + | AppRequestedResolverData; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap index 1dc17054b9f47..b88652097eb5c 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap @@ -12,17 +12,18 @@ Object { "edgeLineSegments": Array [], "processNodePositions": Map { Object { - "data_buffer": Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { "event_subtype_full": "creation_event", "event_type_full": "process_event", - "node_id": 0, "process_name": "", - "process_path": "", - "timestamp_utc": "2019-09-24 01:47:47Z", + "unique_pid": 0, }, - "event_timestamp": 1, - "event_type": 1, - "machine_id": "", } => Array [ 0, -0.8164965809277259, @@ -167,136 +168,137 @@ Object { ], "processNodePositions": Map { Object { - "data_buffer": Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { "event_subtype_full": "creation_event", "event_type_full": "process_event", - "node_id": 0, "process_name": "", - "process_path": "", - "timestamp_utc": "2019-09-24 01:47:47Z", + "unique_pid": 0, }, - "event_timestamp": 1, - "event_type": 1, - "machine_id": "", } => Array [ 0, -0.8164965809277259, ], Object { - "data_buffer": Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { "event_subtype_full": "already_running", "event_type_full": "process_event", - "node_id": 1, - "process_name": "", - "process_path": "", - "source_id": 0, - "timestamp_utc": "2019-09-24 01:47:47Z", + "unique_pid": 1, + "unique_ppid": 0, }, - "event_timestamp": 1, - "event_type": 1, - "machine_id": "", } => Array [ 0, -82.46615467370032, ], Object { - "data_buffer": Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { "event_subtype_full": "creation_event", "event_type_full": "process_event", - "node_id": 2, - "process_name": "", - "process_path": "", - "source_id": 0, - "timestamp_utc": "2019-09-24 01:47:47Z", + "unique_pid": 2, + "unique_ppid": 0, }, - "event_timestamp": 1, - "event_type": 1, - "machine_id": "", } => Array [ 141.4213562373095, -0.8164965809277259, ], Object { - "data_buffer": Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { "event_subtype_full": "creation_event", "event_type_full": "process_event", - "node_id": 3, - "process_name": "", - "process_path": "", - "source_id": 1, - "timestamp_utc": "2019-09-24 01:47:47Z", + "unique_pid": 3, + "unique_ppid": 1, }, - "event_timestamp": 1, - "event_type": 1, - "machine_id": "", } => Array [ 35.35533905932738, -143.70339824327976, ], Object { - "data_buffer": Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { "event_subtype_full": "creation_event", "event_type_full": "process_event", - "node_id": 4, - "process_name": "", - "process_path": "", - "source_id": 1, - "timestamp_utc": "2019-09-24 01:47:47Z", + "unique_pid": 4, + "unique_ppid": 1, }, - "event_timestamp": 1, - "event_type": 1, - "machine_id": "", } => Array [ 106.06601717798213, -102.87856919689347, ], Object { - "data_buffer": Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { "event_subtype_full": "creation_event", "event_type_full": "process_event", - "node_id": 5, - "process_name": "", - "process_path": "", - "source_id": 2, - "timestamp_utc": "2019-09-24 01:47:47Z", + "unique_pid": 5, + "unique_ppid": 2, }, - "event_timestamp": 1, - "event_type": 1, - "machine_id": "", } => Array [ 176.7766952966369, -62.053740150507174, ], Object { - "data_buffer": Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { "event_subtype_full": "creation_event", "event_type_full": "process_event", - "node_id": 6, - "process_name": "", - "process_path": "", - "source_id": 2, - "timestamp_utc": "2019-09-24 01:47:47Z", + "unique_pid": 6, + "unique_ppid": 2, }, - "event_timestamp": 1, - "event_type": 1, - "machine_id": "", } => Array [ 247.48737341529164, -21.228911104120883, ], Object { - "data_buffer": Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { "event_subtype_full": "creation_event", "event_type_full": "process_event", - "node_id": 7, - "process_name": "", - "process_path": "", - "source_id": 6, - "timestamp_utc": "2019-09-24 01:47:47Z", + "unique_pid": 7, + "unique_ppid": 6, }, - "event_timestamp": 1, - "event_type": 1, - "machine_id": "", } => Array [ 318.1980515339464, -62.05374015050717, @@ -321,34 +323,35 @@ Object { ], "processNodePositions": Map { Object { - "data_buffer": Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { "event_subtype_full": "creation_event", "event_type_full": "process_event", - "node_id": 0, "process_name": "", - "process_path": "", - "timestamp_utc": "2019-09-24 01:47:47Z", + "unique_pid": 0, }, - "event_timestamp": 1, - "event_type": 1, - "machine_id": "", } => Array [ 0, -0.8164965809277259, ], Object { - "data_buffer": Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { "event_subtype_full": "already_running", "event_type_full": "process_event", - "node_id": 1, - "process_name": "", - "process_path": "", - "source_id": 0, - "timestamp_utc": "2019-09-24 01:47:47Z", + "unique_pid": 1, + "unique_ppid": 0, }, - "event_timestamp": 1, - "event_type": 1, - "machine_id": "", } => Array [ 70.71067811865476, -41.641325627314025, diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts index 900b9bda571da..f34d7c08ce08c 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ProcessEvent } from '../../types'; +import { LegacyEndpointEvent } from '../../../../../common/types'; interface ServerReturnedResolverData { readonly type: 'serverReturnedResolverData'; readonly payload: { readonly data: { readonly result: { - readonly search_results: readonly ProcessEvent[]; + readonly search_results: readonly LegacyEndpointEvent[]; }; }; }; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/graphing.test.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/graphing.test.ts index fac70433f14b2..f01136fe20ebf 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/graphing.test.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/graphing.test.ts @@ -7,20 +7,21 @@ import { Store, createStore } from 'redux'; import { DataAction } from './action'; import { dataReducer } from './reducer'; -import { DataState, ProcessEvent } from '../../types'; +import { DataState } from '../../types'; +import { LegacyEndpointEvent } from '../../../../../common/types'; import { graphableProcesses, processNodePositionsAndEdgeLineSegments } from './selectors'; import { mockProcessEvent } from '../../models/process_event_test_helpers'; describe('resolver graph layout', () => { - let processA: ProcessEvent; - let processB: ProcessEvent; - let processC: ProcessEvent; - let processD: ProcessEvent; - let processE: ProcessEvent; - let processF: ProcessEvent; - let processG: ProcessEvent; - let processH: ProcessEvent; - let processI: ProcessEvent; + let processA: LegacyEndpointEvent; + let processB: LegacyEndpointEvent; + let processC: LegacyEndpointEvent; + let processD: LegacyEndpointEvent; + let processE: LegacyEndpointEvent; + let processF: LegacyEndpointEvent; + let processG: LegacyEndpointEvent; + let processH: LegacyEndpointEvent; + let processI: LegacyEndpointEvent; let store: Store; beforeEach(() => { @@ -37,75 +38,75 @@ describe('resolver graph layout', () => { * */ processA = mockProcessEvent({ - data_buffer: { + endgame: { process_name: '', event_type_full: 'process_event', event_subtype_full: 'creation_event', - node_id: 0, + unique_pid: 0, }, }); processB = mockProcessEvent({ - data_buffer: { + endgame: { event_type_full: 'process_event', event_subtype_full: 'already_running', - node_id: 1, - source_id: 0, + unique_pid: 1, + unique_ppid: 0, }, }); processC = mockProcessEvent({ - data_buffer: { + endgame: { event_type_full: 'process_event', event_subtype_full: 'creation_event', - node_id: 2, - source_id: 0, + unique_pid: 2, + unique_ppid: 0, }, }); processD = mockProcessEvent({ - data_buffer: { + endgame: { event_type_full: 'process_event', event_subtype_full: 'creation_event', - node_id: 3, - source_id: 1, + unique_pid: 3, + unique_ppid: 1, }, }); processE = mockProcessEvent({ - data_buffer: { + endgame: { event_type_full: 'process_event', event_subtype_full: 'creation_event', - node_id: 4, - source_id: 1, + unique_pid: 4, + unique_ppid: 1, }, }); processF = mockProcessEvent({ - data_buffer: { + endgame: { event_type_full: 'process_event', event_subtype_full: 'creation_event', - node_id: 5, - source_id: 2, + unique_pid: 5, + unique_ppid: 2, }, }); processG = mockProcessEvent({ - data_buffer: { + endgame: { event_type_full: 'process_event', event_subtype_full: 'creation_event', - node_id: 6, - source_id: 2, + unique_pid: 6, + unique_ppid: 2, }, }); processH = mockProcessEvent({ - data_buffer: { + endgame: { event_type_full: 'process_event', event_subtype_full: 'creation_event', - node_id: 7, - source_id: 6, + unique_pid: 7, + unique_ppid: 6, }, }); processI = mockProcessEvent({ - data_buffer: { + endgame: { event_type_full: 'process_event', event_subtype_full: 'termination_event', - node_id: 8, - source_id: 0, + unique_pid: 8, + unique_ppid: 0, }, }); store = createStore(dataReducer, undefined); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts index 848d814808bac..a3184389a794e 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts @@ -6,11 +6,11 @@ import { Reducer } from 'redux'; import { DataState, ResolverAction } from '../../types'; -import { sampleData } from './sample'; function initialState(): DataState { return { - results: sampleData.data.result.search_results, + results: [], + isLoading: false, }; } @@ -24,6 +24,12 @@ export const dataReducer: Reducer = (state = initialS return { ...state, results: search_results, + isLoading: false, + }; + } else if (action.type === 'appRequestedResolverData') { + return { + ...state, + isLoading: true, }; } else { return state; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts index 75b477dd7c7fc..304abbb06880b 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts @@ -7,7 +7,6 @@ import { createSelector } from 'reselect'; import { DataState, - ProcessEvent, IndexedProcessTree, ProcessWidths, ProcessPositions, @@ -15,6 +14,7 @@ import { ProcessWithWidthMetadata, Matrix3, } from '../../types'; +import { LegacyEndpointEvent } from '../../../../../common/types'; import { Vector2 } from '../../types'; import { add as vector2Add, applyMatrix3 } from '../../lib/vector2'; import { isGraphableProcess } from '../../models/process_event'; @@ -29,6 +29,10 @@ import { const unit = 100; const distanceBetweenNodesInUnits = 1; +export function isLoading(state: DataState) { + return state.isLoading; +} + /** * An isometric projection is a method for representing three dimensional objects in 2 dimensions. * More information about isometric projections can be found here https://en.wikipedia.org/wiki/Isometric_projection. @@ -108,7 +112,7 @@ export const graphableProcesses = createSelector( * */ function widthsOfProcessSubtrees(indexedProcessTree: IndexedProcessTree): ProcessWidths { - const widths = new Map(); + const widths = new Map(); if (size(indexedProcessTree) === 0) { return widths; @@ -309,13 +313,13 @@ function processPositions( indexedProcessTree: IndexedProcessTree, widths: ProcessWidths ): ProcessPositions { - const positions = new Map(); + const positions = new Map(); /** * This algorithm iterates the tree in level order. It keeps counters that are reset for each parent. * By keeping track of the last parent node, we can know when we are dealing with a new set of siblings and * reset the counters. */ - let lastProcessedParentNode: ProcessEvent | undefined; + let lastProcessedParentNode: LegacyEndpointEvent | undefined; /** * Nodes are positioned relative to their siblings. We walk this in level order, so we handle * children left -> right. @@ -420,7 +424,7 @@ export const processNodePositionsAndEdgeLineSegments = createSelector( * Transform the positions of nodes and edges so they seem like they are on an isometric grid. */ const transformedEdgeLineSegments: EdgeLineSegment[] = []; - const transformedPositions = new Map(); + const transformedPositions = new Map(); for (const [processEvent, position] of positions) { transformedPositions.set(processEvent, applyMatrix3(position, isometricTransformMatrix)); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/index.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/index.ts index b17572bbc4ab4..2a20c73347348 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/index.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/index.ts @@ -6,17 +6,21 @@ import { createStore, applyMiddleware, Store } from 'redux'; import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; +import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public'; import { ResolverAction, ResolverState } from '../types'; +import { EndpointPluginServices } from '../../../plugin'; import { resolverReducer } from './reducer'; +import { resolverMiddlewareFactory } from './middleware'; -export const storeFactory = (): { store: Store } => { +export const storeFactory = ( + context?: KibanaReactContextValue +): { store: Store } => { const actionsBlacklist: Array = ['userMovedPointer']; const composeEnhancers = composeWithDevTools({ name: 'Resolver', actionsBlacklist, }); - - const middlewareEnhancer = applyMiddleware(); + const middlewareEnhancer = applyMiddleware(resolverMiddlewareFactory(context)); const store = createStore(resolverReducer, composeEnhancers(middlewareEnhancer)); return { diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/methods.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/methods.ts index 8808160c9c631..9f06643626f50 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/methods.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/methods.ts @@ -6,7 +6,8 @@ import { animatePanning } from './camera/methods'; import { processNodePositionsAndEdgeLineSegments } from './selectors'; -import { ResolverState, ProcessEvent } from '../types'; +import { ResolverState } from '../types'; +import { LegacyEndpointEvent } from '../../../../common/types'; const animationDuration = 1000; @@ -16,7 +17,7 @@ const animationDuration = 1000; export function animateProcessIntoView( state: ResolverState, startTime: number, - process: ProcessEvent + process: LegacyEndpointEvent ): ResolverState { const { processNodePositions } = processNodePositionsAndEdgeLineSegments(state); const position = processNodePositions.get(process); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts new file mode 100644 index 0000000000000..900aece60618d --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dispatch, MiddlewareAPI } from 'redux'; +import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public'; +import { EndpointPluginServices } from '../../../plugin'; +import { ResolverState, ResolverAction } from '../types'; + +type MiddlewareFactory = ( + context?: KibanaReactContextValue +) => ( + api: MiddlewareAPI, S> +) => (next: Dispatch) => (action: ResolverAction) => unknown; + +export const resolverMiddlewareFactory: MiddlewareFactory = context => { + return api => next => async (action: ResolverAction) => { + next(action); + if (action.type === 'userChangedSelectedEvent') { + if (context?.services.http) { + api.dispatch({ type: 'appRequestedResolverData' }); + const uniquePid = action.payload.selectedEvent?.endgame?.unique_pid; + const legacyEndpointID = action.payload.selectedEvent?.agent?.id; + const [{ lifecycle }, { children }, { events: relatedEvents }] = await Promise.all([ + context.services.http.get(`/api/endpoint/resolver/${uniquePid}`, { + query: { legacyEndpointID }, + }), + context.services.http.get(`/api/endpoint/resolver/${uniquePid}/children`, { + query: { legacyEndpointID }, + }), + context.services.http.get(`/api/endpoint/resolver/${uniquePid}/related`, { + query: { legacyEndpointID }, + }), + ]); + const response = [...lifecycle, ...children, ...relatedEvents]; + api.dispatch({ + type: 'serverReturnedResolverData', + payload: { data: { result: { search_results: response } } }, + }); + } + } + }; +}; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts index 25d08a8c347ed..708eb684ebd3e 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts @@ -68,6 +68,11 @@ function dataStateSelector(state: ResolverState) { return state.data; } +/** + * Whether or not the resolver is pending fetching data + */ +export const isLoading = composeSelectors(dataStateSelector, dataSelectors.isLoading); + /** * Calls the `secondSelector` with the result of the `selector`. Use this when re-exporting a * concern-specific selector. `selector` should return the concern-specific state. diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts index 6c6936d377dea..4c2a1ea5ac21f 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts @@ -8,6 +8,7 @@ import { Store } from 'redux'; import { ResolverAction } from './store/actions'; export { ResolverAction } from './store/actions'; +import { LegacyEndpointEvent } from '../../../common/types'; /** * Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`. @@ -114,7 +115,8 @@ export type CameraState = { * State for `data` reducer which handles receiving Resolver data from the backend. */ export interface DataState { - readonly results: readonly ProcessEvent[]; + readonly results: readonly LegacyEndpointEvent[]; + isLoading: boolean; } export type Vector2 = readonly [number, number]; @@ -182,21 +184,21 @@ export interface IndexedProcessTree { /** * Map of ID to a process's children */ - idToChildren: Map; + idToChildren: Map; /** * Map of ID to process */ - idToProcess: Map; + idToProcess: Map; } /** * A map of ProcessEvents (representing process nodes) to the 'width' of their subtrees as calculated by `widthsOfProcessSubtrees` */ -export type ProcessWidths = Map; +export type ProcessWidths = Map; /** * Map of ProcessEvents (representing process nodes) to their positions. Calculated by `processPositions` */ -export type ProcessPositions = Map; +export type ProcessPositions = Map; /** * An array of vectors2 forming an polyline. Used to connect process nodes in the graph. */ @@ -206,11 +208,11 @@ export type EdgeLineSegment = Vector2[]; * Used to provide precalculated info from `widthsOfProcessSubtrees`. These 'width' values are used in the layout of the graph. */ export type ProcessWithWidthMetadata = { - process: ProcessEvent; + process: LegacyEndpointEvent; width: number; } & ( | { - parent: ProcessEvent; + parent: LegacyEndpointEvent; parentWidth: number; isOnlyChild: boolean; firstChildWidth: number; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx index d71a4d87b7eab..52a0872f269f5 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx @@ -4,15 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { useSelector } from 'react-redux'; +import React, { useLayoutEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; import styled from 'styled-components'; +import { EuiLoadingSpinner } from '@elastic/eui'; import * as selectors from '../store/selectors'; import { EdgeLine } from './edge_line'; import { Panel } from './panel'; import { GraphControls } from './graph_controls'; import { ProcessEventDot } from './process_event_dot'; import { useCamera } from './use_camera'; +import { ResolverAction } from '../types'; +import { LegacyEndpointEvent } from '../../../../common/types'; const StyledPanel = styled(Panel)` position: absolute; @@ -31,35 +34,57 @@ const StyledGraphControls = styled(GraphControls)` `; export const Resolver = styled( - React.memo(function Resolver({ className }: { className?: string }) { + React.memo(function Resolver({ + className, + selectedEvent, + }: { + className?: string; + selectedEvent?: LegacyEndpointEvent; + }) { const { processNodePositions, edgeLineSegments } = useSelector( selectors.processNodePositionsAndEdgeLineSegments ); + const dispatch: (action: ResolverAction) => unknown = useDispatch(); const { projectionMatrix, ref, onMouseDown } = useCamera(); + const isLoading = useSelector(selectors.isLoading); + useLayoutEffect(() => { + dispatch({ + type: 'userChangedSelectedEvent', + payload: { selectedEvent }, + }); + }, [dispatch, selectedEvent]); return (
-
- {Array.from(processNodePositions).map(([processEvent, position], index) => ( - - ))} - {edgeLineSegments.map(([startPosition, endPosition], index) => ( - - ))} -
- - + {isLoading ? ( +
+ +
+ ) : ( + <> +
+ {Array.from(processNodePositions).map(([processEvent, position], index) => ( + + ))} + {edgeLineSegments.map(([startPosition, endPosition], index) => ( + + ))} +
+ + + + )}
); }) @@ -72,6 +97,12 @@ export const Resolver = styled( display: flex; flex-grow: 1; } + .loading-container { + display: flex; + align-items: center; + justify-content: center; + flex-grow: 1; + } /** * The placeholder components use absolute positioning. */ diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/panel.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/panel.tsx index c75b73b4bceaf..84c299698bb32 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/panel.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/panel.tsx @@ -11,7 +11,7 @@ import euiVars from '@elastic/eui/dist/eui_theme_light.json'; import { useSelector } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { SideEffectContext } from './side_effect_context'; -import { ProcessEvent } from '../types'; +import { LegacyEndpointEvent } from '../../../../common/types'; import { useResolverDispatch } from './use_resolver_dispatch'; import * as selectors from '../store/selectors'; @@ -38,7 +38,7 @@ export const Panel = memo(function Event({ className }: { className?: string }) interface ProcessTableView { name: string; timestamp?: Date; - event: ProcessEvent; + event: LegacyEndpointEvent; } const { processNodePositions } = useSelector(selectors.processNodePositionsAndEdgeLineSegments); @@ -47,11 +47,16 @@ export const Panel = memo(function Event({ className }: { className?: string }) const processTableView: ProcessTableView[] = useMemo( () => [...processNodePositions.keys()].map(processEvent => { - const { data_buffer } = processEvent; - const date = new Date(data_buffer.timestamp_utc); + let dateTime; + if (processEvent.endgame.timestamp_utc) { + const date = new Date(processEvent.endgame.timestamp_utc); + if (isFinite(date.getTime())) { + dateTime = date; + } + } return { - name: data_buffer.process_name, - timestamp: isFinite(date.getTime()) ? date : undefined, + name: processEvent.endgame.process_name ? processEvent.endgame.process_name : '', + timestamp: dateTime, event: processEvent, }; }), diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx index 384fbf90ed984..034780c7ba14c 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx @@ -7,7 +7,8 @@ import React from 'react'; import styled from 'styled-components'; import { applyMatrix3 } from '../lib/vector2'; -import { Vector2, ProcessEvent, Matrix3 } from '../types'; +import { Vector2, Matrix3 } from '../types'; +import { LegacyEndpointEvent } from '../../../../common/types'; /** * A placeholder view for a process node. @@ -31,7 +32,7 @@ export const ProcessEventDot = styled( /** * An event which contains details about the process node. */ - event: ProcessEvent; + event: LegacyEndpointEvent; /** * projectionMatrix which can be used to convert `position` to screen coordinates. */ @@ -48,7 +49,7 @@ export const ProcessEventDot = styled( }; return ( - name: {event.data_buffer.process_name} + name: {event.endgame.process_name}
x: {position[0]}
diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx index f4abb51f062f2..1948c6cae505b 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx @@ -10,16 +10,12 @@ import { useCamera } from './use_camera'; import { Provider } from 'react-redux'; import * as selectors from '../store/selectors'; import { storeFactory } from '../store'; -import { - Matrix3, - ResolverAction, - ResolverStore, - ProcessEvent, - SideEffectSimulator, -} from '../types'; +import { Matrix3, ResolverAction, ResolverStore, SideEffectSimulator } from '../types'; +import { LegacyEndpointEvent } from '../../../../common/types'; import { SideEffectContext } from './side_effect_context'; import { applyMatrix3 } from '../lib/vector2'; import { sideEffectSimulator } from './side_effect_simulator'; +import { mockProcessEvent } from '../models/process_event_test_helpers'; describe('useCamera on an unpainted element', () => { let element: HTMLElement; @@ -28,6 +24,7 @@ describe('useCamera on an unpainted element', () => { let reactRenderResult: RenderResult; let store: ResolverStore; let simulator: SideEffectSimulator; + beforeEach(async () => { ({ store } = storeFactory()); @@ -136,17 +133,45 @@ describe('useCamera on an unpainted element', () => { expect(simulator.mock.requestAnimationFrame).not.toHaveBeenCalled(); }); describe('when the camera begins animation', () => { - let process: ProcessEvent; + let process: LegacyEndpointEvent; beforeEach(() => { - // At this time, processes are provided via mock data. In the future, this test will have to provide those mocks. - const processes: ProcessEvent[] = [ + const events: LegacyEndpointEvent[] = []; + const numberOfEvents: number = Math.floor(Math.random() * 10 + 1); + + for (let index = 0; index < numberOfEvents; index++) { + const uniquePpid = index === 0 ? undefined : index - 1; + events.push( + mockProcessEvent({ + endgame: { + unique_pid: index, + unique_ppid: uniquePpid, + event_type_full: 'process_event', + event_subtype_full: 'creation_event', + }, + }) + ); + } + const serverResponseAction: ResolverAction = { + type: 'serverReturnedResolverData', + payload: { + data: { + result: { + search_results: events, + }, + }, + }, + }; + act(() => { + store.dispatch(serverResponseAction); + }); + const processes: LegacyEndpointEvent[] = [ ...selectors .processNodePositionsAndEdgeLineSegments(store.getState()) .processNodePositions.keys(), ]; process = processes[processes.length - 1]; simulator.controls.time = 0; - const action: ResolverAction = { + const cameraAction: ResolverAction = { type: 'userBroughtProcessIntoView', payload: { time: simulator.controls.time, @@ -154,7 +179,7 @@ describe('useCamera on an unpainted element', () => { }, }; act(() => { - store.dispatch(action); + store.dispatch(cameraAction); }); }); diff --git a/x-pack/plugins/endpoint/public/plugin.ts b/x-pack/plugins/endpoint/public/plugin.ts index 355364253b2a5..0e10fe680e9f0 100644 --- a/x-pack/plugins/endpoint/public/plugin.ts +++ b/x-pack/plugins/endpoint/public/plugin.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Plugin, CoreSetup, AppMountParameters } from 'kibana/public'; +import { Plugin, CoreSetup, AppMountParameters, CoreStart } from 'kibana/public'; import { IEmbeddableSetup } from 'src/plugins/embeddable/public'; import { i18n } from '@kbn/i18n'; import { ResolverEmbeddableFactory } from './embeddables/resolver'; @@ -17,6 +17,15 @@ export interface EndpointPluginSetupDependencies { export interface EndpointPluginStartDependencies {} // eslint-disable-line @typescript-eslint/no-empty-interface +/** + * Functionality that the endpoint plugin uses from core. + */ +export interface EndpointPluginServices extends Partial { + http: CoreStart['http']; + overlays: CoreStart['overlays'] | undefined; + notifications: CoreStart['notifications'] | undefined; +} + export class EndpointPlugin implements Plugin< diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/children.test.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/children.test.ts index 2dd2e0c2d1d5f..08a906e2884d6 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/queries/children.test.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/children.test.ts @@ -8,7 +8,7 @@ import { EndpointAppConstants } from '../../../../common/types'; describe('children events query', () => { it('generates the correct legacy queries', () => { - const timestamp = new Date(); + const timestamp = new Date().getTime(); expect( new ChildrenQuery('awesome-id', { size: 1, timestamp, eventID: 'foo' }).build('5') ).toStrictEqual({ @@ -38,7 +38,7 @@ describe('children events query', () => { }, }, }, - search_after: [timestamp.getTime(), 'foo'], + search_after: [timestamp, 'foo'], size: 1, sort: [{ '@timestamp': 'asc' }, { 'endgame.serial_event_id': 'asc' }], }, @@ -47,7 +47,7 @@ describe('children events query', () => { }); it('generates the correct non-legacy queries', () => { - const timestamp = new Date(); + const timestamp = new Date().getTime(); expect( new ChildrenQuery(undefined, { size: 1, timestamp, eventID: 'bar' }).build('baz') @@ -84,7 +84,7 @@ describe('children events query', () => { }, }, }, - search_after: [timestamp.getTime(), 'bar'], + search_after: [timestamp, 'bar'], size: 1, sort: [{ '@timestamp': 'asc' }, { 'event.id': 'asc' }], }, diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.test.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.test.ts index 8ef680a168310..a91c87274b8dd 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.test.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.test.ts @@ -8,7 +8,7 @@ import { EndpointAppConstants } from '../../../../common/types'; describe('related events query', () => { it('generates the correct legacy queries', () => { - const timestamp = new Date(); + const timestamp = new Date().getTime(); expect( new RelatedEventsQuery('awesome-id', { size: 1, timestamp, eventID: 'foo' }).build('5') ).toStrictEqual({ @@ -39,7 +39,7 @@ describe('related events query', () => { }, }, }, - search_after: [timestamp.getTime(), 'foo'], + search_after: [timestamp, 'foo'], size: 1, sort: [{ '@timestamp': 'asc' }, { 'endgame.serial_event_id': 'asc' }], }, @@ -48,7 +48,7 @@ describe('related events query', () => { }); it('generates the correct non-legacy queries', () => { - const timestamp = new Date(); + const timestamp = new Date().getTime(); expect( new RelatedEventsQuery(undefined, { size: 1, timestamp, eventID: 'bar' }).build('baz') @@ -86,7 +86,7 @@ describe('related events query', () => { }, }, }, - search_after: [timestamp.getTime(), 'bar'], + search_after: [timestamp, 'bar'], size: 1, sort: [{ '@timestamp': 'asc' }, { 'event.id': 'asc' }], }, diff --git a/x-pack/plugins/endpoint/server/routes/resolver/utils/pagination.ts b/x-pack/plugins/endpoint/server/routes/resolver/utils/pagination.ts index 33eb698479308..5a64f3ff9ddb6 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/utils/pagination.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/utils/pagination.ts @@ -11,12 +11,12 @@ import { JsonObject } from '../../../../../../../src/plugins/kibana_utils/public export interface PaginationParams { size: number; - timestamp?: Date; + timestamp?: number; eventID?: string; } interface PaginationCursor { - timestamp: Date; + timestamp: number; eventID: string; } @@ -35,7 +35,7 @@ function urlDecodeCursor(value: string): PaginationCursor { const { timestamp, eventID } = JSON.parse(data); // take some extra care to only grab the things we want // convert the timestamp string to date object - return { timestamp: new Date(timestamp), eventID }; + return { timestamp, eventID }; } export function getPaginationParams(limit: number, after?: string): PaginationParams { @@ -62,7 +62,7 @@ export function paginate(pagination: PaginationParams, field: string, query: Jso query.aggs = { total: { value_count: { field } } }; query.size = size; if (timestamp && eventID) { - query.search_after = [timestamp.getTime(), eventID] as Array; + query.search_after = [timestamp, eventID] as Array; } return query; } From 74d0e9297fd2efdc7943d10f2aa1d10ad4c016e9 Mon Sep 17 00:00:00 2001 From: Charlie Pichette <56399229+charlie-pichette@users.noreply.github.com> Date: Mon, 2 Mar 2020 10:46:20 -0500 Subject: [PATCH 07/36] [Endpoint] [Tests] fixes #57946 flaky endpoint policy list test (#58348) * endpoint-161-refactor-management-list-test * fix location of es archive file * issue 57946 fix flaky endpoint policy list test Co-authored-by: Elastic Machine --- x-pack/test/functional/apps/endpoint/policy_list.ts | 5 +++-- x-pack/test/functional/page_objects/endpoint_page.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/x-pack/test/functional/apps/endpoint/policy_list.ts b/x-pack/test/functional/apps/endpoint/policy_list.ts index 658e4dcd13e1e..382963bc2b0c7 100644 --- a/x-pack/test/functional/apps/endpoint/policy_list.ts +++ b/x-pack/test/functional/apps/endpoint/policy_list.ts @@ -11,10 +11,11 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); // FLAKY: https://github.com/elastic/kibana/issues/57946 - describe.skip('Endpoint Policy List', function() { + describe('Endpoint Policy List', function() { this.tags(['ciGroup7']); before(async () => { await pageObjects.common.navigateToUrlWithBrowserHistory('endpoint', '/policy'); + await pageObjects.endpoint.waitForTableToHaveData('policyTable'); }); it('loads the Policy List Page', async () => { @@ -26,7 +27,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows policy count total', async () => { const policyTotal = await testSubjects.getVisibleText('policyTotalCount'); - expect(policyTotal).to.equal('0 Policies'); + expect(policyTotal).to.equal('100 Policies'); }); it('includes policy list table', async () => { await testSubjects.existOrFail('policyTable'); diff --git a/x-pack/test/functional/page_objects/endpoint_page.ts b/x-pack/test/functional/page_objects/endpoint_page.ts index 185b95b00527d..6350f51f707f4 100644 --- a/x-pack/test/functional/page_objects/endpoint_page.ts +++ b/x-pack/test/functional/page_objects/endpoint_page.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function EndpointPageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const retry = getService('retry'); return { /** @@ -58,5 +59,13 @@ export function EndpointPageProvider({ getService }: FtrProviderContext) { ) ); }, + + async waitForTableToHaveData(dataTestSubj: string) { + await retry.waitForWithTimeout('table to have data', 2000, async () => { + const tableData = await this.getEndpointAppTableData(dataTestSubj); + if (tableData[1][0] === 'No items found') return false; + return true; + }); + }, }; } From 1eca34126061e7226c543f86f867d95f0f1d8ea1 Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Mon, 2 Mar 2020 09:59:29 -0600 Subject: [PATCH 08/36] [DOCS] Rework of main get started page (#58260) * [DOCS] Rework of main gett started page * Redirect for add-sample-data * Link fix * Review comments Co-authored-by: Elastic Machine --- docs/redirects.asciidoc | 5 +++ docs/user/getting-started.asciidoc | 70 +++++++++++++++++------------- docs/user/index.asciidoc | 4 +- 3 files changed, 47 insertions(+), 32 deletions(-) diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index 3843cc27defd5..8ad5330f3fda5 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -56,6 +56,11 @@ This page has moved. Please see <>. This page has moved. Please see <>. +[role="exclude",id="add-sample-data"] +== Add sample data + +This page has moved. Please see <>. + [role="exclude",id="tilemap"] == Coordinate map diff --git a/docs/user/getting-started.asciidoc b/docs/user/getting-started.asciidoc index c6fe5b5b92d69..d426ec111351c 100644 --- a/docs/user/getting-started.asciidoc +++ b/docs/user/getting-started.asciidoc @@ -1,54 +1,65 @@ [[getting-started]] -= Getting Started += Get started [partintro] -- -You’re new to Kibana and want to give it a try. {kib} has sample data sets and -tutorials to help you get started. +Ready to try out {kib} and see what it can do? To quickest way to get started with {kib} is to set up on Cloud, then add a sample data set that helps you get a handle on the full range of {kib} features. [float] -=== Sample data +[[cloud-set-up]] +== Set up on Cloud -You can use the <> to take {kib} for a test ride without having -to go through the process of loading data yourself. With one click, -you can install a sample data set and start interacting with -{kib} visualizations in seconds. You can access the sample data -from the {kib} home page. +To access {kib} in a single click, run our hosted Elasticsearch Service on Elastic Cloud. -[float] +. Log into the link:https://cloud.elastic.co/[Elasticsearch Service Console]. +If you need an account, register for a link:https://www.elastic.co/cloud/elasticsearch-service/signup[free 14-day trial]. + +. Click *Create deployment*, then give your deployment a name. -=== Add data tutorials -{kib} has built-in *Add Data* tutorials to help you set up -data flows in the Elastic Stack. These tutorials are available -from the Kibana home page. In *Add Data to Kibana*, find the data type -you’re interested in, and click its button to view a list of available tutorials. +. To use the default options, click *Create deployment*. You can modify the other deployment options, but the default options are great to get started. + +Be sure to copy down the password for the `elastic` user and Cloud ID information. You'll need that later. [float] -=== Hands-on experience +[[get-data-in]] +== Get data into {kib} + +The easiest way to get data into {kib} is to add a sample data set. + +{kib} has several sample data sets that you can use before loading your own data: + +* *Sample eCommerce orders* includes visualizations for tracking product-related information, +such as cost, revenue, and price. + +* *Sample flight data* includes visualizations for monitoring flight routes. -The following tutorials walk you through searching, analyzing, -and visualizing data. +* *Sample web logs* includes visualizations for monitoring website traffic. -* <>. You'll -learn to filter and query data, edit visualizations, and interact with dashboards. +To use the sample data sets: -* <>. You'll manually load a data set and build -your own visualizations and dashboard. +. Go to the {kib} home page. + +. Click *Load a data set and a {kib} dashboard*. + +. Click *View data* and view the prepackaged dashboards, maps, and more. + +[role="screenshot"] +image::images/add-sample-data.png[] + +NOTE: The timestamps in the sample data sets are relative to when they are installed. +If you uninstall and reinstall a data set, the timestamps change to reflect the most recent installation. [float] -=== Before you begin +[[getting-started-next-steps]] +== Next steps -Make sure you've <> and established -a <>. +* To get a hands-on experience creating visualizations, follow the <> tutorial. -If you are running our hosted Elasticsearch Service on Elastic Cloud, you access Kibana with a single click. (You can {ess-trial}[sign up for a free trial] and start exploring data in minutes.) +* If you're ready to load an actual data set and build a dashboard, follow the <> tutorial. -- -include::{kib-repo-dir}/getting-started/add-sample-data.asciidoc[] - include::{kib-repo-dir}/getting-started/tutorial-sample-data.asciidoc[] include::{kib-repo-dir}/getting-started/tutorial-full-experience.asciidoc[] @@ -60,4 +71,3 @@ include::{kib-repo-dir}/getting-started/tutorial-discovering.asciidoc[] include::{kib-repo-dir}/getting-started/tutorial-visualizing.asciidoc[] include::{kib-repo-dir}/getting-started/tutorial-dashboard.asciidoc[] - diff --git a/docs/user/index.asciidoc b/docs/user/index.asciidoc index 3911d57e05c9a..ff100d0763368 100644 --- a/docs/user/index.asciidoc +++ b/docs/user/index.asciidoc @@ -1,13 +1,13 @@ include::introduction.asciidoc[] +include::getting-started.asciidoc[] + include::setup.asciidoc[] include::monitoring/configuring-monitoring.asciidoc[] include::security/securing-kibana.asciidoc[] -include::getting-started.asciidoc[] - include::discover.asciidoc[] include::visualize.asciidoc[] From 323bb21df30f3a94a83fcb2e561c46c1b17e0e03 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 2 Mar 2020 11:27:15 -0500 Subject: [PATCH 09/36] [Remote clusters] Add indexManagement as required plugin (#58915) --- x-pack/plugins/remote_clusters/kibana.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/remote_clusters/kibana.json b/x-pack/plugins/remote_clusters/kibana.json index 27ae6802966dd..609d0f67f2c7b 100644 --- a/x-pack/plugins/remote_clusters/kibana.json +++ b/x-pack/plugins/remote_clusters/kibana.json @@ -7,7 +7,8 @@ ], "requiredPlugins": [ "licensing", - "management" + "management", + "indexManagement" ], "optionalPlugins": [ "usageCollection" From e9abe735f226cde41ec71321f4e7bbc623a93d7d Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Mon, 2 Mar 2020 09:36:14 -0700 Subject: [PATCH 10/36] [SIEM] Default the Timeline events filter to show All events (#58953) ## [SIEM] Default the Timeline events filter to show All events The Timeline events filter introduced in `7.6` to support the [detection engine](https://www.elastic.co/guide/en/siem/guide/current/detection-engine-overview.html) defaulted to filtering by `Raw events`, and thus required manually selecting `All events` or `Signal events` from the dropdown to view signals. The new default is `All events`, per the screenshots below: ### Before ![event-filter-before](https://user-images.githubusercontent.com/4459398/75593223-ecc61500-5a41-11ea-8d7d-8db5eccb1eb4.png) ### After ![event-filter-after](https://user-images.githubusercontent.com/4459398/75593238-f5b6e680-5a41-11ea-9e12-2fc1232f58d1.png) --- .../components/open_timeline/helpers.test.ts | 8 ++-- .../timeline/search_or_filter/pick_events.tsx | 1 + .../components/timeline/timeline.test.tsx | 43 +++++++++++++++++++ .../siem/public/store/timeline/defaults.ts | 2 +- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.test.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.test.ts index 120d644b3b33a..60ebd2578b7c0 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.test.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.test.ts @@ -236,7 +236,7 @@ describe('helpers', () => { description: '', deletedEventIds: [], eventIdToNoteIds: {}, - eventType: 'raw', + eventType: 'all', filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -330,7 +330,7 @@ describe('helpers', () => { description: '', deletedEventIds: [], eventIdToNoteIds: {}, - eventType: 'raw', + eventType: 'all', filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -417,7 +417,7 @@ describe('helpers', () => { description: '', deletedEventIds: [], eventIdToNoteIds: {}, - eventType: 'raw', + eventType: 'all', filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -539,7 +539,7 @@ describe('helpers', () => { description: '', deletedEventIds: [], eventIdToNoteIds: {}, - eventType: 'raw', + eventType: 'all', filters: [ { $state: { diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/pick_events.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/pick_events.tsx index 76f9e6fe3673a..3117bae745286 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/pick_events.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/pick_events.tsx @@ -77,6 +77,7 @@ const PickEventTypeComponents: React.FC = ({ return ( { expect(wrapper.find('[data-test-subj="table-pagination"]').exists()).toEqual(false); }); + + test('it defaults to showing `All events`', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="pick-event-type"] button').text()).toEqual( + 'All events' + ); + }); }); describe('event wire up', () => { diff --git a/x-pack/legacy/plugins/siem/public/store/timeline/defaults.ts b/x-pack/legacy/plugins/siem/public/store/timeline/defaults.ts index bbaf2a3fb6e30..7f04bb4c4dad0 100644 --- a/x-pack/legacy/plugins/siem/public/store/timeline/defaults.ts +++ b/x-pack/legacy/plugins/siem/public/store/timeline/defaults.ts @@ -14,7 +14,7 @@ export const timelineDefaults: SubsetTimelineModel & Pick Date: Mon, 2 Mar 2020 12:01:00 -0500 Subject: [PATCH 11/36] removing references to visTypes uiExports (#58337) --- src/legacy/core_plugins/kibana/public/kibana.js | 2 -- .../plugins/kbn_tp_run_pipeline/public/legacy.ts | 2 -- .../plugins/kbn_tp_custom_visualizations/index.js | 2 +- x-pack/legacy/plugins/canvas/public/legacy_start.ts | 3 --- .../plugins/dashboard_mode/public/dashboard_viewer.js | 4 +--- x-pack/legacy/plugins/lens/index.ts | 1 - x-pack/legacy/plugins/lens/public/legacy.ts | 7 +++++-- x-pack/legacy/plugins/lens/public/legacy_imports.ts | 2 ++ x-pack/legacy/plugins/lens/public/plugin.tsx | 7 ++++++- .../{register_vis_type_alias.ts => vis_type_alias.ts} | 4 ++-- x-pack/legacy/plugins/maps/index.js | 2 +- 11 files changed, 18 insertions(+), 18 deletions(-) rename x-pack/legacy/plugins/lens/public/{register_vis_type_alias.ts => vis_type_alias.ts} (89%) diff --git a/src/legacy/core_plugins/kibana/public/kibana.js b/src/legacy/core_plugins/kibana/public/kibana.js index a83d1176a7197..a9f32949628e9 100644 --- a/src/legacy/core_plugins/kibana/public/kibana.js +++ b/src/legacy/core_plugins/kibana/public/kibana.js @@ -26,8 +26,6 @@ import { npSetup } from 'ui/new_platform'; // import the uiExports that we want to "use" import 'uiExports/home'; -import 'uiExports/visTypes'; - import 'uiExports/visualize'; import 'uiExports/savedObjectTypes'; import 'uiExports/fieldFormatEditors'; diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/legacy.ts b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/legacy.ts index 39ce2b3077c96..a7cd313038d69 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/legacy.ts +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/legacy.ts @@ -28,8 +28,6 @@ import 'ui/autoload/all'; // Used to run esaggs queries import 'uiExports/fieldFormats'; import 'uiExports/search'; -import 'uiExports/visRequestHandlers'; -import 'uiExports/visResponseHandlers'; // Used for kibana_context function import 'uiExports/savedObjectTypes'; diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/index.js b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/index.js index e15da9daa3cd7..b2497a824ba2b 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/index.js +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/index.js @@ -20,7 +20,7 @@ export default function(kibana) { return new kibana.Plugin({ uiExports: { - visTypes: ['plugins/kbn_tp_custom_visualizations/self_changing_vis/self_changing_vis'], + hacks: ['plugins/kbn_tp_custom_visualizations/self_changing_vis/self_changing_vis'], }, }); } diff --git a/x-pack/legacy/plugins/canvas/public/legacy_start.ts b/x-pack/legacy/plugins/canvas/public/legacy_start.ts index 21bf5aaa6d818..d7d1a940d3b43 100644 --- a/x-pack/legacy/plugins/canvas/public/legacy_start.ts +++ b/x-pack/legacy/plugins/canvas/public/legacy_start.ts @@ -8,9 +8,6 @@ // Import the uiExports that the application uses // These will go away as these plugins are converted to NP import 'ui/autoload/all'; -import 'uiExports/visTypes'; -import 'uiExports/visResponseHandlers'; -import 'uiExports/visRequestHandlers'; import 'uiExports/savedObjectTypes'; import 'uiExports/spyModes'; import 'uiExports/embeddableFactories'; diff --git a/x-pack/legacy/plugins/dashboard_mode/public/dashboard_viewer.js b/x-pack/legacy/plugins/dashboard_mode/public/dashboard_viewer.js index e76a204a6f27d..62cd253ff24d9 100644 --- a/x-pack/legacy/plugins/dashboard_mode/public/dashboard_viewer.js +++ b/x-pack/legacy/plugins/dashboard_mode/public/dashboard_viewer.js @@ -15,9 +15,7 @@ import { uiModules } from 'ui/modules'; // import the uiExports that we want to "use" import 'uiExports/contextMenuActions'; -import 'uiExports/visTypes'; -import 'uiExports/visResponseHandlers'; -import 'uiExports/visRequestHandlers'; + import 'uiExports/inspectorViews'; import 'uiExports/interpreter'; import 'uiExports/savedObjectTypes'; diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index bb0bf9b67ee2c..5eda6c4b4ff7a 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -33,7 +33,6 @@ export const lens: LegacyPluginInitializer = kibana => { embeddableFactories: [`plugins/${PLUGIN_ID}/legacy`], styleSheetPaths: resolve(__dirname, 'public/index.scss'), mappings, - visTypes: ['plugins/lens/register_vis_type_alias'], savedObjectsManagement: { lens: { defaultSearchField: 'title', diff --git a/x-pack/legacy/plugins/lens/public/legacy.ts b/x-pack/legacy/plugins/lens/public/legacy.ts index 8023bad34de66..1cfd3e198547d 100644 --- a/x-pack/legacy/plugins/lens/public/legacy.ts +++ b/x-pack/legacy/plugins/lens/public/legacy.ts @@ -5,12 +5,15 @@ */ import { npSetup, npStart } from 'ui/new_platform'; -import { getFormat } from './legacy_imports'; +import { getFormat, visualizations } from './legacy_imports'; export * from './types'; import { plugin } from './index'; const pluginInstance = plugin(); -pluginInstance.setup(npSetup.core, { ...npSetup.plugins, __LEGACY: { formatFactory: getFormat } }); +pluginInstance.setup(npSetup.core, { + ...npSetup.plugins, + __LEGACY: { formatFactory: getFormat, visualizations }, +}); pluginInstance.start(npStart.core, npStart.plugins); diff --git a/x-pack/legacy/plugins/lens/public/legacy_imports.ts b/x-pack/legacy/plugins/lens/public/legacy_imports.ts index 9dcc22ddb1bb7..88f189fe3db5a 100644 --- a/x-pack/legacy/plugins/lens/public/legacy_imports.ts +++ b/x-pack/legacy/plugins/lens/public/legacy_imports.ts @@ -5,3 +5,5 @@ */ export { getFormat, FormatFactory } from 'ui/visualize/loader/pipeline_helpers/utilities'; +export { setup as visualizations } from '../../../../../src/legacy/core_plugins/visualizations/public/np_ready/public/legacy'; +export { VisualizationsSetup } from '../../../../../src/legacy/core_plugins/visualizations/public'; diff --git a/x-pack/legacy/plugins/lens/public/plugin.tsx b/x-pack/legacy/plugins/lens/public/plugin.tsx index 634d227559835..7f96268fc2e8c 100644 --- a/x-pack/legacy/plugins/lens/public/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/plugin.tsx @@ -38,6 +38,8 @@ import { import { FormatFactory } from './legacy_imports'; import { IEmbeddableSetup, IEmbeddableStart } from '../../../../../src/plugins/embeddable/public'; import { EditorFrameStart } from './types'; +import { getLensAliasConfig } from './vis_type_alias'; +import { VisualizationsSetup } from './legacy_imports'; export interface LensPluginSetupDependencies { kibanaLegacy: KibanaLegacySetup; @@ -46,6 +48,7 @@ export interface LensPluginSetupDependencies { embeddable: IEmbeddableSetup; __LEGACY: { formatFactory: FormatFactory; + visualizations: VisualizationsSetup; }; } @@ -81,7 +84,7 @@ export class LensPlugin { expressions, data, embeddable, - __LEGACY: { formatFactory }, + __LEGACY: { formatFactory, visualizations }, }: LensPluginSetupDependencies ) { const editorFrameSetupInterface = this.editorFrameService.setup(core, { @@ -100,6 +103,8 @@ export class LensPlugin { this.datatableVisualization.setup(core, dependencies); this.metricVisualization.setup(core, dependencies); + visualizations.types.registerAlias(getLensAliasConfig()); + kibanaLegacy.registerLegacyApp({ id: 'lens', title: NOT_INTERNATIONALIZED_PRODUCT_NAME, diff --git a/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts b/x-pack/legacy/plugins/lens/public/vis_type_alias.ts similarity index 89% rename from x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts rename to x-pack/legacy/plugins/lens/public/vis_type_alias.ts index f71796268065b..c4e0a20110c81 100644 --- a/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts +++ b/x-pack/legacy/plugins/lens/public/vis_type_alias.ts @@ -5,10 +5,10 @@ */ import { i18n } from '@kbn/i18n'; -import { setup as visualizations } from '../../../../../src/legacy/core_plugins/visualizations/public/np_ready/public/legacy'; import { getBasePath, getEditPath } from '../../../../plugins/lens/common'; +import { VisTypeAlias } from '../../../../../src/legacy/core_plugins/visualizations/public/np_ready/public/vis_types'; -visualizations.types.registerAlias({ +export const getLensAliasConfig = (): VisTypeAlias => ({ aliasUrl: getBasePath(), name: 'lens', promotion: { diff --git a/x-pack/legacy/plugins/maps/index.js b/x-pack/legacy/plugins/maps/index.js index 5cd5a8731a703..8048c21fe9333 100644 --- a/x-pack/legacy/plugins/maps/index.js +++ b/x-pack/legacy/plugins/maps/index.js @@ -78,7 +78,7 @@ export function maps(kibana) { }, mappings, migrations, - visTypes: ['plugins/maps/register_vis_type_alias'], + hacks: ['plugins/maps/register_vis_type_alias'], }, config(Joi) { return Joi.object({ From 4696736528adb98e6f3ab3f872c3e14e67ca0873 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Mon, 2 Mar 2020 13:26:12 -0500 Subject: [PATCH 12/36] Disallow duplicate percentiles (#57444) (#58299) Added an optional validation step to the number_list component to disallow duplicates, Reworked and consolidated number_list component validations into one method and enabled this option in the percentiles editor. Added unit / integration tests --- .../number_list/number_list.test.tsx | 20 +++++ .../components/number_list/number_list.tsx | 39 ++++------ .../components/number_list/utils.test.ts | 69 +++++++++-------- .../controls/components/number_list/utils.ts | 77 +++++++++++++++---- .../components/controls/percentile_ranks.tsx | 1 + .../components/controls/percentiles.test.tsx | 64 +++++++++++++++ .../components/controls/percentiles.tsx | 2 +- 7 files changed, 199 insertions(+), 73 deletions(-) create mode 100644 src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/number_list.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/number_list.test.tsx index 3faf164c365d9..f547f1dee6a39 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/number_list.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/number_list.test.tsx @@ -63,12 +63,31 @@ describe('NumberList', () => { test('should show an order error', () => { defaultProps.numberArray = [3, 1]; + defaultProps.validateAscendingOrder = true; defaultProps.showValidation = true; const comp = mountWithIntl(); expect(comp.find('EuiFormErrorText').length).toBe(1); }); + test('should show a duplicate error', () => { + defaultProps.numberArray = [3, 1, 3]; + defaultProps.disallowDuplicates = true; + defaultProps.showValidation = true; + const comp = mountWithIntl(); + + expect(comp.find('EuiFormErrorText').length).toBeGreaterThan(0); + }); + + test('should show many duplicate errors', () => { + defaultProps.numberArray = [3, 1, 3, 1, 3, 1, 3, 1]; + defaultProps.disallowDuplicates = true; + defaultProps.showValidation = true; + const comp = mountWithIntl(); + + expect(comp.find('EuiFormErrorText').length).toBe(6); + }); + test('should set validity as true', () => { mountWithIntl(); @@ -77,6 +96,7 @@ describe('NumberList', () => { test('should set validity as false when the order is invalid', () => { defaultProps.numberArray = [3, 2]; + defaultProps.validateAscendingOrder = true; const comp = mountWithIntl(); expect(defaultProps.setValidity).lastCalledWith(false); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/number_list.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/number_list.tsx index 8e290ceedfeac..a43c66c2e08cc 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/number_list.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/number_list.tsx @@ -21,17 +21,14 @@ import React, { Fragment, useState, useEffect, useMemo, useCallback } from 'reac import { EuiSpacer, EuiButtonEmpty, EuiFlexItem, EuiFormErrorText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; import { NumberRow, NumberRowModel } from './number_row'; import { parse, EMPTY_STRING, getRange, - validateOrder, - validateValue, getNextModel, getInitModelList, - getUpdatedModels, + getValidatedModels, hasInvalidValues, } from './utils'; import { useValidation } from '../../utils'; @@ -41,6 +38,7 @@ export interface NumberListProps { numberArray: Array; range?: string; showValidation: boolean; + disallowDuplicates?: boolean; unitName: string; validateAscendingOrder?: boolean; onChange(list: Array): void; @@ -54,31 +52,27 @@ function NumberList({ range, showValidation, unitName, - validateAscendingOrder = true, + validateAscendingOrder = false, + disallowDuplicates = false, onChange, setTouched, setValidity, }: NumberListProps) { const numberRange = useMemo(() => getRange(range), [range]); const [models, setModels] = useState(getInitModelList(numberArray)); - const [ascendingError, setAscendingError] = useState(EMPTY_STRING); // set up validity for each model useEffect(() => { - let id: number | undefined; - if (validateAscendingOrder) { - const { isValidOrder, modelIndex } = validateOrder(numberArray); - id = isValidOrder ? undefined : modelIndex; - setAscendingError( - isValidOrder - ? EMPTY_STRING - : i18n.translate('visDefaultEditor.controls.numberList.invalidAscOrderErrorMessage', { - defaultMessage: 'The values should be in ascending order.', - }) - ); - } - setModels(state => getUpdatedModels(numberArray, state, numberRange, id)); - }, [numberArray, numberRange, validateAscendingOrder]); + setModels(state => + getValidatedModels( + numberArray, + state, + numberRange, + validateAscendingOrder, + disallowDuplicates + ) + ); + }, [numberArray, numberRange, validateAscendingOrder, disallowDuplicates]); // responsible for setting up an initial value ([0]) when there is no default value useEffect(() => { @@ -105,12 +99,10 @@ function NumberList({ onUpdate( models.map(model => { if (model.id === id) { - const { isInvalid, error } = validateValue(parsedValue, numberRange); return { id, value: parsedValue, - isInvalid, - error, + isInvalid: false, }; } return model; @@ -155,7 +147,6 @@ function NumberList({ {models.length - 1 !== arrayIndex && } ))} - {showValidation && ascendingError && {ascendingError}} diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/utils.test.ts b/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/utils.test.ts index 89fb5738db379..9cffaadfc956d 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/utils.test.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/utils.test.ts @@ -19,13 +19,12 @@ import { getInitModelList, - getUpdatedModels, - validateOrder, hasInvalidValues, parse, validateValue, getNextModel, getRange, + getValidatedModels, } from './utils'; import { NumberListRange } from './range'; import { NumberRowModel } from './number_row'; @@ -33,6 +32,7 @@ import { NumberRowModel } from './number_row'; describe('NumberList utils', () => { let modelList: NumberRowModel[]; let range: NumberListRange; + let invalidEntry: NumberRowModel; beforeEach(() => { modelList = [ @@ -46,6 +46,12 @@ describe('NumberList utils', () => { maxInclusive: true, within: jest.fn(() => true), }; + invalidEntry = { + value: expect.any(Number), + isInvalid: true, + error: expect.any(String), + id: expect.any(String), + }; }); describe('getInitModelList', () => { @@ -65,27 +71,27 @@ describe('NumberList utils', () => { }); }); - describe('getUpdatedModels', () => { + describe('getValidatedModels', () => { test('should return model list when number list is empty', () => { - const updatedModelList = getUpdatedModels([], modelList, range); + const updatedModelList = getValidatedModels([], modelList, range); expect(updatedModelList).toEqual([{ value: 0, id: expect.any(String), isInvalid: false }]); }); test('should not update model list when number list is the same', () => { - const updatedModelList = getUpdatedModels([1, 2], modelList, range); + const updatedModelList = getValidatedModels([1, 2], modelList, range); expect(updatedModelList).toEqual(modelList); }); test('should update model list when number list was changed', () => { - const updatedModelList = getUpdatedModels([1, 3], modelList, range); + const updatedModelList = getValidatedModels([1, 3], modelList, range); modelList[1].value = 3; expect(updatedModelList).toEqual(modelList); }); test('should update model list when number list increased', () => { - const updatedModelList = getUpdatedModels([1, 2, 3], modelList, range); + const updatedModelList = getValidatedModels([1, 2, 3], modelList, range); expect(updatedModelList).toEqual([ ...modelList, { value: 3, id: expect.any(String), isInvalid: false }, @@ -93,45 +99,46 @@ describe('NumberList utils', () => { }); test('should update model list when number list decreased', () => { - const updatedModelList = getUpdatedModels([2], modelList, range); + const updatedModelList = getValidatedModels([2], modelList, range); expect(updatedModelList).toEqual([{ value: 2, id: '1', isInvalid: false }]); }); test('should update model list when number list has undefined value', () => { - const updatedModelList = getUpdatedModels([1, undefined], modelList, range); + const updatedModelList = getValidatedModels([1, undefined], modelList, range); modelList[1].value = ''; modelList[1].isInvalid = true; expect(updatedModelList).toEqual(modelList); }); - test('should update model list when number order is invalid', () => { - const updatedModelList = getUpdatedModels([1, 3, 2], modelList, range, 2); - expect(updatedModelList).toEqual([ - modelList[0], - { ...modelList[1], value: 3 }, - { value: 2, id: expect.any(String), isInvalid: true }, - ]); + test('should identify when a number is out of order', () => { + const updatedModelList = getValidatedModels([1, 3, 2], modelList, range, true); + expect(updatedModelList[2]).toEqual(invalidEntry); }); - }); - describe('validateOrder', () => { - test('should return true when order is valid', () => { - expect(validateOrder([1, 2])).toEqual({ - isValidOrder: true, - }); + test('should identify when many numbers are out of order', () => { + const updatedModelList = getValidatedModels([1, 3, 2, 3, 4, 2], modelList, range, true); + expect(updatedModelList[2]).toEqual(invalidEntry); + expect(updatedModelList[5]).toEqual(invalidEntry); }); - test('should return true when a number is undefined', () => { - expect(validateOrder([1, undefined])).toEqual({ - isValidOrder: true, - }); + test('should identify a duplicate', () => { + const updatedModelList = getValidatedModels([1, 2, 3, 6, 2], modelList, range, false, true); + expect(updatedModelList[4]).toEqual(invalidEntry); }); - test('should return false when order is invalid', () => { - expect(validateOrder([2, 1])).toEqual({ - isValidOrder: false, - modelIndex: 1, - }); + test('should identify many duplicates', () => { + const updatedModelList = getValidatedModels( + [2, 2, 2, 3, 4, 5, 2, 2, 3], + modelList, + range, + false, + true + ); + expect(updatedModelList[1]).toEqual(invalidEntry); + expect(updatedModelList[2]).toEqual(invalidEntry); + expect(updatedModelList[6]).toEqual(invalidEntry); + expect(updatedModelList[7]).toEqual(invalidEntry); + expect(updatedModelList[8]).toEqual(invalidEntry); }); }); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/utils.ts b/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/utils.ts index e0f32366fc265..c2ac63c98cbea 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/utils.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/components/number_list/utils.ts @@ -49,6 +49,7 @@ function validateValue(value: number | '', numberRange: NumberListRange) { if (value === EMPTY_STRING) { result.isInvalid = true; + result.error = EMPTY_STRING; } else if (!numberRange.within(value)) { result.isInvalid = true; result.error = i18n.translate('visDefaultEditor.controls.numberList.invalidRangeErrorMessage', { @@ -60,19 +61,46 @@ function validateValue(value: number | '', numberRange: NumberListRange) { return result; } -function validateOrder(list: Array) { - const result: { isValidOrder: boolean; modelIndex?: number } = { - isValidOrder: true, +function validateValueAscending( + inputValue: number | '', + index: number, + list: Array +) { + const result: { isInvalidOrder: boolean; error?: string } = { + isInvalidOrder: false, }; - list.forEach((inputValue, index, array) => { - const previousModel = array[index - 1]; - if (previousModel !== undefined && inputValue !== undefined && inputValue <= previousModel) { - result.isValidOrder = false; - result.modelIndex = index; - } - }); + const previousModel = list[index - 1]; + if (previousModel !== undefined && inputValue !== undefined && inputValue <= previousModel) { + result.isInvalidOrder = true; + result.error = i18n.translate( + 'visDefaultEditor.controls.numberList.invalidAscOrderErrorMessage', + { + defaultMessage: 'Value is not in ascending order.', + } + ); + } + return result; +} + +function validateValueUnique( + inputValue: number | '', + index: number, + list: Array +) { + const result: { isDuplicate: boolean; error?: string } = { + isDuplicate: false, + }; + if (inputValue && list.indexOf(inputValue) !== index) { + result.isDuplicate = true; + result.error = i18n.translate( + 'visDefaultEditor.controls.numberList.duplicateValueErrorMessage', + { + defaultMessage: 'Duplicate value.', + } + ); + } return result; } @@ -101,11 +129,12 @@ function getInitModelList(list: Array): NumberRowModel[] { : [defaultModel]; } -function getUpdatedModels( +function getValidatedModels( numberList: Array, modelList: NumberRowModel[], numberRange: NumberListRange, - invalidOrderModelIndex?: number + validateAscendingOrder: boolean = false, + disallowDuplicates: boolean = false ): NumberRowModel[] { if (!numberList.length) { return [defaultModel]; @@ -113,12 +142,27 @@ function getUpdatedModels( return numberList.map((number, index) => { const model = modelList[index] || { id: generateId() }; const newValue: NumberRowModel['value'] = number === undefined ? EMPTY_STRING : number; - const { isInvalid, error } = validateValue(newValue, numberRange); + + const valueResult = numberRange ? validateValue(newValue, numberRange) : { isInvalid: false }; + + const ascendingResult = validateAscendingOrder + ? validateValueAscending(newValue, index, numberList) + : { isInvalidOrder: false }; + + const duplicationResult = disallowDuplicates + ? validateValueUnique(newValue, index, numberList) + : { isDuplicate: false }; + + const allErrors = [valueResult.error, ascendingResult.error, duplicationResult.error] + .filter(Boolean) + .join(' '); + return { ...model, value: newValue, - isInvalid: invalidOrderModelIndex === index ? true : isInvalid, - error, + isInvalid: + valueResult.isInvalid || ascendingResult.isInvalidOrder || duplicationResult.isDuplicate, + error: allErrors === EMPTY_STRING ? undefined : allErrors, }; }); } @@ -132,9 +176,8 @@ export { parse, getRange, validateValue, - validateOrder, getNextModel, getInitModelList, - getUpdatedModels, + getValidatedModels, hasInvalidValues, }; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentile_ranks.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentile_ranks.tsx index c6057b7ce2a99..fb7d8d78b28e3 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentile_ranks.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentile_ranks.tsx @@ -62,6 +62,7 @@ function PercentileRanksEditor({ unitName={i18n.translate('visDefaultEditor.controls.percentileRanks.valueUnitNameText', { defaultMessage: 'value', })} + validateAscendingOrder={true} showValidation={showValidation} onChange={setValue} setTouched={setTouched} diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx new file mode 100644 index 0000000000000..020dbb351b497 --- /dev/null +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx @@ -0,0 +1,64 @@ +/* + * 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 { AggParamEditorProps } from '../agg_param_props'; +import { IAggConfig } from '../../legacy_imports'; +import { VisState } from 'src/legacy/core_plugins/visualizations/public'; +import { mount } from 'enzyme'; +import { PercentilesEditor } from './percentiles'; + +describe('PercentilesEditor component', () => { + let setValue: jest.Mock; + let setValidity: jest.Mock; + let setTouched: jest.Mock; + let defaultProps: AggParamEditorProps>; + + beforeEach(() => { + setValue = jest.fn(); + setValidity = jest.fn(); + setTouched = jest.fn(); + + defaultProps = { + agg: {} as IAggConfig, + aggParam: {} as any, + formIsTouched: false, + value: [1, 5, 25, 50, 75, 95, 99], + editorConfig: {}, + showValidation: false, + setValue, + setValidity, + setTouched, + state: {} as VisState, + metricAggs: [] as IAggConfig[], + }; + }); + + it('should set valid state to true after adding a unique percentile', () => { + defaultProps.value = [1, 5, 25, 50, 70]; + mount(); + expect(setValidity).lastCalledWith(true); + }); + + it('should set valid state to false after adding a duplicate percentile', () => { + defaultProps.value = [1, 5, 25, 50, 50]; + mount(); + expect(setValidity).lastCalledWith(false); + }); +}); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.tsx index 74e7957bc1944..9f1f26fe5446f 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.tsx @@ -58,7 +58,7 @@ function PercentilesEditor({ labelledbyId={`visEditorPercentileLabel${agg.id}-legend`} numberArray={value} range="[0,100]" - validateAscendingOrder={false} + disallowDuplicates={true} unitName={i18n.translate('visDefaultEditor.controls.percentileRanks.percentUnitNameText', { defaultMessage: 'percent', })} From 2998ec06fe9ded381fe637ab9940dc20a3742aea Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 2 Mar 2020 11:47:35 -0700 Subject: [PATCH 13/36] [Maps] direct Discover "visualize" to open Maps application (#58549) * [Maps] direct Discover visualize to Maps application * pass initial layers to maps app * add functional test * fix parentheses messed up by lint fix * fix i18n expression * move logic into lib * fix typescript errors * use constant for geo_point and geo_shape, more TS noise * use encode_array in an attempt to make TS happy * another round of TS changes * one more thing Co-authored-by: Elastic Machine --- .../np_ready/angular/context_state.ts | 5 +- .../components/field_chooser/field_chooser.js | 17 ++- .../lib/detail_views/string.html | 4 +- .../field_chooser/lib/visualize_url_utils.ts | 108 ++++++++++++++++++ test/functional/page_objects/discover_page.js | 6 - .../maps/public/angular/get_initial_layers.js | 8 +- .../maps/public/angular/map_controller.js | 29 ++++- x-pack/test/functional/apps/maps/discover.js | 50 ++++++++ x-pack/test/functional/apps/maps/index.js | 1 + 9 files changed, 210 insertions(+), 18 deletions(-) create mode 100644 src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/lib/visualize_url_utils.ts create mode 100644 x-pack/test/functional/apps/maps/discover.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.ts index 8fb6140d55e31..bf185f78941de 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.ts @@ -24,9 +24,9 @@ import { syncStates, BaseStateContainer, } from '../../../../../../../plugins/kibana_utils/public'; -import { esFilters, FilterManager, Filter } from '../../../../../../../plugins/data/public'; +import { esFilters, FilterManager, Filter, Query } from '../../../../../../../plugins/data/public'; -interface AppState { +export interface AppState { /** * Columns displayed in the table, cannot be changed by UI, just in discover's main app */ @@ -47,6 +47,7 @@ interface AppState { * Number of records to be fetched after the anchor records (older records) */ successorCount: number; + query?: Query; } interface GlobalState { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/field_chooser.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/field_chooser.js index a175a1aebebdf..df970ab5f2584 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/field_chooser.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/field_chooser.js @@ -24,7 +24,11 @@ import './discover_field'; import './discover_field_search_directive'; import './discover_index_pattern_directive'; import fieldChooserTemplate from './field_chooser.html'; -import { IndexPatternFieldList } from '../../../../../../../../plugins/data/public'; +import { + IndexPatternFieldList, + KBN_FIELD_TYPES, +} from '../../../../../../../../plugins/data/public'; +import { getMapsAppUrl, isFieldVisualizable, isMapsAppRegistered } from './lib/visualize_url_utils'; export function createFieldChooserDirective($location, config, $route) { return { @@ -186,8 +190,15 @@ export function createFieldChooserDirective($location, config, $route) { return ''; } + if ( + (field.type === KBN_FIELD_TYPES.GEO_POINT || field.type === KBN_FIELD_TYPES.GEO_SHAPE) && + isMapsAppRegistered() + ) { + return getMapsAppUrl(field, $scope.indexPattern, $scope.state, $scope.columns); + } + let agg = {}; - const isGeoPoint = field.type === 'geo_point'; + const isGeoPoint = field.type === KBN_FIELD_TYPES.GEO_POINT; const type = isGeoPoint ? 'tile_map' : 'histogram'; // If we're visualizing a date field, and our index is time based (and thus has a time filter), // then run a date histogram @@ -243,7 +254,7 @@ export function createFieldChooserDirective($location, config, $route) { $scope.computeDetails = function(field, recompute) { if (_.isUndefined(field.details) || recompute) { field.details = { - visualizeUrl: field.visualizable ? getVisualizeUrl(field) : null, + visualizeUrl: isFieldVisualizable(field) ? getVisualizeUrl(field) : null, ...fieldCalculator.getFieldValueCounts({ hits: $scope.hits, field: field, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/lib/detail_views/string.html b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/lib/detail_views/string.html index 5d134911fc91b..333dc472e956d 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/lib/detail_views/string.html +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/lib/detail_views/string.html @@ -79,7 +79,7 @@ @@ -87,7 +87,7 @@ diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/lib/visualize_url_utils.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/lib/visualize_url_utils.ts new file mode 100644 index 0000000000000..8dbf3cd79ccb1 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/lib/visualize_url_utils.ts @@ -0,0 +1,108 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import uuid from 'uuid/v4'; +// @ts-ignore +import rison from 'rison-node'; +import { + IFieldType, + IIndexPattern, + KBN_FIELD_TYPES, +} from '../../../../../../../../../plugins/data/public'; +import { AppState } from '../../../angular/context_state'; +import { getServices } from '../../../../kibana_services'; + +function getMapsAppBaseUrl() { + const mapsAppVisAlias = getServices() + .visualizations.types.getAliases() + .find(({ name }) => { + return name === 'maps'; + }); + return mapsAppVisAlias ? mapsAppVisAlias.aliasUrl : null; +} + +export function isMapsAppRegistered() { + return getServices() + .visualizations.types.getAliases() + .some(({ name }) => { + return name === 'maps'; + }); +} + +export function isFieldVisualizable(field: IFieldType) { + if ( + (field.type === KBN_FIELD_TYPES.GEO_POINT || field.type === KBN_FIELD_TYPES.GEO_SHAPE) && + isMapsAppRegistered() + ) { + return true; + } + return field.visualizable; +} + +export function getMapsAppUrl( + field: IFieldType, + indexPattern: IIndexPattern, + appState: AppState, + columns: string[] +) { + const mapAppParams = new URLSearchParams(); + + // Copy global state + const locationSplit = window.location.href.split('discover?'); + if (locationSplit.length > 1) { + const discoverParams = new URLSearchParams(locationSplit[1]); + const globalStateUrlValue = discoverParams.get('_g'); + if (globalStateUrlValue) { + mapAppParams.set('_g', globalStateUrlValue); + } + } + + // Copy filters and query in app state + const mapsAppState: any = { + filters: appState.filters || [], + }; + if (appState.query) { + mapsAppState.query = appState.query; + } + // @ts-ignore + mapAppParams.set('_a', rison.encode(mapsAppState)); + + // create initial layer descriptor + const hasColumns = columns && columns.length && columns[0] !== '_source'; + mapAppParams.set( + 'initialLayers', + // @ts-ignore + rison.encode_array([ + { + id: uuid(), + label: indexPattern.title, + sourceDescriptor: { + id: uuid(), + type: 'ES_SEARCH', + geoField: field.name, + tooltipProperties: hasColumns ? columns : [], + indexPatternId: indexPattern.id, + }, + visible: true, + type: 'VECTOR', + }, + ]) + ); + + return getServices().addBasePath(`${getMapsAppBaseUrl()}?${mapAppParams.toString()}`); +} diff --git a/test/functional/page_objects/discover_page.js b/test/functional/page_objects/discover_page.js index 5ccc5625849d2..080b8c8ee753f 100644 --- a/test/functional/page_objects/discover_page.js +++ b/test/functional/page_objects/discover_page.js @@ -206,12 +206,6 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { return await testSubjects.getVisibleText('discoverQueryHits'); } - async query(queryString) { - await find.setValue('input[aria-label="Search input"]', queryString); - await find.clickByCssSelector('button[aria-label="Search"]'); - await PageObjects.header.waitUntilLoadingHasFinished(); - } - async getDocHeader() { const header = await find.byCssSelector('thead > tr:nth-child(1)'); return await header.getVisibleText(); diff --git a/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.js b/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.js index 45ee441716769..3cae75231d28e 100644 --- a/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.js +++ b/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.js @@ -9,7 +9,7 @@ import { EMSTMSSource } from '../layers/sources/ems_tms_source'; import chrome from 'ui/chrome'; import { getKibanaTileMap } from '../meta'; -export function getInitialLayers(layerListJSON) { +export function getInitialLayers(layerListJSON, initialLayers = []) { if (layerListJSON) { return JSON.parse(layerListJSON); } @@ -19,7 +19,7 @@ export function getInitialLayers(layerListJSON) { const sourceDescriptor = KibanaTilemapSource.createDescriptor(); const source = new KibanaTilemapSource(sourceDescriptor); const layer = source.createDefaultLayer(); - return [layer.toLayerDescriptor()]; + return [layer.toLayerDescriptor(), ...initialLayers]; } const isEmsEnabled = chrome.getInjected('isEmsEnabled', true); @@ -27,8 +27,8 @@ export function getInitialLayers(layerListJSON) { const descriptor = EMSTMSSource.createDescriptor({ isAutoSelect: true }); const source = new EMSTMSSource(descriptor); const layer = source.createDefaultLayer(); - return [layer.toLayerDescriptor()]; + return [layer.toLayerDescriptor(), ...initialLayers]; } - return []; + return initialLayers; } diff --git a/x-pack/legacy/plugins/maps/public/angular/map_controller.js b/x-pack/legacy/plugins/maps/public/angular/map_controller.js index 95c8ff975b1d6..a8e9ae46a3b9a 100644 --- a/x-pack/legacy/plugins/maps/public/angular/map_controller.js +++ b/x-pack/legacy/plugins/maps/public/angular/map_controller.js @@ -6,6 +6,7 @@ import _ from 'lodash'; import chrome from 'ui/chrome'; +import rison from 'rison-node'; import 'ui/directives/listen'; import 'ui/directives/storage'; import React from 'react'; @@ -66,6 +67,32 @@ const REACT_ANCHOR_DOM_ELEMENT_ID = 'react-maps-root'; const app = uiModules.get(MAP_APP_PATH, []); +function getInitialLayersFromUrlParam() { + const locationSplit = window.location.href.split('?'); + if (locationSplit.length <= 1) { + return []; + } + const mapAppParams = new URLSearchParams(locationSplit[1]); + if (!mapAppParams.has('initialLayers')) { + return []; + } + + try { + return rison.decode_array(mapAppParams.get('initialLayers')); + } catch (e) { + toastNotifications.addWarning({ + title: i18n.translate('xpack.maps.initialLayers.unableToParseTitle', { + defaultMessage: `Inital layers not added to map`, + }), + text: i18n.translate('xpack.maps.initialLayers.unableToParseMessage', { + defaultMessage: `Unable to parse contents of 'initialLayers' parameter. Error: {errorMsg}`, + values: { errorMsg: e.message }, + }), + }); + return []; + } +} + app.controller( 'GisMapController', ($scope, $route, kbnUrl, localStorage, AppState, globalState) => { @@ -333,7 +360,7 @@ app.controller( store.dispatch(setOpenTOCDetails(_.get(uiState, 'openTOCDetails', []))); } - const layerList = getInitialLayers(savedMap.layerListJSON); + const layerList = getInitialLayers(savedMap.layerListJSON, getInitialLayersFromUrlParam()); initialLayerListConfig = copyPersistentState(layerList); store.dispatch(replaceLayerList(layerList)); store.dispatch(setRefreshConfig($scope.refreshConfig)); diff --git a/x-pack/test/functional/apps/maps/discover.js b/x-pack/test/functional/apps/maps/discover.js new file mode 100644 index 0000000000000..ce33596476755 --- /dev/null +++ b/x-pack/test/functional/apps/maps/discover.js @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default function({ getService, getPageObjects }) { + const queryBar = getService('queryBar'); + const PageObjects = getPageObjects(['common', 'discover', 'header', 'maps', 'timePicker']); + + describe('discover visualize button', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('discover'); + }); + + it('should link geo_shape fields to Maps application', async () => { + await PageObjects.discover.selectIndexPattern('geo_shapes*'); + await PageObjects.discover.clickFieldListItem('geometry'); + await PageObjects.discover.clickFieldListItemVisualize('geometry'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.maps.waitForLayersToLoad(); + const doesLayerExist = await PageObjects.maps.doesLayerExist('geo_shapes*'); + expect(doesLayerExist).to.equal(true); + const hits = await PageObjects.maps.getHits(); + expect(hits).to.equal('4'); + }); + + it('should link geo_point fields to Maps application with time and query context', async () => { + await PageObjects.discover.selectIndexPattern('logstash-*'); + await PageObjects.timePicker.setAbsoluteRange( + 'Sep 22, 2015 @ 00:00:00.000', + 'Sep 22, 2015 @ 04:00:00.000' + ); + await queryBar.setQuery('machine.os.raw : "ios"'); + await queryBar.submitQuery(); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.discover.clickFieldListItem('geo.coordinates'); + await PageObjects.discover.clickFieldListItemVisualize('geo.coordinates'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.maps.waitForLayersToLoad(); + const doesLayerExist = await PageObjects.maps.doesLayerExist('logstash-*'); + expect(doesLayerExist).to.equal(true); + const hits = await PageObjects.maps.getHits(); + expect(hits).to.equal('7'); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/index.js b/x-pack/test/functional/apps/maps/index.js index 0545fcd1b6453..e8a9d7ba54bc5 100644 --- a/x-pack/test/functional/apps/maps/index.js +++ b/x-pack/test/functional/apps/maps/index.js @@ -45,6 +45,7 @@ export default function({ loadTestFile, getService }) { loadTestFile(require.resolve('./import_geojson')); loadTestFile(require.resolve('./layer_errors')); loadTestFile(require.resolve('./embeddable')); + loadTestFile(require.resolve('./discover')); }); }); } From 17b3d8036914f67a6a71e24f7cc6bd42852195b7 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 2 Mar 2020 20:14:24 +0100 Subject: [PATCH 14/36] improve graph missing workspace error message (#58876) --- x-pack/legacy/plugins/graph/public/app.js | 20 ++++++++++++------- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/x-pack/legacy/plugins/graph/public/app.js b/x-pack/legacy/plugins/graph/public/app.js index 7010e1fa773ea..df968681a38e2 100644 --- a/x-pack/legacy/plugins/graph/public/app.js +++ b/x-pack/legacy/plugins/graph/public/app.js @@ -132,14 +132,20 @@ export function initGraphApp(angularModule, deps) { template: appTemplate, badge: getReadonlyBadge, resolve: { - savedWorkspace: function($route) { + savedWorkspace: function($rootScope, $route, $location) { return $route.current.params.id - ? savedWorkspaceLoader.get($route.current.params.id).catch(function() { - toastNotifications.addDanger( - i18n.translate('xpack.graph.missingWorkspaceErrorMessage', { - defaultMessage: 'Missing workspace', - }) - ); + ? savedWorkspaceLoader.get($route.current.params.id).catch(function(e) { + toastNotifications.addError(e, { + title: i18n.translate('xpack.graph.missingWorkspaceErrorMessage', { + defaultMessage: "Couldn't load graph with ID", + }), + }); + $rootScope.$eval(() => { + $location.path('/home'); + $location.replace(); + }); + // return promise that never returns to prevent the controller from loading + return new Promise(); }) : savedWorkspaceLoader.get(); }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b99a54160bb65..21500c4db9c34 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5734,7 +5734,6 @@ "xpack.graph.listing.table.entityNamePlural": "グラフ", "xpack.graph.listing.table.titleColumnName": "タイトル", "xpack.graph.loadWorkspace.missingIndexPatternErrorMessage": "インデックスパターンが見つかりませんでした", - "xpack.graph.missingWorkspaceErrorMessage": "ワークスペースがありません", "xpack.graph.newGraphTitle": "保存されていないグラフ", "xpack.graph.noDataSourceNotificationMessageText": "データソースが見つかりませんでした。{managementIndexPatternsLink} に移動して Elasticsearch インデックスのインデックスパターンを作成してください。", "xpack.graph.noDataSourceNotificationMessageText.managementIndexPatternLinkText": "管理>インデックスパターン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index bae8fef5ff280..c9e7ea1ec80de 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5734,7 +5734,6 @@ "xpack.graph.listing.table.entityNamePlural": "图表", "xpack.graph.listing.table.titleColumnName": "标题", "xpack.graph.loadWorkspace.missingIndexPatternErrorMessage": "未找到索引模式", - "xpack.graph.missingWorkspaceErrorMessage": "缺少工作空间", "xpack.graph.newGraphTitle": "未保存图表", "xpack.graph.noDataSourceNotificationMessageText": "未找到数据源。前往 {managementIndexPatternsLink},为您的 Elasticsearch 索引创建索引模式。", "xpack.graph.noDataSourceNotificationMessageText.managementIndexPatternLinkText": "管理 > 索引模式", From cbbc963001be1d200c97a9678d59ac0750a3b7c1 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 2 Mar 2020 20:14:37 +0100 Subject: [PATCH 15/36] show timepicker in timelion and tsvb (#58857) --- .../kibana/public/visualize/np_ready/editor/editor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index 415949f88e9d1..2137e413451d2 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -382,7 +382,7 @@ function VisualizeAppController( $scope.showQueryBarTimePicker = () => { // tsvb loads without an indexPattern initially (TODO investigate). // hide timefilter only if timeFieldName is explicitly undefined. - const hasTimeField = $scope.indexPattern ? !!$scope.indexPattern.timeFieldName : true; + const hasTimeField = vis.indexPattern ? !!vis.indexPattern.timeFieldName : true; return vis.type.options.showTimePicker && hasTimeField; }; From a6b166b69c148cbd3c10ddc96bce10a25c6c5476 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 2 Mar 2020 20:14:51 +0100 Subject: [PATCH 16/36] put params into short url instead of behind it (#58846) --- .../share/public/components/url_panel_content.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/plugins/share/public/components/url_panel_content.tsx b/src/plugins/share/public/components/url_panel_content.tsx index d0d4ce55dc1ac..2b77b6f4592a8 100644 --- a/src/plugins/share/public/components/url_panel_content.tsx +++ b/src/plugins/share/public/components/url_panel_content.tsx @@ -183,7 +183,11 @@ export class UrlPanelContent extends Component { }; private getSnapshotUrl = () => { - return this.props.shareableUrl || window.location.href; + let url = this.props.shareableUrl || window.location.href; + if (this.props.isEmbedded) { + url = this.makeUrlEmbeddable(url); + } + return url; }; private makeUrlEmbeddable = (url: string) => { @@ -200,8 +204,7 @@ export class UrlPanelContent extends Component { return; } - const embeddableUrl = this.makeUrlEmbeddable(url); - return ``; + return ``; }; private setUrl = () => { From 2cf863c27b954c593ffc298d7f4524f328d07d7a Mon Sep 17 00:00:00 2001 From: Caroline Horn <549577+cchaos@users.noreply.github.com> Date: Mon, 2 Mar 2020 14:38:43 -0500 Subject: [PATCH 17/36] [Advanced Settings] Fix a11y of unsaved indicator (#58511) * [Advanced Settings] Fix a11y of unsaved indicator - Reduced size of the indicator bar on the left - Added icons with tooltips to indicated unsaved and invalid states * Snaps * Fix mobile view of bottom bar --- .../management_app/advanced_settings.scss | 30 +- .../field/__snapshots__/field.test.tsx.snap | 432 +++++++++++++++--- .../management_app/components/field/field.tsx | 29 +- .../management_app/components/form/form.tsx | 90 ++-- 4 files changed, 468 insertions(+), 113 deletions(-) diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.scss b/src/plugins/advanced_settings/public/management_app/advanced_settings.scss index 016edb2817da8..66ae9cca3f83b 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.scss +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.scss @@ -22,40 +22,42 @@ margin-top: $euiSize; } + .mgtAdvancedSettings__fieldTitle { + padding-left: $euiSizeS; + margin-left: -$euiSizeS; + } - padding-left: $euiSizeS; - margin-left: -$euiSizeS; - &--unsaved { + &--unsaved .mgtAdvancedSettings__fieldTitle { // Simulates a left side border without shifting content - box-shadow: -$euiSizeXS 0px $euiColorSecondary; + box-shadow: -$euiSizeXS 0px $euiColorWarning; } - &--invalid { + &--invalid .mgtAdvancedSettings__fieldTitle { // Simulates a left side border without shifting content box-shadow: -$euiSizeXS 0px $euiColorDanger; } - @include internetExplorerOnly() { - min-height: 1px; - } - &Row { - padding-left: $euiSizeS; - } @include internetExplorerOnly { + min-height: 1px; + &Row { min-height: 1px; } } } +.mgtAdvancedSettings__fieldTitleUnsavedIcon { + margin-left: $euiSizeS; +} + .mgtAdvancedSettingsForm__unsavedCount { - @include euiBreakpoint('xs', 's') { + @include euiBreakpoint('xs') { display: none; } } -.mgtAdvancedSettingsForm__unsavedCountMessage{ +.mgtAdvancedSettingsForm__unsavedCountMessage { // Simulates a left side border without shifting content - box-shadow: -$euiSizeXS 0px $euiColorSecondary; + box-shadow: -$euiSizeXS 0px $euiColorWarning; padding-left: $euiSizeS; } diff --git a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap index 2f4d806e60244..dba1678339f24 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap +++ b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap @@ -17,7 +17,12 @@ exports[`Field for array setting should render as read only if saving is disable fullWidth={true} title={

- Array test setting + + Array test setting + +

} @@ -84,7 +89,12 @@ exports[`Field for array setting should render as read only with help text if ov fullWidth={true} title={

- Array test setting + + Array test setting + +

} @@ -139,7 +149,11 @@ exports[`Field for array setting should render custom setting icon if it is cust fullWidth={true} title={

- Array test setting + + Array test setting + +

} > @@ -195,7 +210,12 @@ exports[`Field for array setting should render default value if there is no user fullWidth={true} title={

- Array test setting + + Array test setting + +

} @@ -240,7 +260,11 @@ exports[`Field for array setting should render unsaved value if there are unsave fullWidth={true} title={

- Array test setting + + Array test setting + +

} > @@ -330,7 +361,12 @@ exports[`Field for array setting should render user value if there is user value fullWidth={true} title={

- Array test setting + + Array test setting + +

} @@ -392,7 +428,12 @@ exports[`Field for boolean setting should render as read only if saving is disab fullWidth={true} title={

- Boolean test setting + + Boolean test setting + +

} @@ -465,7 +506,12 @@ exports[`Field for boolean setting should render as read only with help text if fullWidth={true} title={

- Boolean test setting + + Boolean test setting + +

} @@ -526,7 +572,11 @@ exports[`Field for boolean setting should render custom setting icon if it is cu fullWidth={true} title={

- Boolean test setting + + Boolean test setting + +

} > @@ -588,7 +639,12 @@ exports[`Field for boolean setting should render default value if there is no us fullWidth={true} title={

- Boolean test setting + + Boolean test setting + +

} @@ -639,7 +695,11 @@ exports[`Field for boolean setting should render unsaved value if there are unsa fullWidth={true} title={

- Boolean test setting + + Boolean test setting + +

} > @@ -731,7 +798,12 @@ exports[`Field for boolean setting should render user value if there is user val fullWidth={true} title={

- Boolean test setting + + Boolean test setting + +

} @@ -799,7 +871,12 @@ exports[`Field for image setting should render as read only if saving is disable fullWidth={true} title={

- Image test setting + + Image test setting + +

} @@ -868,7 +945,12 @@ exports[`Field for image setting should render as read only with help text if ov fullWidth={true} title={

- Image test setting + + Image test setting + +

} @@ -921,7 +1003,11 @@ exports[`Field for image setting should render custom setting icon if it is cust fullWidth={true} title={

- Image test setting + + Image test setting + +

} > @@ -979,7 +1066,12 @@ exports[`Field for image setting should render default value if there is no user fullWidth={true} title={

- Image test setting + + Image test setting + +

} @@ -1026,7 +1118,11 @@ exports[`Field for image setting should render unsaved value if there are unsave fullWidth={true} title={

- Image test setting + + Image test setting + +

} > @@ -1113,7 +1216,12 @@ exports[`Field for image setting should render user value if there is user value fullWidth={true} title={

- Image test setting + + Image test setting + +

} @@ -1211,7 +1319,12 @@ exports[`Field for json setting should render as read only if saving is disabled fullWidth={true} title={

- Json test setting + + Json test setting + +

} @@ -1302,7 +1415,12 @@ exports[`Field for json setting should render as read only with help text if ove fullWidth={true} title={

- Json test setting + + Json test setting + +

} @@ -1378,7 +1496,11 @@ exports[`Field for json setting should render custom setting icon if it is custo fullWidth={true} title={

- Json test setting + + Json test setting + +

} > @@ -1480,7 +1603,12 @@ exports[`Field for json setting should render default value if there is no user fullWidth={true} title={

- Json test setting + + Json test setting + +

} @@ -1563,7 +1691,11 @@ exports[`Field for json setting should render unsaved value if there are unsaved fullWidth={true} title={

- Json test setting + + Json test setting + +

} > @@ -1677,7 +1816,12 @@ exports[`Field for json setting should render user value if there is user value fullWidth={true} title={

- Json test setting + + Json test setting + +

} @@ -1760,7 +1904,12 @@ exports[`Field for markdown setting should render as read only if saving is disa fullWidth={true} title={

- Markdown test setting + + Markdown test setting + +

} @@ -1848,7 +1997,12 @@ exports[`Field for markdown setting should render as read only with help text if fullWidth={true} title={

- Markdown test setting + + Markdown test setting + +

} @@ -1924,7 +2078,11 @@ exports[`Field for markdown setting should render custom setting icon if it is c fullWidth={true} title={

- Markdown test setting + + Markdown test setting + +

} > @@ -2001,7 +2160,12 @@ exports[`Field for markdown setting should render default value if there is no u fullWidth={true} title={

- Markdown test setting + + Markdown test setting + +

} @@ -2067,7 +2231,11 @@ exports[`Field for markdown setting should render unsaved value if there are uns fullWidth={true} title={

- Markdown test setting + + Markdown test setting + +

} > @@ -2174,7 +2349,12 @@ exports[`Field for markdown setting should render user value if there is user va fullWidth={true} title={

- Markdown test setting + + Markdown test setting + +

} @@ -2257,7 +2437,12 @@ exports[`Field for number setting should render as read only if saving is disabl fullWidth={true} title={

- Number test setting + + Number test setting + +

} @@ -2324,7 +2509,12 @@ exports[`Field for number setting should render as read only with help text if o fullWidth={true} title={

- Number test setting + + Number test setting + +

} @@ -2379,7 +2569,11 @@ exports[`Field for number setting should render custom setting icon if it is cus fullWidth={true} title={

- Number test setting + + Number test setting + +

} > @@ -2435,7 +2630,12 @@ exports[`Field for number setting should render default value if there is no use fullWidth={true} title={

- Number test setting + + Number test setting + +

} @@ -2480,7 +2680,11 @@ exports[`Field for number setting should render unsaved value if there are unsav fullWidth={true} title={

- Number test setting + + Number test setting + +

} > @@ -2566,7 +2777,12 @@ exports[`Field for number setting should render user value if there is user valu fullWidth={true} title={

- Number test setting + + Number test setting + +

} @@ -2628,7 +2844,12 @@ exports[`Field for select setting should render as read only if saving is disabl fullWidth={true} title={

- Select test setting + + Select test setting + +

} @@ -2711,7 +2932,12 @@ exports[`Field for select setting should render as read only with help text if o fullWidth={true} title={

- Select test setting + + Select test setting + +

} @@ -2782,7 +3008,11 @@ exports[`Field for select setting should render custom setting icon if it is cus fullWidth={true} title={

- Select test setting + + Select test setting + +

} > @@ -2854,7 +3085,12 @@ exports[`Field for select setting should render default value if there is no use fullWidth={true} title={

- Select test setting + + Select test setting + +

} @@ -2915,7 +3151,11 @@ exports[`Field for select setting should render unsaved value if there are unsav fullWidth={true} title={

- Select test setting + + Select test setting + +

} > @@ -3017,7 +3264,12 @@ exports[`Field for select setting should render user value if there is user valu fullWidth={true} title={

- Select test setting + + Select test setting + +

} @@ -3095,7 +3347,12 @@ exports[`Field for string setting should render as read only if saving is disabl fullWidth={true} title={

- String test setting + + String test setting + +

} @@ -3162,7 +3419,12 @@ exports[`Field for string setting should render as read only with help text if o fullWidth={true} title={

- String test setting + + String test setting + +

} @@ -3217,7 +3479,11 @@ exports[`Field for string setting should render custom setting icon if it is cus fullWidth={true} title={

- String test setting + + String test setting + +

} > @@ -3273,7 +3540,12 @@ exports[`Field for string setting should render default value if there is no use fullWidth={true} title={

- String test setting + + String test setting + +

} @@ -3318,7 +3590,11 @@ exports[`Field for string setting should render unsaved value if there are unsav fullWidth={true} title={

- String test setting + + String test setting + +

} > @@ -3404,7 +3687,12 @@ exports[`Field for string setting should render user value if there is user valu fullWidth={true} title={

- String test setting + + String test setting + +

} @@ -3466,7 +3754,12 @@ exports[`Field for stringWithValidation setting should render as read only if sa fullWidth={true} title={

- String test validation setting + + String test validation setting + +

} @@ -3533,7 +3826,12 @@ exports[`Field for stringWithValidation setting should render as read only with fullWidth={true} title={

- String test validation setting + + String test validation setting + +

} @@ -3588,7 +3886,11 @@ exports[`Field for stringWithValidation setting should render custom setting ico fullWidth={true} title={

- String test validation setting + + String test validation setting + +

} > @@ -3644,7 +3947,12 @@ exports[`Field for stringWithValidation setting should render default value if t fullWidth={true} title={

- String test validation setting + + String test validation setting + +

} @@ -3689,7 +3997,11 @@ exports[`Field for stringWithValidation setting should render unsaved value if t fullWidth={true} title={

- String test validation setting + + String test validation setting + +

} > @@ -3775,7 +4094,12 @@ exports[`Field for stringWithValidation setting should render user value if ther fullWidth={true} title={

- String test validation setting + + String test validation setting + +

} diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx index d9c3752d1c0a5..18a1a365709d1 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx @@ -450,9 +450,24 @@ export class Field extends PureComponent { } renderTitle(setting: FieldSetting) { + const { unsavedChanges } = this.props; + const isInvalid = unsavedChanges?.isInvalid; + + const unsavedIconLabel = unsavedChanges + ? isInvalid + ? i18n.translate('advancedSettings.field.invalidIconLabel', { + defaultMessage: 'Invalid', + }) + : i18n.translate('advancedSettings.field.unsavedIconLabel', { + defaultMessage: 'Unsaved', + }) + : undefined; + return (

- {setting.displayName || setting.name} + + {setting.displayName || setting.name} + {setting.isCustom ? ( { ) : ( '' )} + + {unsavedChanges ? ( + + ) : ( + '' + )}

); } diff --git a/src/plugins/advanced_settings/public/management_app/components/form/form.tsx b/src/plugins/advanced_settings/public/management_app/components/form/form.tsx index ef433dd990d33..c859e8fdd7136 100644 --- a/src/plugins/advanced_settings/public/management_app/components/form/form.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/form/form.tsx @@ -331,54 +331,56 @@ export class Form extends PureComponent { }); return ( - +

{this.renderCountOfUnsaved()}

+ - - - - {i18n.translate('advancedSettings.form.cancelButtonLabel', { - defaultMessage: 'Cancel changes', - })} - - - - - - {i18n.translate('advancedSettings.form.saveButtonLabel', { - defaultMessage: 'Save changes', - })} - - - - + + {i18n.translate('advancedSettings.form.cancelButtonLabel', { + defaultMessage: 'Cancel changes', + })} + + + + + + {i18n.translate('advancedSettings.form.saveButtonLabel', { + defaultMessage: 'Save changes', + })} + +
From c90cfe208d790c3816e9d4d8cdc21307882e76ed Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Mon, 2 Mar 2020 15:04:27 -0500 Subject: [PATCH 18/36] [CI] Pipeline refactoring (#56447) --- .ci/Jenkinsfile_coverage | 170 ++++++++++----------- .ci/Jenkinsfile_flaky | 81 +++++----- .ci/es-snapshots/Jenkinsfile_build_es | 2 +- .ci/es-snapshots/Jenkinsfile_verify_es | 79 +++++----- Jenkinsfile | 108 ++++++------- packages/kbn-test/src/junit_report_path.ts | 4 +- test/scripts/jenkins_test_setup_oss.sh | 2 +- test/scripts/jenkins_test_setup_xpack.sh | 2 +- vars/agentInfo.groovy | 4 +- vars/catchErrors.groovy | 8 + vars/githubPr.groovy | 4 +- vars/kibanaPipeline.groovy | 164 +++++--------------- vars/retryWithDelay.groovy | 4 +- vars/retryable.groovy | 2 +- vars/workers.groovy | 147 ++++++++++++++++++ 15 files changed, 406 insertions(+), 375 deletions(-) create mode 100644 vars/catchErrors.groovy create mode 100644 vars/workers.groovy diff --git a/.ci/Jenkinsfile_coverage b/.ci/Jenkinsfile_coverage index fa1e141be93ea..6b8dc31bab34e 100644 --- a/.ci/Jenkinsfile_coverage +++ b/.ci/Jenkinsfile_coverage @@ -3,99 +3,91 @@ library 'kibana-pipeline-library' kibanaLibrary.load() // load from the Jenkins instance -stage("Kibana Pipeline") { // This stage is just here to help the BlueOcean UI a little bit - timeout(time: 180, unit: 'MINUTES') { - timestamps { - ansiColor('xterm') { - catchError { +kibanaPipeline(timeoutMinutes: 180) { + catchErrors { + withEnv([ + 'CODE_COVERAGE=1', // Needed for multiple ci scripts, such as remote.ts, test/scripts/*.sh, schema.js, etc. + ]) { + parallel([ + 'kibana-intake-agent': workers.intake('kibana-intake', './test/scripts/jenkins_unit.sh'), + 'x-pack-intake-agent': { withEnv([ - 'CODE_COVERAGE=1', // Needed for multiple ci scripts, such as remote.ts, test/scripts/*.sh, schema.js, etc. + 'NODE_ENV=test' // Needed for jest tests only ]) { - parallel([ - 'kibana-intake-agent': { - kibanaPipeline.intakeWorker('kibana-intake', './test/scripts/jenkins_unit.sh')() - }, - 'x-pack-intake-agent': { - withEnv([ - 'NODE_ENV=test' // Needed for jest tests only - ]) { - kibanaPipeline.intakeWorker('x-pack-intake', './test/scripts/jenkins_xpack.sh')() - } - }, - 'kibana-oss-agent': kibanaPipeline.withWorkers('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ - 'oss-ciGroup1': kibanaPipeline.getOssCiGroupWorker(1), - 'oss-ciGroup2': kibanaPipeline.getOssCiGroupWorker(2), - 'oss-ciGroup3': kibanaPipeline.getOssCiGroupWorker(3), - 'oss-ciGroup4': kibanaPipeline.getOssCiGroupWorker(4), - 'oss-ciGroup5': kibanaPipeline.getOssCiGroupWorker(5), - 'oss-ciGroup6': kibanaPipeline.getOssCiGroupWorker(6), - 'oss-ciGroup7': kibanaPipeline.getOssCiGroupWorker(7), - 'oss-ciGroup8': kibanaPipeline.getOssCiGroupWorker(8), - 'oss-ciGroup9': kibanaPipeline.getOssCiGroupWorker(9), - 'oss-ciGroup10': kibanaPipeline.getOssCiGroupWorker(10), - 'oss-ciGroup11': kibanaPipeline.getOssCiGroupWorker(11), - 'oss-ciGroup12': kibanaPipeline.getOssCiGroupWorker(12), - ]), - 'kibana-xpack-agent': kibanaPipeline.withWorkers('kibana-xpack-tests', { kibanaPipeline.buildXpack() }, [ - 'xpack-ciGroup1': kibanaPipeline.getXpackCiGroupWorker(1), - 'xpack-ciGroup2': kibanaPipeline.getXpackCiGroupWorker(2), - 'xpack-ciGroup3': kibanaPipeline.getXpackCiGroupWorker(3), - 'xpack-ciGroup4': kibanaPipeline.getXpackCiGroupWorker(4), - 'xpack-ciGroup5': kibanaPipeline.getXpackCiGroupWorker(5), - 'xpack-ciGroup6': kibanaPipeline.getXpackCiGroupWorker(6), - 'xpack-ciGroup7': kibanaPipeline.getXpackCiGroupWorker(7), - 'xpack-ciGroup8': kibanaPipeline.getXpackCiGroupWorker(8), - 'xpack-ciGroup9': kibanaPipeline.getXpackCiGroupWorker(9), - 'xpack-ciGroup10': kibanaPipeline.getXpackCiGroupWorker(10), - ]), - ]) - kibanaPipeline.jobRunner('tests-l', false) { - kibanaPipeline.downloadCoverageArtifacts() - kibanaPipeline.bash( - ''' - # bootstrap from x-pack folder - source src/dev/ci_setup/setup_env.sh - cd x-pack - yarn kbn bootstrap --prefer-offline - cd .. - # extract archives - mkdir -p /tmp/extracted_coverage - echo extracting intakes - tar -xzf /tmp/downloaded_coverage/coverage/kibana-intake/kibana-coverage.tar.gz -C /tmp/extracted_coverage - tar -xzf /tmp/downloaded_coverage/coverage/x-pack-intake/kibana-coverage.tar.gz -C /tmp/extracted_coverage - echo extracting kibana-oss-tests - tar -xzf /tmp/downloaded_coverage/coverage/kibana-oss-tests/kibana-coverage.tar.gz -C /tmp/extracted_coverage - echo extracting kibana-xpack-tests - tar -xzf /tmp/downloaded_coverage/coverage/kibana-xpack-tests/kibana-coverage.tar.gz -C /tmp/extracted_coverage - # replace path in json files to have valid html report - pwd=$(pwd) - du -sh /tmp/extracted_coverage/target/kibana-coverage/ - echo replacing path in json files - for i in {1..9}; do - sed -i "s|/dev/shm/workspace/kibana|$pwd|g" /tmp/extracted_coverage/target/kibana-coverage/functional/${i}*.json & - done - wait - # merge oss & x-pack reports - echo merging coverage reports - yarn nyc report --temp-dir /tmp/extracted_coverage/target/kibana-coverage/jest --report-dir target/kibana-coverage/jest-combined --reporter=html --reporter=json-summary - yarn nyc report --temp-dir /tmp/extracted_coverage/target/kibana-coverage/functional --report-dir target/kibana-coverage/functional-combined --reporter=html --reporter=json-summary - echo copy mocha reports - mkdir -p target/kibana-coverage/mocha-combined - cp -r /tmp/extracted_coverage/target/kibana-coverage/mocha target/kibana-coverage/mocha-combined - ''', - "run `yarn kbn bootstrap && merge coverage`" - ) - sh 'tar -czf kibana-jest-coverage.tar.gz target/kibana-coverage/jest-combined/*' - kibanaPipeline.uploadCoverageArtifacts("coverage/jest-combined", 'kibana-jest-coverage.tar.gz') - sh 'tar -czf kibana-functional-coverage.tar.gz target/kibana-coverage/functional-combined/*' - kibanaPipeline.uploadCoverageArtifacts("coverage/functional-combined", 'kibana-functional-coverage.tar.gz') - sh 'tar -czf kibana-mocha-coverage.tar.gz target/kibana-coverage/mocha-combined/*' - kibanaPipeline.uploadCoverageArtifacts("coverage/mocha-combined", 'kibana-mocha-coverage.tar.gz') - } + workers.intake('x-pack-intake', './test/scripts/jenkins_xpack.sh')() } - } - kibanaPipeline.sendMail() + }, + 'kibana-oss-agent': workers.functional('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ + 'oss-ciGroup1': kibanaPipeline.ossCiGroupProcess(1), + 'oss-ciGroup2': kibanaPipeline.ossCiGroupProcess(2), + 'oss-ciGroup3': kibanaPipeline.ossCiGroupProcess(3), + 'oss-ciGroup4': kibanaPipeline.ossCiGroupProcess(4), + 'oss-ciGroup5': kibanaPipeline.ossCiGroupProcess(5), + 'oss-ciGroup6': kibanaPipeline.ossCiGroupProcess(6), + 'oss-ciGroup7': kibanaPipeline.ossCiGroupProcess(7), + 'oss-ciGroup8': kibanaPipeline.ossCiGroupProcess(8), + 'oss-ciGroup9': kibanaPipeline.ossCiGroupProcess(9), + 'oss-ciGroup10': kibanaPipeline.ossCiGroupProcess(10), + 'oss-ciGroup11': kibanaPipeline.ossCiGroupProcess(11), + 'oss-ciGroup12': kibanaPipeline.ossCiGroupProcess(12), + ]), + 'kibana-xpack-agent': workers.functional('kibana-xpack-tests', { kibanaPipeline.buildXpack() }, [ + 'xpack-ciGroup1': kibanaPipeline.xpackCiGroupProcess(1), + 'xpack-ciGroup2': kibanaPipeline.xpackCiGroupProcess(2), + 'xpack-ciGroup3': kibanaPipeline.xpackCiGroupProcess(3), + 'xpack-ciGroup4': kibanaPipeline.xpackCiGroupProcess(4), + 'xpack-ciGroup5': kibanaPipeline.xpackCiGroupProcess(5), + 'xpack-ciGroup6': kibanaPipeline.xpackCiGroupProcess(6), + 'xpack-ciGroup7': kibanaPipeline.xpackCiGroupProcess(7), + 'xpack-ciGroup8': kibanaPipeline.xpackCiGroupProcess(8), + 'xpack-ciGroup9': kibanaPipeline.xpackCiGroupProcess(9), + 'xpack-ciGroup10': kibanaPipeline.xpackCiGroupProcess(10), + ]), + ]) + workers.base(name: 'coverage-worker', label: 'tests-l', ramDisk: false, bootstrapped: false) { + kibanaPipeline.downloadCoverageArtifacts() + kibanaPipeline.bash( + ''' + # bootstrap from x-pack folder + source src/dev/ci_setup/setup_env.sh + cd x-pack + yarn kbn bootstrap --prefer-offline + cd .. + # extract archives + mkdir -p /tmp/extracted_coverage + echo extracting intakes + tar -xzf /tmp/downloaded_coverage/coverage/kibana-intake/kibana-coverage.tar.gz -C /tmp/extracted_coverage + tar -xzf /tmp/downloaded_coverage/coverage/x-pack-intake/kibana-coverage.tar.gz -C /tmp/extracted_coverage + echo extracting kibana-oss-tests + tar -xzf /tmp/downloaded_coverage/coverage/kibana-oss-tests/kibana-coverage.tar.gz -C /tmp/extracted_coverage + echo extracting kibana-xpack-tests + tar -xzf /tmp/downloaded_coverage/coverage/kibana-xpack-tests/kibana-coverage.tar.gz -C /tmp/extracted_coverage + # replace path in json files to have valid html report + pwd=$(pwd) + du -sh /tmp/extracted_coverage/target/kibana-coverage/ + echo replacing path in json files + for i in {1..9}; do + sed -i "s|/dev/shm/workspace/kibana|$pwd|g" /tmp/extracted_coverage/target/kibana-coverage/functional/${i}*.json & + done + wait + # merge oss & x-pack reports + echo merging coverage reports + yarn nyc report --temp-dir /tmp/extracted_coverage/target/kibana-coverage/jest --report-dir target/kibana-coverage/jest-combined --reporter=html --reporter=json-summary + yarn nyc report --temp-dir /tmp/extracted_coverage/target/kibana-coverage/functional --report-dir target/kibana-coverage/functional-combined --reporter=html --reporter=json-summary + echo copy mocha reports + mkdir -p target/kibana-coverage/mocha-combined + cp -r /tmp/extracted_coverage/target/kibana-coverage/mocha target/kibana-coverage/mocha-combined + ''', + "run `yarn kbn bootstrap && merge coverage`" + ) + sh 'tar -czf kibana-jest-coverage.tar.gz target/kibana-coverage/jest-combined/*' + kibanaPipeline.uploadCoverageArtifacts("coverage/jest-combined", 'kibana-jest-coverage.tar.gz') + sh 'tar -czf kibana-functional-coverage.tar.gz target/kibana-coverage/functional-combined/*' + kibanaPipeline.uploadCoverageArtifacts("coverage/functional-combined", 'kibana-functional-coverage.tar.gz') + sh 'tar -czf kibana-mocha-coverage.tar.gz target/kibana-coverage/mocha-combined/*' + kibanaPipeline.uploadCoverageArtifacts("coverage/mocha-combined", 'kibana-mocha-coverage.tar.gz') } } } + kibanaPipeline.sendMail() } diff --git a/.ci/Jenkinsfile_flaky b/.ci/Jenkinsfile_flaky index f702405aad69e..befb8d259b5b6 100644 --- a/.ci/Jenkinsfile_flaky +++ b/.ci/Jenkinsfile_flaky @@ -21,53 +21,47 @@ def workerFailures = [] currentBuild.displayName += trunc(" ${params.GITHUB_OWNER}:${params.branch_specifier}", 24) currentBuild.description = "${params.CI_GROUP}
Agents: ${AGENT_COUNT}
Executions: ${params.NUMBER_EXECUTIONS}" -stage("Kibana Pipeline") { - timeout(time: 180, unit: 'MINUTES') { - timestamps { - ansiColor('xterm') { - def agents = [:] - for(def agentNumber = 1; agentNumber <= AGENT_COUNT; agentNumber++) { - def agentNumberInside = agentNumber - def agentExecutions = floor(EXECUTIONS/AGENT_COUNT) + (agentNumber <= EXECUTIONS%AGENT_COUNT ? 1 : 0) - agents["agent-${agentNumber}"] = { - catchError { - print "Agent ${agentNumberInside} - ${agentExecutions} executions" - - kibanaPipeline.withWorkers('flaky-test-runner', { - if (NEED_BUILD) { - if (!IS_XPACK) { - kibanaPipeline.buildOss() - if (CI_GROUP == '1') { - runbld("./test/scripts/jenkins_build_kbn_tp_sample_panel_action.sh", "Build kbn tp sample panel action for ciGroup1") - } - } else { - kibanaPipeline.buildXpack() - } - } - }, getWorkerMap(agentNumberInside, agentExecutions, worker, workerFailures))() +kibanaPipeline(timeoutMinutes: 180) { + def agents = [:] + for(def agentNumber = 1; agentNumber <= AGENT_COUNT; agentNumber++) { + def agentNumberInside = agentNumber + def agentExecutions = floor(EXECUTIONS/AGENT_COUNT) + (agentNumber <= EXECUTIONS%AGENT_COUNT ? 1 : 0) + agents["agent-${agentNumber}"] = { + catchErrors { + print "Agent ${agentNumberInside} - ${agentExecutions} executions" + + workers.functional('flaky-test-runner', { + if (NEED_BUILD) { + if (!IS_XPACK) { + kibanaPipeline.buildOss() + if (CI_GROUP == '1') { + runbld("./test/scripts/jenkins_build_kbn_tp_sample_panel_action.sh", "Build kbn tp sample panel action for ciGroup1") + } + } else { + kibanaPipeline.buildXpack() } } - } + }, getWorkerMap(agentNumberInside, agentExecutions, worker, workerFailures))() + } + } + } - parallel(agents) + parallel(agents) - currentBuild.description += ", Failures: ${workerFailures.size()}" + currentBuild.description += ", Failures: ${workerFailures.size()}" - if (workerFailures.size() > 0) { - print "There were ${workerFailures.size()} test suite failures." - print "The executions that failed were:" - print workerFailures.join("\n") - print "Please check 'Test Result' and 'Pipeline Steps' pages for more info" - } - } - } + if (workerFailures.size() > 0) { + print "There were ${workerFailures.size()} test suite failures." + print "The executions that failed were:" + print workerFailures.join("\n") + print "Please check 'Test Result' and 'Pipeline Steps' pages for more info" } } def getWorkerFromParams(isXpack, job, ciGroup) { if (!isXpack) { if (job == 'serverMocha') { - return kibanaPipeline.getPostBuildWorker('serverMocha', { + return kibanaPipeline.functionalTestProcess('serverMocha', { kibanaPipeline.bash( """ source src/dev/ci_setup/setup_env.sh @@ -77,20 +71,20 @@ def getWorkerFromParams(isXpack, job, ciGroup) { ) }) } else if (job == 'firefoxSmoke') { - return kibanaPipeline.getPostBuildWorker('firefoxSmoke', { runbld('./test/scripts/jenkins_firefox_smoke.sh', 'Execute kibana-firefoxSmoke') }) + return kibanaPipeline.functionalTestProcess('firefoxSmoke', './test/scripts/jenkins_firefox_smoke.sh') } else if(job == 'visualRegression') { - return kibanaPipeline.getPostBuildWorker('visualRegression', { runbld('./test/scripts/jenkins_visual_regression.sh', 'Execute kibana-visualRegression') }) + return kibanaPipeline.functionalTestProcess('visualRegression', './test/scripts/jenkins_visual_regression.sh') } else { - return kibanaPipeline.getOssCiGroupWorker(ciGroup) + return kibanaPipeline.ossCiGroupProcess(ciGroup) } } if (job == 'firefoxSmoke') { - return kibanaPipeline.getPostBuildWorker('xpack-firefoxSmoke', { runbld('./test/scripts/jenkins_xpack_firefox_smoke.sh', 'Execute xpack-firefoxSmoke') }) + return kibanaPipeline.functionalTestProcess('xpack-firefoxSmoke', './test/scripts/jenkins_xpack_firefox_smoke.sh') } else if(job == 'visualRegression') { - return kibanaPipeline.getPostBuildWorker('xpack-visualRegression', { runbld('./test/scripts/jenkins_xpack_visual_regression.sh', 'Execute xpack-visualRegression') }) + return kibanaPipeline.functionalTestProcess('xpack-visualRegression', './test/scripts/jenkins_xpack_visual_regression.sh') } else { - return kibanaPipeline.getXpackCiGroupWorker(ciGroup) + return kibanaPipeline.xpackCiGroupProcess(ciGroup) } } @@ -105,10 +99,9 @@ def getWorkerMap(agentNumber, numberOfExecutions, worker, workerFailures, maxWor for(def j = 0; j < workerExecutions; j++) { print "Execute agent-${agentNumber} worker-${workerNumber}: ${j}" withEnv([ - "JOB=agent-${agentNumber}-worker-${workerNumber}-${j}", "REMOVE_KIBANA_INSTALL_DIR=1", ]) { - catchError { + catchErrors { try { worker(workerNumber) } catch (ex) { diff --git a/.ci/es-snapshots/Jenkinsfile_build_es b/.ci/es-snapshots/Jenkinsfile_build_es index ad0ad54275e12..a00bcb3bbc946 100644 --- a/.ci/es-snapshots/Jenkinsfile_build_es +++ b/.ci/es-snapshots/Jenkinsfile_build_es @@ -26,7 +26,7 @@ timeout(time: 120, unit: 'MINUTES') { timestamps { ansiColor('xterm') { node('linux && immutable') { - catchError { + catchErrors { def VERSION def SNAPSHOT_ID def DESTINATION diff --git a/.ci/es-snapshots/Jenkinsfile_verify_es b/.ci/es-snapshots/Jenkinsfile_verify_es index 30d52a56547bd..ce472a404c053 100644 --- a/.ci/es-snapshots/Jenkinsfile_verify_es +++ b/.ci/es-snapshots/Jenkinsfile_verify_es @@ -19,50 +19,45 @@ currentBuild.description = "ES: ${SNAPSHOT_VERSION}
Kibana: ${params.branch def SNAPSHOT_MANIFEST = "https://storage.googleapis.com/kibana-ci-es-snapshots-daily/${SNAPSHOT_VERSION}/archives/${SNAPSHOT_ID}/manifest.json" -timeout(time: 120, unit: 'MINUTES') { - timestamps { - ansiColor('xterm') { - catchError { - withEnv(["ES_SNAPSHOT_MANIFEST=${SNAPSHOT_MANIFEST}"]) { - parallel([ - // TODO we just need to run integration tests from intake? - 'kibana-intake-agent': kibanaPipeline.intakeWorker('kibana-intake', './test/scripts/jenkins_unit.sh'), - 'x-pack-intake-agent': kibanaPipeline.intakeWorker('x-pack-intake', './test/scripts/jenkins_xpack.sh'), - 'kibana-oss-agent': kibanaPipeline.withWorkers('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ - 'oss-ciGroup1': kibanaPipeline.getOssCiGroupWorker(1), - 'oss-ciGroup2': kibanaPipeline.getOssCiGroupWorker(2), - 'oss-ciGroup3': kibanaPipeline.getOssCiGroupWorker(3), - 'oss-ciGroup4': kibanaPipeline.getOssCiGroupWorker(4), - 'oss-ciGroup5': kibanaPipeline.getOssCiGroupWorker(5), - 'oss-ciGroup6': kibanaPipeline.getOssCiGroupWorker(6), - 'oss-ciGroup7': kibanaPipeline.getOssCiGroupWorker(7), - 'oss-ciGroup8': kibanaPipeline.getOssCiGroupWorker(8), - 'oss-ciGroup9': kibanaPipeline.getOssCiGroupWorker(9), - 'oss-ciGroup10': kibanaPipeline.getOssCiGroupWorker(10), - 'oss-ciGroup11': kibanaPipeline.getOssCiGroupWorker(11), - 'oss-ciGroup12': kibanaPipeline.getOssCiGroupWorker(12), - ]), - 'kibana-xpack-agent': kibanaPipeline.withWorkers('kibana-xpack-tests', { kibanaPipeline.buildXpack() }, [ - 'xpack-ciGroup1': kibanaPipeline.getXpackCiGroupWorker(1), - 'xpack-ciGroup2': kibanaPipeline.getXpackCiGroupWorker(2), - 'xpack-ciGroup3': kibanaPipeline.getXpackCiGroupWorker(3), - 'xpack-ciGroup4': kibanaPipeline.getXpackCiGroupWorker(4), - 'xpack-ciGroup5': kibanaPipeline.getXpackCiGroupWorker(5), - 'xpack-ciGroup6': kibanaPipeline.getXpackCiGroupWorker(6), - 'xpack-ciGroup7': kibanaPipeline.getXpackCiGroupWorker(7), - 'xpack-ciGroup8': kibanaPipeline.getXpackCiGroupWorker(8), - 'xpack-ciGroup9': kibanaPipeline.getXpackCiGroupWorker(9), - 'xpack-ciGroup10': kibanaPipeline.getXpackCiGroupWorker(10), - ]), - ]) - } - - promoteSnapshot(SNAPSHOT_VERSION, SNAPSHOT_ID) - } - - kibanaPipeline.sendMail() +kibanaPipeline(timeoutMinutes: 120) { + catchErrors { + withEnv(["ES_SNAPSHOT_MANIFEST=${SNAPSHOT_MANIFEST}"]) { + parallel([ + 'kibana-intake-agent': workers.intake('kibana-intake', './test/scripts/jenkins_unit.sh'), + 'x-pack-intake-agent': workers.intake('x-pack-intake', './test/scripts/jenkins_xpack.sh'), + 'kibana-oss-agent': workers.functional('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ + 'oss-ciGroup1': kibanaPipeline.ossCiGroupProcess(1), + 'oss-ciGroup2': kibanaPipeline.ossCiGroupProcess(2), + 'oss-ciGroup3': kibanaPipeline.ossCiGroupProcess(3), + 'oss-ciGroup4': kibanaPipeline.ossCiGroupProcess(4), + 'oss-ciGroup5': kibanaPipeline.ossCiGroupProcess(5), + 'oss-ciGroup6': kibanaPipeline.ossCiGroupProcess(6), + 'oss-ciGroup7': kibanaPipeline.ossCiGroupProcess(7), + 'oss-ciGroup8': kibanaPipeline.ossCiGroupProcess(8), + 'oss-ciGroup9': kibanaPipeline.ossCiGroupProcess(9), + 'oss-ciGroup10': kibanaPipeline.ossCiGroupProcess(10), + 'oss-ciGroup11': kibanaPipeline.ossCiGroupProcess(11), + 'oss-ciGroup12': kibanaPipeline.ossCiGroupProcess(12), + ]), + 'kibana-xpack-agent': workers.functional('kibana-xpack-tests', { kibanaPipeline.buildXpack() }, [ + 'xpack-ciGroup1': kibanaPipeline.xpackCiGroupProcess(1), + 'xpack-ciGroup2': kibanaPipeline.xpackCiGroupProcess(2), + 'xpack-ciGroup3': kibanaPipeline.xpackCiGroupProcess(3), + 'xpack-ciGroup4': kibanaPipeline.xpackCiGroupProcess(4), + 'xpack-ciGroup5': kibanaPipeline.xpackCiGroupProcess(5), + 'xpack-ciGroup6': kibanaPipeline.xpackCiGroupProcess(6), + 'xpack-ciGroup7': kibanaPipeline.xpackCiGroupProcess(7), + 'xpack-ciGroup8': kibanaPipeline.xpackCiGroupProcess(8), + 'xpack-ciGroup9': kibanaPipeline.xpackCiGroupProcess(9), + 'xpack-ciGroup10': kibanaPipeline.xpackCiGroupProcess(10), + ]), + ]) } + + promoteSnapshot(SNAPSHOT_VERSION, SNAPSHOT_ID) } + + kibanaPipeline.sendMail() } def promoteSnapshot(snapshotVersion, snapshotId) { diff --git a/Jenkinsfile b/Jenkinsfile index 1b4350d5b91e9..85502369b07be 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,71 +3,49 @@ library 'kibana-pipeline-library' kibanaLibrary.load() -stage("Kibana Pipeline") { // This stage is just here to help the BlueOcean UI a little bit - timeout(time: 135, unit: 'MINUTES') { - timestamps { - ansiColor('xterm') { - githubPr.withDefaultPrComments { - catchError { - retryable.enable() - parallel([ - 'kibana-intake-agent': kibanaPipeline.intakeWorker('kibana-intake', './test/scripts/jenkins_unit.sh'), - 'x-pack-intake-agent': kibanaPipeline.intakeWorker('x-pack-intake', './test/scripts/jenkins_xpack.sh'), - 'kibana-oss-agent': kibanaPipeline.withWorkers('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ - // 'oss-firefoxSmoke': kibanaPipeline.getPostBuildWorker('firefoxSmoke', { - // retryable('kibana-firefoxSmoke') { - // runbld('./test/scripts/jenkins_firefox_smoke.sh', 'Execute kibana-firefoxSmoke') - // } - // }), - 'oss-ciGroup1': kibanaPipeline.getOssCiGroupWorker(1), - 'oss-ciGroup2': kibanaPipeline.getOssCiGroupWorker(2), - 'oss-ciGroup3': kibanaPipeline.getOssCiGroupWorker(3), - 'oss-ciGroup4': kibanaPipeline.getOssCiGroupWorker(4), - 'oss-ciGroup5': kibanaPipeline.getOssCiGroupWorker(5), - 'oss-ciGroup6': kibanaPipeline.getOssCiGroupWorker(6), - 'oss-ciGroup7': kibanaPipeline.getOssCiGroupWorker(7), - 'oss-ciGroup8': kibanaPipeline.getOssCiGroupWorker(8), - 'oss-ciGroup9': kibanaPipeline.getOssCiGroupWorker(9), - 'oss-ciGroup10': kibanaPipeline.getOssCiGroupWorker(10), - 'oss-ciGroup11': kibanaPipeline.getOssCiGroupWorker(11), - 'oss-ciGroup12': kibanaPipeline.getOssCiGroupWorker(12), - 'oss-accessibility': kibanaPipeline.getPostBuildWorker('accessibility', { - retryable('kibana-accessibility') { - runbld('./test/scripts/jenkins_accessibility.sh', 'Execute kibana-accessibility') - } - }), - // 'oss-visualRegression': kibanaPipeline.getPostBuildWorker('visualRegression', { runbld('./test/scripts/jenkins_visual_regression.sh', 'Execute kibana-visualRegression') }), - ]), - 'kibana-xpack-agent': kibanaPipeline.withWorkers('kibana-xpack-tests', { kibanaPipeline.buildXpack() }, [ - // 'xpack-firefoxSmoke': kibanaPipeline.getPostBuildWorker('xpack-firefoxSmoke', { - // retryable('xpack-firefoxSmoke') { - // runbld('./test/scripts/jenkins_xpack_firefox_smoke.sh', 'Execute xpack-firefoxSmoke') - // } - // }), - 'xpack-ciGroup1': kibanaPipeline.getXpackCiGroupWorker(1), - 'xpack-ciGroup2': kibanaPipeline.getXpackCiGroupWorker(2), - 'xpack-ciGroup3': kibanaPipeline.getXpackCiGroupWorker(3), - 'xpack-ciGroup4': kibanaPipeline.getXpackCiGroupWorker(4), - 'xpack-ciGroup5': kibanaPipeline.getXpackCiGroupWorker(5), - 'xpack-ciGroup6': kibanaPipeline.getXpackCiGroupWorker(6), - 'xpack-ciGroup7': kibanaPipeline.getXpackCiGroupWorker(7), - 'xpack-ciGroup8': kibanaPipeline.getXpackCiGroupWorker(8), - 'xpack-ciGroup9': kibanaPipeline.getXpackCiGroupWorker(9), - 'xpack-ciGroup10': kibanaPipeline.getXpackCiGroupWorker(10), - 'xpack-accessibility': kibanaPipeline.getPostBuildWorker('xpack-accessibility', { - retryable('xpack-accessibility') { - runbld('./test/scripts/jenkins_xpack_accessibility.sh', 'Execute xpack-accessibility') - } - }), - // 'xpack-visualRegression': kibanaPipeline.getPostBuildWorker('xpack-visualRegression', { runbld('./test/scripts/jenkins_xpack_visual_regression.sh', 'Execute xpack-visualRegression') }), - ]), - ]) - } - } - - retryable.printFlakyFailures() - kibanaPipeline.sendMail() - } +kibanaPipeline(timeoutMinutes: 135) { + githubPr.withDefaultPrComments { + catchError { + retryable.enable() + parallel([ + 'kibana-intake-agent': workers.intake('kibana-intake', './test/scripts/jenkins_unit.sh'), + 'x-pack-intake-agent': workers.intake('x-pack-intake', './test/scripts/jenkins_xpack.sh'), + 'kibana-oss-agent': workers.functional('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ + // 'oss-firefoxSmoke': kibanaPipeline.functionalTestProcess('kibana-firefoxSmoke', './test/scripts/jenkins_firefox_smoke.sh'), + 'oss-ciGroup1': kibanaPipeline.ossCiGroupProcess(1), + 'oss-ciGroup2': kibanaPipeline.ossCiGroupProcess(2), + 'oss-ciGroup3': kibanaPipeline.ossCiGroupProcess(3), + 'oss-ciGroup4': kibanaPipeline.ossCiGroupProcess(4), + 'oss-ciGroup5': kibanaPipeline.ossCiGroupProcess(5), + 'oss-ciGroup6': kibanaPipeline.ossCiGroupProcess(6), + 'oss-ciGroup7': kibanaPipeline.ossCiGroupProcess(7), + 'oss-ciGroup8': kibanaPipeline.ossCiGroupProcess(8), + 'oss-ciGroup9': kibanaPipeline.ossCiGroupProcess(9), + 'oss-ciGroup10': kibanaPipeline.ossCiGroupProcess(10), + 'oss-ciGroup11': kibanaPipeline.ossCiGroupProcess(11), + 'oss-ciGroup12': kibanaPipeline.ossCiGroupProcess(12), + 'oss-accessibility': kibanaPipeline.functionalTestProcess('kibana-accessibility', './test/scripts/jenkins_accessibility.sh'), + // 'oss-visualRegression': kibanaPipeline.functionalTestProcess('visualRegression', './test/scripts/jenkins_visual_regression.sh'), + ]), + 'kibana-xpack-agent': workers.functional('kibana-xpack-tests', { kibanaPipeline.buildXpack() }, [ + // 'xpack-firefoxSmoke': kibanaPipeline.functionalTestProcess('xpack-firefoxSmoke', './test/scripts/jenkins_xpack_firefox_smoke.sh'), + 'xpack-ciGroup1': kibanaPipeline.xpackCiGroupProcess(1), + 'xpack-ciGroup2': kibanaPipeline.xpackCiGroupProcess(2), + 'xpack-ciGroup3': kibanaPipeline.xpackCiGroupProcess(3), + 'xpack-ciGroup4': kibanaPipeline.xpackCiGroupProcess(4), + 'xpack-ciGroup5': kibanaPipeline.xpackCiGroupProcess(5), + 'xpack-ciGroup6': kibanaPipeline.xpackCiGroupProcess(6), + 'xpack-ciGroup7': kibanaPipeline.xpackCiGroupProcess(7), + 'xpack-ciGroup8': kibanaPipeline.xpackCiGroupProcess(8), + 'xpack-ciGroup9': kibanaPipeline.xpackCiGroupProcess(9), + 'xpack-ciGroup10': kibanaPipeline.xpackCiGroupProcess(10), + 'xpack-accessibility': kibanaPipeline.functionalTestProcess('xpack-accessibility', './test/scripts/jenkins_xpack_accessibility.sh'), + // 'xpack-visualRegression': kibanaPipeline.functionalTestProcess('xpack-visualRegression', './test/scripts/jenkins_xpack_visual_regression.sh'), + ]), + ]) } } + + retryable.printFlakyFailures() + kibanaPipeline.sendMail() } diff --git a/packages/kbn-test/src/junit_report_path.ts b/packages/kbn-test/src/junit_report_path.ts index 11eaf3d2b14a5..d46c9455dcff0 100644 --- a/packages/kbn-test/src/junit_report_path.ts +++ b/packages/kbn-test/src/junit_report_path.ts @@ -20,7 +20,9 @@ import { resolve } from 'path'; const job = process.env.JOB ? `job-${process.env.JOB}-` : ''; -const num = process.env.CI_WORKER_NUMBER ? `worker-${process.env.CI_WORKER_NUMBER}-` : ''; +const num = process.env.CI_PARALLEL_PROCESS_NUMBER + ? `worker-${process.env.CI_PARALLEL_PROCESS_NUMBER}-` + : ''; export function makeJunitReportPath(rootDirectory: string, reportName: string) { return resolve( diff --git a/test/scripts/jenkins_test_setup_oss.sh b/test/scripts/jenkins_test_setup_oss.sh index 9e68272053221..7bbb867526384 100644 --- a/test/scripts/jenkins_test_setup_oss.sh +++ b/test/scripts/jenkins_test_setup_oss.sh @@ -4,7 +4,7 @@ source test/scripts/jenkins_test_setup.sh if [[ -z "$CODE_COVERAGE" ]] ; then installDir="$(realpath $PARENT_DIR/kibana/build/oss/kibana-*-SNAPSHOT-linux-x86_64)" - destDir=${installDir}-${CI_WORKER_NUMBER} + destDir=${installDir}-${CI_PARALLEL_PROCESS_NUMBER} cp -R "$installDir" "$destDir" export KIBANA_INSTALL_DIR="$destDir" diff --git a/test/scripts/jenkins_test_setup_xpack.sh b/test/scripts/jenkins_test_setup_xpack.sh index 76fc7cfe6c876..a72e9749ebbd5 100644 --- a/test/scripts/jenkins_test_setup_xpack.sh +++ b/test/scripts/jenkins_test_setup_xpack.sh @@ -4,7 +4,7 @@ source test/scripts/jenkins_test_setup.sh if [[ -z "$CODE_COVERAGE" ]]; then installDir="$PARENT_DIR/install/kibana" - destDir="${installDir}-${CI_WORKER_NUMBER}" + destDir="${installDir}-${CI_PARALLEL_PROCESS_NUMBER}" cp -R "$installDir" "$destDir" export KIBANA_INSTALL_DIR="$destDir" diff --git a/vars/agentInfo.groovy b/vars/agentInfo.groovy index b53ed23f81ff0..166a86c169261 100644 --- a/vars/agentInfo.groovy +++ b/vars/agentInfo.groovy @@ -1,5 +1,5 @@ def print() { - try { + catchError(catchInterruptions: false, buildResult: null) { def startTime = sh(script: "date -d '-3 minutes' -Iseconds | sed s/+/%2B/", returnStdout: true).trim() def endTime = sh(script: "date -d '+1 hour 30 minutes' -Iseconds | sed s/+/%2B/", returnStdout: true).trim() @@ -34,8 +34,6 @@ def print() { echo 'SSH Command:' echo "ssh -F ssh_config \$(hostname --ip-address)" """, label: "Worker/Agent/Node debug links" - } catch(ex) { - print ex.toString() } } diff --git a/vars/catchErrors.groovy b/vars/catchErrors.groovy new file mode 100644 index 0000000000000..460a90b8ec0c0 --- /dev/null +++ b/vars/catchErrors.groovy @@ -0,0 +1,8 @@ +// Basically, this is a shortcut for catchError(catchInterruptions: false) {} +// By default, catchError will swallow aborts/timeouts, which we almost never want +def call(Map params = [:], Closure closure) { + params.catchInterruptions = false + return catchError(params, closure) +} + +return this diff --git a/vars/githubPr.groovy b/vars/githubPr.groovy index 91a4a76894d94..7759edbbf5bfc 100644 --- a/vars/githubPr.groovy +++ b/vars/githubPr.groovy @@ -14,8 +14,8 @@ So, there is only ever one build status comment on a PR at any given time, the most recent one. */ def withDefaultPrComments(closure) { - catchError { - catchError { + catchErrors { + catchErrors { closure() } diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index dd2e626d1c860..2b9b0eba38f46 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -1,92 +1,36 @@ -def withWorkers(machineName, preWorkerClosure = {}, workerClosures = [:]) { - return { - jobRunner('tests-xl', true) { - withGcsArtifactUpload(machineName, { - withPostBuildReporting { - doSetup() - preWorkerClosure() - - def nextWorker = 1 - def worker = { workerClosure -> - def workerNumber = nextWorker - nextWorker++ - - return { - // This delay helps smooth out CPU load caused by ES/Kibana instances starting up at the same time - def delay = (workerNumber-1)*20 - sleep(delay) - - workerClosure(workerNumber) - } - } - - def workers = [:] - workerClosures.each { workerName, workerClosure -> - workers[workerName] = worker(workerClosure) - } - - parallel(workers) - } - }) - } - } -} - -def withWorker(machineName, label, Closure closure) { - return { - jobRunner(label, false) { - withGcsArtifactUpload(machineName) { - withPostBuildReporting { - doSetup() - closure() - } - } - } - } -} - -def intakeWorker(jobName, String script) { - return withWorker(jobName, 'linux && immutable') { - withEnv([ - "JOB=${jobName}", - ]) { - runbld(script, "Execute ${jobName}") - } - } -} - def withPostBuildReporting(Closure closure) { try { closure() } finally { - catchError { + catchErrors { runErrorReporter() } - catchError { + catchErrors { runbld.junit() } - catchError { + catchErrors { publishJunit() } } } -def getPostBuildWorker(name, closure) { - return { workerNumber -> - def kibanaPort = "61${workerNumber}1" - def esPort = "61${workerNumber}2" - def esTransportPort = "61${workerNumber}3" +def functionalTestProcess(String name, Closure closure) { + return { processNumber -> + def kibanaPort = "61${processNumber}1" + def esPort = "61${processNumber}2" + def esTransportPort = "61${processNumber}3" withEnv([ - "CI_WORKER_NUMBER=${workerNumber}", + "CI_PARALLEL_PROCESS_NUMBER=${processNumber}", "TEST_KIBANA_HOST=localhost", "TEST_KIBANA_PORT=${kibanaPort}", "TEST_KIBANA_URL=http://elastic:changeme@localhost:${kibanaPort}", "TEST_ES_URL=http://elastic:changeme@localhost:${esPort}", "TEST_ES_TRANSPORT_PORT=${esTransportPort}", "IS_PIPELINE_JOB=1", + "JOB=${name}", "KBN_NP_PLUGINS_BUILT=true", ]) { closure() @@ -94,8 +38,16 @@ def getPostBuildWorker(name, closure) { } } -def getOssCiGroupWorker(ciGroup) { - return getPostBuildWorker("ciGroup" + ciGroup, { +def functionalTestProcess(String name, String script) { + return functionalTestProcess(name) { + retryable(name) { + runbld(script, "Execute ${name}") + } + } +} + +def ossCiGroupProcess(ciGroup) { + return functionalTestProcess("ciGroup" + ciGroup) { withEnv([ "CI_GROUP=${ciGroup}", "JOB=kibana-ciGroup${ciGroup}", @@ -104,11 +56,11 @@ def getOssCiGroupWorker(ciGroup) { runbld("./test/scripts/jenkins_ci_group.sh", "Execute kibana-ciGroup${ciGroup}") } } - }) + } } -def getXpackCiGroupWorker(ciGroup) { - return getPostBuildWorker("xpack-ciGroup" + ciGroup, { +def xpackCiGroupProcess(ciGroup) { + return functionalTestProcess("xpack-ciGroup" + ciGroup) { withEnv([ "CI_GROUP=${ciGroup}", "JOB=xpack-kibana-ciGroup${ciGroup}", @@ -117,56 +69,6 @@ def getXpackCiGroupWorker(ciGroup) { runbld("./test/scripts/jenkins_xpack_ci_group.sh", "Execute xpack-kibana-ciGroup${ciGroup}") } } - }) -} - -def jobRunner(label, useRamDisk, closure) { - node(label) { - agentInfo.print() - - if (useRamDisk) { - // Move to a temporary workspace, so that we can symlink the real workspace into /dev/shm - def originalWorkspace = env.WORKSPACE - ws('/tmp/workspace') { - sh( - script: """ - mkdir -p /dev/shm/workspace - mkdir -p '${originalWorkspace}' # create all of the directories leading up to the workspace, if they don't exist - rm --preserve-root -rf '${originalWorkspace}' # then remove just the workspace, just in case there's stuff in it - ln -s /dev/shm/workspace '${originalWorkspace}' - """, - label: "Move workspace to RAM - /dev/shm/workspace" - ) - } - } - - def scmVars - - // Try to clone from Github up to 8 times, waiting 15 secs between attempts - retryWithDelay(8, 15) { - scmVars = checkout scm - } - - withEnv([ - "CI=true", - "HOME=${env.JENKINS_HOME}", - "PR_SOURCE_BRANCH=${env.ghprbSourceBranch ?: ''}", - "PR_TARGET_BRANCH=${env.ghprbTargetBranch ?: ''}", - "PR_AUTHOR=${env.ghprbPullAuthorLogin ?: ''}", - "TEST_BROWSER_HEADLESS=1", - "GIT_BRANCH=${scmVars.GIT_BRANCH}", - ]) { - withCredentials([ - string(credentialsId: 'vault-addr', variable: 'VAULT_ADDR'), - string(credentialsId: 'vault-role-id', variable: 'VAULT_ROLE_ID'), - string(credentialsId: 'vault-secret-id', variable: 'VAULT_SECRET_ID'), - ]) { - // scm is configured to check out to the ./kibana directory - dir('kibana') { - closure() - } - } - } } } @@ -210,7 +112,7 @@ def withGcsArtifactUpload(workerName, closure) { try { closure() } finally { - catchError { + catchErrors { ARTIFACT_PATTERNS.each { pattern -> uploadGcsArtifact(uploadPrefix, pattern) } @@ -243,7 +145,7 @@ def sendMail() { } def sendInfraMail() { - catchError { + catchErrors { step([ $class: 'Mailer', notifyEveryUnstableBuild: true, @@ -254,7 +156,7 @@ def sendInfraMail() { } def sendKibanaMail() { - catchError { + catchErrors { def buildStatus = buildUtils.getBuildStatus() if(params.NOTIFY_ON_FAILURE && buildStatus != 'SUCCESS' && buildStatus != 'ABORTED') { emailext( @@ -299,4 +201,18 @@ def runErrorReporter() { ) } +def call(Map params = [:], Closure closure) { + def config = [timeoutMinutes: 135] + params + + stage("Kibana Pipeline") { + timeout(time: config.timeoutMinutes, unit: 'MINUTES') { + timestamps { + ansiColor('xterm') { + closure() + } + } + } + } +} + return this diff --git a/vars/retryWithDelay.groovy b/vars/retryWithDelay.groovy index 70d6f86a63ab2..83fd94c6f2b1e 100644 --- a/vars/retryWithDelay.groovy +++ b/vars/retryWithDelay.groovy @@ -2,7 +2,9 @@ def call(retryTimes, delaySecs, closure) { retry(retryTimes) { try { closure() - } catch (ex) { + } catch (org.jenkinsci.plugins.workflow.steps.FlowInterruptedException ex) { + throw ex // Immediately re-throw build abort exceptions, don't sleep first + } catch (Exception ex) { sleep delaySecs throw ex } diff --git a/vars/retryable.groovy b/vars/retryable.groovy index cc34024958aed..ed84a00ece49d 100644 --- a/vars/retryable.groovy +++ b/vars/retryable.groovy @@ -27,7 +27,7 @@ def getFlakyFailures() { } def printFlakyFailures() { - catchError { + catchErrors { def failures = getFlakyFailures() if (failures && failures.size() > 0) { diff --git a/vars/workers.groovy b/vars/workers.groovy new file mode 100644 index 0000000000000..c5638f2624fe5 --- /dev/null +++ b/vars/workers.groovy @@ -0,0 +1,147 @@ +// "Workers" in this file will spin up an instance, do some setup etc depending on the configuration, and then execute some work that you define +// e.g. workers.base(name: 'my-worker') { sh "echo 'ready to execute some kibana scripts'" } + +/* + The base worker that all of the others use. Will clone the scm (assumed to be kibana), and run kibana bootstrap processes by default. + + Parameters: + label - gobld/agent label to use, e.g. 'linux && immutable' + ramDisk - Should the workspace be mounted in memory? Default: true + bootstrapped - If true, download kibana dependencies, run kbn bootstrap, etc. Default: true + name - Name of the worker for display purposes, filenames, etc. + scm - Jenkins scm configuration for checking out code. Use `null` to disable checkout. Default: inherited from job +*/ +def base(Map params, Closure closure) { + def config = [label: '', ramDisk: true, bootstrapped: true, name: 'unnamed-worker', scm: scm] + params + if (!config.label) { + error "You must specify an agent label, such as 'tests-xl' or 'linux && immutable', when using workers.base()" + } + + node(config.label) { + agentInfo.print() + + if (config.ramDisk) { + // Move to a temporary workspace, so that we can symlink the real workspace into /dev/shm + def originalWorkspace = env.WORKSPACE + ws('/tmp/workspace') { + sh( + script: """ + mkdir -p /dev/shm/workspace + mkdir -p '${originalWorkspace}' # create all of the directories leading up to the workspace, if they don't exist + rm --preserve-root -rf '${originalWorkspace}' # then remove just the workspace, just in case there's stuff in it + ln -s /dev/shm/workspace '${originalWorkspace}' + """, + label: "Move workspace to RAM - /dev/shm/workspace" + ) + } + } + + def scmVars = [:] + + if (config.scm) { + // Try to clone from Github up to 8 times, waiting 15 secs between attempts + retryWithDelay(8, 15) { + scmVars = checkout scm + } + } + + withEnv([ + "CI=true", + "HOME=${env.JENKINS_HOME}", + "PR_SOURCE_BRANCH=${env.ghprbSourceBranch ?: ''}", + "PR_TARGET_BRANCH=${env.ghprbTargetBranch ?: ''}", + "PR_AUTHOR=${env.ghprbPullAuthorLogin ?: ''}", + "TEST_BROWSER_HEADLESS=1", + "GIT_BRANCH=${scmVars.GIT_BRANCH ?: ''}", + ]) { + withCredentials([ + string(credentialsId: 'vault-addr', variable: 'VAULT_ADDR'), + string(credentialsId: 'vault-role-id', variable: 'VAULT_ROLE_ID'), + string(credentialsId: 'vault-secret-id', variable: 'VAULT_SECRET_ID'), + ]) { + // scm is configured to check out to the ./kibana directory + dir('kibana') { + if (config.bootstrapped) { + kibanaPipeline.doSetup() + } + + closure() + } + } + } + } +} + +// Worker for ci processes. Extends the base worker and adds GCS artifact upload, error reporting, junit processing +def ci(Map params, Closure closure) { + def config = [ramDisk: true, bootstrapped: true] + params + + return base(config) { + kibanaPipeline.withGcsArtifactUpload(config.name) { + kibanaPipeline.withPostBuildReporting { + closure() + } + } + } +} + +// Worker for running the current intake jobs. Just runs a single script after bootstrap. +def intake(jobName, String script) { + return { + ci(name: jobName, label: 'linux && immutable', ramDisk: false) { + withEnv(["JOB=${jobName}"]) { + runbld(script, "Execute ${jobName}") + } + } + } +} + +// Worker for running functional tests. Runs a setup process (e.g. the kibana build) then executes a map of closures in parallel (e.g. one for each ciGroup) +def functional(name, Closure setup, Map processes) { + return { + parallelProcesses(name: name, setup: setup, processes: processes, delayBetweenProcesses: 20, label: 'tests-xl') + } +} + +/* + Creates a ci worker that can run a setup process, followed by a group of processes in parallel. + + Parameters: + name: Name of the worker for display purposes, filenames, etc. + setup: Closure to execute after the agent is bootstrapped, before starting the parallel work + processes: Map of closures that will execute in parallel after setup. Each closure is passed a unique number. + delayBetweenProcesses: Number of seconds to wait between starting the parallel processes. Useful to spread the load of heavy init processes, e.g. Elasticsearch starting up. Default: 0 + label: gobld/agent label to use, e.g. 'linux && immutable'. Default: 'tests-xl', a 32 CPU machine used for running many functional test suites in parallel +*/ +def parallelProcesses(Map params) { + def config = [name: 'parallel-worker', setup: {}, processes: [:], delayBetweenProcesses: 0, label: 'tests-xl'] + params + + ci(label: config.label, name: config.name) { + config.setup() + + def nextProcessNumber = 1 + def process = { processName, processClosure -> + def processNumber = nextProcessNumber + nextProcessNumber++ + + return { + if (config.delayBetweenProcesses && config.delayBetweenProcesses > 0) { + // This delay helps smooth out CPU load caused by ES/Kibana instances starting up at the same time + def delay = (processNumber-1)*config.delayBetweenProcesses + sleep(delay) + } + + processClosure(processNumber) + } + } + + def processes = [:] + config.processes.each { processName, processClosure -> + processes[processName] = process(processName, processClosure) + } + + parallel(processes) + } +} + +return this From be0a4c4e229ca3c8bd619cb95965a1049ada6b11 Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Mon, 2 Mar 2020 13:20:18 -0700 Subject: [PATCH 19/36] Downgrade "setting up plugin" log to debug (#58776) --- .../integration_tests/generate_plugin.test.js | 1 + src/core/server/plugins/plugin.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/kbn-plugin-generator/integration_tests/generate_plugin.test.js b/packages/kbn-plugin-generator/integration_tests/generate_plugin.test.js index 771bf43c4020a..d7d4dc14519c3 100644 --- a/packages/kbn-plugin-generator/integration_tests/generate_plugin.test.js +++ b/packages/kbn-plugin-generator/integration_tests/generate_plugin.test.js @@ -102,6 +102,7 @@ describe(`running the plugin-generator via 'node scripts/generate_plugin.js plug 'start', '--optimize.enabled=false', '--logging.json=false', + '--logging.verbose=true', '--migrations.skip=true', ], cwd: generatedPath, diff --git a/src/core/server/plugins/plugin.ts b/src/core/server/plugins/plugin.ts index d6c774f6fc41c..b372874264eb5 100644 --- a/src/core/server/plugins/plugin.ts +++ b/src/core/server/plugins/plugin.ts @@ -95,7 +95,7 @@ export class PluginWrapper< public async setup(setupContext: CoreSetup, plugins: TPluginsSetup) { this.instance = this.createPluginInstance(); - this.log.info('Setting up plugin'); + this.log.debug('Setting up plugin'); return this.instance.setup(setupContext, plugins); } @@ -112,6 +112,8 @@ export class PluginWrapper< throw new Error(`Plugin "${this.name}" can't be started since it isn't set up.`); } + this.log.debug('Starting plugin'); + const startContract = await this.instance.start(startContext, plugins); this.startDependencies$.next([startContext, plugins]); return startContract; From b7c8e3a25229c4fcc6bf9a2ff9364860927a77ac Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Mon, 2 Mar 2020 20:56:07 +0000 Subject: [PATCH 20/36] Dashboard a11y tests (#58122) * adding comprehensive dashboard tests * fixing delete and adding dima changes * Fixing some of the a11y test failures * Fixing i18n issue * Extracting exit fullscreen logic in a separate function * Fixing typo * Upgrading axe * Fixing failing jest tests * Removing main tag as it was causing a test to fail * Adding focusable=false to a range control as well * Update test/accessibility/apps/dashboard.ts Co-Authored-By: Michail Yasonik * Fixing linting error * Update src/legacy/core_plugins/input_control_vis/public/components/vis/list_control.tsx Co-Authored-By: Michail Yasonik * Add comments Co-authored-by: Bhavya RM Co-authored-by: Michail Yasonik Co-authored-by: Elastic Machine --- package.json | 2 +- .../__snapshots__/list_control.test.tsx.snap | 1 + .../public/components/vis/list_control.tsx | 14 ++ .../__snapshots__/clone_modal.test.js.snap | 1 + .../np_ready/top_nav/clone_modal.tsx | 3 + .../tool_bar_pager_buttons.test.tsx.snap | 2 + .../pager/tool_bar_pager_buttons.tsx | 13 ++ .../validated_range/validated_dual_range.js | 1 + .../query_string_input/language_switcher.tsx | 2 +- test/accessibility/apps/dashboard.ts | 131 ++++++++++++++++-- .../functional/page_objects/dashboard_page.ts | 7 + yarn.lock | 8 +- 12 files changed, 171 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 0f04a2fba3b65..5db93e5ab5ab9 100644 --- a/package.json +++ b/package.json @@ -384,7 +384,7 @@ "@typescript-eslint/parser": "^2.15.0", "angular-mocks": "^1.7.9", "archiver": "^3.1.1", - "axe-core": "^3.3.2", + "axe-core": "^3.4.1", "babel-eslint": "^10.0.3", "babel-jest": "^24.9.0", "babel-plugin-dynamic-import-node": "^2.3.0", diff --git a/src/legacy/core_plugins/input_control_vis/public/components/vis/__snapshots__/list_control.test.tsx.snap b/src/legacy/core_plugins/input_control_vis/public/components/vis/__snapshots__/list_control.test.tsx.snap index 99482a4be2d7b..59ae99260cecd 100644 --- a/src/legacy/core_plugins/input_control_vis/public/components/vis/__snapshots__/list_control.test.tsx.snap +++ b/src/legacy/core_plugins/input_control_vis/public/components/vis/__snapshots__/list_control.test.tsx.snap @@ -25,6 +25,7 @@ exports[`renders ListControl 1`] = ` compressed={false} data-test-subj="listControlSelect0" fullWidth={false} + inputRef={[Function]} isClearable={true} isLoading={false} onChange={[Function]} diff --git a/src/legacy/core_plugins/input_control_vis/public/components/vis/list_control.tsx b/src/legacy/core_plugins/input_control_vis/public/components/vis/list_control.tsx index d62adfdce56b4..d01cef15ea41b 100644 --- a/src/legacy/core_plugins/input_control_vis/public/components/vis/list_control.tsx +++ b/src/legacy/core_plugins/input_control_vis/public/components/vis/list_control.tsx @@ -58,8 +58,17 @@ class ListControlUi extends PureComponent { + if (this.textInput) { + this.textInput.setAttribute('focusable', 'false'); // remove when #59039 is fixed + } this.isMounted = true; }; @@ -67,6 +76,10 @@ class ListControlUi extends PureComponent { + this.textInput = ref; + }; + handleOnChange = (selectedOptions: any[]) => { const selectedValues = selectedOptions.map(({ value }) => { return value; @@ -143,6 +156,7 @@ class ListControlUi extends PureComponent ); } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/clone_modal.test.js.snap b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/clone_modal.test.js.snap index f5a00e5435ed6..771d53b73d960 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/clone_modal.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/clone_modal.test.js.snap @@ -28,6 +28,7 @@ exports[`renders DashboardCloneModal 1`] = ` { @@ -41,6 +48,12 @@ export function ToolBarPagerButtons(props: Props) { onClick={() => props.onPageNext()} disabled={!props.hasNextPage} data-test-subj="btnNextPage" + aria-label={i18n.translate( + 'kbn.ddiscover.docTable.pager.toolbarPagerButtons.nextButtonAriaLabel', + { + defaultMessage: 'Next page in table', + } + )} > diff --git a/src/legacy/ui/public/validated_range/validated_dual_range.js b/src/legacy/ui/public/validated_range/validated_dual_range.js index 8689397a78333..3b0efba11afcc 100644 --- a/src/legacy/ui/public/validated_range/validated_dual_range.js +++ b/src/legacy/ui/public/validated_range/validated_dual_range.js @@ -92,6 +92,7 @@ export class ValidatedDualRange extends Component { fullWidth={fullWidth} value={this.state.value} onChange={this._onChange} + focusable={false} // remove when #59039 is fixed {...rest} /> diff --git a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx index d86a8a970a8e7..63f6997ce2fc3 100644 --- a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx +++ b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx @@ -68,7 +68,7 @@ export function QueryLanguageSwitcher(props: Props) { return ( { const dashboardName = 'Dashboard Listing A11y'; + const clonedDashboardName = 'Dashboard Listing A11y Copy'; + before(async () => { - await esArchiver.loadIfNeeded('logstash_functional'); - await kibanaServer.uiSettings.update({ - defaultIndex: 'logstash-*', - }); + await PageObjects.common.navigateToUrl('home', 'tutorial_directory/sampleData'); + await PageObjects.home.addSampleDataSet('flights'); + }); + + after(async () => { await PageObjects.common.navigateToApp('dashboard'); + await listingTable.searchForItemWithName(dashboardName); + await listingTable.checkListingSelectAllCheckbox(); + await listingTable.clickDeleteSelected(); + await PageObjects.common.clickConfirmOnModal(); }); it('dashboard', async () => { + await PageObjects.common.navigateToApp('dashboard'); await a11y.testAppSnapshot(); }); it('create dashboard button', async () => { - await PageObjects.dashboard.clickCreateDashboardPrompt(); + await PageObjects.dashboard.clickNewDashboard(); await a11y.testAppSnapshot(); }); @@ -49,9 +58,115 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); + it('Open Edit mode', async () => { + await PageObjects.dashboard.switchToEditMode(); + await a11y.testAppSnapshot(); + }); + + it('Open add panel', async () => { + await dashboardAddPanel.clickOpenAddPanel(); + await a11y.testAppSnapshot(); + }); + + it('add a visualization', async () => { + await testSubjects.click('savedObjectTitle[Flights]-Delay-Buckets'); + await a11y.testAppSnapshot(); + }); + + it('add a saved search', async () => { + await dashboardAddPanel.addSavedSearch('[Flights] Flight Log'); + await a11y.testAppSnapshot(); + }); + + it('save the dashboard', async () => { + await PageObjects.dashboard.saveDashboard(dashboardName); + await a11y.testAppSnapshot(); + }); + + it('Open Edit mode', async () => { + await PageObjects.dashboard.switchToEditMode(); + await a11y.testAppSnapshot(); + }); + + it('open options menu', async () => { + await PageObjects.dashboard.openOptions(); + await a11y.testAppSnapshot(); + }); + + it('Should be able to hide panel titles', async () => { + await testSubjects.click('dashboardPanelTitlesCheckbox'); + await a11y.testAppSnapshot(); + }); + + it('Should be able display panels without margins', async () => { + await testSubjects.click('dashboardMarginsCheckbox'); + await a11y.testAppSnapshot(); + }); + + it('Open add panel', async () => { + await dashboardAddPanel.clickOpenAddPanel(); + await a11y.testAppSnapshot(); + }); + + it('Add one more saved object to cancel it', async () => { + await testSubjects.click('savedObjectTitle[Flights]-Average-Ticket-Price'); + await a11y.testAppSnapshot(); + }); + + it('Close add panel', async () => { + await dashboardAddPanel.closeAddPanel(); + await a11y.testAppSnapshot(); + }); + + it('Exit out of edit mode', async () => { + await PageObjects.dashboard.clickCancelOutOfEditMode(); + await a11y.testAppSnapshot(); + }); + + it('Discard changes', async () => { + await PageObjects.common.clickConfirmOnModal(); + await PageObjects.dashboard.getIsInViewMode(); + await a11y.testAppSnapshot(); + }); + + it('Test full screen', async () => { + await PageObjects.dashboard.clickFullScreenMode(); + await a11y.testAppSnapshot(); + }); + + it('Exit out of full screen mode', async () => { + await PageObjects.dashboard.exitFullScreenMode(); + await a11y.testAppSnapshot(); + }); + + it('Make a clone of the dashboard', async () => { + await PageObjects.dashboard.clickClone(); + await a11y.testAppSnapshot(); + }); + + it('Confirm clone with *copy* appended', async () => { + await PageObjects.dashboard.confirmClone(); + await a11y.testAppSnapshot(); + }); + it('Dashboard listing table', async () => { await PageObjects.dashboard.gotoDashboardLandingPage(); await a11y.testAppSnapshot(); }); + + it('Delete a11y clone dashboard', async () => { + await listingTable.searchForItemWithName(clonedDashboardName); + await listingTable.checkListingSelectAllCheckbox(); + await listingTable.clickDeleteSelected(); + await a11y.testAppSnapshot(); + await PageObjects.common.clickConfirmOnModal(); + await listingTable.searchForItemWithName(''); + }); + + // Blocked by https://github.com/elastic/kibana/issues/38980 + it.skip('Open flight dashboard', async () => { + await testSubjects.click('dashboardListingTitleLink-[Flights]-Global-Flight-Dashboard'); + await a11y.testAppSnapshot(); + }); }); } diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 3fd7ce27e27d4..0f01097cf50dc 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -65,6 +65,13 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide await this.waitForRenderComplete(); } + public async exitFullScreenMode() { + log.debug(`exitFullScreenMode`); + const logoButton = await this.getExitFullScreenLogoButton(); + await logoButton.moveMouseTo(); + await this.clickExitFullScreenTextButton(); + } + public async fullScreenModeMenuItemExists() { return await testSubjects.exists('dashboardFullScreenMode'); } diff --git a/yarn.lock b/yarn.lock index 7f38495c20f4a..338d516a796e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7121,10 +7121,10 @@ aws4@^1.6.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" integrity sha1-g+9cqGCysy5KDe7e6MdxudtXRx4= -axe-core@^3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-3.3.2.tgz#7baf3c55a5cf1621534a2c38735f5a1bf2f7e1a8" - integrity sha512-lRdxsRt7yNhqpcXQk1ao1BL73OZDzmFCWOG0mC4tGR/r14ohH2payjHwCMQjHGbBKm924eDlmG7utAGHiX/A6g== +axe-core@^3.4.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-3.5.1.tgz#d8d5aaef73f003e8b766ea28bb078343f3622201" + integrity sha512-mwpDgPwWB+5kMHyLjlxh4w25ClJfqSxi+c6LQ4ix349TdCUctMwJNPTkhPD1qP9SYIjFgjeVpVZWCvK9oBGwCg== axios@^0.18.0, axios@^0.18.1: version "0.18.1" From bb6fd0bf4ff8e0a501747aef4b399e997cc25e6b Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 2 Mar 2020 15:15:44 -0700 Subject: [PATCH 21/36] [kbn/optimizer] fix ui/* url rewrites in dist (#58627) * [kbn/optimizer] fix ui/* url rewrites in dist * add tests to verify styles are built correctly and ui-rewrites are happening * clarify change to dirs creation * create tested & shared parsePath helper * update renovate config * split implementation of parsePath for dir and file paths * switch to valid css property Co-authored-by: Elastic Machine --- package.json | 1 + .../mock_repo/plugins/bar/public/index.ts | 1 + .../plugins/bar/public/legacy/styles.scss | 4 + .../mock_repo/src/legacy/ui/public/icon.svg | 1 + .../ui/public/styles/_styling_constants.scss | 1 + .../basic_optimization.test.ts.snap | 527 +----------------- .../basic_optimization.test.ts | 56 +- .../__snapshots__/parse_path.test.ts.snap | 156 ++++++ .../src/worker/parse_path.test.ts | 20 +- .../kbn-optimizer/src/worker/parse_path.ts | 43 ++ .../kbn-optimizer/src/worker/run_compilers.ts | 16 +- .../src/worker/webpack.config.ts | 7 +- renovate.json5 | 8 + yarn.lock | 5 + 14 files changed, 299 insertions(+), 547 deletions(-) create mode 100644 packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss create mode 100644 packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/icon.svg create mode 100644 packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/styles/_styling_constants.scss create mode 100644 packages/kbn-optimizer/src/worker/__snapshots__/parse_path.test.ts.snap rename typings/normalize_path/index.d.ts => packages/kbn-optimizer/src/worker/parse_path.test.ts (57%) create mode 100644 packages/kbn-optimizer/src/worker/parse_path.ts diff --git a/package.json b/package.json index 5db93e5ab5ab9..e727d87a83c53 100644 --- a/package.json +++ b/package.json @@ -349,6 +349,7 @@ "@types/mustache": "^0.8.31", "@types/node": "^10.12.27", "@types/node-forge": "^0.9.0", + "@types/normalize-path": "^3.0.0", "@types/numeral": "^0.0.26", "@types/opn": "^5.1.0", "@types/pegjs": "^0.10.1", diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/index.ts b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/index.ts index 66fa55479f3b9..817c4796562e8 100644 --- a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/index.ts +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/index.ts @@ -17,6 +17,7 @@ * under the License. */ +import './legacy/styles.scss'; import { fooLibFn } from '../../foo/public/index'; export * from './lib'; export { fooLibFn }; diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss new file mode 100644 index 0000000000000..e71a2d485a2f8 --- /dev/null +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss @@ -0,0 +1,4 @@ +body { + width: $globalStyleConstant; + background-image: url("ui/icon.svg"); +} diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/icon.svg b/packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/icon.svg new file mode 100644 index 0000000000000..ae7d5b958bbad --- /dev/null +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/styles/_styling_constants.scss b/packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/styles/_styling_constants.scss new file mode 100644 index 0000000000000..83995ca65211b --- /dev/null +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/styles/_styling_constants.scss @@ -0,0 +1 @@ +$globalStyleConstant: 10; diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index 706f79978beee..1a974d3e81092 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -5,553 +5,56 @@ OptimizerConfig { "bundles": Array [ Bundle { "cache": BundleCache { - "path": /plugins/bar/target/public/.kbn-optimizer-cache, + "path": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/target/public/.kbn-optimizer-cache, "state": undefined, }, - "contextDir": /plugins/bar, + "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar, "entry": "./public/index", "id": "bar", - "outputDir": /plugins/bar/target/public, - "sourceRoot": , + "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/target/public, + "sourceRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, "type": "plugin", }, Bundle { "cache": BundleCache { - "path": /plugins/foo/target/public/.kbn-optimizer-cache, + "path": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/target/public/.kbn-optimizer-cache, "state": undefined, }, - "contextDir": /plugins/foo, + "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo, "entry": "./public/index", "id": "foo", - "outputDir": /plugins/foo/target/public, - "sourceRoot": , + "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/target/public, + "sourceRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, "type": "plugin", }, ], "cache": true, - "dist": false, + "dist": true, "inspectWorkers": false, "maxWorkerCount": 1, "plugins": Array [ Object { - "directory": /plugins/bar, + "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar, "id": "bar", "isUiPlugin": true, }, Object { - "directory": /plugins/baz, + "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/baz, "id": "baz", "isUiPlugin": false, }, Object { - "directory": /plugins/foo, + "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo, "id": "foo", "isUiPlugin": true, }, ], "profileWebpack": false, - "repoRoot": , + "repoRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, "watch": false, } `; -exports[`builds expected bundles, saves bundle counts to metadata: bar bundle 1`] = ` -"var __kbnBundles__ = typeof __kbnBundles__ === \\"object\\" ? __kbnBundles__ : {}; __kbnBundles__[\\"plugin/bar\\"] = -/******/ (function(modules) { // webpackBootstrap -/******/ // The module cache -/******/ var installedModules = {}; -/******/ -/******/ // The require function -/******/ function __webpack_require__(moduleId) { -/******/ -/******/ // Check if module is in cache -/******/ if(installedModules[moduleId]) { -/******/ return installedModules[moduleId].exports; -/******/ } -/******/ // Create a new module (and put it into the cache) -/******/ var module = installedModules[moduleId] = { -/******/ i: moduleId, -/******/ l: false, -/******/ exports: {} -/******/ }; -/******/ -/******/ // Execute the module function -/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); -/******/ -/******/ // Flag the module as loaded -/******/ module.l = true; -/******/ -/******/ // Return the exports of the module -/******/ return module.exports; -/******/ } -/******/ -/******/ -/******/ // expose the modules object (__webpack_modules__) -/******/ __webpack_require__.m = modules; -/******/ -/******/ // expose the module cache -/******/ __webpack_require__.c = installedModules; -/******/ -/******/ // define getter function for harmony exports -/******/ __webpack_require__.d = function(exports, name, getter) { -/******/ if(!__webpack_require__.o(exports, name)) { -/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); -/******/ } -/******/ }; -/******/ -/******/ // define __esModule on exports -/******/ __webpack_require__.r = function(exports) { -/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { -/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); -/******/ } -/******/ Object.defineProperty(exports, '__esModule', { value: true }); -/******/ }; -/******/ -/******/ // create a fake namespace object -/******/ // mode & 1: value is a module id, require it -/******/ // mode & 2: merge all properties of value into the ns -/******/ // mode & 4: return value when already ns object -/******/ // mode & 8|1: behave like require -/******/ __webpack_require__.t = function(value, mode) { -/******/ if(mode & 1) value = __webpack_require__(value); -/******/ if(mode & 8) return value; -/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; -/******/ var ns = Object.create(null); -/******/ __webpack_require__.r(ns); -/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); -/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); -/******/ return ns; -/******/ }; -/******/ -/******/ // getDefaultExport function for compatibility with non-harmony modules -/******/ __webpack_require__.n = function(module) { -/******/ var getter = module && module.__esModule ? -/******/ function getDefault() { return module['default']; } : -/******/ function getModuleExports() { return module; }; -/******/ __webpack_require__.d(getter, 'a', getter); -/******/ return getter; -/******/ }; -/******/ -/******/ // Object.prototype.hasOwnProperty.call -/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; -/******/ -/******/ // __webpack_public_path__ -/******/ __webpack_require__.p = \\"__REPLACE_WITH_PUBLIC_PATH__\\"; -/******/ -/******/ -/******/ // Load entry module and return exports -/******/ return __webpack_require__(__webpack_require__.s = \\"./public/index.ts\\"); -/******/ }) -/************************************************************************/ -/******/ ({ +exports[`builds expected bundles, saves bundle counts to metadata: bar bundle 1`] = `"var __kbnBundles__=typeof __kbnBundles__===\\"object\\"?__kbnBundles__:{};__kbnBundles__[\\"plugin/bar\\"]=function(modules){var installedModules={};function __webpack_require__(moduleId){if(installedModules[moduleId]){return installedModules[moduleId].exports}var module=installedModules[moduleId]={i:moduleId,l:false,exports:{}};modules[moduleId].call(module.exports,module,module.exports,__webpack_require__);module.l=true;return module.exports}__webpack_require__.m=modules;__webpack_require__.c=installedModules;__webpack_require__.d=function(exports,name,getter){if(!__webpack_require__.o(exports,name)){Object.defineProperty(exports,name,{enumerable:true,get:getter})}};__webpack_require__.r=function(exports){if(typeof Symbol!==\\"undefined\\"&&Symbol.toStringTag){Object.defineProperty(exports,Symbol.toStringTag,{value:\\"Module\\"})}Object.defineProperty(exports,\\"__esModule\\",{value:true})};__webpack_require__.t=function(value,mode){if(mode&1)value=__webpack_require__(value);if(mode&8)return value;if(mode&4&&typeof value===\\"object\\"&&value&&value.__esModule)return value;var ns=Object.create(null);__webpack_require__.r(ns);Object.defineProperty(ns,\\"default\\",{enumerable:true,value:value});if(mode&2&&typeof value!=\\"string\\")for(var key in value)__webpack_require__.d(ns,key,function(key){return value[key]}.bind(null,key));return ns};__webpack_require__.n=function(module){var getter=module&&module.__esModule?function getDefault(){return module[\\"default\\"]}:function getModuleExports(){return module};__webpack_require__.d(getter,\\"a\\",getter);return getter};__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)};__webpack_require__.p=\\"__REPLACE_WITH_PUBLIC_PATH__\\";return __webpack_require__(__webpack_require__.s=4)}([function(module,exports,__webpack_require__){\\"use strict\\";var isOldIE=function isOldIE(){var memo;return function memorize(){if(typeof memo===\\"undefined\\"){memo=Boolean(window&&document&&document.all&&!window.atob)}return memo}}();var getTarget=function getTarget(){var memo={};return function memorize(target){if(typeof memo[target]===\\"undefined\\"){var styleTarget=document.querySelector(target);if(window.HTMLIFrameElement&&styleTarget instanceof window.HTMLIFrameElement){try{styleTarget=styleTarget.contentDocument.head}catch(e){styleTarget=null}}memo[target]=styleTarget}return memo[target]}}();var stylesInDom=[];function getIndexByIdentifier(identifier){var result=-1;for(var i=0;i { await del(TMP_DIR); @@ -51,20 +51,25 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { repoRoot: MOCK_REPO_DIR, pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')], maxWorkerCount: 1, + dist: true, }); expect(config).toMatchSnapshot('OptimizerConfig'); - const msgs = await runOptimizer(config) - .pipe( - tap(state => { - if (state.event?.type === 'worker stdio') { - // eslint-disable-next-line no-console - console.log('worker', state.event.stream, state.event.chunk.toString('utf8')); + const log = new ToolingLog({ + level: 'error', + writeTo: { + write(chunk) { + if (chunk.endsWith('\n')) { + chunk = chunk.slice(0, -1); } - }), - toArray() - ) + // eslint-disable-next-line no-console + console.error(chunk); + }, + }, + }); + const msgs = await runOptimizer(config) + .pipe(logOptimizerState(log, config), toArray()) .toPromise(); const assert = (statement: string, truth: boolean, altStates?: OptimizerUpdate[]) => { @@ -133,23 +138,31 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { expect(foo.cache.getModuleCount()).toBe(3); expect(foo.cache.getReferencedFiles()).toMatchInlineSnapshot(` Array [ - /plugins/foo/public/ext.ts, - /plugins/foo/public/index.ts, - /plugins/foo/public/lib.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/lib.ts, ] `); const bar = config.bundles.find(b => b.id === 'bar')!; expect(bar).toBeTruthy(); bar.cache.refresh(); - expect(bar.cache.getModuleCount()).toBe(5); + expect(bar.cache.getModuleCount()).toBe( + // code + styles + style/css-loader runtime + 14 + ); + expect(bar.cache.getReferencedFiles()).toMatchInlineSnapshot(` Array [ - /plugins/foo/public/ext.ts, - /plugins/foo/public/index.ts, - /plugins/foo/public/lib.ts, - /plugins/bar/public/index.ts, - /plugins/bar/public/lib.ts, + /node_modules/css-loader/package.json, + /node_modules/style-loader/package.json, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/legacy/styles.scss, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/lib.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/lib.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/legacy/ui/public/icon.svg, ] `); }); @@ -159,6 +172,7 @@ it('uses cache on second run and exist cleanly', async () => { repoRoot: MOCK_REPO_DIR, pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')], maxWorkerCount: 1, + dist: true, }); const msgs = await runOptimizer(config) diff --git a/packages/kbn-optimizer/src/worker/__snapshots__/parse_path.test.ts.snap b/packages/kbn-optimizer/src/worker/__snapshots__/parse_path.test.ts.snap new file mode 100644 index 0000000000000..2973ac116d6bd --- /dev/null +++ b/packages/kbn-optimizer/src/worker/__snapshots__/parse_path.test.ts.snap @@ -0,0 +1,156 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`parseDirPath() parses / 1`] = ` +Object { + "dirs": Array [], + "filename": undefined, + "root": "/", +} +`; + +exports[`parseDirPath() parses /foo 1`] = ` +Object { + "dirs": Array [ + "foo", + ], + "filename": undefined, + "root": "/", +} +`; + +exports[`parseDirPath() parses /foo/bar/baz 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + "baz", + ], + "filename": undefined, + "root": "/", +} +`; + +exports[`parseDirPath() parses /foo/bar/baz/ 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + "baz", + ], + "filename": undefined, + "root": "/", +} +`; + +exports[`parseDirPath() parses c:\\ 1`] = ` +Object { + "dirs": Array [], + "filename": undefined, + "root": "c:", +} +`; + +exports[`parseDirPath() parses c:\\foo 1`] = ` +Object { + "dirs": Array [ + "foo", + ], + "filename": undefined, + "root": "c:", +} +`; + +exports[`parseDirPath() parses c:\\foo\\bar\\baz 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + "baz", + ], + "filename": undefined, + "root": "c:", +} +`; + +exports[`parseDirPath() parses c:\\foo\\bar\\baz\\ 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + "baz", + ], + "filename": undefined, + "root": "c:", +} +`; + +exports[`parseFilePath() parses /foo 1`] = ` +Object { + "dirs": Array [], + "filename": "foo", + "root": "/", +} +`; + +exports[`parseFilePath() parses /foo/bar/baz 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + ], + "filename": "baz", + "root": "/", +} +`; + +exports[`parseFilePath() parses /foo/bar/baz.json 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + ], + "filename": "baz.json", + "root": "/", +} +`; + +exports[`parseFilePath() parses c:/foo/bar/baz.json 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + ], + "filename": "baz.json", + "root": "c:", +} +`; + +exports[`parseFilePath() parses c:\\foo 1`] = ` +Object { + "dirs": Array [], + "filename": "foo", + "root": "c:", +} +`; + +exports[`parseFilePath() parses c:\\foo\\bar\\baz 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + ], + "filename": "baz", + "root": "c:", +} +`; + +exports[`parseFilePath() parses c:\\foo\\bar\\baz.json 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + ], + "filename": "baz.json", + "root": "c:", +} +`; diff --git a/typings/normalize_path/index.d.ts b/packages/kbn-optimizer/src/worker/parse_path.test.ts similarity index 57% rename from typings/normalize_path/index.d.ts rename to packages/kbn-optimizer/src/worker/parse_path.test.ts index 31e064ca63d90..72197e8c8fb07 100644 --- a/typings/normalize_path/index.d.ts +++ b/packages/kbn-optimizer/src/worker/parse_path.test.ts @@ -17,8 +17,20 @@ * under the License. */ -declare function NormalizePath(path: string, stripTrailing?: boolean): string; +import { parseFilePath, parseDirPath } from './parse_path'; -declare module 'normalize-path' { - export = NormalizePath; -} +const DIRS = ['/', '/foo/bar/baz/', 'c:\\', 'c:\\foo\\bar\\baz\\']; +const AMBIGUOUS = ['/foo', '/foo/bar/baz', 'c:\\foo', 'c:\\foo\\bar\\baz']; +const FILES = ['/foo/bar/baz.json', 'c:/foo/bar/baz.json', 'c:\\foo\\bar\\baz.json']; + +describe('parseFilePath()', () => { + it.each([...FILES, ...AMBIGUOUS])('parses %s', path => { + expect(parseFilePath(path)).toMatchSnapshot(); + }); +}); + +describe('parseDirPath()', () => { + it.each([...DIRS, ...AMBIGUOUS])('parses %s', path => { + expect(parseDirPath(path)).toMatchSnapshot(); + }); +}); diff --git a/packages/kbn-optimizer/src/worker/parse_path.ts b/packages/kbn-optimizer/src/worker/parse_path.ts new file mode 100644 index 0000000000000..88152df55b84f --- /dev/null +++ b/packages/kbn-optimizer/src/worker/parse_path.ts @@ -0,0 +1,43 @@ +/* + * 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 normalizePath from 'normalize-path'; + +/** + * Parse an absolute path, supporting normalized paths from webpack, + * into a list of directories and root + */ +export function parseDirPath(path: string) { + const filePath = parseFilePath(path); + return { + ...filePath, + dirs: [...filePath.dirs, ...(filePath.filename ? [filePath.filename] : [])], + filename: undefined, + }; +} + +export function parseFilePath(path: string) { + const normalized = normalizePath(path); + const [root, ...others] = normalized.split('/'); + return { + root: root === '' ? '/' : root, + dirs: others.slice(0, -1), + filename: others[others.length - 1] || undefined, + }; +} diff --git a/packages/kbn-optimizer/src/worker/run_compilers.ts b/packages/kbn-optimizer/src/worker/run_compilers.ts index 7dcce8a0fae8d..7a8097fd2b2c7 100644 --- a/packages/kbn-optimizer/src/worker/run_compilers.ts +++ b/packages/kbn-optimizer/src/worker/run_compilers.ts @@ -27,9 +27,10 @@ import webpack, { Stats } from 'webpack'; import * as Rx from 'rxjs'; import { mergeMap, map, mapTo, takeUntil } from 'rxjs/operators'; -import { CompilerMsgs, CompilerMsg, maybeMap, Bundle, WorkerConfig } from '../common'; +import { CompilerMsgs, CompilerMsg, maybeMap, Bundle, WorkerConfig, ascending } from '../common'; import { getWebpackConfig } from './webpack.config'; import { isFailureStats, failedStatsToErrorMessage } from './webpack_helpers'; +import { parseFilePath } from './parse_path'; import { isExternalModule, isNormalModule, @@ -108,20 +109,19 @@ const observeCompiler = ( for (const module of normalModules) { const path = getModulePath(module); + const parsedPath = parseFilePath(path); - const parsedPath = Path.parse(path); - const dirSegments = parsedPath.dir.split(Path.sep); - if (!dirSegments.includes('node_modules')) { + if (!parsedPath.dirs.includes('node_modules')) { referencedFiles.add(path); continue; } - const nmIndex = dirSegments.lastIndexOf('node_modules'); - const isScoped = dirSegments[nmIndex + 1].startsWith('@'); + const nmIndex = parsedPath.dirs.lastIndexOf('node_modules'); + const isScoped = parsedPath.dirs[nmIndex + 1].startsWith('@'); referencedFiles.add( Path.join( parsedPath.root, - ...dirSegments.slice(0, nmIndex + 1 + (isScoped ? 2 : 1)), + ...parsedPath.dirs.slice(0, nmIndex + 1 + (isScoped ? 2 : 1)), 'package.json' ) ); @@ -146,7 +146,7 @@ const observeCompiler = ( optimizerCacheKey: workerConfig.optimizerCacheKey, cacheKey: bundle.createCacheKey(files, mtimes), moduleCount: normalModules.length, - files, + files: files.sort(ascending(f => f)), }); return compilerMsgs.compilerSuccess({ diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index 3c6ae78bc4d91..5d8ef7626f630 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -30,6 +30,7 @@ import { CleanWebpackPlugin } from 'clean-webpack-plugin'; import * as SharedDeps from '@kbn/ui-shared-deps'; import { Bundle, WorkerConfig } from '../common'; +import { parseDirPath } from './parse_path'; const IS_CODE_COVERAGE = !!process.env.CODE_COVERAGE; const ISTANBUL_PRESET_PATH = require.resolve('@kbn/babel-preset/istanbul_preset'); @@ -135,7 +136,7 @@ export function getWebpackConfig(bundle: Bundle, worker: WorkerConfig) { } // manually force ui/* urls in legacy styles to resolve to ui/legacy/public - if (uri.startsWith('ui/') && base.split(Path.sep).includes('legacy')) { + if (uri.startsWith('ui/') && parseDirPath(base).dirs.includes('legacy')) { return Path.resolve( worker.repoRoot, 'src/legacy/ui/public', @@ -150,7 +151,9 @@ export function getWebpackConfig(bundle: Bundle, worker: WorkerConfig) { { loader: 'sass-loader', options: { - sourceMap: !worker.dist, + // must always be enabled as long as we're using the `resolve-url-loader` to + // rewrite `ui/*` urls. They're dropped by subsequent loaders though + sourceMap: true, prependData(loaderContext: webpack.loader.LoaderContext) { return `@import ${stringifyRequest( loaderContext, diff --git a/renovate.json5 b/renovate.json5 index 58a64a5d0f967..ca2cd2e6bcd93 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -665,6 +665,14 @@ '@types/nodemailer', ], }, + { + groupSlug: 'normalize-path', + groupName: 'normalize-path related packages', + packageNames: [ + 'normalize-path', + '@types/normalize-path', + ], + }, { groupSlug: 'numeral', groupName: 'numeral related packages', diff --git a/yarn.lock b/yarn.lock index 338d516a796e1..e4d5dcce5bca0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4864,6 +4864,11 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== +"@types/normalize-path@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/normalize-path/-/normalize-path-3.0.0.tgz#bb5c46cab77b93350b4cf8d7ff1153f47189ae31" + integrity sha512-Nd8y/5t/7CRakPYiyPzr/IAfYusy1FkcZYFEAcoMZkwpJv2n4Wm+olW+e7xBdHEXhOnWdG9ddbar0gqZWS4x5Q== + "@types/numeral@^0.0.25": version "0.0.25" resolved "https://registry.yarnpkg.com/@types/numeral/-/numeral-0.0.25.tgz#b6f55062827a4787fe4ab151cf3412a468e65271" From 48a33abdeed147932401cc3a24c36669189f67f3 Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Mon, 2 Mar 2020 15:51:33 -0700 Subject: [PATCH 22/36] Remove appBasePath from docs + add mock for AppMountParameters (#58775) --- ...lugin-public.appmountparameters.history.md | 1 - ...in-public.appmountparameters.onappleave.md | 4 +-- src/core/CONVENTIONS.md | 4 +-- src/core/TESTING.md | 14 ++++++----- src/core/public/application/types.ts | 5 ++-- src/core/public/mocks.ts | 25 ++++++++++++++++++- 6 files changed, 38 insertions(+), 15 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.history.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.history.md index 9a3fa1a1bb48a..f22e70b0e7bee 100644 --- a/docs/development/core/public/kibana-plugin-public.appmountparameters.history.md +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.history.md @@ -44,7 +44,6 @@ import { MyPluginDepsStart } from './plugin'; export renderApp = ({ element, history }: AppMountParameters) => { ReactDOM.render( - // pass `appBasePath` to `basename` , diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.onappleave.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.onappleave.md index 283ae34f14c54..6c5b89ffda05b 100644 --- a/docs/development/core/public/kibana-plugin-public.appmountparameters.onappleave.md +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.onappleave.md @@ -26,7 +26,7 @@ import { BrowserRouter, Route } from 'react-router-dom'; import { CoreStart, AppMountParams } from 'src/core/public'; import { MyPluginDepsStart } from './plugin'; -export renderApp = ({ appBasePath, element, onAppLeave }: AppMountParams) => { +export renderApp = ({ element, history, onAppLeave }: AppMountParams) => { const { renderApp, hasUnsavedChanges } = await import('./application'); onAppLeave(actions => { if(hasUnsavedChanges()) { @@ -34,7 +34,7 @@ export renderApp = ({ appBasePath, element, onAppLeave }: AppMountParams) => { } return actions.default(); }); - return renderApp(params); + return renderApp({ element, history }); } ``` diff --git a/src/core/CONVENTIONS.md b/src/core/CONVENTIONS.md index 2769079757bc3..0f592d108c561 100644 --- a/src/core/CONVENTIONS.md +++ b/src/core/CONVENTIONS.md @@ -148,8 +148,8 @@ import { MyAppRoot } from './components/app.ts'; /** * This module will be loaded asynchronously to reduce the bundle size of your plugin's main bundle. */ -export const renderApp = (core: CoreStart, deps: MyPluginDepsStart, { element, appBasePath }: AppMountParams) => { - ReactDOM.render(, element); +export const renderApp = (core: CoreStart, deps: MyPluginDepsStart, { element, history }: AppMountParams) => { + ReactDOM.render(, element); return () => ReactDOM.unmountComponentAtNode(element); } ``` diff --git a/src/core/TESTING.md b/src/core/TESTING.md index 9abc2bb77d7d1..cb38dac0e20ce 100644 --- a/src/core/TESTING.md +++ b/src/core/TESTING.md @@ -453,7 +453,7 @@ describe('Plugin', () => { const [coreStartMock, startDepsMock] = await coreSetup.getStartServices(); const unmountMock = jest.fn(); renderAppMock.mockReturnValue(unmountMock); - const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const params = coreMock.createAppMountParamters('/fake/base/path'); new Plugin(coreMock.createPluginInitializerContext()).setup(coreSetup); // Grab registered mount function @@ -478,7 +478,7 @@ import ReactDOM from 'react-dom'; import { AppMountParams, CoreStart } from 'src/core/public'; import { AppRoot } from './components/app_root'; -export const renderApp = ({ element, appBasePath }: AppMountParams, core: CoreStart, plugins: MyPluginDepsStart) => { +export const renderApp = ({ element, history }: AppMountParams, core: CoreStart, plugins: MyPluginDepsStart) => { // Hide the chrome while this app is mounted for a full screen experience core.chrome.setIsVisible(false); @@ -491,7 +491,7 @@ export const renderApp = ({ element, appBasePath }: AppMountParams, core: CoreSt // Render app ReactDOM.render( - , + , element ); @@ -512,12 +512,14 @@ In testing `renderApp` you should be verifying that: ```typescript /** public/application.test.ts */ +import { createMemoryHistory } from 'history'; +import { ScopedHistory } from 'src/core/public'; import { coreMock } from 'src/core/public/mocks'; import { renderApp } from './application'; describe('renderApp', () => { it('mounts and unmounts UI', () => { - const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const params = coreMock.createAppMountParamters('/fake/base/path'); const core = coreMock.createStart(); // Verify some expected DOM element is rendered into the element @@ -529,7 +531,7 @@ describe('renderApp', () => { }); it('unsubscribes from uiSettings', () => { - const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const params = coreMock.createAppMountParamters('/fake/base/path'); const core = coreMock.createStart(); // Create a fake Subject you can use to monitor observers const settings$ = new Subject(); @@ -544,7 +546,7 @@ describe('renderApp', () => { }); it('resets chrome visibility', () => { - const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const params = coreMock.createAppMountParamters('/fake/base/path'); const core = coreMock.createStart(); // Verify stateful Core API was called on mount diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index facb818c60ff9..318afb652999e 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -347,7 +347,6 @@ export interface AppMountParameters { * * export renderApp = ({ element, history }: AppMountParameters) => { * ReactDOM.render( - * // pass `appBasePath` to `basename` * * * , @@ -429,7 +428,7 @@ export interface AppMountParameters { * import { CoreStart, AppMountParams } from 'src/core/public'; * import { MyPluginDepsStart } from './plugin'; * - * export renderApp = ({ appBasePath, element, onAppLeave }: AppMountParams) => { + * export renderApp = ({ element, history, onAppLeave }: AppMountParams) => { * const { renderApp, hasUnsavedChanges } = await import('./application'); * onAppLeave(actions => { * if(hasUnsavedChanges()) { @@ -437,7 +436,7 @@ export interface AppMountParameters { * } * return actions.default(); * }); - * return renderApp(params); + * return renderApp({ element, history }); * } * ``` */ diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 8ea672890ca29..c860e9de8334e 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -16,9 +16,15 @@ * specific language governing permissions and limitations * under the License. */ +import { createMemoryHistory } from 'history'; + +// Only import types from '.' to avoid triggering default Jest mocks. +import { CoreContext, PluginInitializerContext, AppMountParameters } from '.'; +// Import values from their individual modules instead. +import { ScopedHistory } from './application'; + import { applicationServiceMock } from './application/application_service.mock'; import { chromeServiceMock } from './chrome/chrome_service.mock'; -import { CoreContext, PluginInitializerContext } from '.'; import { docLinksServiceMock } from './doc_links/doc_links_service.mock'; import { fatalErrorsServiceMock } from './fatal_errors/fatal_errors_service.mock'; import { httpServiceMock } from './http/http_service.mock'; @@ -139,10 +145,27 @@ function createStorageMock() { return storageMock; } +function createAppMountParametersMock(appBasePath = '') { + // Assemble an in-memory history mock using the provided basePath + const rawHistory = createMemoryHistory(); + rawHistory.push(appBasePath); + const history = new ScopedHistory(rawHistory, appBasePath); + + const params: jest.Mocked = { + appBasePath, + element: document.createElement('div'), + history, + onAppLeave: jest.fn(), + }; + + return params; +} + export const coreMock = { createCoreContext, createSetup: createCoreSetupMock, createStart: createCoreStartMock, createPluginInitializerContext: pluginInitializerContextMock, createStorage: createStorageMock, + createAppMountParamters: createAppMountParametersMock, }; From 90b3678dffa20ff7e0ab806861bfa64d46d86cb9 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Mon, 2 Mar 2020 16:04:29 -0700 Subject: [PATCH 23/36] [SIEM] [Case] Comments to case view (#58315) --- .../edit_data_provider/translations.ts | 2 +- .../components/formatted_date/index.tsx | 29 +++ .../editable_title.test.tsx.snap | 2 +- .../components/header_page/editable_title.tsx | 64 ++++-- .../components/header_page/translations.ts | 4 +- .../components/markdown_editor/constants.ts | 7 + .../components/markdown_editor/form.tsx | 58 ++++++ .../components/markdown_editor/index.tsx | 121 +++++++++++ .../markdown_editor/translations.ts | 18 ++ .../components/navigation/index.test.tsx | 4 +- .../siem/public/containers/case/api.ts | 38 +++- .../siem/public/containers/case/constants.ts | 2 +- .../siem/public/containers/case/types.ts | 28 ++- .../public/containers/case/use_get_case.tsx | 4 +- .../public/containers/case/use_post_case.tsx | 3 +- .../containers/case/use_post_comment.tsx | 97 +++++++++ .../containers/case/use_update_case.tsx | 68 +++--- .../containers/case/use_update_comment.tsx | 92 ++++++++ .../case/components/add_comment/index.tsx | 73 +++++++ .../case/components/add_comment/schema.tsx | 20 ++ .../components/all_cases/__mock__/index.tsx | 10 + .../case/components/all_cases/columns.tsx | 2 +- .../components/case_view/__mock__/index.tsx | 29 ++- .../case/components/case_view/index.test.tsx | 41 +++- .../pages/case/components/case_view/index.tsx | 196 ++++++------------ .../case/components/case_view/translations.ts | 12 ++ .../pages/case/components/create/index.tsx | 77 ++++--- .../pages/case/components/create/schema.tsx | 6 +- .../description_md_editor/index.tsx | 111 ---------- .../pages/case/components/tag_list/index.tsx | 68 +++--- .../pages/case/components/tag_list/schema.tsx | 2 +- .../components/user_action_tree/index.tsx | 139 ++++++++++--- .../user_action_tree/user_action_avatar.tsx | 18 ++ .../user_action_tree/user_action_item.tsx | 60 ++++++ .../user_action_tree/user_action_markdown.tsx | 89 ++++++++ .../user_action_tree/user_action_title.tsx | 70 +++++++ .../pages/case/components/user_list/index.tsx | 4 +- .../siem/public/pages/case/translations.ts | 38 +++- .../rules/components/add_item_form/index.tsx | 2 +- .../components/description_step/index.tsx | 2 +- .../rules/components/mitre/index.tsx | 2 +- .../rules/components/pick_timeline/index.tsx | 2 +- .../rules/components/query_bar/index.tsx | 2 +- .../components/schedule_item_form/index.tsx | 2 +- .../components/step_about_rule/index.tsx | 2 +- .../components/step_about_rule/schema.tsx | 2 +- .../components/step_define_rule/index.tsx | 2 +- .../components/step_define_rule/schema.tsx | 2 +- .../components/step_schedule_rule/index.tsx | 2 +- .../components/step_schedule_rule/schema.tsx | 2 +- .../detection_engine/rules/create/index.tsx | 2 +- .../detection_engine/rules/edit/index.tsx | 2 +- .../pages/detection_engine/rules/helpers.tsx | 2 +- .../pages/detection_engine/rules/types.ts | 2 +- .../siem/public/pages/home/translations.ts | 2 +- .../siem/public/{pages => }/shared_imports.ts | 8 +- .../api/__tests__/update_comment.test.ts | 19 ++ .../plugins/case/server/routes/api/schema.ts | 5 + .../case/server/routes/api/update_comment.ts | 36 +++- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 61 files changed, 1355 insertions(+), 455 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/markdown_editor/constants.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/markdown_editor/form.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/markdown_editor/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/markdown_editor/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_avatar.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_markdown.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx rename x-pack/legacy/plugins/siem/public/{pages => }/shared_imports.ts (52%) diff --git a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/translations.ts b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/translations.ts index dadd349096a53..53d2ffa197327 100644 --- a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/translations.ts @@ -18,7 +18,7 @@ export const FIELD = i18n.translate('xpack.siem.editDataProvider.fieldLabel', { defaultMessage: 'Field', }); -export const FIELD_PLACEHOLDER = i18n.translate('xpack.siem.editDataProvider.fieldPlaceholder', { +export const FIELD_PLACEHOLDER = i18n.translate('xpack.siem.editDataProvider.placeholder', { defaultMessage: 'Select a field', }); diff --git a/x-pack/legacy/plugins/siem/public/components/formatted_date/index.tsx b/x-pack/legacy/plugins/siem/public/components/formatted_date/index.tsx index f74ee995c965b..d100f89182014 100644 --- a/x-pack/legacy/plugins/siem/public/components/formatted_date/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/formatted_date/index.tsx @@ -125,3 +125,32 @@ export const FormattedRelativePreferenceDate = ({ value }: { value?: string | nu ); }; + +/** + * Renders a preceding label according to under/over one hour + */ + +export const FormattedRelativePreferenceLabel = ({ + value, + preferenceLabel, + relativeLabel, +}: { + value?: string | number | null; + preferenceLabel?: string | null; + relativeLabel?: string | null; +}) => { + if (value == null) { + return null; + } + const maybeDate = getMaybeDate(value); + if (!maybeDate.isValid()) { + return null; + } + return moment(maybeDate.toDate()) + .add(1, 'hours') + .isBefore(new Date()) ? ( + <>{preferenceLabel} + ) : ( + <>{relativeLabel} + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/components/header_page/__snapshots__/editable_title.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/header_page/__snapshots__/editable_title.test.tsx.snap index 30c70d7f5a2a6..24b1756aade2e 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_page/__snapshots__/editable_title.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/header_page/__snapshots__/editable_title.test.tsx.snap @@ -15,7 +15,7 @@ exports[`EditableTitle it renders 1`] = ` - css` margin-left: ${theme.eui.euiSize}; `} `; -StyledEuiButtonIcon.displayName = 'StyledEuiButtonIcon'; +const MySpinner = styled(EuiLoadingSpinner)` + ${({ theme }) => css` + margin-left: ${theme.eui.euiSize}; + `} +`; interface Props { isLoading: boolean; @@ -36,24 +41,30 @@ interface Props { const EditableTitleComponent: React.FC = ({ onSubmit, isLoading, title }) => { const [editMode, setEditMode] = useState(false); - const [changedTitle, onTitleChange] = useState(title); + const [changedTitle, onTitleChange] = useState(typeof title === 'string' ? title : ''); const onCancel = useCallback(() => setEditMode(false), []); const onClickEditIcon = useCallback(() => setEditMode(true), []); - const onClickSubmit = useCallback( - (newTitle: string): void => { - onSubmit(newTitle); - setEditMode(false); + const onClickSubmit = useCallback((): void => { + if (changedTitle !== title) { + onSubmit(changedTitle); + } + setEditMode(false); + }, [changedTitle, title]); + + const handleOnChange = useCallback( + (e: ChangeEvent) => { + onTitleChange(e.target.value); }, - [changedTitle] + [onTitleChange] ); return editMode ? ( onTitleChange(e.target.value)} + onChange={handleOnChange} value={`${changedTitle}`} data-test-subj="editable-title-input-field" /> @@ -61,17 +72,23 @@ const EditableTitleComponent: React.FC = ({ onSubmit, isLoading, title }) onClickSubmit(changedTitle as string)} + color="secondary" data-test-subj="editable-title-submit-btn" + fill + iconType="save" + onClick={onClickSubmit} + size="s" > - {i18n.SUBMIT} + {i18n.SAVE} - + {i18n.CANCEL} @@ -84,12 +101,15 @@ const EditableTitleComponent: React.FC = ({ onSubmit, isLoading, title }) </EuiFlexItem> <EuiFlexItem grow={false}> - <StyledEuiButtonIcon - aria-label={i18n.EDIT_TITLE_ARIA(title as string)} - iconType="pencil" - onClick={onClickEditIcon} - data-test-subj="editable-title-edit-icon" - /> + {isLoading && <MySpinner />} + {!isLoading && ( + <MyEuiButtonIcon + aria-label={i18n.EDIT_TITLE_ARIA(title as string)} + iconType="pencil" + onClick={onClickEditIcon} + data-test-subj="editable-title-edit-icon" + /> + )} </EuiFlexItem> </EuiFlexGroup> ); diff --git a/x-pack/legacy/plugins/siem/public/components/header_page/translations.ts b/x-pack/legacy/plugins/siem/public/components/header_page/translations.ts index 2bc2ac492b0b1..764f1e5ac3731 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_page/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/header_page/translations.ts @@ -6,8 +6,8 @@ import { i18n } from '@kbn/i18n'; -export const SUBMIT = i18n.translate('xpack.siem.header.editableTitle.submit', { - defaultMessage: 'Submit', +export const SAVE = i18n.translate('xpack.siem.header.editableTitle.save', { + defaultMessage: 'Save', }); export const CANCEL = i18n.translate('xpack.siem.header.editableTitle.cancel', { diff --git a/x-pack/legacy/plugins/siem/public/components/markdown_editor/constants.ts b/x-pack/legacy/plugins/siem/public/components/markdown_editor/constants.ts new file mode 100644 index 0000000000000..dc57de5252b3e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/markdown_editor/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const MARKDOWN_HELP_LINK = 'https://www.markdownguide.org/cheat-sheet/'; diff --git a/x-pack/legacy/plugins/siem/public/components/markdown_editor/form.tsx b/x-pack/legacy/plugins/siem/public/components/markdown_editor/form.tsx new file mode 100644 index 0000000000000..3c5287a6fac24 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/markdown_editor/form.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFormRow } from '@elastic/eui'; +import React, { useCallback } from 'react'; + +import { FieldHook, getFieldValidityAndErrorMessage } from '../../shared_imports'; +import { MarkdownEditor } from '.'; + +interface IMarkdownEditorForm { + dataTestSubj: string; + field: FieldHook; + idAria: string; + isDisabled: boolean; + placeholder?: string; + footerContentRight?: React.ReactNode; +} +export const MarkdownEditorForm = ({ + dataTestSubj, + field, + idAria, + isDisabled = false, + placeholder, + footerContentRight, +}: IMarkdownEditorForm) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + const handleContentChange = useCallback( + (newContent: string) => { + field.setValue(newContent); + }, + [field] + ); + + return ( + <EuiFormRow + label={field.label} + labelAppend={field.labelAppend} + helpText={field.helpText} + error={errorMessage} + isInvalid={isInvalid} + fullWidth + data-test-subj={dataTestSubj} + describedByIds={idAria ? [idAria] : undefined} + > + <MarkdownEditor + initialContent={field.value as string} + isDisabled={isDisabled} + footerContentRight={footerContentRight} + onChange={handleContentChange} + placeholder={placeholder} + /> + </EuiFormRow> + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/components/markdown_editor/index.tsx b/x-pack/legacy/plugins/siem/public/components/markdown_editor/index.tsx new file mode 100644 index 0000000000000..8572b447cced8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/markdown_editor/index.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiPanel, + EuiTabbedContent, + EuiTextArea, +} from '@elastic/eui'; +import React, { useEffect, useMemo, useState } from 'react'; +import styled, { css } from 'styled-components'; + +import { Markdown } from '../markdown'; +import * as i18n from './translations'; +import { MARKDOWN_HELP_LINK } from './constants'; + +const TextArea = styled(EuiTextArea)` + width: 100%; +`; + +const Container = styled(EuiPanel)` + ${({ theme }) => css` + padding: 0; + background: ${theme.eui.euiColorLightestShade}; + position: relative; + .euiTab { + padding: 10px; + } + .euiFormRow__labelWrapper { + position: absolute; + top: -${theme.eui.euiSizeL}; + } + .euiFormErrorText { + padding: 0 ${theme.eui.euiSizeM}; + } + `} +`; + +const Tabs = styled(EuiTabbedContent)` + width: 100%; +`; + +const Footer = styled(EuiFlexGroup)` + ${({ theme }) => css` + height: 41px; + padding: 0 ${theme.eui.euiSizeM}; + .euiLink { + font-size: ${theme.eui.euiSizeM}; + } + `} +`; + +const MarkdownContainer = styled(EuiPanel)` + min-height: 150px; + overflow: auto; +`; + +/** An input for entering a new case description */ +export const MarkdownEditor = React.memo<{ + placeholder?: string; + footerContentRight?: React.ReactNode; + initialContent: string; + isDisabled?: boolean; + onChange: (description: string) => void; +}>(({ placeholder, footerContentRight, initialContent, isDisabled = false, onChange }) => { + const [content, setContent] = useState(initialContent); + useEffect(() => { + onChange(content); + }, [content]); + const tabs = useMemo( + () => [ + { + id: 'comment', + name: i18n.MARKDOWN, + content: ( + <TextArea + onChange={e => { + setContent(e.target.value); + }} + aria-label={`markdown-editor-comment`} + fullWidth={true} + disabled={isDisabled} + placeholder={placeholder ?? ''} + spellCheck={false} + value={content} + /> + ), + }, + { + id: 'preview', + name: i18n.PREVIEW, + content: ( + <MarkdownContainer data-test-subj="markdown-container" paddingSize="s"> + <Markdown raw={content} /> + </MarkdownContainer> + ), + }, + ], + [content, isDisabled, placeholder] + ); + return ( + <Container> + <Tabs data-test-subj={`markdown-tabs`} size="s" tabs={tabs} initialSelectedTab={tabs[0]} /> + <Footer alignItems="center" gutterSize="none" justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiLink href={MARKDOWN_HELP_LINK} external target="_blank"> + {i18n.MARKDOWN_SYNTAX_HELP} + </EuiLink> + </EuiFlexItem> + {footerContentRight && <EuiFlexItem grow={false}>{footerContentRight}</EuiFlexItem>} + </Footer> + </Container> + ); +}); + +MarkdownEditor.displayName = 'MarkdownEditor'; diff --git a/x-pack/legacy/plugins/siem/public/components/markdown_editor/translations.ts b/x-pack/legacy/plugins/siem/public/components/markdown_editor/translations.ts new file mode 100644 index 0000000000000..642c524c48be0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/markdown_editor/translations.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const MARKDOWN_SYNTAX_HELP = i18n.translate('xpack.siem.markdownEditor.markdownInputHelp', { + defaultMessage: 'Markdown syntax help', +}); + +export const MARKDOWN = i18n.translate('xpack.siem.markdownEditor.markdown', { + defaultMessage: 'Markdown', +}); +export const PREVIEW = i18n.translate('xpack.siem.markdownEditor.preview', { + defaultMessage: 'Preview', +}); diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx index e1b3951a2317d..a821d310344d8 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx @@ -70,7 +70,7 @@ describe('SIEM Navigation', () => { disabled: false, href: '#/link-to/case', id: 'case', - name: 'Case', + name: 'Cases', urlKey: 'case', }, detections: { @@ -163,7 +163,7 @@ describe('SIEM Navigation', () => { disabled: false, href: '#/link-to/case', id: 'case', - name: 'Case', + name: 'Cases', urlKey: 'case', }, detections: { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index bff3bfd62a85c..f1d87ca58b44b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -5,12 +5,22 @@ */ import { KibanaServices } from '../../lib/kibana'; -import { FetchCasesProps, Case, NewCase, SortFieldCase, AllCases, CaseSnake } from './types'; +import { + AllCases, + Case, + CaseSnake, + Comment, + CommentSnake, + FetchCasesProps, + NewCase, + NewComment, + SortFieldCase, +} from './types'; import { throwIfNotOk } from '../../hooks/api/api'; import { CASES_URL } from './constants'; import { convertToCamelCase, convertAllCasesToCamel } from './utils'; -export const getCase = async (caseId: string, includeComments: boolean): Promise<Case> => { +export const getCase = async (caseId: string, includeComments: boolean = true): Promise<Case> => { const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { method: 'GET', asResponse: true, @@ -72,3 +82,27 @@ export const updateCaseProperty = async ( await throwIfNotOk(response.response); return convertToCamelCase<Partial<CaseSnake>, Partial<Case>>(response.body!); }; + +export const createComment = async (newComment: NewComment, caseId: string): Promise<Comment> => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}/comment`, { + method: 'POST', + asResponse: true, + body: JSON.stringify(newComment), + }); + await throwIfNotOk(response.response); + return convertToCamelCase<CommentSnake, Comment>(response.body!); +}; + +export const updateComment = async ( + commentId: string, + commentUpdate: string, + version: string +): Promise<Partial<Comment>> => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}/comment/${commentId}`, { + method: 'PATCH', + asResponse: true, + body: JSON.stringify({ comment: commentUpdate, version }), + }); + await throwIfNotOk(response.response); + return convertToCamelCase<Partial<CommentSnake>, Partial<Comment>>(response.body!); +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/constants.ts b/x-pack/legacy/plugins/siem/public/containers/case/constants.ts index c8d668527ae32..031ba1c128a24 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/constants.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/constants.ts @@ -11,6 +11,6 @@ export const FETCH_FAILURE = 'FETCH_FAILURE'; export const FETCH_INIT = 'FETCH_INIT'; export const FETCH_SUCCESS = 'FETCH_SUCCESS'; export const POST_NEW_CASE = 'POST_NEW_CASE'; -export const UPDATE_CASE_PROPERTY = 'UPDATE_CASE_PROPERTY'; +export const POST_NEW_COMMENT = 'POST_NEW_COMMENT'; export const UPDATE_FILTER_OPTIONS = 'UPDATE_FILTER_OPTIONS'; export const UPDATE_QUERY_PARAMS = 'UPDATE_QUERY_PARAMS'; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 1aea0b0f50a89..75ed6f7c2366d 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -14,8 +14,31 @@ export interface NewCase extends FormData { title: string; } +export interface NewComment extends FormData { + comment: string; +} + +export interface CommentSnake { + comment_id: string; + created_at: string; + created_by: ElasticUserSnake; + comment: string; + updated_at: string; + version: string; +} + +export interface Comment { + commentId: string; + createdAt: string; + createdBy: ElasticUser; + comment: string; + updatedAt: string; + version: string; +} + export interface CaseSnake { case_id: string; + comments: CommentSnake[]; created_at: string; created_by: ElasticUserSnake; description: string; @@ -23,11 +46,12 @@ export interface CaseSnake { tags: string[]; title: string; updated_at: string; - version?: string; + version: string; } export interface Case { caseId: string; + comments: Comment[]; createdAt: string; createdBy: ElasticUser; description: string; @@ -35,7 +59,7 @@ export interface Case { tags: string[]; title: string; updatedAt: string; - version?: string; + version: string; } export interface QueryParams { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index bf76b69ef22d6..ce71c26078db9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -52,6 +52,7 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { const initialData: Case = { caseId: '', createdAt: '', + comments: [], createdBy: { username: '', }, @@ -60,6 +61,7 @@ const initialData: Case = { tags: [], title: '', updatedAt: '', + version: '', }; export const useGetCase = (caseId: string): [CaseState] => { @@ -75,7 +77,7 @@ export const useGetCase = (caseId: string): [CaseState] => { const fetchData = async () => { dispatch({ type: FETCH_INIT }); try { - const response = await getCase(caseId, false); + const response = await getCase(caseId); if (!didCancel) { dispatch({ type: FETCH_SUCCESS, payload: response }); } diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx index 5cf99701977d2..0fcc8a3a1abec 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx @@ -80,8 +80,7 @@ export const usePostCase = (): [NewCaseState, Dispatch<SetStateAction<NewCase>>] const postCase = async () => { dispatch({ type: FETCH_INIT }); try { - const dataWithoutIsNew = state.data; - delete dataWithoutIsNew.isNew; + const { isNew, ...dataWithoutIsNew } = state.data; const response = await createCase(dataWithoutIsNew); dispatch({ type: FETCH_SUCCESS, payload: response }); } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx new file mode 100644 index 0000000000000..d8abda25af286 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react'; +import { useStateToaster } from '../../components/toasters'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import * as i18n from './translations'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, POST_NEW_COMMENT } from './constants'; +import { Comment, NewComment } from './types'; +import { createComment } from './api'; +import { getTypedPayload } from './utils'; + +interface NewCommentState { + data: NewComment; + newComment?: Comment; + isLoading: boolean; + isError: boolean; + caseId: string; +} +interface Action { + type: string; + payload?: NewComment | Comment; +} + +const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoading: true, + isError: false, + }; + case POST_NEW_COMMENT: + return { + ...state, + isLoading: false, + isError: false, + data: getTypedPayload<NewComment>(action.payload), + }; + case FETCH_SUCCESS: + return { + ...state, + isLoading: false, + isError: false, + newComment: getTypedPayload<Comment>(action.payload), + }; + case FETCH_FAILURE: + return { + ...state, + isLoading: false, + isError: true, + }; + default: + throw new Error(); + } +}; +const initialData: NewComment = { + comment: '', +}; + +export const usePostComment = ( + caseId: string +): [NewCommentState, Dispatch<SetStateAction<NewComment>>] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + caseId, + data: initialData, + }); + const [formData, setFormData] = useState(initialData); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + dispatch({ type: POST_NEW_COMMENT, payload: formData }); + }, [formData]); + + useEffect(() => { + const postComment = async () => { + dispatch({ type: FETCH_INIT }); + try { + const { isNew, ...dataWithoutIsNew } = state.data; + const response = await createComment(dataWithoutIsNew, state.caseId); + dispatch({ type: FETCH_SUCCESS, payload: response }); + } catch (error) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); + } + }; + if (state.data.isNew) { + postComment(); + } + }, [state.data.isNew]); + return [state, setFormData]; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index 62e3d87b528c0..ebbb1e14dc237 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useReducer } from 'react'; +import { useReducer } from 'react'; import { useStateToaster } from '../../components/toasters'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; import * as i18n from './translations'; -import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, UPDATE_CASE_PROPERTY } from './constants'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; import { Case } from './types'; import { updateCaseProperty } from './api'; import { getTypedPayload } from './utils'; @@ -19,7 +19,7 @@ interface NewCaseState { data: Case; isLoading: boolean; isError: boolean; - updateKey?: UpdateKey | null; + updateKey: UpdateKey | null; } interface UpdateByKey { @@ -29,7 +29,7 @@ interface UpdateByKey { interface Action { type: string; - payload?: Partial<Case> | UpdateByKey; + payload?: Partial<Case> | UpdateKey; } const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => { @@ -39,20 +39,9 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => ...state, isLoading: true, isError: false, - updateKey: null, - }; - case UPDATE_CASE_PROPERTY: - const { updateKey, updateValue } = getTypedPayload<UpdateByKey>(action.payload); - return { - ...state, - isLoading: false, - isError: false, - data: { - ...state.data, - [updateKey]: updateValue, - }, - updateKey, + updateKey: getTypedPayload<UpdateKey>(action.payload), }; + case FETCH_SUCCESS: return { ...state, @@ -62,12 +51,14 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => ...state.data, ...getTypedPayload<Case>(action.payload), }, + updateKey: null, }; case FETCH_FAILURE: return { ...state, isLoading: false, isError: true, + updateKey: null, }; default: throw new Error(); @@ -77,40 +68,29 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => export const useUpdateCase = ( caseId: string, initialData: Case -): [{ data: Case }, (updates: UpdateByKey) => void] => { +): [NewCaseState, (updates: UpdateByKey) => void] => { const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, data: initialData, + updateKey: null, }); const [, dispatchToaster] = useStateToaster(); - const dispatchUpdateCaseProperty = ({ updateKey, updateValue }: UpdateByKey) => { - dispatch({ - type: UPDATE_CASE_PROPERTY, - payload: { updateKey, updateValue }, - }); - }; - - useEffect(() => { - const updateData = async (updateKey: keyof Case) => { - dispatch({ type: FETCH_INIT }); - try { - const response = await updateCaseProperty( - caseId, - { [updateKey]: state.data[updateKey] }, - state.data.version ?? '' // saved object versions are typed as string | undefined, hope that's not true - ); - dispatch({ type: FETCH_SUCCESS, payload: response }); - } catch (error) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); - dispatch({ type: FETCH_FAILURE }); - } - }; - if (state.updateKey) { - updateData(state.updateKey); + const dispatchUpdateCaseProperty = async ({ updateKey, updateValue }: UpdateByKey) => { + dispatch({ type: FETCH_INIT, payload: updateKey }); + try { + const response = await updateCaseProperty( + caseId, + { [updateKey]: updateValue }, + state.data.version ?? '' // saved object versions are typed as string | undefined, hope that's not true + ); + dispatch({ type: FETCH_SUCCESS, payload: response }); + } catch (error) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); } - }, [state.updateKey]); + }; - return [{ data: state.data }, dispatchUpdateCaseProperty]; + return [state, dispatchUpdateCaseProperty]; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx new file mode 100644 index 0000000000000..bc8369117433a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx @@ -0,0 +1,92 @@ +/* + * 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 { useReducer, useRef } from 'react'; +import { useStateToaster } from '../../components/toasters'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import * as i18n from './translations'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; +import { Comment } from './types'; +import { updateComment } from './api'; +import { getTypedPayload } from './utils'; + +interface CommetUpdateState { + data: Comment[]; + isLoadingIds: string[]; + isError: boolean; +} + +interface CommentUpdate { + update: Partial<Comment>; + commentId: string; +} + +interface Action { + type: string; + payload?: CommentUpdate | string; +} + +const dataFetchReducer = (state: CommetUpdateState, action: Action): CommetUpdateState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoadingIds: [...state.isLoadingIds, getTypedPayload<string>(action.payload)], + isError: false, + }; + + case FETCH_SUCCESS: + const updatePayload = getTypedPayload<CommentUpdate>(action.payload); + const foundIndex = state.data.findIndex( + comment => comment.commentId === updatePayload.commentId + ); + state.data[foundIndex] = { ...state.data[foundIndex], ...updatePayload.update }; + return { + ...state, + isLoadingIds: state.isLoadingIds.filter(id => updatePayload.commentId !== id), + isError: false, + data: [...state.data], + }; + case FETCH_FAILURE: + return { + ...state, + isLoadingIds: state.isLoadingIds.filter( + id => getTypedPayload<string>(action.payload) !== id + ), + isError: true, + }; + default: + throw new Error(); + } +}; + +export const useUpdateComment = ( + comments: Comment[] +): [CommetUpdateState, (commentId: string, commentUpdate: string) => void] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoadingIds: [], + isError: false, + data: comments, + }); + const dispatchUpdateComment = useRef<(commentId: string, commentUpdate: string) => void>(); + const [, dispatchToaster] = useStateToaster(); + + dispatchUpdateComment.current = async (commentId: string, commentUpdate: string) => { + dispatch({ type: FETCH_INIT, payload: commentId }); + try { + const currentComment = state.data.find(comment => comment.commentId === commentId) ?? { + version: '', + }; + const response = await updateComment(commentId, commentUpdate, currentComment.version); + dispatch({ type: FETCH_SUCCESS, payload: { update: response, commentId } }); + } catch (error) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE, payload: commentId }); + } + }; + + return [state, dispatchUpdateComment.current]; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx new file mode 100644 index 0000000000000..c8e0dafcf5742 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback } from 'react'; +import { EuiButton, EuiLoadingSpinner } from '@elastic/eui'; +import styled from 'styled-components'; +import { Form, useForm, UseField } from '../../../../shared_imports'; +import { NewComment } from '../../../../containers/case/types'; +import { usePostComment } from '../../../../containers/case/use_post_comment'; +import { schema } from './schema'; +import * as i18n from '../../translations'; +import { MarkdownEditorForm } from '../../../../components/markdown_editor/form'; + +const MySpinner = styled(EuiLoadingSpinner)` + position: absolute; + top: 50%; + left: 50%; +`; + +export const AddComment = React.memo<{ + caseId: string; +}>(({ caseId }) => { + const [{ data, isLoading, newComment }, setFormData] = usePostComment(caseId); + const { form } = useForm({ + defaultValue: data, + options: { stripEmptyFields: false }, + schema, + }); + + const onSubmit = useCallback(async () => { + const { isValid, data: newData } = await form.submit(); + if (isValid && newData.comment) { + setFormData({ ...newData, isNew: true } as NewComment); + } else if (isValid && data.comment) { + setFormData({ ...data, ...newData, isNew: true } as NewComment); + } + }, [form, data]); + + return ( + <> + {isLoading && <MySpinner size="xl" />} + <Form form={form}> + <UseField + path="comment" + component={MarkdownEditorForm} + componentProps={{ + idAria: 'caseComment', + isDisabled: isLoading, + dataTestSubj: 'caseComment', + placeholder: i18n.ADD_COMMENT_HELP_TEXT, + footerContentRight: ( + <EuiButton + iconType="plusInCircle" + isDisabled={isLoading} + isLoading={isLoading} + onClick={onSubmit} + size="s" + > + {i18n.ADD_COMMENT} + </EuiButton> + ), + }} + /> + </Form> + {newComment && + 'TO DO new comment got added but we didnt update the UI yet. Refresh the page to see your comment ;)'} + </> + ); +}); + +AddComment.displayName = 'AddComment'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx new file mode 100644 index 0000000000000..5f30f59149d99 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../../shared_imports'; +import * as i18n from '../../translations'; + +const { emptyField } = fieldValidators; + +export const schema: FormSchema = { + comment: { + type: FIELD_TYPES.TEXTAREA, + validations: [ + { + validator: emptyField(i18n.COMMENT_REQUIRED), + }, + ], + }, +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 98a67304fcf1f..0169493773b74 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -14,51 +14,61 @@ export const useGetCasesMockState: UseGetCasesState = { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, + comments: [], description: 'Security banana Issue', state: 'open', tags: ['defacement'], title: 'Another horrible breach', updatedAt: '2020-02-13T19:44:23.627Z', + version: 'WzQ3LDFd', }, { caseId: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, + comments: [], description: 'Security banana Issue', state: 'open', tags: ['phishing'], title: 'Bad email', updatedAt: '2020-02-13T19:44:13.328Z', + version: 'WzQ3LDFd', }, { caseId: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, + comments: [], description: 'Security banana Issue', state: 'open', tags: ['phishing'], title: 'Bad email', updatedAt: '2020-02-13T19:44:11.328Z', + version: 'WzQ3LDFd', }, { caseId: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, + comments: [], description: 'Security banana Issue', state: 'closed', tags: ['phishing'], title: 'Uh oh', updatedAt: '2020-02-18T21:32:24.056Z', + version: 'WzQ3LDFd', }, { caseId: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, + comments: [], description: 'Security banana Issue', state: 'open', tags: ['phishing'], title: 'Uh oh', updatedAt: '2020-02-13T19:44:01.901Z', + version: 'WzQ3LDFd', }, ], page: 1, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 4c47bf605051d..9c276d1b24da1 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -19,7 +19,7 @@ const renderStringField = (field: string, dataTestSubj: string) => export const getCasesColumns = (): CasesColumns[] => [ { - name: i18n.CASE_TITLE, + name: i18n.NAME, render: (theCase: Case) => { if (theCase.caseId != null && theCase.title != null) { return <CaseDetailsLink detailName={theCase.caseId}>{theCase.title}</CaseDetailsLink>; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index 7480c4fc4bb2a..89d321c6d106a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -11,6 +11,19 @@ export const caseProps: CaseProps = { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', initialData: { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + comments: [ + { + comment: 'Solve this fast!', + commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', + createdAt: '2020-02-20T23:06:33.798Z', + createdBy: { + fullName: 'Steph Milovic', + username: 'smilovic', + }, + updatedAt: '2020-02-20T23:06:33.798Z', + version: 'WzQ3LDFd', + }, + ], createdAt: '2020-02-13T19:44:23.627Z', createdBy: { fullName: null, username: 'elastic' }, description: 'Security banana Issue', @@ -18,12 +31,25 @@ export const caseProps: CaseProps = { tags: ['defacement'], title: 'Another horrible breach!!', updatedAt: '2020-02-19T15:02:57.995Z', + version: 'WzQ3LDFd', }, - isLoading: false, }; export const data: Case = { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + comments: [ + { + comment: 'Solve this fast!', + commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', + createdAt: '2020-02-20T23:06:33.798Z', + createdBy: { + fullName: 'Steph Milovic', + username: 'smilovic', + }, + updatedAt: '2020-02-20T23:06:33.798Z', + version: 'WzQ3LDFd', + }, + ], createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic', fullName: null }, description: 'Security banana Issue', @@ -31,4 +57,5 @@ export const data: Case = { tags: ['defacement'], title: 'Another horrible breach!!', updatedAt: '2020-02-19T15:02:57.995Z', + version: 'WzQ3LDFd', }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index a9e694bad705d..1539b3de5a0c1 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -16,7 +16,12 @@ describe('CaseView ', () => { beforeEach(() => { jest.resetAllMocks(); - jest.spyOn(apiHook, 'useUpdateCase').mockReturnValue([{ data }, dispatchUpdateCaseProperty]); + jest + .spyOn(apiHook, 'useUpdateCase') + .mockReturnValue([ + { data, isLoading: false, isError: false, updateKey: null }, + dispatchUpdateCaseProperty, + ]); }); it('should render CaseComponent', () => { @@ -79,4 +84,38 @@ describe('CaseView ', () => { updateValue: 'closed', }); }); + + it('should render comments', () => { + const wrapper = mount( + <TestProviders> + <CaseComponent {...caseProps} /> + </TestProviders> + ); + expect( + wrapper + .find( + `div[data-test-subj="user-action-${data.comments[0].commentId}-avatar"] [data-test-subj="user-action-avatar"]` + ) + .first() + .prop('name') + ).toEqual(data.comments[0].createdBy.fullName); + + expect( + wrapper + .find( + `div[data-test-subj="user-action-${data.comments[0].commentId}"] [data-test-subj="user-action-title"] strong` + ) + .first() + .text() + ).toEqual(data.comments[0].createdBy.username); + + expect( + wrapper + .find( + `div[data-test-subj="user-action-${data.comments[0].commentId}"] [data-test-subj="markdown"]` + ) + .first() + .prop('source') + ).toEqual(data.comments[0].comment); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index df3e30a698b56..605f9e8fa1713 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback } from 'react'; import { EuiBadge, - EuiButton, - EuiButtonEmpty, EuiButtonToggle, EuiDescriptionList, EuiDescriptionListDescription, @@ -20,13 +18,11 @@ import { import styled, { css } from 'styled-components'; import * as i18n from './translations'; -import { DescriptionMarkdown } from '../description_md_editor'; import { Case } from '../../../../containers/case/types'; import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; import { getCaseUrl } from '../../../../components/link_to'; import { HeaderPage } from '../../../../components/header_page'; import { EditableTitle } from '../../../../components/header_page/editable_title'; -import { Markdown } from '../../../../components/markdown'; import { PropertyActions } from '../property_actions'; import { TagList } from '../tag_list'; import { useGetCase } from '../../../../containers/case/use_get_case'; @@ -34,6 +30,7 @@ import { UserActionTree } from '../user_action_tree'; import { UserList } from '../user_list'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; import { WrapperPage } from '../../../../components/wrapper_page'; +import { getTypedPayload } from '../../../../containers/case/utils'; import { WhitePageWrapper } from '../wrappers'; interface Props { @@ -53,95 +50,71 @@ const MyWrapper = styled(WrapperPage)` padding-bottom: 0; `; +const MyEuiFlexGroup = styled(EuiFlexGroup)` + height: 100%; +`; + export interface CaseProps { caseId: string; initialData: Case; - isLoading: boolean; } -export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoading }) => { - const [{ data }, dispatchUpdateCaseProperty] = useUpdateCase(caseId, initialData); - const [isEditDescription, setIsEditDescription] = useState(false); - const [isEditTags, setIsEditTags] = useState(false); - const [isCaseOpen, setIsCaseOpen] = useState(data.state === 'open'); - const [description, setDescription] = useState(data.description); - const [title, setTitle] = useState(data.title); - const [tags, setTags] = useState(data.tags); +export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => { + const [{ data, isLoading, updateKey }, dispatchUpdateCaseProperty] = useUpdateCase( + caseId, + initialData + ); const onUpdateField = useCallback( - async (updateKey: keyof Case, updateValue: string | string[]) => { - switch (updateKey) { + (newUpdateKey: keyof Case, updateValue: Case[keyof Case]) => { + switch (newUpdateKey) { case 'title': - if (updateValue.length > 0) { + const titleUpdate = getTypedPayload<string>(updateValue); + if (titleUpdate.length > 0) { dispatchUpdateCaseProperty({ updateKey: 'title', - updateValue, + updateValue: titleUpdate, }); } break; case 'description': - if (updateValue.length > 0) { + const descriptionUpdate = getTypedPayload<string>(updateValue); + if (descriptionUpdate.length > 0) { dispatchUpdateCaseProperty({ updateKey: 'description', - updateValue, + updateValue: descriptionUpdate, }); - setIsEditDescription(false); } break; case 'tags': - setTags(updateValue as string[]); - if (updateValue.length > 0) { + const tagsUpdate = getTypedPayload<string[]>(updateValue); + dispatchUpdateCaseProperty({ + updateKey: 'tags', + updateValue: tagsUpdate, + }); + break; + case 'state': + const stateUpdate = getTypedPayload<string>(updateValue); + if (data.state !== updateValue) { dispatchUpdateCaseProperty({ - updateKey: 'tags', - updateValue, + updateKey: 'state', + updateValue: stateUpdate, }); - setIsEditTags(false); } - break; default: return null; } }, - [dispatchUpdateCaseProperty, title] + [dispatchUpdateCaseProperty, data.state] ); - const onSetIsCaseOpen = useCallback(() => setIsCaseOpen(!isCaseOpen), [ - isCaseOpen, - setIsCaseOpen, - ]); - - useEffect(() => { - const caseState = isCaseOpen ? 'open' : 'closed'; - if (data.state !== caseState) { - dispatchUpdateCaseProperty({ - updateKey: 'state', - updateValue: caseState, - }); - } - }, [isCaseOpen]); - // TO DO refactor each of these const's into their own components const propertyActions = [ - { - iconType: 'documentEdit', - label: 'Edit description', - onClick: () => setIsEditDescription(true), - }, - { - iconType: 'securitySignalResolved', - label: 'Close case', - onClick: () => null, - }, { iconType: 'trash', label: 'Delete case', onClick: () => null, }, - { - iconType: 'importAction', - label: 'Push as ServiceNow incident', - onClick: () => null, - }, { iconType: 'popout', label: 'View ServiceNow incident', @@ -153,66 +126,13 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoa onClick: () => null, }, ]; - const userActions = [ - { - avatarName: data.createdBy.username, - title: ( - <EuiFlexGroup alignItems="baseline" gutterSize="none" justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - <p> - <strong>{`${data.createdBy.username}`}</strong> - {` ${i18n.ADDED_DESCRIPTION} `}{' '} - <FormattedRelativePreferenceDate value={data.createdAt} /> - {/* STEPH FIX come back and add label `on` */} - </p> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <PropertyActions propertyActions={propertyActions} /> - </EuiFlexItem> - </EuiFlexGroup> - ), - children: isEditDescription ? ( - <> - <DescriptionMarkdown - descriptionInputHeight={200} - initialDescription={data.description} - isLoading={isLoading} - onChange={updatedDescription => setDescription(updatedDescription)} - /> - <EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={true}> - <EuiFlexItem grow={false}> - <EuiButton - fill - isDisabled={isLoading} - isLoading={isLoading} - onClick={() => onUpdateField('description', description)} - > - {i18n.SUBMIT} - </EuiButton> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonEmpty onClick={() => setIsEditDescription(false)}> - {i18n.CANCEL} - </EuiButtonEmpty> - </EuiFlexItem> - </EuiFlexGroup> - </> - ) : ( - <Markdown raw={data.description} data-test-subj="case-view-description" /> - ), - }, - ]; - - const onSubmit = useCallback( - newTitle => { - onUpdateField('title', newTitle); - setTitle(newTitle); - }, - [title] + const onSubmit = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); + const toggleStateCase = useCallback( + e => onUpdateField('state', e.target.checked ? 'open' : 'closed'), + [onUpdateField] ); - - const titleNode = <EditableTitle isLoading={isLoading} title={title} onSubmit={onSubmit} />; + const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); return ( <> @@ -223,8 +143,14 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoa text: i18n.BACK_TO_ALL, }} data-test-subj="case-view-title" - titleNode={titleNode} - title={title} + titleNode={ + <EditableTitle + isLoading={isLoading && updateKey === 'title'} + title={data.title} + onSubmit={onSubmit} + /> + } + title={data.title} > <EuiFlexGroup gutterSize="l" justifyContent="flexEnd"> <EuiFlexItem grow={false}> @@ -234,7 +160,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoa <EuiDescriptionListTitle>{i18n.STATUS}</EuiDescriptionListTitle> <EuiDescriptionListDescription> <EuiBadge - color={isCaseOpen ? 'secondary' : 'danger'} + color={data.state === 'open' ? 'secondary' : 'danger'} data-test-subj="case-view-state" > {data.state} @@ -258,10 +184,11 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoa <EuiFlexItem> <EuiButtonToggle data-test-subj="toggle-case-state" - label={isCaseOpen ? 'Close case' : 'Reopen case'} - iconType={isCaseOpen ? 'checkInCircleFilled' : 'magnet'} - onChange={onSetIsCaseOpen} - isSelected={isCaseOpen} + iconType={data.state === 'open' ? 'checkInCircleFilled' : 'magnet'} + isLoading={isLoading && updateKey === 'state'} + isSelected={data.state === 'open'} + label={data.state === 'open' ? 'Close case' : 'Reopen case'} + onChange={toggleStateCase} /> </EuiFlexItem> <EuiFlexItem grow={false}> @@ -276,7 +203,11 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoa <MyWrapper> <EuiFlexGroup> <EuiFlexItem grow={6}> - <UserActionTree userActions={userActions} /> + <UserActionTree + data={data} + isLoadingDescription={isLoading && updateKey === 'description'} + onUpdateField={onUpdateField} + /> </EuiFlexItem> <EuiFlexItem grow={2}> <UserList @@ -286,14 +217,9 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoa /> <TagList data-test-subj="case-view-tag-list" - tags={tags} - iconAction={{ - 'aria-label': title, - iconType: 'pencil', - onSubmit: newTags => onUpdateField('tags', newTags), - onClick: isEdit => setIsEditTags(isEdit), - }} - isEditTags={isEditTags} + tags={data.tags} + onSubmit={onSubmitTags} + isLoading={isLoading && updateKey === 'tags'} /> </EuiFlexItem> </EuiFlexGroup> @@ -310,15 +236,15 @@ export const CaseView = React.memo(({ caseId }: Props) => { } if (isLoading) { return ( - <EuiFlexGroup justifyContent="center" alignItems="center"> + <MyEuiFlexGroup justifyContent="center" alignItems="center"> <EuiFlexItem grow={false}> <EuiLoadingSpinner size="xl" /> </EuiFlexItem> - </EuiFlexGroup> + </MyEuiFlexGroup> ); } - return <CaseComponent caseId={caseId} initialData={data} isLoading={isLoading} />; + return <CaseComponent caseId={caseId} initialData={data} />; }); CaseView.displayName = 'CaseView'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index f45c52533d2e7..82b5e771e2151 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -32,6 +32,18 @@ export const EDITED_DESCRIPTION = i18n.translate( } ); +export const EDIT_DESCRIPTION = i18n.translate('xpack.siem.case.caseView.edit.description', { + defaultMessage: 'Edit description', +}); + +export const EDIT_COMMENT = i18n.translate('xpack.siem.case.caseView.edit.comment', { + defaultMessage: 'Edit comment', +}); + +export const ON = i18n.translate('xpack.siem.case.caseView.actionLabel.on', { + defaultMessage: 'on', +}); + export const ADDED_COMMENT = i18n.translate('xpack.siem.case.caseView.actionLabel.addComment', { defaultMessage: 'added comment', }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx index 7d79e287b22e7..65d7256fd6e20 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx @@ -3,38 +3,48 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiButton, + EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, - EuiHorizontalRule, EuiLoadingSpinner, EuiPanel, } from '@elastic/eui'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { Redirect } from 'react-router-dom'; -import { Field, Form, getUseField, useForm } from '../../../shared_imports'; +import { Field, Form, getUseField, useForm, UseField } from '../../../../shared_imports'; import { NewCase } from '../../../../containers/case/types'; import { usePostCase } from '../../../../containers/case/use_post_case'; import { schema } from './schema'; import * as i18n from '../../translations'; import { SiemPageName } from '../../../home/types'; -import { DescriptionMarkdown } from '../description_md_editor'; +import { MarkdownEditorForm } from '../../../../components/markdown_editor/form'; export const CommonUseField = getUseField({ component: Field }); -const TagContainer = styled.div` - margin-top: 16px; +const ContainerBig = styled.div` + ${({ theme }) => css` + margin-top: ${theme.eui.euiSizeXL}; + `} +`; + +const Container = styled.div` + ${({ theme }) => css` + margin-top: ${theme.eui.euiSize}; + `} `; const MySpinner = styled(EuiLoadingSpinner)` position: absolute; top: 50%; left: 50%; + z-index: 99; `; export const Create = React.memo(() => { const [{ data, isLoading, newCase }, setFormData] = usePostCase(); + const [isCancel, setIsCancel] = useState(false); const { form } = useForm({ defaultValue: data, options: { stripEmptyFields: false }, @@ -43,14 +53,19 @@ export const Create = React.memo(() => { const onSubmit = useCallback(async () => { const { isValid, data: newData } = await form.submit(); - if (isValid) { + if (isValid && newData.description) { setFormData({ ...newData, isNew: true } as NewCase); + } else if (isValid && data.description) { + setFormData({ ...data, ...newData, isNew: true } as NewCase); } - }, [form]); + }, [form, data]); if (newCase && newCase.caseId) { return <Redirect to={`/${SiemPageName.case}/${newCase.caseId}`} />; } + if (isCancel) { + return <Redirect to={`/${SiemPageName.case}`} />; + } return ( <EuiPanel> {isLoading && <MySpinner size="xl" />} @@ -62,18 +77,11 @@ export const Create = React.memo(() => { 'data-test-subj': 'caseTitle', euiFieldProps: { fullWidth: false, + disabled: isLoading, }, - isDisabled: isLoading, }} /> - <DescriptionMarkdown - descriptionInputHeight={200} - formHook={true} - initialDescription={data.description} - isLoading={isLoading} - onChange={description => setFormData({ ...data, description })} - /> - <TagContainer> + <Container> <CommonUseField path="tags" componentProps={{ @@ -82,14 +90,24 @@ export const Create = React.memo(() => { euiFieldProps: { fullWidth: true, placeholder: '', + isDisabled: isLoading, }, + }} + /> + </Container> + <ContainerBig> + <UseField + path="description" + component={MarkdownEditorForm} + componentProps={{ + idAria: 'caseDescription', + dataTestSubj: 'caseDescription', isDisabled: isLoading, }} /> - </TagContainer> + </ContainerBig> </Form> - <> - <EuiHorizontalRule margin="m" /> + <Container> <EuiFlexGroup alignItems="center" justifyContent="flexEnd" @@ -97,12 +115,23 @@ export const Create = React.memo(() => { responsive={false} > <EuiFlexItem grow={false}> - <EuiButton fill isDisabled={isLoading} isLoading={isLoading} onClick={onSubmit}> - {i18n.SUBMIT} + <EuiButtonEmpty size="s" onClick={() => setIsCancel(true)} iconType="cross"> + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton + fill + iconType="plusInCircle" + isDisabled={isLoading} + isLoading={isLoading} + onClick={onSubmit} + > + {i18n.CREATE_CASE} </EuiButton> </EuiFlexItem> </EuiFlexGroup> - </> + </Container> </EuiPanel> ); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx index 1b5df72a6671c..c81a31f0d4f3f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports'; +import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../../shared_imports'; import { OptionalFieldLabel } from './optional_field_label'; import * as i18n from '../../translations'; @@ -13,7 +13,7 @@ const { emptyField } = fieldValidators; export const schema: FormSchema = { title: { type: FIELD_TYPES.TEXT, - label: i18n.CASE_TITLE, + label: i18n.NAME, validations: [ { validator: emptyField(i18n.TITLE_REQUIRED), @@ -21,7 +21,7 @@ export const schema: FormSchema = { ], }, description: { - type: FIELD_TYPES.TEXTAREA, + label: i18n.DESCRIPTION, validations: [ { validator: emptyField(i18n.DESCRIPTION_REQUIRED), diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx deleted file mode 100644 index 44062a5a1d589..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx +++ /dev/null @@ -1,111 +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 { EuiFlexItem, EuiPanel, EuiTabbedContent, EuiTextArea } from '@elastic/eui'; -import React, { useState } from 'react'; -import styled from 'styled-components'; - -import { Markdown } from '../../../../components/markdown'; -import * as i18n from '../../translations'; -import { MarkdownHint } from '../../../../components/markdown/markdown_hint'; -import { CommonUseField } from '../create'; - -const TextArea = styled(EuiTextArea)<{ height: number }>` - min-height: ${({ height }) => `${height}px`}; - width: 100%; -`; - -TextArea.displayName = 'TextArea'; - -const DescriptionContainer = styled.div` - margin-top: 15px; - margin-bottom: 15px; -`; - -const DescriptionMarkdownTabs = styled(EuiTabbedContent)` - width: 100%; -`; - -DescriptionMarkdownTabs.displayName = 'DescriptionMarkdownTabs'; - -const MarkdownContainer = styled(EuiPanel)<{ height: number }>` - height: ${({ height }) => height}px; - overflow: auto; -`; - -MarkdownContainer.displayName = 'MarkdownContainer'; - -/** An input for entering a new case description */ -export const DescriptionMarkdown = React.memo<{ - descriptionInputHeight: number; - initialDescription: string; - isLoading: boolean; - formHook?: boolean; - onChange: (description: string) => void; -}>(({ initialDescription, isLoading, descriptionInputHeight, onChange, formHook = false }) => { - const [description, setDescription] = useState(initialDescription); - const tabs = [ - { - id: 'description', - name: i18n.DESCRIPTION, - content: formHook ? ( - <CommonUseField - path="description" - onChange={e => { - setDescription(e as string); - onChange(e as string); - }} - componentProps={{ - idAria: 'caseDescription', - 'data-test-subj': 'caseDescription', - isDisabled: isLoading, - spellcheck: false, - }} - /> - ) : ( - <TextArea - onChange={e => { - setDescription(e.target.value); - onChange(e.target.value); - }} - fullWidth={true} - height={descriptionInputHeight} - aria-label={i18n.DESCRIPTION} - disabled={isLoading} - spellCheck={false} - value={description} - /> - ), - }, - { - id: 'preview', - name: i18n.PREVIEW, - content: ( - <MarkdownContainer - data-test-subj="markdown-container" - height={descriptionInputHeight} - paddingSize="s" - > - <Markdown raw={description} /> - </MarkdownContainer> - ), - }, - ]; - return ( - <DescriptionContainer> - <DescriptionMarkdownTabs - data-test-subj="new-description-tabs" - tabs={tabs} - initialSelectedTab={tabs[0]} - /> - <EuiFlexItem grow={true}> - <MarkdownHint show={description.trim().length > 0} /> - </EuiFlexItem> - </DescriptionContainer> - ); -}); - -DescriptionMarkdown.displayName = 'DescriptionMarkdown'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx index 6634672cb6a77..3513d4de12aa1 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiText, EuiHorizontalRule, @@ -14,24 +14,18 @@ import { EuiButton, EuiButtonEmpty, EuiButtonIcon, + EuiLoadingSpinner, } from '@elastic/eui'; import styled, { css } from 'styled-components'; import * as i18n from '../../translations'; -import { Form, useForm } from '../../../shared_imports'; +import { Form, useForm } from '../../../../shared_imports'; import { schema } from './schema'; import { CommonUseField } from '../create'; -interface IconAction { - 'aria-label': string; - iconType: string; - onClick: (b: boolean) => void; - onSubmit: (a: string[]) => void; -} - interface TagListProps { + isLoading: boolean; + onSubmit: (a: string[]) => void; tags: string[]; - iconAction?: IconAction; - isEditTags?: boolean; } const MyFlexGroup = styled(EuiFlexGroup)` @@ -43,37 +37,35 @@ const MyFlexGroup = styled(EuiFlexGroup)` `} `; -export const TagList = React.memo(({ tags, isEditTags, iconAction }: TagListProps) => { +export const TagList = React.memo(({ isLoading, onSubmit, tags }: TagListProps) => { const { form } = useForm({ defaultValue: { tags }, options: { stripEmptyFields: false }, schema, }); + const [isEditTags, setIsEditTags] = useState(false); - const onSubmit = useCallback(async () => { + const onSubmitTags = useCallback(async () => { const { isValid, data: newData } = await form.submit(); - if (isValid && iconAction) { - iconAction.onSubmit(newData.tags); - iconAction.onClick(false); + if (isValid && newData.tags) { + onSubmit(newData.tags); + setIsEditTags(false); } - }, [form]); + }, [form, onSubmit]); - const onActionClick = useCallback( - (cb: (b: boolean) => void, onClickBool: boolean) => cb(onClickBool), - [iconAction] - ); return ( <EuiText> <EuiFlexGroup alignItems="center" gutterSize="xs" justifyContent="spaceBetween"> <EuiFlexItem grow={false}> <h4>{i18n.TAGS}</h4> </EuiFlexItem> - {iconAction && ( + {isLoading && <EuiLoadingSpinner />} + {!isLoading && ( <EuiFlexItem grow={false}> <EuiButtonIcon - aria-label={iconAction['aria-label']} - iconType={iconAction.iconType} - onClick={() => onActionClick(iconAction.onClick, true)} + aria-label={'tags'} + iconType={'pencil'} + onClick={setIsEditTags.bind(null, true)} /> </EuiFlexItem> )} @@ -88,7 +80,7 @@ export const TagList = React.memo(({ tags, isEditTags, iconAction }: TagListProp <EuiBadge color="hollow">{tag}</EuiBadge> </EuiFlexItem> ))} - {isEditTags && iconAction && ( + {isEditTags && ( <EuiFlexGroup direction="column"> <EuiFlexItem> <Form form={form}> @@ -106,14 +98,22 @@ export const TagList = React.memo(({ tags, isEditTags, iconAction }: TagListProp </Form> </EuiFlexItem> <EuiFlexItem> - <EuiButton fill onClick={onSubmit}> - {i18n.SUBMIT} - </EuiButton> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonEmpty onClick={() => onActionClick(iconAction.onClick, false)}> - {i18n.CANCEL} - </EuiButtonEmpty> + <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiButton color="secondary" fill iconType="save" onClick={onSubmitTags} size="s"> + {i18n.SAVE} + </EuiButton> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + iconType="cross" + onClick={setIsEditTags.bind(null, false)} + size="s" + > + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> </EuiFlexItem> </EuiFlexGroup> )} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx index dfc9c61cd5f0c..26a89408069fb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.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 { FormSchema } from '../../../shared_imports'; +import { FormSchema } from '../../../../shared_imports'; import { schema as createSchema } from '../create/schema'; export const schema: FormSchema = { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 8df98a4cef0e8..6599151f9d4fd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -4,18 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { ReactNode } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiAvatar, EuiPanel, EuiText } from '@elastic/eui'; +import React, { ReactNode, useCallback, useMemo, useState } from 'react'; +import { EuiFlexGroup } from '@elastic/eui'; import styled, { css } from 'styled-components'; +import * as i18n from '../case_view/translations'; + +import { Case } from '../../../../containers/case/types'; +import { useUpdateComment } from '../../../../containers/case/use_update_comment'; +import { UserActionItem } from './user_action_item'; +import { UserActionMarkdown } from './user_action_markdown'; +import { AddComment } from '../add_comment'; export interface UserActionItem { avatarName: string; children?: ReactNode; - title: ReactNode; + skipPanel?: boolean; + title?: ReactNode; } export interface UserActionTreeProps { - userActions: UserActionItem[]; + data: Case; + isLoadingDescription: boolean; + onUpdateField: (updateKey: keyof Case, updateValue: string | string[]) => void; } const UserAction = styled(EuiFlexGroup)` @@ -48,35 +58,110 @@ const UserAction = styled(EuiFlexGroup)` border-bottom: ${theme.eui.euiBorderThin}; border-radius: ${theme.eui.euiBorderRadius} ${theme.eui.euiBorderRadius} 0 0; } - .userAction__content { - padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeL}; - } .euiText--small * { margin-bottom: 0; } `} `; -const renderUserActions = (userActions: UserActionItem[]) => { - return userActions.map(({ avatarName, children, title }, key) => ( - <UserAction key={key} gutterSize={'none'}> - <EuiFlexItem grow={false}> - <EuiAvatar className="userAction__circle" name={avatarName} /> - </EuiFlexItem> - <EuiFlexItem> - <EuiPanel className="userAction__panel" paddingSize="none"> - <EuiText size="s" className="userAction__title"> - {title} - </EuiText> - {children && <div className="userAction__content">{children}</div>} - </EuiPanel> - </EuiFlexItem> - </UserAction> - )); -}; +const DescriptionId = 'description'; +const NewId = 'newComent'; + +export const UserActionTree = React.memo( + ({ data, onUpdateField, isLoadingDescription }: UserActionTreeProps) => { + const [{ data: comments, isLoadingIds }, dispatchUpdateComment] = useUpdateComment( + data.comments + ); + + const [manageMarkdownEditIds, setManangeMardownEditIds] = useState<string[]>([]); + + const handleManageMarkdownEditId = useCallback( + (id: string) => { + if (!manageMarkdownEditIds.includes(id)) { + setManangeMardownEditIds([...manageMarkdownEditIds, id]); + } else { + setManangeMardownEditIds(manageMarkdownEditIds.filter(myId => id !== myId)); + } + }, + [manageMarkdownEditIds] + ); + + const handleSaveComment = useCallback( + (id: string, content: string) => { + handleManageMarkdownEditId(id); + dispatchUpdateComment(id, content); + }, + [handleManageMarkdownEditId, dispatchUpdateComment] + ); + + const MarkdownDescription = useMemo( + () => ( + <UserActionMarkdown + id={DescriptionId} + content={data.description} + isEditable={manageMarkdownEditIds.includes(DescriptionId)} + onSaveContent={(content: string) => { + handleManageMarkdownEditId(DescriptionId); + onUpdateField(DescriptionId, content); + }} + onChangeEditable={handleManageMarkdownEditId} + /> + ), + [data.description, handleManageMarkdownEditId, manageMarkdownEditIds, onUpdateField] + ); + + const MarkdownNewComment = useMemo(() => <AddComment caseId={data.caseId} />, [data.caseId]); -export const UserActionTree = React.memo(({ userActions }: UserActionTreeProps) => ( - <div>{renderUserActions(userActions)}</div> -)); + return ( + <UserAction data-test-subj="user-action-description" gutterSize={'none'}> + <UserActionItem + createdAt={data.createdAt} + id={DescriptionId} + isEditable={manageMarkdownEditIds.includes(DescriptionId)} + isLoading={isLoadingDescription} + labelAction={i18n.EDIT_DESCRIPTION} + labelTitle={i18n.ADDED_DESCRIPTION} + fullName={data.createdBy.fullName ?? data.createdBy.username} + markdown={MarkdownDescription} + onEdit={handleManageMarkdownEditId.bind(null, DescriptionId)} + userName={data.createdBy.username} + /> + {comments.map(comment => ( + <UserActionItem + key={comment.commentId} + createdAt={comment.createdAt} + id={comment.commentId} + isEditable={manageMarkdownEditIds.includes(comment.commentId)} + isLoading={isLoadingIds.includes(comment.commentId)} + labelAction={i18n.EDIT_COMMENT} + labelTitle={i18n.ADDED_COMMENT} + fullName={comment.createdBy.fullName ?? comment.createdBy.username} + markdown={ + <UserActionMarkdown + id={comment.commentId} + content={comment.comment} + isEditable={manageMarkdownEditIds.includes(comment.commentId)} + onChangeEditable={handleManageMarkdownEditId} + onSaveContent={handleSaveComment.bind(null, comment.commentId)} + /> + } + onEdit={handleManageMarkdownEditId.bind(null, comment.commentId)} + userName={comment.createdBy.username} + /> + ))} + <UserActionItem + createdAt={new Date().toISOString()} + id={NewId} + isEditable={true} + isLoading={isLoadingIds.includes(NewId)} + fullName="to be determined" + markdown={MarkdownNewComment} + onEdit={handleManageMarkdownEditId.bind(null, NewId)} + userName="to be determined" + /> + </UserAction> + ); + } +); UserActionTree.displayName = 'UserActionTree'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_avatar.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_avatar.tsx new file mode 100644 index 0000000000000..f3276bd50e72c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_avatar.tsx @@ -0,0 +1,18 @@ +/* + * 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 { EuiAvatar } from '@elastic/eui'; +import React from 'react'; + +interface UserActionAvatarProps { + name: string; +} + +export const UserActionAvatar = ({ name }: UserActionAvatarProps) => { + return ( + <EuiAvatar data-test-subj={`user-action-avatar`} className="userAction__circle" name={name} /> + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx new file mode 100644 index 0000000000000..816e500827590 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexItem, EuiPanel } from '@elastic/eui'; +import React from 'react'; + +import { UserActionAvatar } from './user_action_avatar'; +import { UserActionTitle } from './user_action_title'; + +interface UserActionItemProps { + createdAt: string; + id: string; + isEditable: boolean; + isLoading: boolean; + labelAction?: string; + labelTitle?: string; + fullName: string; + markdown: React.ReactNode; + onEdit: (id: string) => void; + userName: string; +} + +export const UserActionItem = ({ + createdAt, + id, + isEditable, + isLoading, + labelAction, + labelTitle, + fullName, + markdown, + onEdit, + userName, +}: UserActionItemProps) => ( + <> + <EuiFlexItem data-test-subj={`user-action-${id}-avatar`} grow={false}> + <UserActionAvatar name={fullName ?? userName} /> + </EuiFlexItem> + <EuiFlexItem data-test-subj={`user-action-${id}`}> + {isEditable && markdown} + {!isEditable && ( + <EuiPanel className="userAction__panel" paddingSize="none"> + <UserActionTitle + createdAt={createdAt} + id={id} + isLoading={isLoading} + labelAction={labelAction ?? ''} + labelTitle={labelTitle ?? ''} + userName={userName} + onEdit={onEdit} + /> + {markdown} + </EuiPanel> + )} + </EuiFlexItem> + </> +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_markdown.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_markdown.tsx new file mode 100644 index 0000000000000..6a50bf24e9d7e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_markdown.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import styled, { css } from 'styled-components'; + +import { MarkdownEditor } from '../../../../components/markdown_editor'; +import * as i18n from '../case_view/translations'; +import { Markdown } from '../../../../components/markdown'; + +const ContentWrapper = styled.div` + ${({ theme }) => css` + padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeL}; + `} +`; + +interface UserActionMarkdownProps { + content: string; + id: string; + isEditable: boolean; + onChangeEditable: (id: string) => void; + onSaveContent: (content: string) => void; +} + +export const UserActionMarkdown = ({ + id, + content, + isEditable, + onChangeEditable, + onSaveContent, +}: UserActionMarkdownProps) => { + const [myContent, setMyContent] = useState(content); + + const handleCancelAction = useCallback(() => { + onChangeEditable(id); + }, [id, onChangeEditable]); + + const handleSaveAction = useCallback(() => { + if (myContent !== content) { + onSaveContent(content); + } + onChangeEditable(id); + }, [content, id, myContent, onChangeEditable, onSaveContent]); + + const handleOnChange = useCallback(() => { + if (myContent !== content) { + setMyContent(content); + } + }, [content, myContent]); + + const renderButtons = useCallback( + ({ cancelAction, saveAction }) => { + return ( + <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty size="s" onClick={cancelAction} iconType="cross"> + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton color="secondary" fill iconType="save" onClick={saveAction} size="s"> + {i18n.SAVE} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + ); + }, + [handleCancelAction, handleSaveAction] + ); + + return isEditable ? ( + <MarkdownEditor + footerContentRight={renderButtons({ + cancelAction: handleCancelAction, + saveAction: handleSaveAction, + })} + initialContent={content} + onChange={handleOnChange} + /> + ) : ( + <ContentWrapper> + <Markdown raw={content} data-test-subj="case-view-description" /> + </ContentWrapper> + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx new file mode 100644 index 0000000000000..6ad60fb9f963e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; + +import { + FormattedRelativePreferenceDate, + FormattedRelativePreferenceLabel, +} from '../../../../components/formatted_date'; +import * as i18n from '../case_view/translations'; +import { PropertyActions } from '../property_actions'; + +const MySpinner = styled(EuiLoadingSpinner)` + .euiLoadingSpinner { + margin-top: 1px; // yes it matters! + } +`; + +interface UserActionTitleProps { + createdAt: string; + id: string; + isLoading: boolean; + labelAction: string; + labelTitle: string; + userName: string; + onEdit: (id: string) => void; +} + +export const UserActionTitle = ({ + createdAt, + id, + isLoading, + labelAction, + labelTitle, + userName, + onEdit, +}: UserActionTitleProps) => { + const propertyActions = useMemo(() => { + return [ + { + iconType: 'documentEdit', + label: labelAction, + onClick: () => onEdit(id), + }, + ]; + }, [id, onEdit]); + return ( + <EuiText size="s" className="userAction__title" data-test-subj={`user-action-title`}> + <EuiFlexGroup alignItems="baseline" gutterSize="none" justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <p> + <strong>{userName}</strong> + {` ${labelTitle} `} + <FormattedRelativePreferenceLabel value={createdAt} preferenceLabel={`${i18n.ON} `} /> + <FormattedRelativePreferenceDate value={createdAt} /> + </p> + </EuiFlexItem> + <EuiFlexItem grow={false}> + {isLoading && <MySpinner />} + {!isLoading && <PropertyActions propertyActions={propertyActions} />} + </EuiFlexItem> + </EuiFlexGroup> + </EuiText> + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx index 33e0a9541c5b4..abb49122dc142 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx @@ -32,12 +32,12 @@ const MyFlexGroup = styled(EuiFlexGroup)` `; const renderUsers = (users: ElasticUser[]) => { - return users.map(({ username }, key) => ( + return users.map(({ fullName, username }, key) => ( <MyFlexGroup key={key} justifyContent="spaceBetween"> <EuiFlexItem grow={false}> <EuiFlexGroup gutterSize="xs"> <EuiFlexItem> - <MyAvatar name={username} /> + <MyAvatar name={fullName ? fullName : username} /> </EuiFlexItem> <EuiFlexItem> <p> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 265af0bde547f..5f0509586fc81 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -14,8 +14,8 @@ export const CANCEL = i18n.translate('xpack.siem.case.caseView.cancel', { defaultMessage: 'Cancel', }); -export const CASE_TITLE = i18n.translate('xpack.siem.case.caseView.caseTitle', { - defaultMessage: 'Case Title', +export const NAME = i18n.translate('xpack.siem.case.caseView.name', { + defaultMessage: 'Name', }); export const CREATED_AT = i18n.translate('xpack.siem.case.caseView.createdAt', { @@ -45,6 +45,13 @@ export const DESCRIPTION_REQUIRED = i18n.translate( } ); +export const COMMENT_REQUIRED = i18n.translate( + 'xpack.siem.case.caseView.commentFieldRequiredError', + { + defaultMessage: 'A comment is required.', + } +); + export const EDIT = i18n.translate('xpack.siem.case.caseView.edit', { defaultMessage: 'Edit', }); @@ -58,15 +65,11 @@ export const LAST_UPDATED = i18n.translate('xpack.siem.case.caseView.updatedAt', }); export const PAGE_SUBTITLE = i18n.translate('xpack.siem.case.caseView.pageSubtitle', { - defaultMessage: 'Case Workflow Management within the Elastic SIEM', + defaultMessage: 'Cases within the Elastic SIEM', }); export const PAGE_TITLE = i18n.translate('xpack.siem.case.pageTitle', { - defaultMessage: 'Case Workflows', -}); - -export const PREVIEW = i18n.translate('xpack.siem.case.caseView.preview', { - defaultMessage: 'Preview', + defaultMessage: 'Cases', }); export const STATE = i18n.translate('xpack.siem.case.caseView.state', { @@ -77,6 +80,10 @@ export const SUBMIT = i18n.translate('xpack.siem.case.caseView.submit', { defaultMessage: 'Submit', }); +export const CREATE_CASE = i18n.translate('xpack.siem.case.caseView.createCase', { + defaultMessage: 'Create case', +}); + export const TAGS = i18n.translate('xpack.siem.case.caseView.tags', { defaultMessage: 'Tags', }); @@ -104,3 +111,18 @@ export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate( export const CONFIGURE_CASES_BUTTON = i18n.translate('xpack.siem.case.configureCasesButton', { defaultMessage: 'Configure cases', }); + +export const ADD_COMMENT = i18n.translate('xpack.siem.case.caseView.comment.addComment', { + defaultMessage: 'Add comment', +}); + +export const ADD_COMMENT_HELP_TEXT = i18n.translate( + 'xpack.siem.case.caseView.comment.addCommentHelpText', + { + defaultMessage: 'Add a new comment...', + } +); + +export const SAVE = i18n.translate('xpack.siem.case.caseView.description.save', { + defaultMessage: 'Save', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx index cc5e9b38eb2f8..abbaa6d6192ee 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx @@ -18,7 +18,7 @@ import React, { ChangeEvent, useCallback, useEffect, useState, useRef } from 're import styled from 'styled-components'; import * as RuleI18n from '../../translations'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; interface AddItemProps { addText: string; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx index 1cc7bba5558db..f921c29c06ab0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx @@ -19,7 +19,7 @@ import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/searc import { useKibana } from '../../../../../lib/kibana'; import { IMitreEnterpriseAttack } from '../../types'; import { FieldValueTimeline } from '../pick_timeline'; -import { FormSchema } from '../../../../shared_imports'; +import { FormSchema } from '../../../../../shared_imports'; import { ListItems } from './types'; import { buildQueryBarDescription, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx index b49126c8c0fe0..e87dba251ed6d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx @@ -20,7 +20,7 @@ import styled from 'styled-components'; import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; import * as Rulei18n from '../../translations'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; import { threatDefault } from '../step_about_rule/default_value'; import { IMitreEnterpriseAttack } from '../../types'; import { MyAddItemButton } from '../add_item_form'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx index 56cb02c9ec817..923ec3a7f0066 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx @@ -8,7 +8,7 @@ import { EuiFormRow } from '@elastic/eui'; import React, { useCallback, useEffect, useState } from 'react'; import { SearchTimelineSuperSelect } from '../../../../../components/timeline/search_super_select'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; export interface FieldValueTimeline { id: string | null; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx index fbe854c1ee346..5886a76182eec 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx @@ -29,7 +29,7 @@ import { convertKueryToElasticSearchQuery } from '../../../../../lib/keury'; import { useKibana } from '../../../../../lib/kibana'; import { TimelineModel } from '../../../../../store/timeline/model'; import { useSavedQueryServices } from '../../../../../utils/saved_query_services'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; import * as i18n from './translations'; export interface FieldValueQueryBar { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx index ffb6c4eda3243..1b7d17016f83c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx @@ -16,7 +16,7 @@ import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; import * as I18n from './translations'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index 431d793d6e68a..d93c057506ca7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -30,7 +30,7 @@ import { getUseField, UseField, useForm, -} from '../../../../shared_imports'; +} from '../../../../../shared_imports'; import { defaultRiskScoreBySeverity, severityOptions, SeverityValue } from './data'; import { stepAboutDefaultValue } from './default_value'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index 27887bcbbe600..42cf1e0d95649 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx @@ -13,7 +13,7 @@ import { FormSchema, ValidationFunc, ERROR_CODE, -} from '../../../../shared_imports'; +} from '../../../../../shared_imports'; import { isMitreAttackInvalid } from '../mitre/helpers'; import { OptionalFieldLabel } from '../optional_field_label'; import { isUrlInvalid } from './helpers'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index 773eb44efb26c..837bc79e968e8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -33,7 +33,7 @@ import { getUseField, UseField, useForm, -} from '../../../../shared_imports'; +} from '../../../../../shared_imports'; import { schema } from './schema'; import * as i18n from './translations'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index bb178d7197069..e202ff030cd90 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -17,7 +17,7 @@ import { fieldValidators, FormSchema, ValidationFunc, -} from '../../../../shared_imports'; +} from '../../../../../shared_imports'; import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY, INDEX_HELPER_TEXT } from './translations'; const { emptyField } = fieldValidators; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx index 2e2c7e068dd85..e9632966fdfaf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx @@ -12,7 +12,7 @@ import { setFieldValue } from '../../helpers'; import { RuleStep, RuleStepProps, ScheduleStepRule } from '../../types'; import { StepRuleDescription } from '../description_step'; import { ScheduleItem } from '../schedule_item_form'; -import { Form, UseField, useForm } from '../../../../shared_imports'; +import { Form, UseField, useForm } from '../../../../../shared_imports'; import { StepContentWrapper } from '../step_content_wrapper'; import { schema } from './schema'; import * as I18n from './translations'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx index 9932e4f6ef435..8fbfdf5f25a51 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { OptionalFieldLabel } from '../optional_field_label'; -import { FormSchema } from '../../../../shared_imports'; +import { FormSchema } from '../../../../../shared_imports'; export const schema: FormSchema = { interval: { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx index c985045b1897b..d816c7e867057 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx @@ -17,7 +17,7 @@ import { displaySuccessToast, useStateToaster } from '../../../../components/toa import { SpyRoute } from '../../../../utils/route/spy_routes'; import { useUserInfo } from '../../components/user_info'; import { AccordionTitle } from '../components/accordion_title'; -import { FormData, FormHook } from '../../../shared_imports'; +import { FormData, FormHook } from '../../../../shared_imports'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx index 0fac4641e54a7..5e0e4223e3e27 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx @@ -26,7 +26,7 @@ import { displaySuccessToast, useStateToaster } from '../../../../components/toa import { SpyRoute } from '../../../../utils/route/spy_routes'; import { useUserInfo } from '../../components/user_info'; import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page'; -import { FormHook, FormData } from '../../../shared_imports'; +import { FormHook, FormData } from '../../../../shared_imports'; import { StepPanel } from '../components/step_panel'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index 3fab456d856ca..85f3bcbd236e9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -11,7 +11,7 @@ import { useLocation } from 'react-router-dom'; import { Filter } from '../../../../../../../../src/plugins/data/public'; import { Rule } from '../../../containers/detection_engine/rules'; -import { FormData, FormHook, FormSchema } from '../../shared_imports'; +import { FormData, FormHook, FormSchema } from '../../../shared_imports'; import { AboutStepRule, DefineStepRule, IMitreEnterpriseAttack, ScheduleStepRule } from './types'; interface GetStepsData { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index b2650dcc2b77e..34df20de1e461 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -6,7 +6,7 @@ import { Filter } from '../../../../../../../../src/plugins/data/common'; import { FieldValueQueryBar } from './components/query_bar'; -import { FormData, FormHook } from '../../shared_imports'; +import { FormData, FormHook } from '../../../shared_imports'; import { FieldValueTimeline } from './components/pick_timeline'; export interface EuiBasicTableSortTypes { diff --git a/x-pack/legacy/plugins/siem/public/pages/home/translations.ts b/x-pack/legacy/plugins/siem/public/pages/home/translations.ts index 581c81d9f98a0..f2bcaa07b1a25 100644 --- a/x-pack/legacy/plugins/siem/public/pages/home/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/home/translations.ts @@ -27,5 +27,5 @@ export const TIMELINES = i18n.translate('xpack.siem.navigation.timelines', { }); export const CASE = i18n.translate('xpack.siem.navigation.case', { - defaultMessage: 'Case', + defaultMessage: 'Cases', }); diff --git a/x-pack/legacy/plugins/siem/public/pages/shared_imports.ts b/x-pack/legacy/plugins/siem/public/shared_imports.ts similarity index 52% rename from x-pack/legacy/plugins/siem/public/pages/shared_imports.ts rename to x-pack/legacy/plugins/siem/public/shared_imports.ts index a41f121b36926..edd7812b3bd16 100644 --- a/x-pack/legacy/plugins/siem/public/pages/shared_imports.ts +++ b/x-pack/legacy/plugins/siem/public/shared_imports.ts @@ -17,7 +17,7 @@ export { UseField, useForm, ValidationFunc, -} from '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; -export { Field } from '../../../../../../src/plugins/es_ui_shared/static/forms/components'; -export { fieldValidators } from '../../../../../../src/plugins/es_ui_shared/static/forms/helpers'; -export { ERROR_CODE } from '../../../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; +} from '../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; +export { Field } from '../../../../../src/plugins/es_ui_shared/static/forms/components'; +export { fieldValidators } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers'; +export { ERROR_CODE } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts index 5bfd121691ab4..6b4e3c194eb82 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts @@ -28,6 +28,7 @@ describe('UPDATE comment', () => { }, body: { comment: 'Update my comment', + version: 'WzEsMV0=', }, }); @@ -37,6 +38,24 @@ describe('UPDATE comment', () => { expect(response.status).toEqual(200); expect(response.payload.comment).toEqual('Update my comment'); }); + it(`Fails with 409 if version does not match`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comment/{id}', + method: 'patch', + params: { + id: 'mock-comment-1', + }, + body: { + comment: 'Update my comment', + version: 'badv=', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(409); + }); it(`Returns an error if updateComment throws`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases/comment/{id}', diff --git a/x-pack/plugins/case/server/routes/api/schema.ts b/x-pack/plugins/case/server/routes/api/schema.ts index 468abc8e7226f..765f9c722219f 100644 --- a/x-pack/plugins/case/server/routes/api/schema.ts +++ b/x-pack/plugins/case/server/routes/api/schema.ts @@ -15,6 +15,11 @@ export const NewCommentSchema = schema.object({ comment: schema.string(), }); +export const UpdateCommentArguments = schema.object({ + comment: schema.string(), + version: schema.string(), +}); + export const CommentSchema = schema.object({ comment: schema.string(), created_at: schema.string(), diff --git a/x-pack/plugins/case/server/routes/api/update_comment.ts b/x-pack/plugins/case/server/routes/api/update_comment.ts index 815f44a14e2e7..9f99253f76629 100644 --- a/x-pack/plugins/case/server/routes/api/update_comment.ts +++ b/x-pack/plugins/case/server/routes/api/update_comment.ts @@ -5,9 +5,12 @@ */ import { schema } from '@kbn/config-schema'; +import { SavedObject } from 'kibana/server'; +import Boom from 'boom'; import { wrapError } from './utils'; -import { NewCommentSchema } from './schema'; +import { UpdateCommentArguments } from './schema'; import { RouteDeps } from '.'; +import { CommentAttributes } from './types'; export function initUpdateCommentApi({ caseService, router }: RouteDeps) { router.patch( @@ -17,20 +20,45 @@ export function initUpdateCommentApi({ caseService, router }: RouteDeps) { params: schema.object({ id: schema.string(), }), - body: NewCommentSchema, + body: UpdateCommentArguments, }, }, async (context, request, response) => { + let theComment: SavedObject<CommentAttributes>; + try { + theComment = await caseService.getComment({ + client: context.core.savedObjects.client, + commentId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + if (request.body.version !== theComment.version) { + return response.customError( + wrapError( + Boom.conflict( + 'This comment has been updated. Please refresh before saving additional updates.' + ) + ) + ); + } + if (request.body.comment === theComment.attributes.comment) { + return response.customError( + wrapError(Boom.notAcceptable('Comment is identical to current version.')) + ); + } try { const updatedComment = await caseService.updateComment({ client: context.core.savedObjects.client, commentId: request.params.id, updatedAttributes: { - ...request.body, + comment: request.body.comment, updated_at: new Date().toISOString(), }, }); - return response.ok({ body: updatedComment.attributes }); + return response.ok({ + body: { ...updatedComment.attributes, version: updatedComment.version }, + }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 21500c4db9c34..a97cf608abc71 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10986,7 +10986,6 @@ "xpack.siem.editDataProvider.doesNotExistLabel": "存在しません", "xpack.siem.editDataProvider.existsLabel": "存在する", "xpack.siem.editDataProvider.fieldLabel": "フィールド", - "xpack.siem.editDataProvider.fieldPlaceholder": "フィールドを選択", "xpack.siem.editDataProvider.isLabel": "が", "xpack.siem.editDataProvider.isNotLabel": "is not", "xpack.siem.editDataProvider.operatorLabel": "演算子", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c9e7ea1ec80de..e6055680e1240 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10986,7 +10986,6 @@ "xpack.siem.editDataProvider.doesNotExistLabel": "不存在", "xpack.siem.editDataProvider.existsLabel": "存在", "xpack.siem.editDataProvider.fieldLabel": "字段", - "xpack.siem.editDataProvider.fieldPlaceholder": "选择字段", "xpack.siem.editDataProvider.isLabel": "是", "xpack.siem.editDataProvider.isNotLabel": "不是", "xpack.siem.editDataProvider.operatorLabel": "运算符", From 2378d8a0fdd425920d8321aaae729e0dee95013b Mon Sep 17 00:00:00 2001 From: Nathan L Smith <nathan.smith@elastic.co> Date: Mon, 2 Mar 2020 17:36:43 -0600 Subject: [PATCH 24/36] Service map language icons (#58633) Add icons as described in #56235. Also: * Add double-border and ghost "shadow" on nodes * Add framework name capability to popover metrics --- .../app/ServiceMap/Cytoscape.stories.tsx | 188 ++++++++++++++---- .../app/ServiceMap/Popover/Contents.tsx | 6 +- .../ServiceMap/Popover/Popover.stories.tsx | 1 + .../Popover/ServiceMetricFetcher.tsx | 10 +- .../ServiceMap/Popover/ServiceMetricList.tsx | 34 ++-- .../app/ServiceMap/cytoscapeOptions.ts | 10 +- .../app/ServiceMap/get_cytoscape_elements.ts | 3 +- .../public/components/app/ServiceMap/icons.ts | 55 +++-- .../app/ServiceMap/icons/default.svg | 3 + .../app/ServiceMap/icons/dot-net.svg | 127 ++++++++++++ .../components/app/ServiceMap/icons/go.svg | 11 + .../components/app/ServiceMap/icons/java.svg | 7 + .../app/ServiceMap/icons/nodejs.svg | 46 +++++ .../components/app/ServiceMap/icons/php.svg | 18 ++ .../app/ServiceMap/icons/python.svg | 19 ++ .../components/app/ServiceMap/icons/ruby.svg | 125 ++++++++++++ .../components/app/ServiceMap/icons/rumjs.svg | 3 + .../elasticsearch_fieldnames.test.ts.snap | 6 + .../apm/common/elasticsearch_fieldnames.ts | 1 + x-pack/plugins/apm/common/service_map.ts | 1 + .../server/lib/service_map/get_service_map.ts | 14 +- x-pack/plugins/apm/server/lib/services/map.ts | 88 -------- 22 files changed, 599 insertions(+), 177 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/default.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/go.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/java.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/php.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/python.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg delete mode 100644 x-pack/plugins/apm/server/lib/services/map.ts diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx index 731555694bff7..52941391ca364 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx @@ -4,51 +4,52 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiCard, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { storiesOf } from '@storybook/react'; import cytoscape from 'cytoscape'; import React from 'react'; import { Cytoscape } from './Cytoscape'; - -const elements: cytoscape.ElementDefinition[] = [ - { - data: { - id: 'opbeans-python', - label: 'opbeans-python', - agentName: 'python', - type: 'service' - } - }, - { - data: { - id: 'opbeans-node', - label: 'opbeans-node', - agentName: 'nodejs', - type: 'service' - } - }, - { - data: { - id: 'opbeans-ruby', - label: 'opbeans-ruby', - agentName: 'ruby', - type: 'service' - } - }, - { data: { source: 'opbeans-python', target: 'opbeans-node' } }, - { - data: { - bidirectional: true, - source: 'opbeans-python', - target: 'opbeans-ruby' - } - } -]; -const height = 300; -const serviceName = 'opbeans-python'; +import { iconForNode } from './icons'; storiesOf('app/ServiceMap/Cytoscape', module).add( 'example', () => { + const elements: cytoscape.ElementDefinition[] = [ + { + data: { + id: 'opbeans-python', + label: 'opbeans-python', + agentName: 'python', + type: 'service' + } + }, + { + data: { + id: 'opbeans-node', + label: 'opbeans-node', + agentName: 'nodejs', + type: 'service' + } + }, + { + data: { + id: 'opbeans-ruby', + label: 'opbeans-ruby', + agentName: 'ruby', + type: 'service' + } + }, + { data: { source: 'opbeans-python', target: 'opbeans-node' } }, + { + data: { + bidirectional: true, + source: 'opbeans-python', + target: 'opbeans-ruby' + } + } + ]; + const height = 300; + const serviceName = 'opbeans-python'; return ( <Cytoscape elements={elements} @@ -59,6 +60,119 @@ storiesOf('app/ServiceMap/Cytoscape', module).add( }, { info: { + propTables: false, + source: false + } + } +); + +storiesOf('app/ServiceMap/Cytoscape', module).add( + 'node icons', + () => { + const cy = cytoscape(); + const elements = [ + { data: { id: 'default', label: 'default', type: undefined } }, + { data: { id: 'cache', label: 'cache', type: 'cache' } }, + { data: { id: 'database', label: 'database', type: 'database' } }, + { data: { id: 'external', label: 'external', type: 'external' } }, + { data: { id: 'messaging', label: 'messaging', type: 'messaging' } }, + + { + data: { + id: 'dotnet', + label: 'dotnet service', + type: 'service', + agentName: 'dotnet' + } + }, + { + data: { + id: 'go', + label: 'go service', + type: 'service', + agentName: 'go' + } + }, + { + data: { + id: 'java', + label: 'java service', + type: 'service', + agentName: 'java' + } + }, + { + data: { + id: 'js-base', + label: 'js-base service', + type: 'service', + agentName: 'js-base' + } + }, + { + data: { + id: 'nodejs', + label: 'nodejs service', + type: 'service', + agentName: 'nodejs' + } + }, + { + data: { + id: 'php', + label: 'php service', + type: 'service', + agentName: 'php' + } + }, + { + data: { + id: 'python', + label: 'python service', + type: 'service', + agentName: 'python' + } + }, + { + data: { + id: 'ruby', + label: 'ruby service', + type: 'service', + agentName: 'ruby' + } + } + ]; + cy.add(elements); + + return ( + <EuiFlexGroup gutterSize="l" wrap={true}> + {cy.nodes().map(node => ( + <EuiFlexItem key={node.data('id')}> + <EuiCard + description={ + <pre> + agentName: {node.data('agentName') || 'undefined'}, type:{' '} + {node.data('type') || 'undefined'} + </pre> + } + icon={ + <img + alt={node.data('label')} + src={iconForNode(node)} + height={80} + width={80} + /> + } + title={node.data('label')} + /> + </EuiFlexItem> + ))} + </EuiFlexGroup> + ); + }, + { + info: { + propTables: false, source: false } } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx index f1c53673c8755..405bd855898b7 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx @@ -35,6 +35,7 @@ export function Contents({ onFocusClick, selectedNodeServiceName }: ContentsProps) { + const frameworkName = selectedNodeData.frameworkName; return ( <EuiFlexGroup direction="column" @@ -49,7 +50,10 @@ export function Contents({ </EuiFlexItem> <EuiFlexItem> {isService ? ( - <ServiceMetricFetcher serviceName={selectedNodeServiceName} /> + <ServiceMetricFetcher + frameworkName={frameworkName} + serviceName={selectedNodeServiceName} + /> ) : ( <Info {...selectedNodeData} /> )} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx index e5962afd76eb8..23e9e737be9a6 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx @@ -16,6 +16,7 @@ storiesOf('app/ServiceMap/Popover/ServiceMetricList', module) avgRequestsPerMinute={164.47222031860858} avgCpuUsage={0.32809666568309237} avgMemoryUsage={0.5504868173242986} + frameworkName="Spring" numInstances={2} isLoading={false} /> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx index b0a5e892b5a7e..697aa6a1b652b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx @@ -11,10 +11,12 @@ import { useUrlParams } from '../../../../hooks/useUrlParams'; import { ServiceMetricList } from './ServiceMetricList'; interface ServiceMetricFetcherProps { + frameworkName?: string; serviceName: string; } export function ServiceMetricFetcher({ + frameworkName, serviceName }: ServiceMetricFetcherProps) { const { @@ -37,5 +39,11 @@ export function ServiceMetricFetcher({ ); const isLoading = status === 'loading'; - return <ServiceMetricList {...data} isLoading={isLoading} />; + return ( + <ServiceMetricList + {...data} + frameworkName={frameworkName} + isLoading={isLoading} + /> + ); } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx index 3a6b4c5ebcaac..056af68cc8173 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx @@ -30,6 +30,10 @@ function LoadingSpinner() { ); } +const BadgeRow = styled(EuiFlexItem)` + padding-bottom: ${lightTheme.gutterTypes.gutterSmall}; +`; + const ItemRow = styled('tr')` line-height: 2; `; @@ -44,6 +48,7 @@ const ItemDescription = styled('td')` `; interface ServiceMetricListProps extends ServiceNodeMetrics { + frameworkName?: string; isLoading: boolean; } @@ -53,6 +58,7 @@ export function ServiceMetricList({ avgErrorsPerMinute, avgCpuUsage, avgMemoryUsage, + frameworkName, numInstances, isLoading }: ServiceMetricListProps) { @@ -106,23 +112,27 @@ export function ServiceMetricList({ : null } ]; + const showBadgeRow = frameworkName || numInstances > 1; + return isLoading ? ( <LoadingSpinner /> ) : ( <> - {numInstances && numInstances > 1 && ( - <EuiFlexItem> - <div> - <EuiBadge iconType="apps" color="hollow"> - {i18n.translate('xpack.apm.serviceMap.numInstancesMetric', { - values: { numInstances }, - defaultMessage: '{numInstances} instances' - })} - </EuiBadge> - </div> - </EuiFlexItem> + {showBadgeRow && ( + <BadgeRow> + <EuiFlexGroup gutterSize="none"> + {frameworkName && <EuiBadge>{frameworkName}</EuiBadge>} + {numInstances > 1 && ( + <EuiBadge iconType="apps" color="hollow"> + {i18n.translate('xpack.apm.serviceMap.numInstancesMetric', { + values: { numInstances }, + defaultMessage: '{numInstances} instances' + })} + </EuiBadge> + )} + </EuiFlexGroup> + </BadgeRow> )} - <table> <tbody> {listItems.map( diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index af5bd17f71ca4..8411169dbc944 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -42,19 +42,23 @@ const style: cytoscape.Stylesheet[] = [ 'background-image': (el: cytoscape.NodeSingular) => iconForNode(el) ?? defaultIcon, 'background-height': (el: cytoscape.NodeSingular) => - isService(el) ? '85%' : '40%', + isService(el) ? '60%' : '40%', 'background-width': (el: cytoscape.NodeSingular) => - isService(el) ? '85%' : '40%', + isService(el) ? '60%' : '40%', 'border-color': (el: cytoscape.NodeSingular) => el.hasClass('primary') || el.selected() ? theme.euiColorPrimary : theme.euiColorMediumShade, - 'border-width': 1, + 'border-width': 2, color: theme.textColors.default, // theme.euiFontFamily doesn't work here for some reason, so we're just // specifying a subset of the fonts for the label text. 'font-family': 'Inter UI, Segoe UI, Helvetica, Arial, sans-serif', 'font-size': theme.euiFontSizeXS, + ghost: 'yes', + 'ghost-offset-x': 0, + 'ghost-offset-y': 2, + 'ghost-opacity': 0.15, height: nodeHeight, label: 'data(label)', 'min-zoomed-font-size': theme.euiSizeL, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts index 2403ed047cbc0..bc619b1ecdfe5 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts @@ -105,7 +105,8 @@ export function getCytoscapeElements( `/services/${node['service.name']}/service-map`, search ), - agentName: node['agent.name'] || node['agent.name'], + agentName: node['agent.name'], + frameworkName: node['service.framework.name'], type: 'service' }; } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts index c637d145639ce..1b57cd52082d8 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts @@ -4,26 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import theme from '@elastic/eui/dist/eui_theme_light.json'; import cytoscape from 'cytoscape'; import databaseIcon from './icons/database.svg'; import documentsIcon from './icons/documents.svg'; +import dotNetIcon from './icons/dot-net.svg'; import globeIcon from './icons/globe.svg'; +import goIcon from './icons/go.svg'; +import javaIcon from './icons/java.svg'; +import nodeJsIcon from './icons/nodejs.svg'; +import phpIcon from './icons/php.svg'; +import pythonIcon from './icons/python.svg'; +import rubyIcon from './icons/ruby.svg'; +import rumJsIcon from './icons/rumjs.svg'; +import defaultIconImport from './icons/default.svg'; -function getAvatarIcon( - text = '', - backgroundColor = 'transparent', - foregroundColor = 'white' -) { - return ( - 'data:image/svg+xml;utf8,' + - encodeURIComponent(`<svg width="80" height="80" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> - <circle cx="40" cy="40" fill="${backgroundColor}" r="40" stroke-width="0" /> - <text fill="${foregroundColor}" font-family="'Inter UI', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Helvetica', 'Arial', sans-serif" font-size="36" text-anchor="middle" x="40" xml:space="preserve" y="52">${text}</text> -</svg> -`) - ); -} +export const defaultIcon = defaultIconImport; // The colors here are taken from the logos of the corresponding technologies const icons: { [key: string]: string } = { @@ -34,18 +29,17 @@ const icons: { [key: string]: string } = { resource: globeIcon }; -const serviceAbbreviations: { [key: string]: string } = { - dotnet: '.N', - go: 'Go', - java: 'Jv', - 'js-base': 'JS', - nodejs: 'No', - python: 'Py', - ruby: 'Rb' +const serviceIcons: { [key: string]: string } = { + dotnet: dotNetIcon, + go: goIcon, + java: javaIcon, + 'js-base': rumJsIcon, + nodejs: nodeJsIcon, + php: phpIcon, + python: pythonIcon, + ruby: rubyIcon }; -export const defaultIcon = getAvatarIcon(); - // IE 11 does not properly load some SVGs, which causes a runtime error and the // map to not work at all. We would prefer to do some kind of feature detection // rather than browser detection, but IE 11 does support SVG, just not well @@ -61,15 +55,12 @@ export function iconForNode(node: cytoscape.NodeSingular) { const type = node.data('type'); if (type === 'service') { - return getAvatarIcon( - serviceAbbreviations[node.data('agentName') as string], - node.selected() || node.hasClass('primary') - ? theme.euiColorPrimary - : theme.euiColorDarkestShade - ); + return serviceIcons[node.data('agentName') as string]; } else if (isIE11) { return defaultIcon; - } else { + } else if (icons[type]) { return icons[type]; + } else { + return defaultIcon; } } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/default.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/default.svg new file mode 100644 index 0000000000000..08bc5331e083b --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/default.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M16.75 6.165a1.5 1.5 0 00-1.5 0l-7.392 4.268a1.5 1.5 0 00-.75 1.3v8.535a1.5 1.5 0 00.75 1.299l7.392 4.268a1.5 1.5 0 001.5 0l7.392-4.268a1.5 1.5 0 00.75-1.299v-8.536a1.5 1.5 0 00-.75-1.299L16.75 6.165zm.75-1.299l7.392 4.268a3 3 0 011.5 2.598v8.536a3 3 0 01-1.5 2.598L17.5 27.134a3 3 0 01-3 0l-7.392-4.268a3 3 0 01-1.5-2.598v-8.536a3 3 0 011.5-2.598L14.5 4.866a3 3 0 013 0z" fill="#98A2B3"/> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg new file mode 100644 index 0000000000000..9f7427f0e1001 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg @@ -0,0 +1,127 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M11.164 13.586c1.145 3.503 1.58 9.753 4.93 9.753.254 0 .512-.025.77-.074-3.045-.71-3.405-6.892-5.264-10.093-.148.135-.293.273-.436.414" fill="url(#paint0_linear)"/> + <path d="M11.6 13.172c1.859 3.201 2.22 9.383 5.265 10.093.239-.044.479-.11.719-.195-2.733-1.339-3.489-7.341-5.6-10.231-.13.108-.258.22-.384.333" fill="url(#paint1_linear)"/> + <path d="M14.278 11.268a4.14 4.14 0 00-.772.074c-.678.128-1.367.42-2.06.862.189.187.37.4.539.635.706-.586 1.407-1.02 2.106-1.28.255-.095.518-.168.786-.218a2.42 2.42 0 00-.6-.073" fill="#14559A"/> + <path d="M19.718 21.836c.29-.233.571-.478.84-.737-1.164-3.487-1.58-9.826-4.954-9.826-.241 0-.485.023-.727.068 3.072.764 3.466 7.45 4.84 10.495" fill="url(#paint2_linear)"/> + <path d="M14.877 11.34a2.425 2.425 0 00-.6-.072l1.327.005a3.95 3.95 0 00-.727.068" fill="#3092C4"/> + <path d="M19.659 22.577a5.018 5.018 0 01-.38-.411 6.977 6.977 0 01-1.695.904 2.609 2.609 0 001.17.269c.63 0 1.129-.075 1.553-.278a2.95 2.95 0 01-.648-.484" fill="#1969BC"/> + <path d="M14.09 11.56c2.753 1.44 2.992 7.959 5.189 10.606.15-.106.295-.216.438-.33-1.374-3.045-1.767-9.731-4.84-10.495-.268.05-.53.124-.786.22" fill="url(#paint3_linear)"/> + <path d="M11.985 12.839c2.11 2.89 2.866 8.892 5.599 10.231a6.975 6.975 0 001.695-.904C17.082 19.519 16.843 13 14.09 11.56c-.699.26-1.4.693-2.106 1.279" fill="url(#paint4_linear)"/> + <path d="M9.814 13.502c-.331.748-.67 1.73-1.078 3.014.813-1.145 1.623-2.131 2.428-2.93a8.789 8.789 0 00-.359-.935c-.345.265-.676.55-.991.85" fill="url(#paint5_linear)"/> + <path d="M11.099 12.435c-.1.07-.197.142-.293.216.128.28.247.594.358.935.143-.14.288-.279.436-.414a5.808 5.808 0 00-.501-.737" fill="#2B74B1"/> + <path d="M11.445 12.204a9.562 9.562 0 00-.347.23c.18.223.346.47.502.738.126-.114.254-.225.385-.333a5.094 5.094 0 00-.54-.635" fill="#125A9E"/> + <path d="M30.218 11.001c-1.556 6.004-4.807 10.825-7.533 12.04h-.005c-.05.023-.1.044-.148.064-.006.004-.012.004-.018.007l-.041.016c-.007.004-.013.005-.02.007-.022.009-.045.015-.067.024-.01.005-.02.007-.028.01a.36.36 0 01-.034.011l-.033.012-.03.01-.057.017c-.009 0-.016.005-.025.007l-.04.01c-.008.005-.016.006-.026.008a.934.934 0 01-.043.01l-.052.012c.125.046.257.07.39.069 2.585 0 5.19-4.632 9.503-12.335h-1.693v.001z" fill="url(#paint6_linear)"/> + <path d="M8.175 11.512c.002 0 .004-.004.005-.004.002 0 .005 0 .006-.004h.003l.042-.016c.004 0 .006 0 .008-.004.004 0 .008-.004.011-.005l.045-.016h.003c.034-.01.066-.023.099-.035.004 0 .009-.004.015-.004l.042-.012c.007-.004.013-.004.02-.007l.042-.012c.007 0 .012-.004.017-.005.047-.013.094-.025.142-.036.006 0 .012-.005.02-.005a.274.274 0 01.04-.008c.007-.004.014-.004.021-.006.014-.004.027-.005.042-.008h.01l.086-.016h.018c.013-.004.026-.004.04-.007.007 0 .015-.004.022-.004.013 0 .025-.004.038-.006.007 0 .013 0 .021-.004.03-.004.061-.005.093-.008a2.87 2.87 0 00-.275-.014c-2.91 0-6.921 5.4-8.728 12.395h.349a101.37 101.37 0 001.572-2.922c1.265-4.954 3.842-8.333 6.131-9.228" fill="url(#paint7_linear)"/> + <path d="M9.814 13.502a12.1 12.1 0 01.991-.851 4.68 4.68 0 00-.236-.457c-.266.323-.51.75-.755 1.308M10.053 11.553c.19.16.36.379.516.641.05-.06.099-.116.15-.168a2.921 2.921 0 00-.666-.473" fill="#0D82CA"/> + <path d="M2.044 20.74c2.654-5.115 3.912-8.352 6.131-9.228-2.288.895-4.866 4.274-6.131 9.227" fill="url(#paint8_linear)"/> + <path d="M10.72 12.026c-.053.053-.102.108-.151.168.087.148.166.3.236.457.097-.074.195-.146.293-.216a3.989 3.989 0 00-.379-.409" fill="#127BCA"/> + <path d="M3.249 23.638c-.017.004-.033.004-.048.006h-.01c-.013 0-.028.004-.04.004h-.007c-.033.004-.066.004-.098.007h-.01c2.686-.075 3.914-1.42 4.524-3.371.463-1.48.843-2.725 1.177-3.77C7.61 18.097 6.48 19.985 5.345 22.1c-.548 1.02-1.382 1.445-2.096 1.537" fill="url(#paint9_linear)"/> + <path d="M3.249 23.637c.714-.09 1.548-.516 2.096-1.536 1.136-2.114 2.267-4.002 3.39-5.586.41-1.284.747-2.266 1.079-3.014-2.241 2.133-4.49 5.679-6.565 10.136" fill="url(#paint10_linear)"/> + <path d="M2.044 20.74c-.475.915-.995 1.89-1.573 2.922h1.013c.128-.985.315-1.96.56-2.922" fill="#05A1E6"/> + <path d="M9.033 11.29c-.008 0-.014.005-.021.005-.013.004-.025.004-.038.005-.008 0-.015 0-.023.004-.014.004-.026.005-.04.007-.006 0-.012 0-.017.004l-.086.015h-.01a.315.315 0 01-.042.009c-.007.004-.014.004-.021.005-.014.004-.028.006-.04.008-.008.005-.014.005-.02.005a4.64 4.64 0 00-.142.037c-.005 0-.01.004-.017.005a2.21 2.21 0 00-.043.012c-.006.004-.013.004-.02.006a1.055 1.055 0 00-.042.013c-.005 0-.01.004-.015.004-.033.012-.065.024-.099.034l-.045.016c-.006.004-.011.005-.018.008-.015.004-.03.01-.042.015-.005.004-.01.005-.014.007-2.22.876-3.477 4.113-6.132 9.228a24.646 24.646 0 00-.56 2.921h.143c.4 0 .513-.004.974-.004h.446c.032-.004.064-.004.097-.006h.007c.013 0 .027-.005.04-.005h.01c.015 0 .032-.003.048-.005 2.075-4.457 4.325-8.003 6.565-10.136.247-.558.49-.984.755-1.307a2.476 2.476 0 00-.516-.642s-.005 0-.005-.004l-.032-.015-.031-.016a.344.344 0 01-.03-.015c-.014-.004-.025-.01-.036-.015l-.029-.012a1.243 1.243 0 01-.058-.025l-.025-.01a.868.868 0 01-.044-.017c-.008-.004-.016-.005-.024-.008l-.06-.02h-.006c-.023-.008-.046-.014-.07-.02-.006-.005-.01-.005-.016-.006l-.06-.016c-.004 0-.01-.004-.013-.004a2.13 2.13 0 00-.148-.033c-.005 0-.01-.004-.015-.004a.86.86 0 00-.064-.01c-.005-.004-.008-.004-.013-.004a1.609 1.609 0 00-.076-.01h-.013c-.02-.004-.04-.004-.058-.006-.032.004-.063.005-.093.008" fill="url(#paint11_linear)"/> + <path d="M24.138 14.325c-.51 1.636-.924 2.985-1.284 4.096 1.401-1.929 2.781-4.354 4.096-7.154-1.534.482-2.351 1.584-2.812 3.058z" fill="url(#paint12_linear)"/> + <path d="M22.898 22.94a4.26 4.26 0 01-.213.102c2.726-1.216 5.977-6.038 7.533-12.04h-.315c-3.576 6.388-4.727 10.664-7.005 11.937z" fill="url(#paint13_linear)"/> + <path d="M21.119 22.403c.593-.724 1.076-1.954 1.735-3.982-.76 1.045-1.526 1.943-2.293 2.675 0 .004 0 .004-.004.007.167.5.35.943.56 1.3" fill="#079AE1"/> + <path d="M21.119 22.403a2.43 2.43 0 01-.812.658 2.295 2.295 0 00.967.274h.009c.02 0 .043 0 .065.004h.222c.008 0 .017 0 .025-.004.016 0 .033 0 .049-.004h.024c.017 0 .035-.004.053-.006h.004c.006 0 .011-.004.017-.004.018-.004.038-.006.056-.009h.017l.064-.01h.01l.128-.027c-.352-.129-.647-.433-.9-.865" fill="#1969BC"/> + <path d="M16.093 23.339c.255 0 .513-.025.772-.075.239-.043.479-.11.719-.193a2.614 2.614 0 001.17.268h-2.66z" fill="#1E5CB3"/> + <path d="M18.754 23.339c.63 0 1.129-.075 1.553-.278a2.298 2.298 0 00.967.274h.009c.02 0 .043 0 .065.004h.077-2.673.002z" fill="#1E5CB3"/> + <path d="M21.426 23.339h.146c.008 0 .017 0 .025-.004.016 0 .032 0 .05-.004h.024c.016 0 .035-.004.052-.006h.005c.005 0 .01-.004.016-.004.018-.004.038-.006.056-.009.006 0 .011 0 .018-.004l.064-.01h.01c.043-.008.085-.016.127-.027.125.046.257.07.39.069l-.982.004-.001-.005z" fill="#1D60B5"/> + <path d="M20.559 21.103v-.004c-.269.258-.55.504-.84.736-.144.114-.29.224-.44.33.123.147.249.285.38.411.201.194.415.358.649.484a2.424 2.424 0 00.812-.658c-.21-.357-.393-.799-.56-1.3" fill="#175FAB"/> + <path d="M28.904 11.001h-1.588c-.085.004-.169.007-.251.013-.039.083-.079.166-.117.25-1.314 2.8-2.695 5.225-4.096 7.153-.659 2.028-1.142 3.259-1.735 3.983.253.432.548.736.899.865.01-.004.018-.004.027-.006h.006c.006 0 .013-.004.02-.004l.044-.011c.009-.004.016-.005.026-.008l.039-.01c.008-.004.016-.005.024-.007l.059-.017a.169.169 0 01.029-.01l.033-.012.033-.01c.01-.005.02-.007.028-.01.023-.01.045-.016.068-.025.007 0 .013-.006.02-.007l.041-.016c.005-.004.011-.005.018-.007.048-.02.098-.04.147-.063h.005a4.21 4.21 0 00.214-.102c2.278-1.273 3.429-5.55 7.005-11.938h-1 .002z" fill="url(#paint14_linear)"/> + <path d="M9.126 11.282c.006 0 .01 0 .016.004a.13.13 0 01.042.005h.014l.075.01a1.473 1.473 0 00.077.015c.005 0 .01.003.016.003.049.01.1.02.147.033.005 0 .01.004.013.004l.06.017c.006 0 .01.004.016.005.024.006.047.012.07.02.001 0 .002 0 .005.004l.061.02c.009.005.016.006.024.008.014.007.03.013.044.018.008 0 .016.006.025.01.019.008.039.015.058.024l.03.012a1.675 1.675 0 00.066.03l.03.016c.254.124.488.29.704.492.078-.082.16-.157.25-.226a2.745 2.745 0 00-1.843-.518" fill="#7DCBEC"/> + <path d="M10.72 12.026c.135.127.262.264.378.409.114-.08.23-.157.347-.231a3.65 3.65 0 00-.477-.405c-.088.07-.172.145-.249.227" fill="#5EC5ED"/> + <path d="M9.126 11.282a2.749 2.749 0 011.842.518c.5-.394 1.103-.532 1.94-.532H8.851c.094 0 .185.006.275.014" fill="url(#paint15_linear)"/> + <path d="M12.908 11.268c-.838 0-1.44.138-1.94.531.17.121.33.257.477.405.694-.442 1.383-.735 2.061-.862.254-.049.512-.073.771-.074h-1.369z" fill="url(#paint16_linear)"/> + <path d="M22.854 18.421c.36-1.11.773-2.46 1.285-4.096.46-1.475 1.277-2.577 2.81-3.058.04-.082.079-.167.118-.25-2.499.15-3.667 1.42-4.255 3.303-1.028 3.288-1.65 5.418-2.25 6.776.765-.732 1.531-1.63 2.292-2.675" fill="url(#paint17_linear)"/> + <path d="M26.52 22.463h-.283v.88h-.115v-.88h-.285v-.105h.683v.105zm1.16.88h-.115v-.661c0-.052.004-.116.01-.191a.68.68 0 01-.031.096l-.335.756h-.057l-.336-.75a.526.526 0 01-.03-.102h-.004c.004.04.005.104.005.192v.66h-.11v-.985h.152l.302.688a.902.902 0 01.045.118h.004l.047-.121.309-.685h.145v.985z" fill="#000"/> + <defs> + <linearGradient id="paint0_linear" x1="14.014" y1="11.188" x2="14.014" y2="26.351" gradientUnits="userSpaceOnUse"> + <stop stop-color="#0994DC"/> + <stop offset=".35" stop-color="#66CEF5"/> + <stop offset=".846" stop-color="#127BCA"/> + <stop offset="1" stop-color="#127BCA"/> + </linearGradient> + <linearGradient id="paint1_linear" x1="14.592" y1="10.804" x2="14.592" y2="26.353" gradientUnits="userSpaceOnUse"> + <stop stop-color="#0E76BC"/> + <stop offset=".36" stop-color="#36AEE8"/> + <stop offset=".846" stop-color="#00ADEF"/> + <stop offset="1" stop-color="#00ADEF"/> + </linearGradient> + <linearGradient id="paint2_linear" x1="17.717" y1="22.99" x2="17.717" y2="10.617" gradientUnits="userSpaceOnUse"> + <stop stop-color="#1C63B7"/> + <stop offset=".5" stop-color="#33BDF2"/> + <stop offset="1" stop-color="#33BDF2" stop-opacity=".42"/> + </linearGradient> + <linearGradient id="paint3_linear" x1="16.904" y1="9.228" x2="16.904" y2="25.373" gradientUnits="userSpaceOnUse"> + <stop stop-color="#166AB8"/> + <stop offset=".4" stop-color="#36AEE8"/> + <stop offset=".846" stop-color="#0798DD"/> + <stop offset="1" stop-color="#0798DD"/> + </linearGradient> + <linearGradient id="paint4_linear" x1="15.632" y1="9.313" x2="15.632" y2="26.48" gradientUnits="userSpaceOnUse"> + <stop stop-color="#124379"/> + <stop offset=".39" stop-color="#1487CB"/> + <stop offset=".78" stop-color="#165197"/> + <stop offset="1" stop-color="#165197"/> + </linearGradient> + <linearGradient id="paint5_linear" x1="8.736" y1="14.583" x2="11.164" y2="14.583" gradientUnits="userSpaceOnUse"> + <stop stop-color="#33BDF2" stop-opacity=".698"/> + <stop offset="1" stop-color="#1DACD8"/> + </linearGradient> + <linearGradient id="paint6_linear" x1="26.965" y1="22.679" x2="26.965" y2="10.627" gradientUnits="userSpaceOnUse"> + <stop stop-color="#136AB4"/> + <stop offset=".6" stop-color="#59CAF5" stop-opacity=".549"/> + <stop offset="1" stop-color="#59CAF5" stop-opacity=".235"/> + </linearGradient> + <linearGradient id="paint7_linear" x1=".123" y1="17.463" x2="9.126" y2="17.463" gradientUnits="userSpaceOnUse"> + <stop stop-color="#05A1E6" stop-opacity=".247"/> + <stop offset="1" stop-color="#05A1E6"/> + </linearGradient> + <linearGradient id="paint8_linear" x1="5.109" y1="22.983" x2="5.109" y2="10.642" gradientUnits="userSpaceOnUse"> + <stop stop-color="#318ED5"/> + <stop offset="1" stop-color="#38A7E4"/> + </linearGradient> + <linearGradient id="paint9_linear" x1="5.886" y1="23.026" x2="5.886" y2="11.03" gradientUnits="userSpaceOnUse"> + <stop stop-color="#05A1E6"/> + <stop offset="1" stop-color="#05A1E6" stop-opacity=".549"/> + </linearGradient> + <linearGradient id="paint10_linear" x1="6.531" y1="23.639" x2="6.531" y2="13.502" gradientUnits="userSpaceOnUse"> + <stop stop-color="#1959A6"/> + <stop offset=".5" stop-color="#05A1E6"/> + <stop offset=".918" stop-color="#7EC5EA"/> + <stop offset="1" stop-color="#7EC5EA"/> + </linearGradient> + <linearGradient id="paint11_linear" x1="6.92" y1="22.991" x2="3.484" y2="12.49" gradientUnits="userSpaceOnUse"> + <stop stop-color="#165096"/> + <stop offset="1" stop-color="#0D82CA"/> + </linearGradient> + <linearGradient id="paint12_linear" x1="24.901" y1="16.775" x2="24.901" y2="11.031" gradientUnits="userSpaceOnUse"> + <stop stop-color="#05A1E6"/> + <stop offset=".874" stop-color="#0495D6"/> + <stop offset="1" stop-color="#0495D6"/> + </linearGradient> + <linearGradient id="paint13_linear" x1="27.601" y1="10.5" x2="22.248" y2="22.386" gradientUnits="userSpaceOnUse"> + <stop stop-color="#38A7E4" stop-opacity=".329"/> + <stop offset=".962" stop-color="#0E88D3"/> + <stop offset="1" stop-color="#0E88D3"/> + </linearGradient> + <linearGradient id="paint14_linear" x1="25.511" y1="10.965" x2="25.511" y2="23.496" gradientUnits="userSpaceOnUse"> + <stop stop-color="#168CD4"/> + <stop offset=".5" stop-color="#1C87CC"/> + <stop offset="1" stop-color="#154B8D"/> + </linearGradient> + <linearGradient id="paint15_linear" x1="9.527" y1="10.707" x2="9.582" y2="12.022" gradientUnits="userSpaceOnUse"> + <stop stop-color="#97D6EE"/> + <stop offset=".703" stop-color="#55C1EA"/> + <stop offset="1" stop-color="#55C1EA"/> + </linearGradient> + <linearGradient id="paint16_linear" x1="11.196" y1="12.145" x2="11.442" y2="10.753" gradientUnits="userSpaceOnUse"> + <stop stop-color="#7ACCEC"/> + <stop offset="1" stop-color="#3FB7ED"/> + </linearGradient> + <linearGradient id="paint17_linear" x1="23.814" y1="11.658" x2="23.814" y2="23.914" gradientUnits="userSpaceOnUse"> + <stop stop-color="#1DA7E7"/> + <stop offset="1" stop-color="#37ABE7" stop-opacity="0"/> + </linearGradient> + </defs> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/go.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/go.svg new file mode 100644 index 0000000000000..fb171e2813fac --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/go.svg @@ -0,0 +1,11 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g clip-path="url(#clip0)" fill="#00ADD8"> + <path d="M2.414 13.15c-.062 0-.077-.031-.046-.078l.324-.417c.031-.047.109-.078.17-.078h5.525c.062 0 .077.047.046.093l-.263.402c-.03.047-.108.093-.155.093l-5.601-.015zM.077 14.573c-.061 0-.077-.03-.046-.077l.325-.418c.03-.046.108-.077.17-.077h7.056c.062 0 .093.046.078.093l-.124.371c-.016.062-.078.093-.14.093l-7.319.015zm3.745 1.424c-.062 0-.077-.046-.046-.093l.216-.387c.031-.046.093-.093.155-.093h3.095c.062 0 .093.047.093.109l-.031.371c0 .062-.062.108-.109.108l-3.373-.015zm16.062-3.126c-.975.248-1.64.433-2.6.681-.232.062-.247.077-.448-.155-.232-.263-.403-.433-.728-.588-.975-.48-1.918-.34-2.8.232-1.053.681-1.594 1.687-1.579 2.94.016 1.238.867 2.26 2.09 2.43 1.051.14 1.933-.232 2.63-1.021.139-.17.263-.356.418-.573H13.88c-.325 0-.402-.201-.294-.464.201-.48.573-1.285.79-1.687a.418.418 0 01.386-.247h5.633c-.031.417-.031.835-.093 1.253a6.598 6.598 0 01-1.27 3.033c-1.113 1.47-2.568 2.383-4.41 2.63-1.516.201-2.924-.093-4.162-1.02-1.145-.867-1.795-2.013-1.965-3.436-.201-1.687.294-3.203 1.315-4.534 1.1-1.439 2.554-2.352 4.333-2.677 1.455-.263 2.847-.093 4.1.758.82.542 1.409 1.285 1.796 2.182.093.14.03.217-.155.263z"/> + <path d="M25.006 21.428c-1.408-.03-2.693-.433-3.776-1.361-.913-.79-1.485-1.795-1.671-2.987-.279-1.748.201-3.296 1.253-4.673 1.13-1.486 2.492-2.26 4.333-2.584 1.578-.279 3.064-.124 4.41.789 1.223.836 1.98 1.965 2.182 3.45.263 2.09-.34 3.792-1.78 5.246-1.02 1.037-2.274 1.687-3.713 1.981-.418.077-.836.093-1.238.14zm3.683-6.251c-.016-.201-.016-.356-.047-.51-.278-1.533-1.686-2.4-3.157-2.059-1.439.325-2.367 1.238-2.707 2.693-.279 1.207.309 2.429 1.423 2.924.851.371 1.702.325 2.522-.093 1.223-.634 1.888-1.625 1.966-2.955z"/> + </g> + <defs> + <clipPath id="clip0"> + <path fill="#fff" d="M0 9.333h32v12.275H0z"/> + </clipPath> + </defs> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/java.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/java.svg new file mode 100644 index 0000000000000..52a410e2eaa1a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/java.svg @@ -0,0 +1,7 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M11.729 24.747s-1.243.71.885.951c2.58.29 3.897.248 6.738-.28 0 0 .748.46 1.792.858-6.371 2.685-14.419-.155-9.415-1.53zm-.779-3.503s-1.394 1.014.736 1.231c2.755.28 4.93.303 8.695-.41 0 0 .52.52 1.338.803-7.702 2.215-16.28.174-10.769-1.624z" fill="#3174B9"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M17.512 15.3c1.57 1.778-.411 3.377-.411 3.377s3.986-2.023 2.155-4.557c-1.71-2.362-3.02-3.536 4.077-7.583 0 0-11.141 2.735-5.82 8.763z" fill="#CA3132"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M25.938 27.338s.92.746-1.013 1.323c-3.677 1.095-15.304 1.425-18.534.044-1.16-.497 1.016-1.186 1.701-1.332.714-.151 1.122-.124 1.122-.124-1.291-.894-8.346 1.756-3.583 2.516 12.988 2.071 23.675-.932 20.307-2.427zm-13.611-9.724s-5.914 1.381-2.094 1.884c1.613.212 4.827.163 7.823-.084a61.883 61.883 0 004.905-.634s-.862.363-1.487.782c-6.007 1.554-17.608.83-14.268-.758 2.824-1.343 5.12-1.19 5.12-1.19zm10.61 5.831c6.105-3.12 3.282-6.117 1.311-5.713a4.702 4.702 0 00-.698.184s.18-.276.522-.395c3.898-1.347 6.896 3.974-1.257 6.082 0 0 .094-.084.122-.158z" fill="#3174B9"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M19.256 0s3.38 3.326-3.207 8.44c-5.283 4.103-1.205 6.442-.002 9.115-3.084-2.736-5.347-5.144-3.83-7.386C14.448 6.879 20.62 5.283 19.257 0z" fill="#CA3132"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M12.927 31.9c5.86.368 14.86-.205 15.073-2.932 0 0-.41 1.033-4.843 1.853-5.002.926-11.172.819-14.83.225 0 0 .75.61 4.6.853z" fill="#3174B9"/> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg new file mode 100644 index 0000000000000..d327b1ba65ad2 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg @@ -0,0 +1,46 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <mask id="a" maskUnits="userSpaceOnUse" x="5" y="4" width="22" height="24"> + <path d="M16.101 4.15a1.151 1.151 0 00-1.139 0L5.56 9.579c-.358.201-.559.58-.559.983v10.878c0 .402.223.782.559.983l9.403 5.427a1.152 1.152 0 001.14 0l9.403-5.427c.357-.201.559-.581.559-.983V10.56c0-.402-.224-.782-.559-.983l-9.404-5.427z" fill="#fff"/> + </mask> + <g mask="url(#a)"> + <path d="M16.101 4.15a1.151 1.151 0 00-1.139 0L5.56 9.579c-.358.201-.559.58-.559.983v10.878c0 .402.223.782.559.983l9.403 5.427a1.152 1.152 0 001.14 0l9.403-5.427c.357-.201.559-.581.559-.983V10.56c0-.402-.224-.782-.559-.983l-9.404-5.427z" fill="url(#paint0_linear)"/> + <path d="M25.527 9.577L16.08 4.15a1.55 1.55 0 00-.29-.112L5.2 22.175c.088.107.194.198.313.268l9.448 5.428c.268.157.581.201.871.112L25.773 9.8a1.173 1.173 0 00-.246-.223z" fill="url(#paint1_linear)"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M25.55 22.422c.267-.157.468-.425.558-.715L15.744 4.017c-.268-.045-.559-.023-.804.133L5.559 9.556l10.118 18.45c.147-.024.29-.07.424-.134l9.448-5.45z" fill="url(#paint2_linear)"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M25.55 22.422l-9.426 5.428a1.349 1.349 0 01-.424.134l.178.335 10.453-6.053v-.134l-.268-.447c-.044.313-.245.58-.514.737z" fill="url(#paint3_linear)"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M25.55 22.422l-9.426 5.428a1.349 1.349 0 01-.424.134l.178.335 10.453-6.053v-.134l-.268-.447c-.044.313-.245.58-.514.737z" fill="url(#paint4_linear)"/> + </g> + <defs> + <linearGradient id="paint0_linear" x1="19.363" y1="8.197" x2="9.056" y2="24.392" gradientUnits="userSpaceOnUse"> + <stop stop-color="#41873F"/> + <stop offset=".329" stop-color="#418B3D"/> + <stop offset=".635" stop-color="#419637"/> + <stop offset=".932" stop-color="#3FA92D"/> + <stop offset="1" stop-color="#3FAE2A"/> + </linearGradient> + <linearGradient id="paint1_linear" x1="14.104" y1="17.273" x2="39.918" y2="3.249" gradientUnits="userSpaceOnUse"> + <stop offset=".138" stop-color="#41873F"/> + <stop offset=".403" stop-color="#54A044"/> + <stop offset=".714" stop-color="#66B848"/> + <stop offset=".908" stop-color="#6CC04A"/> + </linearGradient> + <linearGradient id="paint2_linear" x1="4.657" y1="16" x2="26.416" y2="16" gradientUnits="userSpaceOnUse"> + <stop offset=".092" stop-color="#6CC04A"/> + <stop offset=".286" stop-color="#66B848"/> + <stop offset=".597" stop-color="#54A044"/> + <stop offset=".862" stop-color="#41873F"/> + </linearGradient> + <linearGradient id="paint3_linear" x1="4.657" y1="25.02" x2="26.416" y2="25.02" gradientUnits="userSpaceOnUse"> + <stop offset=".092" stop-color="#6CC04A"/> + <stop offset=".286" stop-color="#66B848"/> + <stop offset=".597" stop-color="#54A044"/> + <stop offset=".862" stop-color="#41873F"/> + </linearGradient> + <linearGradient id="paint4_linear" x1="29.586" y1="7.683" x2="24.073" y2="36.568" gradientUnits="userSpaceOnUse"> + <stop stop-color="#41873F"/> + <stop offset=".329" stop-color="#418B3D"/> + <stop offset=".635" stop-color="#419637"/> + <stop offset=".932" stop-color="#3FA92D"/> + <stop offset="1" stop-color="#3FAE2A"/> + </linearGradient> + </defs> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/php.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/php.svg new file mode 100644 index 0000000000000..c8af5dc331269 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/php.svg @@ -0,0 +1,18 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M.45 15.63c0 4.518 6.962 8.18 15.55 8.18 8.588 0 15.55-3.662 15.55-8.18S24.588 7.45 16 7.45C7.412 7.45.45 11.112.45 15.63z" fill="url(#paint0_radial)"/> + <path d="M16 23.203c8.253 0 14.943-3.39 14.943-7.573 0-4.182-6.69-7.573-14.943-7.573-8.252 0-14.943 3.39-14.943 7.573 0 4.182 6.69 7.573 14.943 7.573z" fill="#777BB3"/> + <path d="M8.898 16.569c.679 0 1.186-.125 1.506-.372.317-.244.536-.667.651-1.257.107-.552.066-.937-.121-1.145-.192-.212-.606-.32-1.232-.32H8.618l-.601 3.094h.881zm-3.546 3.81a.168.168 0 01-.166-.2l1.593-8.198a.169.169 0 01.166-.137h3.434c1.079 0 1.882.293 2.387.871.508.581.664 1.394.466 2.414-.08.416-.22.802-.413 1.148a3.841 3.841 0 01-.76.952c-.372.349-.793.601-1.25.75-.45.146-1.029.22-1.719.22H7.7l-.397 2.044a.169.169 0 01-.166.136H5.352z" fill="#000"/> + <path d="M8.757 13.644h.946c.754 0 1.017.166 1.106.264.148.164.175.51.08 1-.106.548-.304.937-.588 1.155-.29.224-.762.337-1.403.337h-.676l.535-2.756zm1.622-1.969H6.945c-.162 0-.3.115-.331.274L5.02 20.146a.338.338 0 00.332.402h1.785c.162 0 .3-.115.332-.273l.37-1.907H9.09c.708 0 1.304-.077 1.77-.229.481-.156.923-.42 1.314-.786.324-.298.59-.632.792-.993.202-.362.347-.765.432-1.198.209-1.075.039-1.935-.505-2.558-.538-.616-1.384-.928-2.514-.928zm-2.567 5.062h1.086c.72 0 1.257-.135 1.609-.406.353-.272.59-.725.714-1.36.118-.608.064-1.038-.162-1.289-.226-.25-.678-.375-1.356-.375H8.479l-.667 3.43zm2.567-4.724c1.033 0 1.786.271 2.26.813.474.543.616 1.3.427 2.271-.078.4-.209.766-.394 1.098a3.663 3.663 0 01-.727.91 3.154 3.154 0 01-1.189.713c-.436.142-.99.213-1.666.213H7.56l-.423 2.18H5.352l1.593-8.198h3.434" fill="#fff"/> + <path d="M17.326 18.2a.168.168 0 01-.165-.201l.705-3.627c.067-.345.05-.593-.047-.697-.06-.064-.238-.172-.765-.172h-1.277l-.886 4.56a.169.169 0 01-.166.137h-1.771a.169.169 0 01-.166-.201L14.38 9.8a.169.169 0 01.166-.136h1.771a.169.169 0 01.166.2l-.384 1.98h1.373c1.047 0 1.756.184 2.17.563.42.387.552 1.007.39 1.84l-.741 3.815a.169.169 0 01-.166.137h-1.8z" fill="#000"/> + <path d="M16.318 9.496h-1.771c-.162 0-.3.115-.332.273l-1.593 8.198a.337.337 0 00.331.401h1.772c.162 0 .3-.114.331-.273l.86-4.423h1.138c.526 0 .637.113.641.117.032.035.074.194.005.55l-.705 3.628a.337.337 0 00.331.401h1.8c.162 0 .3-.114.331-.273l.742-3.814c.174-.896.025-1.568-.443-1.997-.445-.41-1.192-.609-2.283-.609h-1.169l.346-1.777a.337.337 0 00-.332-.402zm0 .338l-.423 2.179h1.578c.993 0 1.678.173 2.055.52.377.346.49.907.34 1.683l-.742 3.815h-1.8l.705-3.627c.08-.413.05-.694-.088-.844-.14-.15-.436-.225-.89-.225h-1.415l-.913 4.696h-1.772l1.594-8.197h1.771z" fill="#fff"/> + <path d="M22.836 16.569c.679 0 1.185-.125 1.506-.372.317-.244.536-.667.65-1.257.108-.552.067-.937-.12-1.145-.192-.212-.606-.32-1.232-.32h-1.084l-.601 3.094h.881zm-3.546 3.81a.168.168 0 01-.166-.2l1.593-8.198a.169.169 0 01.166-.137h3.434c1.079 0 1.882.293 2.387.871.508.581.664 1.393.466 2.414-.081.416-.22.802-.413 1.148-.194.346-.45.667-.76.952-.372.349-.793.601-1.25.75-.45.146-1.029.22-1.719.22h-1.39l-.397 2.044a.169.169 0 01-.166.136H19.29z" fill="#000"/> + <path d="M22.695 13.644h.945c.755 0 1.017.166 1.107.264.147.164.175.51.08 1-.107.548-.304.937-.588 1.155-.29.224-.762.337-1.403.337h-.676l.535-2.756zm1.622-1.969h-3.434c-.162 0-.3.115-.331.274l-1.594 8.197a.338.338 0 00.332.402h1.785c.162 0 .3-.115.331-.273l.37-1.907h1.252c.708 0 1.304-.077 1.77-.229.481-.156.923-.42 1.314-.787.324-.297.59-.631.792-.992.202-.362.347-.765.431-1.198.21-1.075.04-1.936-.504-2.558-.538-.616-1.384-.928-2.514-.928zm-2.567 5.062h1.086c.72 0 1.257-.135 1.609-.406.352-.272.59-.725.713-1.36.119-.608.065-1.038-.161-1.289-.226-.25-.678-.375-1.357-.375h-1.223l-.667 3.43zm2.567-4.724c1.033 0 1.786.271 2.26.813.474.543.616 1.3.427 2.271-.078.4-.21.766-.394 1.098a3.66 3.66 0 01-.727.91 3.154 3.154 0 01-1.189.713c-.436.142-.99.213-1.666.213h-1.53l-.423 2.18H19.29l1.593-8.198h3.434" fill="#fff"/> + <defs> + <radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(9.786 10.326) scale(20.4194)"> + <stop stop-color="#AEB2D5"/> + <stop offset=".3" stop-color="#AEB2D5"/> + <stop offset=".75" stop-color="#484C89"/> + <stop offset="1" stop-color="#484C89"/> + </radialGradient> + </defs> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/python.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/python.svg new file mode 100644 index 0000000000000..9b8d0a2836c28 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/python.svg @@ -0,0 +1,19 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g clip-path="url(#clip0)"> + <path d="M15.897 4.007c-6.078 0-5.698 2.635-5.698 2.635l.007 2.73h5.8v.82H7.901s-3.889-.44-3.889 5.691c0 6.133 3.394 5.915 3.394 5.915h2.026v-2.845s-.109-3.395 3.34-3.395h5.753s3.231.053 3.231-3.123v-5.25s.491-3.178-5.86-3.178zm-3.198 1.836a1.043 1.043 0 11.002 2.086 1.043 1.043 0 01-.002-2.086z" fill="url(#paint0_linear)"/> + <path d="M16.07 27.822c6.077 0 5.698-2.635 5.698-2.635l-.007-2.73h-5.8v-.82h8.103s3.89.44 3.89-5.692c0-6.132-3.395-5.915-3.395-5.915h-2.026v2.846s.11 3.394-3.34 3.394h-5.752s-3.232-.052-3.232 3.124v5.25s-.49 3.178 5.86 3.178zm3.198-1.836a1.04 1.04 0 01-1.044-1.043 1.041 1.041 0 011.443-.965 1.042 1.042 0 010 1.929 1.04 1.04 0 01-.4.079z" fill="url(#paint1_linear)"/> + </g> + <defs> + <linearGradient id="paint0_linear" x1="6.314" y1="6.149" x2="18.178" y2="17.894" gradientUnits="userSpaceOnUse"> + <stop stop-color="#387EB8"/> + <stop offset="1" stop-color="#366994"/> + </linearGradient> + <linearGradient id="paint1_linear" x1="13.596" y1="13.691" x2="26.337" y2="25.735" gradientUnits="userSpaceOnUse"> + <stop stop-color="#FFE052"/> + <stop offset="1" stop-color="#FFC331"/> + </linearGradient> + <clipPath id="clip0"> + <path fill="#fff" d="M4 4h24v23.904H4z"/> + </clipPath> + </defs> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg new file mode 100644 index 0000000000000..fdc54b91f9c29 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg @@ -0,0 +1,125 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g clip-path="url(#clip0)"> + <path d="M22.51 19.726l-13.64 8.1 17.662-1.198 1.36-17.81-5.381 10.908z" fill="url(#paint0_linear)"/> + <path d="M26.561 26.616l-1.518-10.478-4.135 5.46 5.653 5.018z" fill="url(#paint1_linear)"/> + <path d="M26.582 26.616l-11.122-.873-6.532 2.06 17.654-1.187z" fill="url(#paint2_linear)"/> + <path d="M8.944 27.806l2.779-9.102-6.114 1.307 3.335 7.795z" fill="url(#paint3_linear)"/> + <path d="M20.907 21.628l-2.556-10.014-7.317 6.858 9.873 3.156z" fill="url(#paint4_linear)"/> + <path d="M27.313 11.755l-6.916-5.648-1.926 6.226 8.842-.578z" fill="url(#paint5_linear)"/> + <path d="M24.078 4.093L20.011 6.34l-2.566-2.278 6.633.03z" fill="url(#paint6_linear)"/> + <path d="M4 23.064l1.704-3.107-1.378-3.702L4 23.065z" fill="url(#paint7_linear)"/> + <path d="M4.234 16.138l1.387 3.933 6.026-1.352 6.88-6.394 1.941-6.166L17.411 4l-5.198 1.945c-1.637 1.523-4.815 4.537-4.93 4.593-.113.058-2.098 3.81-3.049 5.6z" fill="#fff"/> + <path d="M9.104 9.071c3.549-3.519 8.124-5.598 9.88-3.826 1.754 1.771-.107 6.076-3.656 9.594s-8.067 5.711-9.822 3.94c-1.756-1.77.048-6.19 3.598-9.708z" fill="url(#paint8_linear)"/> + <path d="M8.944 27.802l2.757-9.13 9.155 2.94c-3.31 3.104-6.992 5.729-11.912 6.19z" fill="url(#paint9_linear)"/> + <path d="M18.539 12.308l2.35 9.31c2.765-2.908 5.247-6.034 6.462-9.9l-8.812.59z" fill="url(#paint10_linear)"/> + <path d="M27.327 11.765c.94-2.839 1.158-6.911-3.278-7.667l-3.64 2.01 6.918 5.657z" fill="url(#paint11_linear)"/> + <path d="M4 23.023c.13 4.686 3.51 4.755 4.95 4.796l-3.326-7.767L4 23.023z" fill="#9E1209"/> + <path d="M18.552 12.322c2.125 1.306 6.407 3.929 6.494 3.977.135.076 1.846-2.886 2.234-4.56l-8.728.583z" fill="url(#paint12_radial)"/> + <path d="M11.697 18.671l3.686 7.11c2.179-1.182 3.885-2.621 5.448-4.164l-9.134-2.946z" fill="url(#paint13_radial)"/> + <path d="M5.61 20.062l-.522 6.217c.985 1.346 2.34 1.463 3.762 1.358-1.028-2.56-3.083-7.68-3.24-7.575z" fill="url(#paint14_linear)"/> + <path d="M20.388 6.123l7.321 1.028c-.39-1.656-1.59-2.724-3.635-3.058l-3.686 2.03z" fill="url(#paint15_linear)"/> + </g> + <defs> + <linearGradient id="paint0_linear" x1="24.992" y1="29.993" x2="19.957" y2="21.091" gradientUnits="userSpaceOnUse"> + <stop stop-color="#FB7655"/> + <stop offset=".41" stop-color="#E42B1E"/> + <stop offset=".99" stop-color="#900"/> + <stop offset="1" stop-color="#900"/> + </linearGradient> + <linearGradient id="paint1_linear" x1="27.503" y1="22.518" x2="20.425" y2="21.135" gradientUnits="userSpaceOnUse"> + <stop stop-color="#871101"/> + <stop offset=".99" stop-color="#911209"/> + <stop offset="1" stop-color="#911209"/> + </linearGradient> + <linearGradient id="paint2_linear" x1="22.305" y1="30.263" x2="22.214" y2="25.774" gradientUnits="userSpaceOnUse"> + <stop stop-color="#871101"/> + <stop offset=".99" stop-color="#911209"/> + <stop offset="1" stop-color="#911209"/> + </linearGradient> + <linearGradient id="paint3_linear" x1="8.666" y1="19.362" x2="10.771" y2="25.533" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff"/> + <stop offset=".23" stop-color="#E57252"/> + <stop offset=".46" stop-color="#DE3B20"/> + <stop offset=".99" stop-color="#A60003"/> + <stop offset="1" stop-color="#A60003"/> + </linearGradient> + <linearGradient id="paint4_linear" x1="15.593" y1="13.251" x2="15.975" y2="19.93" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff"/> + <stop offset=".23" stop-color="#E4714E"/> + <stop offset=".56" stop-color="#BE1A0D"/> + <stop offset=".99" stop-color="#A80D00"/> + <stop offset="1" stop-color="#A80D00"/> + </linearGradient> + <linearGradient id="paint5_linear" x1="21.739" y1="7.078" x2="22.297" y2="11.928" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff"/> + <stop offset=".18" stop-color="#E46342"/> + <stop offset=".4" stop-color="#C82410"/> + <stop offset=".99" stop-color="#A80D00"/> + <stop offset="1" stop-color="#A80D00"/> + </linearGradient> + <linearGradient id="paint6_linear" x1="18.347" y1="5.392" x2="19.134" y2="2.055" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff"/> + <stop offset=".54" stop-color="#C81F11"/> + <stop offset=".99" stop-color="#BF0905"/> + <stop offset="1" stop-color="#BF0905"/> + </linearGradient> + <linearGradient id="paint7_linear" x1="4.471" y1="17.694" x2="6.529" y2="18.984" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff"/> + <stop offset=".31" stop-color="#DE4024"/> + <stop offset=".99" stop-color="#BF190B"/> + <stop offset="1" stop-color="#BF190B"/> + </linearGradient> + <linearGradient id="paint8_linear" x1="1.762" y1="22.704" x2="20.255" y2="3.635" gradientUnits="userSpaceOnUse"> + <stop stop-color="#BD0012"/> + <stop offset=".07" stop-color="#fff"/> + <stop offset=".17" stop-color="#fff"/> + <stop offset=".27" stop-color="#C82F1C"/> + <stop offset=".33" stop-color="#820C01"/> + <stop offset=".46" stop-color="#A31601"/> + <stop offset=".72" stop-color="#B31301"/> + <stop offset=".99" stop-color="#E82609"/> + <stop offset="1" stop-color="#E82609"/> + </linearGradient> + <linearGradient id="paint9_linear" x1="15.948" y1="24.625" x2="10.714" y2="22.427" gradientUnits="userSpaceOnUse"> + <stop stop-color="#8C0C01"/> + <stop offset=".54" stop-color="#990C00"/> + <stop offset=".99" stop-color="#A80D0E"/> + <stop offset="1" stop-color="#A80D0E"/> + </linearGradient> + <linearGradient id="paint10_linear" x1="25.529" y1="17.93" x2="20.138" y2="14.101" gradientUnits="userSpaceOnUse"> + <stop stop-color="#7E110B"/> + <stop offset=".99" stop-color="#9E0C00"/> + <stop offset="1" stop-color="#9E0C00"/> + </linearGradient> + <linearGradient id="paint11_linear" x1="27.349" y1="9.781" x2="24.814" y2="7.207" gradientUnits="userSpaceOnUse"> + <stop stop-color="#79130D"/> + <stop offset=".99" stop-color="#9E120B"/> + <stop offset="1" stop-color="#9E120B"/> + </linearGradient> + <linearGradient id="paint14_linear" x1="7.216" y1="27.797" x2="2.671" y2="24.024" gradientUnits="userSpaceOnUse"> + <stop stop-color="#8B2114"/> + <stop offset=".43" stop-color="#9E100A"/> + <stop offset=".99" stop-color="#B3100C"/> + <stop offset="1" stop-color="#B3100C"/> + </linearGradient> + <linearGradient id="paint15_linear" x1="22.648" y1="5.181" x2="23.939" y2="8.445" gradientUnits="userSpaceOnUse"> + <stop stop-color="#B31000"/> + <stop offset=".44" stop-color="#910F08"/> + <stop offset=".99" stop-color="#791C12"/> + <stop offset="1" stop-color="#791C12"/> + </linearGradient> + <radialGradient id="paint12_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(6.07279 0 0 3.17318 21.345 13.573)"> + <stop stop-color="#A80D00"/> + <stop offset=".99" stop-color="#7E0E08"/> + <stop offset="1" stop-color="#7E0E08"/> + </radialGradient> + <radialGradient id="paint13_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(8.07284 0 0 6.28431 12.935 21.576)"> + <stop stop-color="#A30C00"/> + <stop offset=".99" stop-color="#800E08"/> + <stop offset="1" stop-color="#800E08"/> + </radialGradient> + <clipPath id="clip0"> + <path fill="#fff" d="M4 4h24v23.904H4z"/> + </clipPath> + </defs> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg new file mode 100644 index 0000000000000..87043159ed8c3 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M5 20.245l2.438-1.485c.47.84.898 1.55 1.924 1.55.984 0 1.604-.387 1.604-1.894V8.172h2.994V18.46c0 3.12-1.818 4.54-4.47 4.54-2.394 0-3.784-1.248-4.49-2.754zm10.586-.323l2.438-1.42c.642 1.055 1.476 1.83 2.95 1.83 1.241 0 2.032-.625 2.032-1.486 0-1.033-.812-1.398-2.18-2l-.75-.324c-2.159-.925-3.592-2.087-3.592-4.54 0-2.26 1.711-3.982 4.384-3.982 1.903 0 3.272.667 4.255 2.41l-2.33 1.507c-.514-.925-1.07-1.291-1.925-1.291-.877 0-1.433.56-1.433 1.29 0 .905.556 1.27 1.84 1.83l.748.323C24.567 15.167 26 16.286 26 18.803 26 21.515 23.883 23 21.039 23c-2.78 0-4.576-1.334-5.453-3.078" fill="#000"/> +</svg> diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 8f87b3473b2e4..b4b4e7866e9b7 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -66,6 +66,8 @@ exports[`Error SERVICE_AGENT_NAME 1`] = `"java"`; exports[`Error SERVICE_ENVIRONMENT 1`] = `undefined`; +exports[`Error SERVICE_FRAMEWORK_NAME 1`] = `undefined`; + exports[`Error SERVICE_NAME 1`] = `"service name"`; exports[`Error SERVICE_NODE_NAME 1`] = `undefined`; @@ -176,6 +178,8 @@ exports[`Span SERVICE_AGENT_NAME 1`] = `"java"`; exports[`Span SERVICE_ENVIRONMENT 1`] = `undefined`; +exports[`Span SERVICE_FRAMEWORK_NAME 1`] = `undefined`; + exports[`Span SERVICE_NAME 1`] = `"service name"`; exports[`Span SERVICE_NODE_NAME 1`] = `undefined`; @@ -286,6 +290,8 @@ exports[`Transaction SERVICE_AGENT_NAME 1`] = `"java"`; exports[`Transaction SERVICE_ENVIRONMENT 1`] = `undefined`; +exports[`Transaction SERVICE_FRAMEWORK_NAME 1`] = `undefined`; + exports[`Transaction SERVICE_NAME 1`] = `"service name"`; exports[`Transaction SERVICE_NODE_NAME 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index ce2db4964a412..14233aad0f53c 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -7,6 +7,7 @@ export const SERVICE_NAME = 'service.name'; export const SERVICE_ENVIRONMENT = 'service.environment'; export const SERVICE_AGENT_NAME = 'agent.name'; +export const SERVICE_FRAMEWORK_NAME = 'service.framework.name'; export const SERVICE_NODE_NAME = 'service.node.name'; export const SERVICE_VERSION = 'service.version'; export const URL_FULL = 'url.full'; diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 548b29346e483..f4354baa97655 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -10,6 +10,7 @@ import { ILicense } from '../../licensing/public'; export interface ServiceConnectionNode { 'service.name': string; 'service.environment': string | null; + 'service.framework.name': string | null; 'agent.name': string; } export interface ExternalConnectionNode { diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts index 04e2a43a4b8f1..85d71784b55c7 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts @@ -16,7 +16,8 @@ import { getServicesProjection } from '../../../common/projections/services'; import { mergeProjection } from '../../../common/projections/util/merge_projection'; import { SERVICE_AGENT_NAME, - SERVICE_NAME + SERVICE_NAME, + SERVICE_FRAMEWORK_NAME } from '../../../common/elasticsearch_fieldnames'; export interface IEnvOptions { @@ -92,6 +93,11 @@ async function getServicesData(options: IEnvOptions) { terms: { field: SERVICE_AGENT_NAME } + }, + service_framework_name: { + terms: { + field: SERVICE_FRAMEWORK_NAME + } } } } @@ -109,7 +115,11 @@ async function getServicesData(options: IEnvOptions) { 'service.name': bucket.key as string, 'agent.name': (bucket.agent_name.buckets[0]?.key as string | undefined) || '', - 'service.environment': options.environment || null + 'service.environment': options.environment || null, + 'service.framework.name': + (bucket.service_framework_name.buckets[0]?.key as + | string + | undefined) || null }; }) || [] ); diff --git a/x-pack/plugins/apm/server/lib/services/map.ts b/x-pack/plugins/apm/server/lib/services/map.ts deleted file mode 100644 index 97bb925674e26..0000000000000 --- a/x-pack/plugins/apm/server/lib/services/map.ts +++ /dev/null @@ -1,88 +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 cytoscape from 'cytoscape'; -import { PromiseReturnType } from '../../../typings/common'; - -// This response right now just returns experimental data. -export type ServiceMapResponse = PromiseReturnType<typeof getServiceMap>; -export async function getServiceMap(): Promise<cytoscape.ElementDefinition[]> { - return [ - { data: { id: 'client', agentName: 'js-base' } }, - { data: { id: 'opbeans-node', agentName: 'nodejs' } }, - { data: { id: 'opbeans-python', agentName: 'python' } }, - { data: { id: 'opbeans-java', agentName: 'java' } }, - { data: { id: 'opbeans-ruby', agentName: 'ruby' } }, - { data: { id: 'opbeans-go', agentName: 'go' } }, - { data: { id: 'opbeans-go-2', agentName: 'go' } }, - { data: { id: 'opbeans-dotnet', agentName: 'dotnet' } }, - { data: { id: 'database', agentName: 'database' } }, - { data: { id: 'external API', agentName: 'external' } }, - - { - data: { - id: 'opbeans-client~opbeans-node', - source: 'client', - target: 'opbeans-node' - } - }, - { - data: { - id: 'opbeans-client~opbeans-python', - source: 'client', - target: 'opbeans-python' - } - }, - { - data: { - id: 'opbeans-python~opbeans-go', - source: 'opbeans-python', - target: 'opbeans-go' - } - }, - { - data: { - id: 'opbeans-python~opbeans-go-2', - source: 'opbeans-python', - target: 'opbeans-go-2' - } - }, - { - data: { - id: 'opbeans-python~opbeans-dotnet', - source: 'opbeans-python', - target: 'opbeans-dotnet' - } - }, - { - data: { - id: 'opbeans-node~opbeans-java', - source: 'opbeans-node', - target: 'opbeans-java' - } - }, - { - data: { - id: 'opbeans-node~database', - source: 'opbeans-node', - target: 'database' - } - }, - { - data: { - id: 'opbeans-go-2~opbeans-ruby', - source: 'opbeans-go-2', - target: 'opbeans-ruby' - } - }, - { - data: { - id: 'opbeans-go-2~external API', - source: 'opbeans-go-2', - target: 'external API' - } - } - ]; -} From ffab68d01bd4cf1abee7a8278329f30dada065c4 Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Mon, 2 Mar 2020 19:32:04 -0500 Subject: [PATCH 25/36] [Endpoint] Alert Details Overview (#58412) --- x-pack/plugins/endpoint/common/types.ts | 144 ++++++++++++++--- .../endpoint/store/alerts/action.ts | 9 +- .../store/alerts/alert_details.test.ts | 65 ++++++++ .../endpoint/store/alerts/middleware.ts | 11 +- .../store/alerts/mock_alert_result_list.ts | 153 ++++++++++++++++-- .../endpoint/store/alerts/reducer.ts | 6 + .../endpoint/store/alerts/selectors.ts | 25 ++- .../public/applications/endpoint/types.ts | 11 +- .../endpoint/view/alerts/details/index.ts | 7 + .../details/metadata/file_accordion.tsx | 80 +++++++++ .../details/metadata/general_accordion.tsx | 68 ++++++++ .../details/metadata/hash_accordion.tsx | 49 ++++++ .../details/metadata/host_accordion.tsx | 55 +++++++ .../view/alerts/details/metadata/index.ts | 12 ++ .../metadata/source_process_accordion.tsx | 97 +++++++++++ .../source_process_token_accordion.tsx | 45 ++++++ .../view/alerts/details/overview/index.tsx | 94 +++++++++++ .../details/overview/metadata_panel.tsx | 40 +++++ .../endpoint/view/alerts/formatted_date.tsx | 22 +++ .../endpoint/view/alerts/index.test.tsx | 3 - .../endpoint/view/alerts/index.tsx | 97 ++++++----- .../endpoint/view/alerts/resolver.tsx | 1 + 22 files changed, 986 insertions(+), 108 deletions(-) create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_details.test.ts create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/index.ts create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/file_accordion.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/general_accordion.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/hash_accordion.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/host_accordion.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/index.ts create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_accordion.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_token_accordion.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/index.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/metadata_panel.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/formatted_date.tsx diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts index 6d904fda6f747..d804350a9002d 100644 --- a/x-pack/plugins/endpoint/common/types.ts +++ b/x-pack/plugins/endpoint/common/types.ts @@ -96,6 +96,59 @@ export interface EndpointResultList { request_page_index: number; } +export interface OSFields { + full: string; + name: string; + version: string; + variant: string; +} +export interface HostFields { + id: string; + hostname: string; + ip: string[]; + mac: string[]; + os: OSFields; +} +export interface HashFields { + md5: string; + sha1: string; + sha256: string; +} +export interface MalwareClassifierFields { + identifier: string; + score: number; + threshold: number; + version: string; +} +export interface PrivilegesFields { + description: string; + name: string; + enabled: boolean; +} +export interface ThreadFields { + id: number; + service_name: string; + start: number; + start_address: number; + start_address_module: string; +} +export interface DllFields { + pe: { + architecture: string; + imphash: string; + }; + code_signature: { + subject_name: string; + trusted: boolean; + }; + compile_time: number; + hash: HashFields; + malware_classifier: MalwareClassifierFields; + mapped_address: number; + mapped_size: number; + path: string; +} + /** * Describes an Alert Event. * Should be in line with ECS schema. @@ -109,26 +162,78 @@ export type AlertEvent = Immutable<{ event: { id: string; action: string; + category: string; + kind: string; + dataset: string; + module: string; + type: string; }; - file_classification: { - malware_classification: { - score: number; + process: { + code_signature: { + subject_name: string; + trusted: boolean; }; - }; - process?: { - unique_pid: number; + command_line: string; + domain: string; pid: number; + ppid: number; + entity_id: string; + parent: { + pid: number; + entity_id: string; + }; + name: string; + hash: HashFields; + pe: { + imphash: string; + }; + executable: string; + sid: string; + start: number; + malware_classifier: MalwareClassifierFields; + token: { + domain: string; + type: string; + user: string; + sid: string; + integrity_level: number; + integrity_level_name: string; + privileges: PrivilegesFields[]; + }; + thread: ThreadFields[]; + uptime: number; + user: string; }; - host: { - hostname: string; - ip: string; - os: { - name: string; + file: { + owner: string; + name: string; + path: string; + accessed: number; + mtime: number; + created: number; + size: number; + hash: HashFields; + pe: { + imphash: string; + }; + code_signature: { + trusted: boolean; + subject_name: string; }; + malware_classifier: { + features: { + data: { + buffer: string; + decompressed_size: number; + encoding: string; + }; + }; + } & MalwareClassifierFields; + temp_file_path: string; }; + host: HostFields; thread: {}; - endpoint?: {}; - endgame?: {}; + dll: DllFields[]; }>; /** @@ -161,18 +266,7 @@ export interface EndpointMetadata { id: string; name: string; }; - host: { - id: string; - hostname: string; - ip: string[]; - mac: string[]; - os: { - name: string; - full: string; - version: string; - variant: string; - }; - }; + host: HostFields; } /** diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/action.ts index a628a95003a7f..6c6310a7349ed 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/action.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/action.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Immutable } from '../../../../../common/types'; +import { Immutable, AlertData } from '../../../../../common/types'; import { AlertListData } from '../../types'; interface ServerReturnedAlertsData { @@ -12,4 +12,9 @@ interface ServerReturnedAlertsData { readonly payload: Immutable<AlertListData>; } -export type AlertAction = ServerReturnedAlertsData; +interface ServerReturnedAlertDetailsData { + readonly type: 'serverReturnedAlertDetailsData'; + readonly payload: Immutable<AlertData>; +} + +export type AlertAction = ServerReturnedAlertsData | ServerReturnedAlertDetailsData; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_details.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_details.test.ts new file mode 100644 index 0000000000000..4edc31831eb14 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_details.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Store, createStore, applyMiddleware } from 'redux'; +import { History } from 'history'; +import { alertListReducer } from './reducer'; +import { AlertListState } from '../../types'; +import { alertMiddlewareFactory } from './middleware'; +import { AppAction } from '../action'; +import { coreMock } from 'src/core/public/mocks'; +import { createBrowserHistory } from 'history'; + +describe('alert details tests', () => { + let store: Store<AlertListState, AppAction>; + let coreStart: ReturnType<typeof coreMock.createStart>; + let history: History<never>; + /** + * A function that waits until a selector returns true. + */ + let selectorIsTrue: (selector: (state: AlertListState) => boolean) => Promise<void>; + beforeEach(() => { + coreStart = coreMock.createStart(); + history = createBrowserHistory(); + const middleware = alertMiddlewareFactory(coreStart); + store = createStore(alertListReducer, applyMiddleware(middleware)); + + selectorIsTrue = async selector => { + // If the selector returns true, we're done + while (selector(store.getState()) !== true) { + // otherwise, wait til the next state change occurs + await new Promise(resolve => { + const unsubscribe = store.subscribe(() => { + unsubscribe(); + resolve(); + }); + }); + } + }; + }); + describe('when the user is on the alert list page with a selected alert in the url', () => { + beforeEach(() => { + const firstResponse: Promise<unknown> = Promise.resolve(1); + const secondResponse: Promise<unknown> = Promise.resolve(2); + coreStart.http.get.mockReturnValueOnce(firstResponse).mockReturnValueOnce(secondResponse); + + // Simulates user navigating to the /alerts page + store.dispatch({ + type: 'userChangedUrl', + payload: { + ...history.location, + pathname: '/alerts', + search: '?selected_alert=q9ncfh4q9ctrmc90umcq4', + }, + }); + }); + + it('should return alert details data', async () => { + // wait for alertDetails to be defined + await selectorIsTrue(state => state.alertDetails !== undefined); + }); + }); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts index 76a6867418bd8..2cb381e901b4e 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertResultList } from '../../../../../common/types'; +import { AlertResultList, AlertData } from '../../../../../common/types'; import { AppAction } from '../action'; import { MiddlewareFactory, AlertListState } from '../../types'; -import { isOnAlertPage, apiQueryParams } from './selectors'; +import { isOnAlertPage, apiQueryParams, hasSelectedAlert, uiQueryParams } from './selectors'; export const alertMiddlewareFactory: MiddlewareFactory<AlertListState> = coreStart => { return api => next => async (action: AppAction) => { @@ -19,5 +19,12 @@ export const alertMiddlewareFactory: MiddlewareFactory<AlertListState> = coreSta }); api.dispatch({ type: 'serverReturnedAlertsData', payload: response }); } + if (action.type === 'userChangedUrl' && isOnAlertPage(state) && hasSelectedAlert(state)) { + const uiParams = uiQueryParams(state); + const response: AlertData = await coreStart.http.get( + `/api/endpoint/alerts/${uiParams.selected_alert}` + ); + api.dispatch({ type: 'serverReturnedAlertDetailsData', payload: response }); + } }; }; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts index 8eadb3e7fb3df..7db94fc9d4266 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts @@ -32,29 +32,152 @@ export const mockAlertResultList: (options?: { id: 'ced9c68e-b94a-4d66-bb4c-6106514f0a2f', version: '3.0.0', }, - event: { - id: '2f1c0928-3876-4e11-acbb-9199257c7b1c', - action: 'open', - }, - file_classification: { - malware_classification: { - score: 3, - }, - }, - process: { - pid: 107, - unique_pid: 1, - }, host: { + id: 'xrctvybuni', hostname: 'HD-c15-bc09190a', - ip: '10.179.244.14', + ip: ['10.179.244.14'], + mac: ['xsertcyvbunimkn56edtyf'], os: { - name: 'Windows', + full: 'Windows 10', + name: 'windows', + version: '10', + variant: '3', }, }, thread: {}, prev: null, next: null, + event: { + id: '2f1c0928-3876-4e11-acbb-9199257c7b1c', + action: 'creation', + category: 'malware', + dataset: 'endpoint', + kind: 'alert', + module: 'endpoint', + type: 'creation', + }, + file: { + accessed: 1542789400, + created: 1542789400, + hash: { + md5: '4ace3baaa509d08510405e1b169e325b', + sha1: '27fb21cf5db95ffca43b234affa99becc4023b9d', + sha256: '6ed1c836dbf099be7845bdab7671def2c157643761b52251e04e9b6ee109ec75', + }, + pe: { + imphash: '835d619dfdf3cc727cebd91300ab3462', + }, + mtime: 1542789400, + owner: 'Administrators', + name: 'test name', + path: 'C:\\Windows\\TEMP\\tmp0000008f\\tmp00001be5', + size: 188416, + code_signature: { + subject_name: 'Cybereason Inc', + trusted: false, + }, + malware_classifier: { + features: { + data: { + buffer: + 'eAHtnU1oHHUUwHsQ7MGDiIIUD4sH8WBBxJtopiLoUY0pYo2ZTbJJ0yQ17m4+ms/NRzeVWpuUWCL4sWlEYvFQ8KJQ6NCTEA8eRD30sIo3PdSriLi7837Pko3LbHZ2M5m+XObHm/d/X////83O7jCZvzacHBpPplNdfalkdjSdyty674Ft59dN71Dpb9v5eKh8LMEHjsCF2wIfVlRKsHROYPGkQO5+gY2vBSYYdWZFYGwEO/cITHMqkxPYnBBY+07gtCuQ9gSGigJ5lPPYGXcE+jA4z3Ad1ZtAUiDUyrEEPYzqRnIKgxd/Rgc7gygPo5wn95PouN7OeEYJ1UXiJgRmvscgp/LOziIkkSyT+xRVnXhZ4DKh5goCkzidRHkGO4uvCyw9LDDtCay8ILCAzrJOJaGuZwUuvSewivJVIPsklq8JbL4qMJsTSCcExrGs83WKU295ZFo5lr2TaZbcUw5FeJy8tgTeLpCy2iGeS67ABXzlgbEi1UC5FxcZnA4y/CLK82Qxi847FGGZRTLsCUxR1aWEwOp1AmOjDRYYzgwusL9WfqBiGJxnVAanixTq7Dp22LBdlWMJzlOx8wmBK2Rx5WmBLJIRwtAijOQE+ooCb2B5xBOYRtlfNeXpLpA7oyZRTqHzGenkmIJPnhBIMrzTwSA6H93CO5l+c1NA99f6IwLH8fUKdjTmDpTbgS50+gGVnECnE4PpooC2guPoaPADSHrcncNHmEHtAFkq3+EI+A37zsrrTvH3WTkvJLoOTyBp10wx2JcgVCRahA4NrICE4a+hrMXsA3qAHItW188E8ejO7XV3eh/KCYwxlamEwCgL8lN2wTntfrhY/U0g/5KAdvUpT+AszWqBdqH7VLeeZrExK9Cv1UgIDKA8g/cx7QAEP+AhAfRaMKB2HOJh+BSFSqKjSytNGBlc6PrpxvK7lCVDxbSG3Z7AhCMwx6gelwgLAltXBXJUTH29j+U1LHdipx/QprfKfGnF0sBpdBYxmEQyTzW0h6/0khcuhhJYRufym+i4VKMocJMs/KvfoW3/UJb4PeZOSZVONThZz4djP/75TAXa/CVfOvX3RgVLIDreLPN1pP1osW7lGmHsEhjBOzf+EPBE4vndvWz5xb/cChxGcv1LAb+tluALKnZ47isf1MXvz1ZMlsCXbXtPceqhrcp1ps6YHwQeBXLEPCf7q23tl9uJui0bGBgYRAccv7uXr/g5Af+2oNTrpgTa/vnpjBvpLAwM4gRBPvIZGBgYGBgYGBgYGBgYGBgYGBgYGBgYNAOc9oMXs4GBgYFBcNBnww5QzDXgRtPSaZ5lg/itsRaslgZ3bnWEEVnhMetIBwiiVnlbCbWrEftrt11zdwWnseFW1QO63w1is3ptD1pV9xG0t+zvfUrzrvh380qwXWAVCw6h78GIfG7ZlzltXu6hd+y92fECRFhjuH3bXG8N43oXEHperdzvUbteaDxhVTUeq25fqhG1X6Ai8mtF6BDXz2wR+dzSgg4Qsxls5T11XMG+82y8GkG+b7kL69xg7mF1SFvhBgYGsYH/Xi7HE+PVkiB2jt1bNZxT+k4558jR53ydz5//1m1KOgYGBgYGBgYGEQfnsYaG2z1sdPJS79XQSu91ndobOAHCaN5vNzUk1bceQVzUpbw3iOuT+UFmR18bHrp3gyhDC56lCd1y85w2+HSNUwVhhdGC7blLf+bV/fqtvhMg1NDjCcugB1QXswbs8ekj/v1BgzFHBIIsyP+HfwFdMpzu', + decompressed_size: 27831, + encoding: 'zlib', + }, + }, + identifier: 'endpointpe', + score: 1, + threshold: 0.66, + version: '3.0.33', + }, + temp_file_path: 'C:\\Windows\\TEMP\\1bb9abfc-ca14-47b2-9f2c-10c323df42f9', + }, + process: { + pid: 1076, + ppid: 432, + entity_id: 'wertqwer', + parent: { + pid: 432, + entity_id: 'adsfsdaf', + }, + name: 'test name', + code_signature: { + subject_name: 'Cybereason Inc', + trusted: true, + }, + command_line: '"C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe"', + domain: 'NT AUTHORITY', + executable: 'C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe', + hash: { + md5: '1f2d082566b0fc5f2c238a5180db7451', + sha1: 'ca85243c0af6a6471bdaa560685c51eefd6dbc0d', + sha256: '8ad40c90a611d36eb8f9eb24fa04f7dbca713db383ff55a03aa0f382e92061a2', + }, + pe: { + imphash: 'c30d230b81c734e82e86e2e2fe01cd01', + }, + malware_classifier: { + identifier: 'Whitelisted', + score: 0, + threshold: 0, + version: '3.0.0', + }, + thread: [ + { + id: 1652, + service_name: 'CybereasonAntiMalware', + start: 1542788400, + start_address: 8791698721056, + start_address_module: 'C:\\Program Files\\Cybereason ActiveProbe\\gzfltum.dll', + }, + ], + sid: 'S-1-5-18', + start: 1542788400, + token: { + domain: 'NT AUTHORITY', + integrity_level: 16384, + integrity_level_name: 'system', + privileges: [ + { + description: 'Replace a process level token', + enabled: false, + name: 'SeAssignPrimaryTokenPrivilege', + }, + ], + sid: 'S-1-5-18', + type: 'tokenPrimary', + user: 'SYSTEM', + }, + uptime: 1025, + user: 'SYSTEM', + }, + dll: [ + { + pe: { + architecture: 'x64', + imphash: 'c30d230b81c734e82e86e2e2fe01cd01', + }, + code_signature: { + subject_name: 'Cybereason Inc', + trusted: true, + }, + compile_time: 1534424710, + hash: { + md5: '1f2d082566b0fc5f2c238a5180db7451', + sha1: 'ca85243c0af6a6471bdaa560685c51eefd6dbc0d', + sha256: '8ad40c90a611d36eb8f9eb24fa04f7dbca713db383ff55a03aa0f382e92061a2', + }, + malware_classifier: { + identifier: 'Whitelisted', + score: 0, + threshold: 0, + version: '3.0.0', + }, + mapped_address: 5362483200, + mapped_size: 0, + path: 'C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe', + }, + ], }); } const mock: AlertResultList = { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts index 77d7397d72581..ee172fa80f1fe 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts @@ -11,6 +11,7 @@ import { AppAction } from '../action'; const initialState = (): AlertListState => { return { alerts: [], + alertDetails: undefined, pageSize: 10, pageIndex: 0, total: 0, @@ -43,6 +44,11 @@ export const alertListReducer: Reducer<AlertListState, AppAction> = ( ...state, location: action.payload, }; + } else if (action.type === 'serverReturnedAlertDetailsData') { + return { + ...state, + alertDetails: action.payload, + }; } return state; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts index f217e3cda9191..7ce7c2d08691e 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts @@ -15,7 +15,7 @@ import { AlertsAPIQueryParams, CreateStructuredSelector, } from '../../types'; -import { Immutable, LegacyEndpointEvent } from '../../../../../common/types'; +import { Immutable } from '../../../../../common/types'; const createStructuredSelector: CreateStructuredSelector = createStructuredSelectorWithBadType; /** @@ -23,6 +23,8 @@ const createStructuredSelector: CreateStructuredSelector = createStructuredSelec */ export const alertListData = (state: AlertListState) => state.alerts; +export const selectedAlertDetailsData = (state: AlertListState) => state.alertDetails; + /** * Returns the alert list pagination data from state */ @@ -96,20 +98,11 @@ export const hasSelectedAlert: (state: AlertListState) => boolean = createSelect /** * Determine if the alert event is most likely compatible with LegacyEndpointEvent. */ -function isAlertEventLegacyEndpointEvent(event: { endgame?: {} }): event is LegacyEndpointEvent { - return event.endgame !== undefined && 'unique_pid' in event.endgame; -} - -export const selectedEvent: ( +export const selectedAlertIsLegacyEndpointEvent: ( state: AlertListState -) => LegacyEndpointEvent | undefined = createSelector( - uiQueryParams, - alertListData, - ({ selected_alert: selectedAlert }, alertList) => { - const found = alertList.find(alert => alert.event.id === selectedAlert); - if (!found) { - return found; - } - return isAlertEventLegacyEndpointEvent(found) ? found : undefined; +) => boolean = createSelector(selectedAlertDetailsData, function(event) { + if (event === undefined) { + return false; } -); + return 'endgame' in event; +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts index 6498462a8fc06..b46785d3190e5 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts @@ -93,19 +93,22 @@ export type AlertListData = AlertResultList; export interface AlertListState { /** Array of alert items. */ - alerts: ImmutableArray<AlertData>; + readonly alerts: ImmutableArray<AlertData>; /** The total number of alerts on the page. */ - total: number; + readonly total: number; /** Number of alerts per page. */ - pageSize: number; + readonly pageSize: number; /** Page number, starting at 0. */ - pageIndex: number; + readonly pageIndex: number; /** Current location object from React Router history. */ readonly location?: Immutable<EndpointAppLocation>; + + /** Specific Alert data to be shown in the details view */ + readonly alertDetails?: Immutable<AlertData>; } /** diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/index.ts new file mode 100644 index 0000000000000..1c78309474737 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AlertDetailsOverview } from './overview'; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/file_accordion.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/file_accordion.tsx new file mode 100644 index 0000000000000..ac67e54f38779 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/file_accordion.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiAccordion, EuiDescriptionList } from '@elastic/eui'; +import { Immutable, AlertData } from '../../../../../../../common/types'; +import { FormattedDate } from '../../formatted_date'; + +export const FileAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => { + const columns = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileName', { + defaultMessage: 'File Name', + }), + description: alertData.file.name, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.filePath', { + defaultMessage: 'File Path', + }), + description: alertData.file.path, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileSize', { + defaultMessage: 'File Size', + }), + description: alertData.file.size, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileCreated', { + defaultMessage: 'File Created', + }), + description: <FormattedDate timestamp={alertData.file.created} />, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileModified', { + defaultMessage: 'File Modified', + }), + description: <FormattedDate timestamp={alertData.file.mtime} />, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileAccessed', { + defaultMessage: 'File Accessed', + }), + description: <FormattedDate timestamp={alertData.file.accessed} />, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.signer', { + defaultMessage: 'Signer', + }), + description: alertData.file.code_signature.subject_name, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.owner', { + defaultMessage: 'Owner', + }), + description: alertData.file.owner, + }, + ]; + }, [alertData]); + + return ( + <EuiAccordion + id="alertDetailsFileAccordion" + buttonContent={i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.file', + { + defaultMessage: 'File', + } + )} + paddingSize="l" + > + <EuiDescriptionList type="column" listItems={columns} /> + </EuiAccordion> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/general_accordion.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/general_accordion.tsx new file mode 100644 index 0000000000000..070c78c968585 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/general_accordion.tsx @@ -0,0 +1,68 @@ +/* + * 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, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiAccordion, EuiDescriptionList } from '@elastic/eui'; +import { Immutable, AlertData } from '../../../../../../../common/types'; +import { FormattedDate } from '../../formatted_date'; + +export const GeneralAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => { + const columns = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.alertType', { + defaultMessage: 'Alert Type', + }), + description: alertData.event.category, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.eventType', { + defaultMessage: 'Event Type', + }), + description: alertData.event.kind, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.status', { + defaultMessage: 'Status', + }), + description: 'TODO', + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.dateCreated', { + defaultMessage: 'Date Created', + }), + description: <FormattedDate timestamp={alertData['@timestamp']} />, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.malwareScore', { + defaultMessage: 'MalwareScore', + }), + description: alertData.file.malware_classifier.score, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileName', { + defaultMessage: 'File Name', + }), + description: alertData.file.name, + }, + ]; + }, [alertData]); + return ( + <EuiAccordion + id="alertDetailsAlertAccordion" + buttonContent={i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.alert', + { + defaultMessage: 'Alert', + } + )} + paddingSize="l" + initialIsOpen={true} + > + <EuiDescriptionList type="column" listItems={columns} /> + </EuiAccordion> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/hash_accordion.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/hash_accordion.tsx new file mode 100644 index 0000000000000..b2be083ce8f59 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/hash_accordion.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiAccordion, EuiDescriptionList } from '@elastic/eui'; +import { Immutable, AlertData } from '../../../../../../../common/types'; + +export const HashAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => { + const columns = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.md5', { + defaultMessage: 'MD5', + }), + description: alertData.file.hash.md5, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sha1', { + defaultMessage: 'SHA1', + }), + description: alertData.file.hash.sha1, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sha256', { + defaultMessage: 'SHA256', + }), + description: alertData.file.hash.sha256, + }, + ]; + }, [alertData]); + + return ( + <EuiAccordion + id="alertDetailsHashAccordion" + buttonContent={i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.hash', + { + defaultMessage: 'Hash', + } + )} + paddingSize="l" + > + <EuiDescriptionList type="column" listItems={columns} /> + </EuiAccordion> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/host_accordion.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/host_accordion.tsx new file mode 100644 index 0000000000000..4108781f0a79b --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/host_accordion.tsx @@ -0,0 +1,55 @@ +/* + * 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, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiAccordion, EuiDescriptionList } from '@elastic/eui'; +import { Immutable, AlertData } from '../../../../../../../common/types'; + +export const HostAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => { + const columns = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.hostName', { + defaultMessage: 'Host Name', + }), + description: alertData.host.hostname, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.hostIP', { + defaultMessage: 'Host IP', + }), + description: alertData.host.ip.join(', '), + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.status', { + defaultMessage: 'Status', + }), + description: 'TODO', + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.os', { + defaultMessage: 'OS', + }), + description: alertData.host.os.name, + }, + ]; + }, [alertData]); + + return ( + <EuiAccordion + id="alertDetailsHostAccordion" + buttonContent={i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.host', + { + defaultMessage: 'Host', + } + )} + paddingSize="l" + > + <EuiDescriptionList type="column" listItems={columns} /> + </EuiAccordion> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/index.ts new file mode 100644 index 0000000000000..1eb755242d701 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { GeneralAccordion } from './general_accordion'; +export { HostAccordion } from './host_accordion'; +export { HashAccordion } from './hash_accordion'; +export { FileAccordion } from './file_accordion'; +export { SourceProcessAccordion } from './source_process_accordion'; +export { SourceProcessTokenAccordion } from './source_process_token_accordion'; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_accordion.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_accordion.tsx new file mode 100644 index 0000000000000..4c961ad4b4964 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_accordion.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiAccordion, EuiDescriptionList } from '@elastic/eui'; +import { Immutable, AlertData } from '../../../../../../../common/types'; + +export const SourceProcessAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => { + const columns = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.processID', { + defaultMessage: 'Process ID', + }), + description: alertData.process.pid, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.processName', { + defaultMessage: 'Process Name', + }), + description: alertData.process.name, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.processPath', { + defaultMessage: 'Process Path', + }), + description: alertData.process.executable, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.md5', { + defaultMessage: 'MD5', + }), + description: alertData.process.hash.md5, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sha1', { + defaultMessage: 'SHA1', + }), + description: alertData.process.hash.sha1, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sha256', { + defaultMessage: 'SHA256', + }), + description: alertData.process.hash.sha256, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.malwareScore', { + defaultMessage: 'MalwareScore', + }), + description: alertData.process.malware_classifier.score, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.parentProcessID', { + defaultMessage: 'Parent Process ID', + }), + description: alertData.process.parent.pid, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.signer', { + defaultMessage: 'Signer', + }), + description: alertData.process.code_signature.subject_name, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.username', { + defaultMessage: 'Username', + }), + description: alertData.process.token.user, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.domain', { + defaultMessage: 'Domain', + }), + description: alertData.process.token.domain, + }, + ]; + }, [alertData]); + + return ( + <EuiAccordion + id="alertDetailsSourceProcessAccordion" + buttonContent={i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.sourceProcess', + { + defaultMessage: 'Source Process', + } + )} + paddingSize="l" + > + <EuiDescriptionList type="column" listItems={columns} /> + </EuiAccordion> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_token_accordion.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_token_accordion.tsx new file mode 100644 index 0000000000000..7d75d4478afb3 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_token_accordion.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiAccordion, EuiDescriptionList } from '@elastic/eui'; +import { Immutable, AlertData } from '../../../../../../../common/types'; + +export const SourceProcessTokenAccordion = memo( + ({ alertData }: { alertData: Immutable<AlertData> }) => { + const columns = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sid', { + defaultMessage: 'SID', + }), + description: alertData.process.token.sid, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.integrityLevel', { + defaultMessage: 'Integrity Level', + }), + description: alertData.process.token.integrity_level, + }, + ]; + }, [alertData]); + + return ( + <EuiAccordion + id="alertDetailsSourceProcessTokenAccordion" + buttonContent={i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.sourceProcessToken', + { + defaultMessage: 'Source Process Token', + } + )} + paddingSize="l" + > + <EuiDescriptionList type="column" listItems={columns} /> + </EuiAccordion> + ); + } +); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/index.tsx new file mode 100644 index 0000000000000..080c70ca43bae --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/index.tsx @@ -0,0 +1,94 @@ +/* + * 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, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiTitle, EuiText, EuiHealth, EuiTabbedContent } from '@elastic/eui'; +import { useAlertListSelector } from '../../hooks/use_alerts_selector'; +import * as selectors from '../../../../store/alerts/selectors'; +import { MetadataPanel } from './metadata_panel'; +import { FormattedDate } from '../../formatted_date'; +import { AlertDetailResolver } from '../../resolver'; + +export const AlertDetailsOverview = memo(() => { + const alertDetailsData = useAlertListSelector(selectors.selectedAlertDetailsData); + if (alertDetailsData === undefined) { + return null; + } + const selectedAlertIsLegacyEndpointEvent = useAlertListSelector( + selectors.selectedAlertIsLegacyEndpointEvent + ); + + const tabs = useMemo(() => { + return [ + { + id: 'overviewMetadata', + name: i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.overview.tabs.overview', + { + defaultMessage: 'Overview', + } + ), + content: ( + <> + <EuiSpacer /> + <MetadataPanel /> + </> + ), + }, + { + id: 'overviewResolver', + name: i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.overview.tabs.resolver', + { + defaultMessage: 'Resolver', + } + ), + content: ( + <> + <EuiSpacer /> + {selectedAlertIsLegacyEndpointEvent && <AlertDetailResolver />} + </> + ), + }, + ]; + }, [selectedAlertIsLegacyEndpointEvent]); + + return ( + <> + <section className="details-overview-summary"> + <EuiTitle size="s"> + <h3> + <FormattedMessage + id="xpack.endpoint.application.endpoint.alertDetails.overview.title" + defaultMessage="Detected Malicious File" + /> + </h3> + </EuiTitle> + <EuiSpacer /> + <EuiText> + <p> + <FormattedMessage + id="xpack.endpoint.application.endpoint.alertDetails.overview.summary" + defaultMessage="MalwareScore detected the opening of a document on {hostname} on {date}" + values={{ + hostname: alertDetailsData.host.hostname, + date: <FormattedDate timestamp={alertDetailsData['@timestamp']} />, + }} + /> + </p> + </EuiText> + <EuiSpacer /> + <EuiText> + Endpoint Status: <EuiHealth color="success">Online</EuiHealth> + </EuiText> + <EuiText>Alert Status: Open</EuiText> + <EuiSpacer /> + </section> + <EuiTabbedContent tabs={tabs} initialSelectedTab={tabs[0]} /> + </> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/metadata_panel.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/metadata_panel.tsx new file mode 100644 index 0000000000000..556d7bea2e310 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/metadata_panel.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { useAlertListSelector } from '../../hooks/use_alerts_selector'; +import * as selectors from '../../../../store/alerts/selectors'; +import { + GeneralAccordion, + HostAccordion, + HashAccordion, + FileAccordion, + SourceProcessAccordion, + SourceProcessTokenAccordion, +} from '../metadata'; + +export const MetadataPanel = memo(() => { + const alertDetailsData = useAlertListSelector(selectors.selectedAlertDetailsData); + if (alertDetailsData === undefined) { + return null; + } + return ( + <section className="overview-metadata-panel"> + <GeneralAccordion alertData={alertDetailsData} /> + <EuiSpacer /> + <HostAccordion alertData={alertDetailsData} /> + <EuiSpacer /> + <HashAccordion alertData={alertDetailsData} /> + <EuiSpacer /> + <FileAccordion alertData={alertDetailsData} /> + <EuiSpacer /> + <SourceProcessAccordion alertData={alertDetailsData} /> + <EuiSpacer /> + <SourceProcessTokenAccordion alertData={alertDetailsData} /> + </section> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/formatted_date.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/formatted_date.tsx new file mode 100644 index 0000000000000..731bd31b26cef --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/formatted_date.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo } from 'react'; +import { FormattedDate as ReactIntlFormattedDate } from '@kbn/i18n/react'; + +export const FormattedDate = memo(({ timestamp }: { timestamp: number }) => { + const date = new Date(timestamp); + return ( + <ReactIntlFormattedDate + value={date} + year="numeric" + month="2-digit" + day="2-digit" + hour="2-digit" + minute="2-digit" + second="2-digit" + /> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx index fe362f21a178e..aae44824c3164 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx @@ -140,9 +140,6 @@ describe('when on the alerting page', () => { it('should show the flyout', async () => { await render().findByTestId('alertDetailFlyout'); }); - it('should render resolver', async () => { - await render().findByTestId('alertResolver'); - }); describe('when the user clicks the close button on the flyout', () => { let renderResult: reactTestingLibrary.RenderResult; beforeEach(async () => { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx index 3c229484ede4e..5d405f8c6fbae 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx @@ -17,15 +17,21 @@ import { EuiFlyoutBody, EuiTitle, EuiBadge, + EuiLoadingSpinner, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageContentBody, + EuiLink, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useHistory, Link } from 'react-router-dom'; -import { FormattedDate } from 'react-intl'; +import { useHistory } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; import { urlFromQueryParams } from './url_from_query_params'; import { AlertData } from '../../../../../common/types'; import * as selectors from '../../store/alerts/selectors'; import { useAlertListSelector } from './hooks/use_alerts_selector'; -import { AlertDetailResolver } from './resolver'; +import { AlertDetailsOverview } from './details'; +import { FormattedDate } from './formatted_date'; export const AlertIndex = memo(() => { const history = useHistory(); @@ -87,7 +93,6 @@ export const AlertIndex = memo(() => { const alertListData = useAlertListSelector(selectors.alertListData); const hasSelectedAlert = useAlertListSelector(selectors.hasSelectedAlert); const queryParams = useAlertListSelector(selectors.uiQueryParams); - const selectedEvent = useAlertListSelector(selectors.selectedEvent); const onChangeItemsPerPage = useCallback( newPageSize => { @@ -119,10 +124,10 @@ export const AlertIndex = memo(() => { history.push(urlFromQueryParams(paramsWithoutSelectedAlert)); }, [history, queryParams]); - const datesForRows: Map<AlertData, Date> = useMemo(() => { + const timestampForRows: Map<AlertData, number> = useMemo(() => { return new Map( alertListData.map(alertData => { - return [alertData, new Date(alertData['@timestamp'])]; + return [alertData, alertData['@timestamp']]; }) ); }, [alertListData]); @@ -136,9 +141,11 @@ export const AlertIndex = memo(() => { const row = alertListData[rowIndex % pageSize]; if (columnId === 'alert_type') { return ( - <Link + <EuiLink data-testid="alertTypeCellLink" - to={urlFromQueryParams({ ...queryParams, selected_alert: row.event.id })} + onClick={() => + history.push(urlFromQueryParams({ ...queryParams, selected_alert: row.id })) + } > {i18n.translate( 'xpack.endpoint.application.endpoint.alerts.alertType.maliciousFileDescription', @@ -146,7 +153,7 @@ export const AlertIndex = memo(() => { defaultMessage: 'Malicious File', } )} - </Link> + </EuiLink> ); } else if (columnId === 'event_type') { return row.event.action; @@ -157,19 +164,9 @@ export const AlertIndex = memo(() => { } else if (columnId === 'host_name') { return row.host.hostname; } else if (columnId === 'timestamp') { - const date = datesForRows.get(row)!; - if (date && isFinite(date.getTime())) { - return ( - <FormattedDate - value={date} - year="numeric" - month="2-digit" - day="2-digit" - hour="2-digit" - minute="2-digit" - second="2-digit" - /> - ); + const timestamp = timestampForRows.get(row)!; + if (timestamp) { + return <FormattedDate timestamp={timestamp} />; } else { return ( <EuiBadge color="warning"> @@ -185,11 +182,11 @@ export const AlertIndex = memo(() => { } else if (columnId === 'archived') { return null; } else if (columnId === 'malware_score') { - return row.file_classification.malware_classification.score; + return row.file.malware_classifier.score; } return null; }; - }, [alertListData, datesForRows, pageSize, queryParams, total]); + }, [total, alertListData, pageSize, history, queryParams, timestampForRows]); const pagination = useMemo(() => { return { @@ -201,6 +198,16 @@ export const AlertIndex = memo(() => { }; }, [onChangeItemsPerPage, onChangePage, pageIndex, pageSize]); + const columnVisibility = useMemo( + () => ({ + visibleColumns, + setVisibleColumns, + }), + [setVisibleColumns, visibleColumns] + ); + + const selectedAlertData = useAlertListSelector(selectors.selectedAlertDetailsData); + return ( <> {hasSelectedAlert && ( @@ -215,29 +222,37 @@ export const AlertIndex = memo(() => { </EuiTitle> </EuiFlyoutHeader> <EuiFlyoutBody> - <AlertDetailResolver selectedEvent={selectedEvent} /> + {selectedAlertData ? <AlertDetailsOverview /> : <EuiLoadingSpinner size="xl" />} </EuiFlyoutBody> </EuiFlyout> )} <EuiPage data-test-subj="alertListPage" data-testid="alertListPage"> <EuiPageBody> <EuiPageContent> - <EuiDataGrid - aria-label="Alert List" - rowCount={total} - columns={columns} - columnVisibility={useMemo( - () => ({ - visibleColumns, - setVisibleColumns, - }), - [setVisibleColumns, visibleColumns] - )} - renderCellValue={renderCellValue} - pagination={pagination} - data-test-subj="alertListGrid" - data-testid="alertListGrid" - /> + <EuiPageContentHeader> + <EuiPageContentHeaderSection> + <EuiTitle size="l"> + <h1> + <FormattedMessage + id="xpack.endpoint.alertList.viewTitle" + defaultMessage="Alerts" + /> + </h1> + </EuiTitle> + </EuiPageContentHeaderSection> + </EuiPageContentHeader> + <EuiPageContentBody> + <EuiDataGrid + aria-label="Alert List" + rowCount={total} + columns={columns} + columnVisibility={columnVisibility} + renderCellValue={renderCellValue} + pagination={pagination} + data-test-subj="alertListGrid" + data-testid="alertListGrid" + /> + </EuiPageContentBody> </EuiPageContent> </EuiPageBody> </EuiPage> diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx index c7ef7f73dfe05..ec1dab45d50ab 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx @@ -18,6 +18,7 @@ export const AlertDetailResolver = styled( ({ className, selectedEvent }: { className?: string; selectedEvent?: LegacyEndpointEvent }) => { const context = useKibana<EndpointPluginServices>(); const { store } = storeFactory(context); + return ( <div className={className} data-test-subj="alertResolver" data-testid="alertResolver"> <Provider store={store}> From 41deda35841fc609a58aba5fc87a477e589b522c Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Tue, 3 Mar 2020 10:28:40 +0300 Subject: [PATCH 26/36] Fix monaco editor styling (#58888) * Fix monaco editor styling * Change line highlight border --- src/plugins/kibana_react/public/code_editor/editor_theme.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/plugins/kibana_react/public/code_editor/editor_theme.ts b/src/plugins/kibana_react/public/code_editor/editor_theme.ts index 6e30135686797..586b4a568c348 100644 --- a/src/plugins/kibana_react/public/code_editor/editor_theme.ts +++ b/src/plugins/kibana_react/public/code_editor/editor_theme.ts @@ -101,6 +101,11 @@ export function createTheme( 'editor.selectionBackground': selectionBackgroundColor, 'editorWidget.border': euiTheme.euiColorLightShade, 'editorWidget.background': euiTheme.euiColorLightestShade, + 'editorCursor.foreground': euiTheme.euiColorDarkestShade, + 'editorSuggestWidget.selectedBackground': euiTheme.euiColorLightShade, + 'list.hoverBackground': euiTheme.euiColorLightShade, + 'list.highlightForeground': euiTheme.euiColorPrimary, + 'editor.lineHighlightBorder': euiTheme.euiColorLightestShade, }, }; } From 421d9d502b45b04569c48d0c42e48872fdaffc98 Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Tue, 3 Mar 2020 10:31:02 +0300 Subject: [PATCH 27/36] [Vis Editor] Fix field combo box search value (#58601) * Fix field combo box search value * Fix inconsistent behavior * Apply validation for agg_select --- .../vis_default_editor/public/components/agg_select.tsx | 7 +++++-- .../public/components/controls/field.tsx | 8 ++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_select.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_select.tsx index a2cec61b122ef..9a408c2d98b22 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_select.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_select.tsx @@ -17,7 +17,7 @@ * under the License. */ import { get, has } from 'lodash'; -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useState } from 'react'; import { EuiComboBox, EuiComboBoxOptionProps, EuiFormRow, EuiLink, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -52,6 +52,7 @@ function DefaultEditorAggSelect({ isSubAggregation, onChangeAggType, }: DefaultEditorAggSelectProps) { + const [isDirty, setIsDirty] = useState(false); const { services } = useKibana(); const selectedOptions: ComboBoxGroupedOptions<IAggType> = value ? [{ label: value.title, target: value }] @@ -100,7 +101,7 @@ function DefaultEditorAggSelect({ ); } - const isValid = !!value && !errors.length; + const isValid = !!value && !errors.length && !isDirty; const onChange = useCallback( (options: EuiComboBoxOptionProps[]) => { @@ -111,6 +112,7 @@ function DefaultEditorAggSelect({ }, [setValue] ); + const onSearchChange = useCallback(searchValue => setIsDirty(Boolean(searchValue)), []); const setTouched = useCallback( () => onChangeAggType({ type: AGG_TYPE_ACTION_KEYS.TOUCHED, payload: true }), @@ -151,6 +153,7 @@ function DefaultEditorAggSelect({ singleSelection={{ asPlainText: true }} onBlur={setTouched} onChange={onChange} + onSearchChange={onSearchChange} data-test-subj="defaultEditorAggSelect" isClearable={false} isInvalid={showValidation ? !isValid : false} diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.tsx index 8bf7bc384b07a..d605fb203f4d3 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.tsx @@ -18,7 +18,7 @@ */ import { get } from 'lodash'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { EuiComboBox, EuiComboBoxOptionProps, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -50,6 +50,7 @@ function FieldParamEditor({ setValidity, setValue, }: FieldParamEditorProps) { + const [isDirty, setIsDirty] = useState(false); const selectedOptions: ComboBoxGroupedOptions<IndexPatternField> = value ? [{ label: value.displayName || value.name, target: value }] : []; @@ -79,7 +80,7 @@ function FieldParamEditor({ ); } - const isValid = !!value && !errors.length; + const isValid = !!value && !errors.length && !isDirty; useValidation(setValidity, isValid); @@ -98,6 +99,8 @@ function FieldParamEditor({ } }, []); + const onSearchChange = useCallback(searchValue => setIsDirty(Boolean(searchValue)), []); + return ( <EuiFormRow label={customLabel || label} @@ -119,6 +122,7 @@ function FieldParamEditor({ isInvalid={showValidation ? !isValid : false} onChange={onChange} onBlur={setTouched} + onSearchChange={onSearchChange} data-test-subj="visDefaultEditorField" fullWidth={true} /> From ecbcceb74d8e37d069b6dafc2136908f664b7bb6 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger <walter@elastic.co> Date: Tue, 3 Mar 2020 08:59:00 +0100 Subject: [PATCH 28/36] [ML] Transform: Fix advanced editor initialization. (#59006) Fixes regression introduced by #58015 to correctly initialize the transform wizard advanced editor with the current configuration. --- .../step_define/step_define_form.tsx | 5 ++++ .../apps/transform/creation_index_pattern.ts | 29 +++++++++++++++++++ .../services/transform_ui/wizard.ts | 14 +++++++++ 3 files changed, 48 insertions(+) diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx index 3adb74e4704dc..bde832894632c 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx @@ -404,6 +404,10 @@ export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange xJson: advancedEditorConfig, } = useXJsonMode(stringifiedPivotConfig); + useEffect(() => { + setAdvancedEditorConfig(stringifiedPivotConfig); + }, [setAdvancedEditorConfig, stringifiedPivotConfig]); + // source config const stringifiedSourceConfig = JSON.stringify(previewRequest.source.query, null, 2); const [ @@ -797,6 +801,7 @@ export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange > <EuiPanel grow={false} paddingSize="none"> <EuiCodeEditor + data-test-subj="transformAdvancedPivotEditor" mode={xJsonMode} width="100%" value={advancedEditorConfig} diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 6e35b0c1a81ca..5b54bfdafdbdb 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -57,6 +57,28 @@ export default function({ getService }: FtrProviderContext) { return `user-${this.transformId}`; }, expected: { + pivotAdvancedEditorValue: { + group_by: { + 'category.keyword': { + terms: { + field: 'category.keyword', + }, + }, + order_date: { + date_histogram: { + field: 'order_date', + calendar_interval: '1m', + }, + }, + }, + aggregations: { + 'products.base_price.avg': { + avg: { + field: 'products.base_price', + }, + }, + }, + }, pivotPreview: { column: 0, values: [`Men's Accessories`], @@ -152,6 +174,13 @@ export default function({ getService }: FtrProviderContext) { await transform.wizard.assertAdvancedPivotEditorSwitchCheckState(false); }); + it('displays the advanced configuration', async () => { + await transform.wizard.enabledAdvancedPivotEditor(); + await transform.wizard.assertAdvancedPivotEditorContent( + testData.expected.pivotAdvancedEditorValue + ); + }); + it('loads the pivot preview', async () => { await transform.wizard.assertPivotPreviewLoaded(); }); diff --git a/x-pack/test/functional/services/transform_ui/wizard.ts b/x-pack/test/functional/services/transform_ui/wizard.ts index e823117ad7016..aca08f7083aa8 100644 --- a/x-pack/test/functional/services/transform_ui/wizard.ts +++ b/x-pack/test/functional/services/transform_ui/wizard.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export function TransformWizardProvider({ getService }: FtrProviderContext) { + const aceEditor = getService('aceEditor'); const testSubjects = getService('testSubjects'); const comboBox = getService('comboBox'); const retry = getService('retry'); @@ -273,6 +274,12 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { await this.assertAggregationEntryExists(index, expectedLabel); }, + async assertAdvancedPivotEditorContent(expectedValue: Record<string, any>) { + const advancedEditorString = await aceEditor.getValue('transformAdvancedPivotEditor'); + const advancedEditorValue = JSON.parse(advancedEditorString); + expect(advancedEditorValue).to.eql(expectedValue); + }, + async assertAdvancedPivotEditorSwitchExists() { await testSubjects.existOrFail(`transformAdvancedPivotEditorSwitch`, { allowHidden: true }); }, @@ -287,6 +294,13 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { ); }, + async enabledAdvancedPivotEditor() { + await this.assertAdvancedPivotEditorSwitchCheckState(false); + await testSubjects.click('transformAdvancedPivotEditorSwitch'); + await this.assertAdvancedPivotEditorSwitchCheckState(true); + await testSubjects.existOrFail('transformAdvancedPivotEditor'); + }, + async assertTransformIdInputExists() { await testSubjects.existOrFail('transformIdInput'); }, From bb55e8a21cc50cda8cf8753064b49f433a796451 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger <walter@elastic.co> Date: Tue, 3 Mar 2020 09:01:45 +0100 Subject: [PATCH 29/36] [ML] Transforms: Migrate server plugin to NP. (#58714) Migrate transform legacy server to NP. - Create server plugin/index for transform in x-pack/plugins. - Move all legacy/server files to plugins/transform --- x-pack/.i18nrc.json | 2 +- x-pack/legacy/plugins/transform/index.ts | 19 +- .../legacy/plugins/transform/server/plugin.ts | 17 - .../transform/server/routes/api/app.ts | 99 ----- .../server/routes/api/register_routes.ts | 14 - .../routes/api/transform_audit_messages.ts | 84 ---- .../transform/server/routes/api/transforms.ts | 261 ------------- .../legacy/plugins/transform/server/shim.ts | 46 --- x-pack/plugins/transform/kibana.json | 15 + .../server/client/elasticsearch_transform.ts | 0 x-pack/plugins/transform/server/index.ts | 11 + x-pack/plugins/transform/server/plugin.ts | 89 +++++ .../server/routes/api/error_utils.ts | 16 +- .../transform/server/routes/api/privileges.ts | 85 +++++ .../transform/server/routes/api/schema.ts | 16 + .../transform/server/routes/api/transforms.ts | 360 ++++++++++++++++++ .../routes/api/transforms_audit_messages.ts | 91 +++++ .../plugins/transform/server/routes/index.ts | 24 ++ .../transform/server/services/index.ts | 7 + .../transform/server/services/license.ts | 91 +++++ x-pack/plugins/transform/server/types.ts | 18 + 21 files changed, 824 insertions(+), 541 deletions(-) delete mode 100644 x-pack/legacy/plugins/transform/server/plugin.ts delete mode 100644 x-pack/legacy/plugins/transform/server/routes/api/app.ts delete mode 100644 x-pack/legacy/plugins/transform/server/routes/api/register_routes.ts delete mode 100644 x-pack/legacy/plugins/transform/server/routes/api/transform_audit_messages.ts delete mode 100644 x-pack/legacy/plugins/transform/server/routes/api/transforms.ts delete mode 100644 x-pack/legacy/plugins/transform/server/shim.ts create mode 100644 x-pack/plugins/transform/kibana.json rename x-pack/{legacy => }/plugins/transform/server/client/elasticsearch_transform.ts (100%) create mode 100644 x-pack/plugins/transform/server/index.ts create mode 100644 x-pack/plugins/transform/server/plugin.ts rename x-pack/{legacy => }/plugins/transform/server/routes/api/error_utils.ts (80%) create mode 100644 x-pack/plugins/transform/server/routes/api/privileges.ts create mode 100644 x-pack/plugins/transform/server/routes/api/schema.ts create mode 100644 x-pack/plugins/transform/server/routes/api/transforms.ts create mode 100644 x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts create mode 100644 x-pack/plugins/transform/server/routes/index.ts create mode 100644 x-pack/plugins/transform/server/services/index.ts create mode 100644 x-pack/plugins/transform/server/services/license.ts create mode 100644 x-pack/plugins/transform/server/types.ts diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 51099815ec938..8f5a5ea4f10e4 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -39,7 +39,7 @@ "xpack.snapshotRestore": "legacy/plugins/snapshot_restore", "xpack.spaces": ["legacy/plugins/spaces", "plugins/spaces"], "xpack.taskManager": "legacy/plugins/task_manager", - "xpack.transform": "legacy/plugins/transform", + "xpack.transform": ["legacy/plugins/transform", "plugins/transform"], "xpack.triggersActionsUI": "plugins/triggers_actions_ui", "xpack.upgradeAssistant": "plugins/upgrade_assistant", "xpack.uptime": "legacy/plugins/uptime", diff --git a/x-pack/legacy/plugins/transform/index.ts b/x-pack/legacy/plugins/transform/index.ts index d0799f46cbd25..10f4732152c43 100644 --- a/x-pack/legacy/plugins/transform/index.ts +++ b/x-pack/legacy/plugins/transform/index.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; import { resolve } from 'path'; + import { PLUGIN } from './common/constants'; -import { Plugin as TransformPlugin } from './server/plugin'; -import { createServerShim } from './server/shim'; export function transform(kibana: any) { return new kibana.Plugin({ @@ -20,20 +18,5 @@ export function transform(kibana: any) { styleSheetPaths: resolve(__dirname, 'public/app/index.scss'), managementSections: ['plugins/transform'], }, - init(server: Legacy.Server) { - const { core, plugins } = createServerShim(server, PLUGIN.ID); - const transformPlugin = new TransformPlugin(); - - // Start plugin - transformPlugin.start(core, plugins); - - // Register license checker - plugins.license.registerLicenseChecker( - server, - PLUGIN.ID, - PLUGIN.getI18nName(), - PLUGIN.MINIMUM_LICENSE_REQUIRED - ); - }, }); } diff --git a/x-pack/legacy/plugins/transform/server/plugin.ts b/x-pack/legacy/plugins/transform/server/plugin.ts deleted file mode 100644 index f9264ee1f2507..0000000000000 --- a/x-pack/legacy/plugins/transform/server/plugin.ts +++ /dev/null @@ -1,17 +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 { API_BASE_PATH } from '../common/constants'; -import { registerRoutes } from './routes/api/register_routes'; -import { Core, Plugins } from './shim'; - -export class Plugin { - public start(core: Core, plugins: Plugins): void { - const router = core.http.createRouter(API_BASE_PATH); - - // Register routes - registerRoutes(router, plugins); - } -} diff --git a/x-pack/legacy/plugins/transform/server/routes/api/app.ts b/x-pack/legacy/plugins/transform/server/routes/api/app.ts deleted file mode 100644 index c3189794b6eb0..0000000000000 --- a/x-pack/legacy/plugins/transform/server/routes/api/app.ts +++ /dev/null @@ -1,99 +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 { Router, RouterRouteHandler } from '../../../../../server/lib/create_router'; -import { wrapCustomError } from '../../../../../server/lib/create_router/error_wrappers'; -import { APP_CLUSTER_PRIVILEGES, APP_INDEX_PRIVILEGES } from '../../../common/constants'; -// NOTE: now we import it from our "public" folder, but when the Authorisation lib -// will move to the "es_ui_shared" plugin, it will be imported from its "static" folder -import { Privileges } from '../../../public/app/lib/authorization'; -import { Plugins } from '../../shim'; - -let xpackMainPlugin: any; - -export function registerAppRoutes(router: Router, plugins: Plugins) { - xpackMainPlugin = plugins.xpack_main; - router.get('privileges', getPrivilegesHandler); -} - -export function getXpackMainPlugin() { - return xpackMainPlugin; -} - -const extractMissingPrivileges = (privilegesObject: { [key: string]: boolean } = {}): string[] => - Object.keys(privilegesObject).reduce((privileges: string[], privilegeName: string): string[] => { - if (!privilegesObject[privilegeName]) { - privileges.push(privilegeName); - } - return privileges; - }, []); - -export const getPrivilegesHandler: RouterRouteHandler = async ( - req, - callWithRequest -): Promise<Privileges> => { - const xpackInfo = getXpackMainPlugin() && getXpackMainPlugin().info; - if (!xpackInfo) { - // xpackInfo is updated via poll, so it may not be available until polling has begun. - // In this rare situation, tell the client the service is temporarily unavailable. - throw wrapCustomError(new Error('Security info unavailable'), 503); - } - - const privilegesResult: Privileges = { - hasAllPrivileges: true, - missingPrivileges: { - cluster: [], - index: [], - }, - }; - - const securityInfo = xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security'); - if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) { - // If security isn't enabled, let the user use app. - return privilegesResult; - } - - // Get cluster priviliges - const { has_all_requested: hasAllPrivileges, cluster } = await callWithRequest( - 'transport.request', - { - path: '/_security/user/_has_privileges', - method: 'POST', - body: { - cluster: APP_CLUSTER_PRIVILEGES, - }, - } - ); - - // Find missing cluster privileges and set overall app privileges - privilegesResult.missingPrivileges.cluster = extractMissingPrivileges(cluster); - privilegesResult.hasAllPrivileges = hasAllPrivileges; - - // Get all index privileges the user has - const { indices } = await callWithRequest('transport.request', { - path: '/_security/user/_privileges', - method: 'GET', - }); - - // Check if they have all the required index privileges for at least one index - const oneIndexWithAllPrivileges = indices.find(({ privileges }: { privileges: string[] }) => { - if (privileges.includes('all')) { - return true; - } - - const indexHasAllPrivileges = APP_INDEX_PRIVILEGES.every(privilege => - privileges.includes(privilege) - ); - - return indexHasAllPrivileges; - }); - - // If they don't, return list of required index privileges - if (!oneIndexWithAllPrivileges) { - privilegesResult.missingPrivileges.index = [...APP_INDEX_PRIVILEGES]; - } - - return privilegesResult; -}; diff --git a/x-pack/legacy/plugins/transform/server/routes/api/register_routes.ts b/x-pack/legacy/plugins/transform/server/routes/api/register_routes.ts deleted file mode 100644 index c01647c598d86..0000000000000 --- a/x-pack/legacy/plugins/transform/server/routes/api/register_routes.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { Router } from '../../../../../server/lib/create_router'; -import { Plugins } from '../../shim'; -import { registerAppRoutes } from './app'; -import { registerTransformsRoutes } from './transforms'; - -export const registerRoutes = (router: Router, plugins: Plugins): void => { - registerAppRoutes(router, plugins); - registerTransformsRoutes(router, plugins); -}; diff --git a/x-pack/legacy/plugins/transform/server/routes/api/transform_audit_messages.ts b/x-pack/legacy/plugins/transform/server/routes/api/transform_audit_messages.ts deleted file mode 100644 index c4b5fbd4d3b60..0000000000000 --- a/x-pack/legacy/plugins/transform/server/routes/api/transform_audit_messages.ts +++ /dev/null @@ -1,84 +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 { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; - -import { AuditMessage } from '../../../common/types/messages'; - -const ML_DF_NOTIFICATION_INDEX_PATTERN = '.transform-notifications-read'; -const SIZE = 500; - -interface BoolQuery { - bool: { [key: string]: any }; -} - -export function transformAuditMessagesProvider(callWithRequest: CallCluster) { - // search for audit messages, - // transformId is optional. without it, all transforms will be listed. - async function getTransformAuditMessages(transformId: string) { - const query: BoolQuery = { - bool: { - filter: [ - { - bool: { - must_not: { - term: { - level: 'activity', - }, - }, - }, - }, - ], - }, - }; - - // if no transformId specified, load all of the messages - if (transformId !== undefined) { - query.bool.filter.push({ - bool: { - should: [ - { - term: { - transform_id: '', // catch system messages - }, - }, - { - term: { - transform_id: transformId, // messages for specified transformId - }, - }, - ], - }, - }); - } - - try { - const resp = await callWithRequest('search', { - index: ML_DF_NOTIFICATION_INDEX_PATTERN, - ignore_unavailable: true, - rest_total_hits_as_int: true, - size: SIZE, - body: { - sort: [{ timestamp: { order: 'desc' } }, { transform_id: { order: 'asc' } }], - query, - }, - }); - - let messages = []; - if (resp.hits.total !== 0) { - messages = resp.hits.hits.map((hit: AuditMessage) => hit._source); - messages.reverse(); - } - return messages; - } catch (e) { - throw e; - } - } - - return { - getTransformAuditMessages, - }; -} diff --git a/x-pack/legacy/plugins/transform/server/routes/api/transforms.ts b/x-pack/legacy/plugins/transform/server/routes/api/transforms.ts deleted file mode 100644 index 6e833854a24c9..0000000000000 --- a/x-pack/legacy/plugins/transform/server/routes/api/transforms.ts +++ /dev/null @@ -1,261 +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 { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; -import { Router, RouterRouteHandler } from '../../../../../server/lib/create_router'; -import { wrapEsError } from '../../../../../server/lib/create_router/error_wrappers'; -import { Plugins } from '../../shim'; -import { TRANSFORM_STATE } from '../../../public/app/common'; -import { - TransformEndpointRequest, - TransformEndpointResult, -} from '../../../public/app/hooks/use_api_types'; -import { TransformId } from '../../../public/app/common/transform'; -import { isRequestTimeout, fillResultsWithTimeouts } from './error_utils'; -import { transformAuditMessagesProvider } from './transform_audit_messages'; - -enum TRANSFORM_ACTIONS { - STOP = 'stop', - START = 'start', - DELETE = 'delete', -} - -interface StartOptions { - transformId: TransformId; -} - -interface StopOptions { - transformId: TransformId; - force: boolean; - waitForCompletion?: boolean; -} - -export function registerTransformsRoutes(router: Router, plugins: Plugins) { - router.get('transforms', getTransformHandler); - router.get('transforms/{transformId}', getTransformHandler); - router.get('transforms/_stats', getTransformStatsHandler); - router.get('transforms/{transformId}/_stats', getTransformStatsHandler); - router.get('transforms/{transformId}/messages', getTransformMessagesHandler); - router.put('transforms/{transformId}', putTransformHandler); - router.post('delete_transforms', deleteTransformsHandler); - router.post('transforms/_preview', previewTransformHandler); - router.post('start_transforms', startTransformsHandler); - router.post('stop_transforms', stopTransformsHandler); - router.post('es_search', esSearchHandler); -} - -const getTransformHandler: RouterRouteHandler = async (req, callWithRequest) => { - const { transformId } = req.params; - const options = { - ...(transformId !== undefined ? { transformId } : {}), - }; - - try { - return await callWithRequest('transform.getTransforms', options); - } catch (e) { - return { error: wrapEsError(e) }; - } -}; - -const getTransformStatsHandler: RouterRouteHandler = async (req, callWithRequest) => { - const { transformId } = req.params; - const options = { - ...(transformId !== undefined ? { transformId } : {}), - }; - - try { - return await callWithRequest('transform.getTransformsStats', options); - } catch (e) { - return { error: wrapEsError(e) }; - } -}; - -const deleteTransformsHandler: RouterRouteHandler = async (req, callWithRequest) => { - const transformsInfo = req.payload as TransformEndpointRequest[]; - - try { - return await deleteTransforms(transformsInfo, callWithRequest); - } catch (e) { - return { error: wrapEsError(e) }; - } -}; - -const putTransformHandler: RouterRouteHandler = async (req, callWithRequest) => { - const { transformId } = req.params; - - const response: { - transformsCreated: Array<{ transform: string }>; - errors: any[]; - } = { - transformsCreated: [], - errors: [], - }; - - await callWithRequest('transform.createTransform', { body: req.payload, transformId }) - .then(() => response.transformsCreated.push({ transform: transformId })) - .catch(e => - response.errors.push({ - id: transformId, - error: wrapEsError(e), - }) - ); - - return response; -}; - -async function deleteTransforms( - transformsInfo: TransformEndpointRequest[], - callWithRequest: CallCluster -) { - const results: TransformEndpointResult = {}; - - for (const transformInfo of transformsInfo) { - const transformId = transformInfo.id; - try { - if (transformInfo.state === TRANSFORM_STATE.FAILED) { - try { - await callWithRequest('transform.stopTransform', { - transformId, - force: true, - waitForCompletion: true, - } as StopOptions); - } catch (e) { - if (isRequestTimeout(e)) { - return fillResultsWithTimeouts({ - results, - id: transformId, - items: transformsInfo, - action: TRANSFORM_ACTIONS.DELETE, - }); - } - } - } - - await callWithRequest('transform.deleteTransform', { transformId }); - results[transformId] = { success: true }; - } catch (e) { - if (isRequestTimeout(e)) { - return fillResultsWithTimeouts({ - results, - id: transformInfo.id, - items: transformsInfo, - action: TRANSFORM_ACTIONS.DELETE, - }); - } - results[transformId] = { success: false, error: JSON.stringify(e) }; - } - } - return results; -} - -const previewTransformHandler: RouterRouteHandler = async (req, callWithRequest) => { - try { - return await callWithRequest('transform.getTransformsPreview', { body: req.payload }); - } catch (e) { - return wrapEsError(e); - } -}; - -const startTransformsHandler: RouterRouteHandler = async (req, callWithRequest) => { - const { transformsInfo } = req.payload as { - transformsInfo: TransformEndpointRequest[]; - }; - - try { - return await startTransforms(transformsInfo, callWithRequest); - } catch (e) { - return wrapEsError(e); - } -}; - -async function startTransforms( - transformsInfo: TransformEndpointRequest[], - callWithRequest: CallCluster -) { - const results: TransformEndpointResult = {}; - - for (const transformInfo of transformsInfo) { - const transformId = transformInfo.id; - try { - await callWithRequest('transform.startTransform', { transformId } as StartOptions); - results[transformId] = { success: true }; - } catch (e) { - if (isRequestTimeout(e)) { - return fillResultsWithTimeouts({ - results, - id: transformId, - items: transformsInfo, - action: TRANSFORM_ACTIONS.START, - }); - } - results[transformId] = { success: false, error: JSON.stringify(e) }; - } - } - return results; -} - -const stopTransformsHandler: RouterRouteHandler = async (req, callWithRequest) => { - const { transformsInfo } = req.payload as { - transformsInfo: TransformEndpointRequest[]; - }; - - try { - return await stopTransforms(transformsInfo, callWithRequest); - } catch (e) { - return wrapEsError(e); - } -}; - -async function stopTransforms( - transformsInfo: TransformEndpointRequest[], - callWithRequest: CallCluster -) { - const results: TransformEndpointResult = {}; - - for (const transformInfo of transformsInfo) { - const transformId = transformInfo.id; - try { - await callWithRequest('transform.stopTransform', { - transformId, - force: - transformInfo.state !== undefined - ? transformInfo.state === TRANSFORM_STATE.FAILED - : false, - waitForCompletion: true, - } as StopOptions); - results[transformId] = { success: true }; - } catch (e) { - if (isRequestTimeout(e)) { - return fillResultsWithTimeouts({ - results, - id: transformId, - items: transformsInfo, - action: TRANSFORM_ACTIONS.STOP, - }); - } - results[transformId] = { success: false, error: JSON.stringify(e) }; - } - } - return results; -} - -const getTransformMessagesHandler: RouterRouteHandler = async (req, callWithRequest) => { - const { getTransformAuditMessages } = transformAuditMessagesProvider(callWithRequest); - const { transformId } = req.params; - - try { - return await getTransformAuditMessages(transformId); - } catch (e) { - return wrapEsError(e); - } -}; - -const esSearchHandler: RouterRouteHandler = async (req, callWithRequest) => { - try { - return await callWithRequest('search', req.payload); - } catch (e) { - return { error: wrapEsError(e) }; - } -}; diff --git a/x-pack/legacy/plugins/transform/server/shim.ts b/x-pack/legacy/plugins/transform/server/shim.ts deleted file mode 100644 index 8f477d86441f4..0000000000000 --- a/x-pack/legacy/plugins/transform/server/shim.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Legacy } from 'kibana'; -import { createRouter, Router } from '../../../server/lib/create_router'; -import { registerLicenseChecker } from '../../../server/lib/register_license_checker'; -import { elasticsearchJsPlugin } from './client/elasticsearch_transform'; -export interface Core { - http: { - createRouter(basePath: string): Router; - }; -} - -export interface Plugins { - license: { - registerLicenseChecker: typeof registerLicenseChecker; - }; - xpack_main: any; - elasticsearch: any; -} - -export function createServerShim( - server: Legacy.Server, - pluginId: string -): { core: Core; plugins: Plugins } { - return { - core: { - http: { - createRouter: (basePath: string) => - createRouter(server, pluginId, basePath, { - plugins: [elasticsearchJsPlugin], - }), - }, - }, - plugins: { - license: { - registerLicenseChecker, - }, - xpack_main: server.plugins.xpack_main, - elasticsearch: server.plugins.elasticsearch, - }, - }; -} diff --git a/x-pack/plugins/transform/kibana.json b/x-pack/plugins/transform/kibana.json new file mode 100644 index 0000000000000..87e38f83ef640 --- /dev/null +++ b/x-pack/plugins/transform/kibana.json @@ -0,0 +1,15 @@ +{ + "id": "transform", + "version": "kibana", + "server": true, + "ui": false, + "requiredPlugins": [ + "home", + "licensing", + "management" + ], + "optionalPlugins": [ + "usageCollection" + ], + "configPath": ["xpack", "transform"] +} diff --git a/x-pack/legacy/plugins/transform/server/client/elasticsearch_transform.ts b/x-pack/plugins/transform/server/client/elasticsearch_transform.ts similarity index 100% rename from x-pack/legacy/plugins/transform/server/client/elasticsearch_transform.ts rename to x-pack/plugins/transform/server/client/elasticsearch_transform.ts diff --git a/x-pack/plugins/transform/server/index.ts b/x-pack/plugins/transform/server/index.ts new file mode 100644 index 0000000000000..7b7cf3ee44fb5 --- /dev/null +++ b/x-pack/plugins/transform/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/server'; + +import { TransformServerPlugin } from './plugin'; + +export const plugin = (ctx: PluginInitializerContext) => new TransformServerPlugin(ctx); diff --git a/x-pack/plugins/transform/server/plugin.ts b/x-pack/plugins/transform/server/plugin.ts new file mode 100644 index 0000000000000..7da991bc02b37 --- /dev/null +++ b/x-pack/plugins/transform/server/plugin.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { + CoreSetup, + Plugin, + IScopedClusterClient, + Logger, + PluginInitializerContext, +} from 'src/core/server'; + +import { LicenseType } from '../../licensing/common/types'; + +import { elasticsearchJsPlugin } from './client/elasticsearch_transform'; +import { Dependencies } from './types'; +import { ApiRoutes } from './routes'; +import { License } from './services'; + +declare module 'kibana/server' { + interface RequestHandlerContext { + transform?: { + dataClient: IScopedClusterClient; + }; + } +} + +const basicLicense: LicenseType = 'basic'; + +const PLUGIN = { + id: 'transform', + minimumLicenseType: basicLicense, + getI18nName: (): string => + i18n.translate('xpack.transform.appTitle', { + defaultMessage: 'Transforms', + }), +}; + +export class TransformServerPlugin implements Plugin<{}, void, any, any> { + private readonly apiRoutes: ApiRoutes; + private readonly license: License; + private readonly logger: Logger; + + constructor(initContext: PluginInitializerContext) { + this.logger = initContext.logger.get(); + this.apiRoutes = new ApiRoutes(); + this.license = new License(); + } + + setup({ elasticsearch, http }: CoreSetup, { licensing }: Dependencies): {} { + const router = http.createRouter(); + + this.license.setup( + { + pluginId: PLUGIN.id, + minimumLicenseType: PLUGIN.minimumLicenseType, + defaultErrorMessage: i18n.translate('xpack.transform.licenseCheckErrorMessage', { + defaultMessage: 'License check failed', + }), + }, + { + licensing, + logger: this.logger, + } + ); + + this.apiRoutes.setup({ + router, + license: this.license, + }); + + // Can access via new platform router's handler function 'context' parameter - context.transform.client + const transformClient = elasticsearch.createClient('transform', { + plugins: [elasticsearchJsPlugin], + }); + http.registerRouteHandlerContext('transform', (context, request) => { + return { + dataClient: transformClient.asScoped(request), + }; + }); + + return {}; + } + + start() {} + stop() {} +} diff --git a/x-pack/legacy/plugins/transform/server/routes/api/error_utils.ts b/x-pack/plugins/transform/server/routes/api/error_utils.ts similarity index 80% rename from x-pack/legacy/plugins/transform/server/routes/api/error_utils.ts rename to x-pack/plugins/transform/server/routes/api/error_utils.ts index 094c0308ff20f..d09152bf1a603 100644 --- a/x-pack/legacy/plugins/transform/server/routes/api/error_utils.ts +++ b/x-pack/plugins/transform/server/routes/api/error_utils.ts @@ -4,11 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import { boomify, isBoom } from 'boom'; + import { i18n } from '@kbn/i18n'; + +import { ResponseError, CustomHttpResponseOptions } from 'src/core/server'; + import { TransformEndpointRequest, TransformEndpointResult, -} from '../../../public/app/hooks/use_api_types'; +} from '../../../../../legacy/plugins/transform/public/app/hooks/use_api_types'; const REQUEST_TIMEOUT = 'RequestTimeout'; @@ -71,3 +76,12 @@ export function fillResultsWithTimeouts({ results, id, items, action }: Params) return accumResults; }, newResults); } + +export function wrapError(error: any): CustomHttpResponseOptions<ResponseError> { + const boom = isBoom(error) ? error : boomify(error, { statusCode: error.status }); + return { + body: boom, + headers: boom.output.headers, + statusCode: boom.output.statusCode, + }; +} diff --git a/x-pack/plugins/transform/server/routes/api/privileges.ts b/x-pack/plugins/transform/server/routes/api/privileges.ts new file mode 100644 index 0000000000000..6003a88ffa40c --- /dev/null +++ b/x-pack/plugins/transform/server/routes/api/privileges.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + APP_CLUSTER_PRIVILEGES, + APP_INDEX_PRIVILEGES, +} from '../../../../../legacy/plugins/transform/common/constants'; +// NOTE: now we import it from our "public" folder, but when the Authorisation lib +// will move to the "es_ui_shared" plugin, it will be imported from its "static" folder +import { Privileges } from '../../../../../legacy/plugins/transform/public/app/lib/authorization'; + +import { RouteDependencies } from '../../types'; +import { addBasePath } from '../index'; + +export function registerPrivilegesRoute({ router, license }: RouteDependencies) { + router.get( + { path: addBasePath('privileges'), validate: {} }, + license.guardApiRoute(async (ctx, req, res) => { + const privilegesResult: Privileges = { + hasAllPrivileges: true, + missingPrivileges: { + cluster: [], + index: [], + }, + }; + + if (license.getStatus().isSecurityEnabled === false) { + // If security isn't enabled, let the user use app. + return res.ok({ body: privilegesResult }); + } + + // Get cluster priviliges + const { + has_all_requested: hasAllPrivileges, + cluster, + } = await ctx.transform!.dataClient.callAsCurrentUser('transport.request', { + path: '/_security/user/_has_privileges', + method: 'POST', + body: { + cluster: APP_CLUSTER_PRIVILEGES, + }, + }); + + // Find missing cluster privileges and set overall app privileges + privilegesResult.missingPrivileges.cluster = extractMissingPrivileges(cluster); + privilegesResult.hasAllPrivileges = hasAllPrivileges; + + // Get all index privileges the user has + const { indices } = await ctx.transform!.dataClient.callAsCurrentUser('transport.request', { + path: '/_security/user/_privileges', + method: 'GET', + }); + + // Check if they have all the required index privileges for at least one index + const oneIndexWithAllPrivileges = indices.find(({ privileges }: { privileges: string[] }) => { + if (privileges.includes('all')) { + return true; + } + + const indexHasAllPrivileges = APP_INDEX_PRIVILEGES.every(privilege => + privileges.includes(privilege) + ); + + return indexHasAllPrivileges; + }); + + // If they don't, return list of required index privileges + if (!oneIndexWithAllPrivileges) { + privilegesResult.missingPrivileges.index = [...APP_INDEX_PRIVILEGES]; + } + + return res.ok({ body: privilegesResult }); + }) + ); +} + +const extractMissingPrivileges = (privilegesObject: { [key: string]: boolean } = {}): string[] => + Object.keys(privilegesObject).reduce((privileges: string[], privilegeName: string): string[] => { + if (!privilegesObject[privilegeName]) { + privileges.push(privilegeName); + } + return privileges; + }, []); diff --git a/x-pack/plugins/transform/server/routes/api/schema.ts b/x-pack/plugins/transform/server/routes/api/schema.ts new file mode 100644 index 0000000000000..0b994406d324d --- /dev/null +++ b/x-pack/plugins/transform/server/routes/api/schema.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 { schema } from '@kbn/config-schema'; + +export const schemaTransformId = { + params: schema.object({ + transformId: schema.string(), + }), +}; + +export interface SchemaTransformId { + transformId: string; +} diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts new file mode 100644 index 0000000000000..7aaae1f1c7039 --- /dev/null +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -0,0 +1,360 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; + +import { RequestHandler } from 'kibana/server'; +import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { wrapEsError } from '../../../../../legacy/server/lib/create_router/error_wrappers'; + +import { TRANSFORM_STATE } from '../../../../../legacy/plugins/transform/public/app/common'; +import { + TransformEndpointRequest, + TransformEndpointResult, +} from '../../../../../legacy/plugins/transform/public/app/hooks/use_api_types'; +import { TransformId } from '../../../../../legacy/plugins/transform/public/app/common/transform'; + +import { RouteDependencies } from '../../types'; + +import { addBasePath } from '../index'; + +import { isRequestTimeout, fillResultsWithTimeouts, wrapError } from './error_utils'; +import { schemaTransformId, SchemaTransformId } from './schema'; +import { registerTransformsAuditMessagesRoutes } from './transforms_audit_messages'; + +enum TRANSFORM_ACTIONS { + STOP = 'stop', + START = 'start', + DELETE = 'delete', +} + +interface StopOptions { + transformId: TransformId; + force: boolean; + waitForCompletion?: boolean; +} + +export function registerTransformsRoutes(routeDependencies: RouteDependencies) { + const { router, license } = routeDependencies; + router.get( + { path: addBasePath('transforms'), validate: false }, + license.guardApiRoute(async (ctx, req, res) => { + const options = {}; + try { + const transforms = await getTransforms( + options, + ctx.transform!.dataClient.callAsCurrentUser + ); + return res.ok({ body: transforms }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); + router.get( + { + path: addBasePath('transforms/{transformId}'), + validate: schemaTransformId, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { transformId } = req.params as SchemaTransformId; + const options = { + ...(transformId !== undefined ? { transformId } : {}), + }; + try { + const transforms = await getTransforms( + options, + ctx.transform!.dataClient.callAsCurrentUser + ); + return res.ok({ body: transforms }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); + router.get( + { path: addBasePath('transforms/_stats'), validate: false }, + license.guardApiRoute(async (ctx, req, res) => { + const options = {}; + try { + const stats = await ctx.transform!.dataClient.callAsCurrentUser( + 'transform.getTransformsStats', + options + ); + return res.ok({ body: stats }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); + router.get( + { + path: addBasePath('transforms/{transformId}/_stats'), + validate: schemaTransformId, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { transformId } = req.params as SchemaTransformId; + const options = { + ...(transformId !== undefined ? { transformId } : {}), + }; + try { + const stats = await ctx.transform!.dataClient.callAsCurrentUser( + 'transform.getTransformsStats', + options + ); + return res.ok({ body: stats }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); + registerTransformsAuditMessagesRoutes(routeDependencies); + router.put( + { + path: addBasePath('transforms/{transformId}'), + validate: { + ...schemaTransformId, + body: schema.maybe(schema.any()), + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { transformId } = req.params as SchemaTransformId; + + const response: { + transformsCreated: Array<{ transform: string }>; + errors: any[]; + } = { + transformsCreated: [], + errors: [], + }; + + await ctx + .transform!.dataClient.callAsCurrentUser('transform.createTransform', { + body: req.body, + transformId, + }) + .then(() => response.transformsCreated.push({ transform: transformId })) + .catch(e => + response.errors.push({ + id: transformId, + error: wrapEsError(e), + }) + ); + + return res.ok({ body: response }); + }) + ); + router.post( + { + path: addBasePath('delete_transforms'), + validate: { + body: schema.maybe(schema.any()), + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const transformsInfo = req.body as TransformEndpointRequest[]; + + try { + return res.ok({ + body: await deleteTransforms(transformsInfo, ctx.transform!.dataClient.callAsCurrentUser), + }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); + router.post( + { + path: addBasePath('transforms/_preview'), + validate: { + body: schema.maybe(schema.any()), + }, + }, + license.guardApiRoute(previewTransformHandler) + ); + router.post( + { + path: addBasePath('start_transforms'), + validate: { + body: schema.maybe(schema.any()), + }, + }, + license.guardApiRoute(startTransformsHandler) + ); + router.post( + { + path: addBasePath('stop_transforms'), + validate: { + body: schema.maybe(schema.any()), + }, + }, + license.guardApiRoute(stopTransformsHandler) + ); + router.post( + { + path: addBasePath('es_search'), + validate: { + body: schema.maybe(schema.any()), + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + try { + return res.ok({ + body: await ctx.transform!.dataClient.callAsCurrentUser('search', req.body), + }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); +} + +const getTransforms = async (options: { transformId?: string }, callAsCurrentUser: CallCluster) => { + return await callAsCurrentUser('transform.getTransforms', options); +}; + +async function deleteTransforms( + transformsInfo: TransformEndpointRequest[], + callAsCurrentUser: CallCluster +) { + const results: TransformEndpointResult = {}; + + for (const transformInfo of transformsInfo) { + const transformId = transformInfo.id; + try { + if (transformInfo.state === TRANSFORM_STATE.FAILED) { + try { + await callAsCurrentUser('transform.stopTransform', { + transformId, + force: true, + waitForCompletion: true, + } as StopOptions); + } catch (e) { + if (isRequestTimeout(e)) { + return fillResultsWithTimeouts({ + results, + id: transformId, + items: transformsInfo, + action: TRANSFORM_ACTIONS.DELETE, + }); + } + } + } + + await callAsCurrentUser('transform.deleteTransform', { transformId }); + results[transformId] = { success: true }; + } catch (e) { + if (isRequestTimeout(e)) { + return fillResultsWithTimeouts({ + results, + id: transformInfo.id, + items: transformsInfo, + action: TRANSFORM_ACTIONS.DELETE, + }); + } + results[transformId] = { success: false, error: JSON.stringify(e) }; + } + } + return results; +} + +const previewTransformHandler: RequestHandler = async (ctx, req, res) => { + try { + return res.ok({ + body: await ctx.transform!.dataClient.callAsCurrentUser('transform.getTransformsPreview', { + body: req.body, + }), + }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } +}; + +const startTransformsHandler: RequestHandler = async (ctx, req, res) => { + const { transformsInfo } = req.body as { + transformsInfo: TransformEndpointRequest[]; + }; + + try { + return res.ok({ + body: await startTransforms(transformsInfo, ctx.transform!.dataClient.callAsCurrentUser), + }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } +}; + +async function startTransforms( + transformsInfo: TransformEndpointRequest[], + callAsCurrentUser: CallCluster +) { + const results: TransformEndpointResult = {}; + + for (const transformInfo of transformsInfo) { + const transformId = transformInfo.id; + try { + await callAsCurrentUser('transform.startTransform', { transformId } as SchemaTransformId); + results[transformId] = { success: true }; + } catch (e) { + if (isRequestTimeout(e)) { + return fillResultsWithTimeouts({ + results, + id: transformId, + items: transformsInfo, + action: TRANSFORM_ACTIONS.START, + }); + } + results[transformId] = { success: false, error: JSON.stringify(e) }; + } + } + return results; +} + +const stopTransformsHandler: RequestHandler = async (ctx, req, res) => { + const { transformsInfo } = req.body as { + transformsInfo: TransformEndpointRequest[]; + }; + + try { + return res.ok({ + body: await stopTransforms(transformsInfo, ctx.transform!.dataClient.callAsCurrentUser), + }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } +}; + +async function stopTransforms( + transformsInfo: TransformEndpointRequest[], + callAsCurrentUser: CallCluster +) { + const results: TransformEndpointResult = {}; + + for (const transformInfo of transformsInfo) { + const transformId = transformInfo.id; + try { + await callAsCurrentUser('transform.stopTransform', { + transformId, + force: + transformInfo.state !== undefined + ? transformInfo.state === TRANSFORM_STATE.FAILED + : false, + waitForCompletion: true, + } as StopOptions); + results[transformId] = { success: true }; + } catch (e) { + if (isRequestTimeout(e)) { + return fillResultsWithTimeouts({ + results, + id: transformId, + items: transformsInfo, + action: TRANSFORM_ACTIONS.STOP, + }); + } + results[transformId] = { success: false, error: JSON.stringify(e) }; + } + } + return results; +} diff --git a/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts b/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts new file mode 100644 index 0000000000000..422fdec7ab77e --- /dev/null +++ b/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AuditMessage } from '../../../../../legacy/plugins/transform/common/types/messages'; +import { wrapEsError } from '../../../../../legacy/server/lib/create_router/error_wrappers'; + +import { RouteDependencies } from '../../types'; + +import { addBasePath } from '../index'; + +import { wrapError } from './error_utils'; +import { schemaTransformId, SchemaTransformId } from './schema'; + +const ML_DF_NOTIFICATION_INDEX_PATTERN = '.transform-notifications-read'; +const SIZE = 500; + +interface BoolQuery { + bool: { [key: string]: any }; +} + +export function registerTransformsAuditMessagesRoutes({ router, license }: RouteDependencies) { + router.get( + { path: addBasePath('transforms/{transformId}/messages'), validate: schemaTransformId }, + license.guardApiRoute(async (ctx, req, res) => { + const { transformId } = req.params as SchemaTransformId; + + // search for audit messages, + // transformId is optional. without it, all transforms will be listed. + const query: BoolQuery = { + bool: { + filter: [ + { + bool: { + must_not: { + term: { + level: 'activity', + }, + }, + }, + }, + ], + }, + }; + + // if no transformId specified, load all of the messages + if (transformId !== undefined) { + query.bool.filter.push({ + bool: { + should: [ + { + term: { + transform_id: '', // catch system messages + }, + }, + { + term: { + transform_id: transformId, // messages for specified transformId + }, + }, + ], + }, + }); + } + + try { + const resp = await ctx.transform!.dataClient.callAsCurrentUser('search', { + index: ML_DF_NOTIFICATION_INDEX_PATTERN, + ignore_unavailable: true, + rest_total_hits_as_int: true, + size: SIZE, + body: { + sort: [{ timestamp: { order: 'desc' } }, { transform_id: { order: 'asc' } }], + query, + }, + }); + + let messages = []; + if (resp.hits.total !== 0) { + messages = resp.hits.hits.map((hit: AuditMessage) => hit._source); + messages.reverse(); + } + return res.ok({ body: messages }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); +} diff --git a/x-pack/plugins/transform/server/routes/index.ts b/x-pack/plugins/transform/server/routes/index.ts new file mode 100644 index 0000000000000..953490920cbcb --- /dev/null +++ b/x-pack/plugins/transform/server/routes/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RouteDependencies } from '../types'; + +import { registerPrivilegesRoute } from './api/privileges'; +import { registerTransformsRoutes } from './api/transforms'; + +import { API_BASE_PATH } from '../../../../legacy/plugins/transform/common/constants'; + +export const addBasePath = (uri: string): string => `${API_BASE_PATH}${uri}`; + +export class ApiRoutes { + setup(dependencies: RouteDependencies) { + registerPrivilegesRoute(dependencies); + registerTransformsRoutes(dependencies); + } + + start() {} + stop() {} +} diff --git a/x-pack/plugins/transform/server/services/index.ts b/x-pack/plugins/transform/server/services/index.ts new file mode 100644 index 0000000000000..b7a45e59549eb --- /dev/null +++ b/x-pack/plugins/transform/server/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { License } from './license'; diff --git a/x-pack/plugins/transform/server/services/license.ts b/x-pack/plugins/transform/server/services/license.ts new file mode 100644 index 0000000000000..93346160c6f44 --- /dev/null +++ b/x-pack/plugins/transform/server/services/license.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Logger } from 'src/core/server'; +import { + IKibanaResponse, + KibanaRequest, + KibanaResponseFactory, + RequestHandler, + RequestHandlerContext, +} from 'kibana/server'; + +import { LicensingPluginSetup, LicenseType, LICENSE_CHECK_STATE } from '../../../licensing/server'; + +export interface LicenseStatus { + isValid: boolean; + isSecurityEnabled: boolean; + message?: string; +} + +interface SetupSettings { + pluginId: string; + minimumLicenseType: LicenseType; + defaultErrorMessage: string; +} + +export class License { + private licenseStatus: LicenseStatus = { + isValid: false, + isSecurityEnabled: false, + message: 'Invalid License', + }; + + setup( + { pluginId, minimumLicenseType, defaultErrorMessage }: SetupSettings, + { licensing, logger }: { licensing: LicensingPluginSetup; logger: Logger } + ) { + licensing.license$.subscribe(license => { + const { state, message } = license.check(pluginId, minimumLicenseType); + const hasRequiredLicense = state === LICENSE_CHECK_STATE.Valid; + + const securityFeature = license.getFeature('security'); + const isSecurityEnabled = + securityFeature !== undefined && + securityFeature.isAvailable === true && + securityFeature.isEnabled === true; + + if (hasRequiredLicense) { + this.licenseStatus = { isValid: true, isSecurityEnabled }; + } else { + this.licenseStatus = { + isValid: false, + isSecurityEnabled, + message: message || defaultErrorMessage, + }; + if (message) { + logger.info(message); + } + } + }); + } + + guardApiRoute(handler: RequestHandler<unknown, unknown, any, any>) { + const license = this; + + return function licenseCheck( + ctx: RequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory + ): IKibanaResponse<any> | Promise<IKibanaResponse<any>> { + const licenseStatus = license.getStatus(); + + if (!licenseStatus.isValid) { + return response.customError({ + body: { + message: licenseStatus.message || '', + }, + statusCode: 403, + }); + } + + return handler(ctx, request, response); + }; + } + + getStatus() { + return this.licenseStatus; + } +} diff --git a/x-pack/plugins/transform/server/types.ts b/x-pack/plugins/transform/server/types.ts new file mode 100644 index 0000000000000..5fcc23a6d9f48 --- /dev/null +++ b/x-pack/plugins/transform/server/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'src/core/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { License } from './services'; + +export interface Dependencies { + licensing: LicensingPluginSetup; +} + +export interface RouteDependencies { + router: IRouter; + license: License; +} From 4712faefb76facc4c7ae400aa140fe2a0ac0dfde Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 3 Mar 2020 09:06:00 +0100 Subject: [PATCH 30/36] Fix the namespace for indices autocompletion (#59043) --- .../spec_definitions/spec/overrides/indices.put_settings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/console/server/lib/spec_definitions/spec/overrides/indices.put_settings.json b/src/plugins/console/server/lib/spec_definitions/spec/overrides/indices.put_settings.json index 78a5bbdbcf6c2..2e1e3024665a4 100644 --- a/src/plugins/console/server/lib/spec_definitions/spec/overrides/indices.put_settings.json +++ b/src/plugins/console/server/lib/spec_definitions/spec/overrides/indices.put_settings.json @@ -1,5 +1,5 @@ { - "put_settings": { + "indices.put_settings": { "data_autocomplete_rules": { "refresh_interval": "1s", "number_of_shards": 1, @@ -105,4 +105,4 @@ } } } -} \ No newline at end of file +} From 166716a405d737bdb33b6c4a5cc75107a59d80b4 Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Tue, 3 Mar 2020 12:39:52 +0300 Subject: [PATCH 31/36] [Visualize] Move linked search to react component (#58590) * Move linked_search to react * Use i18n from start contract * Move linked search to the editor * Updating layout and fixing truncation * Fix functional test, add a tooltip Co-authored-by: Caroline Horn <549577+cchaos@users.noreply.github.com> --- .../visualize/np_ready/editor/_editor.scss | 4 - .../visualize/np_ready/editor/editor.html | 24 --- .../visualize/np_ready/editor/editor.js | 20 +- .../np_ready/editor/visualization_editor.js | 1 + .../public/visualize/np_ready/types.d.ts | 4 + .../vis_default_editor/public/_sidebar.scss | 6 +- .../public/components/sidebar/sidebar.tsx | 26 +-- .../components/sidebar/sidebar_title.tsx | 175 ++++++++++++++++++ .../public/default_editor.tsx | 5 +- .../functional/page_objects/visualize_page.ts | 3 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 12 files changed, 216 insertions(+), 56 deletions(-) create mode 100644 src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/_editor.scss b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/_editor.scss index 2f48ecc322fea..3a542cacc44be 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/_editor.scss +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/_editor.scss @@ -22,10 +22,6 @@ a tilemap in an iframe: https://github.com/elastic/kibana/issues/16457 */ } } -.visEditor__linkedMessage { - padding: $euiSizeS; -} - .visEditor__content { @include flex-parent(); width: 100%; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html index 4979d9dc89a0c..9dbb05ea95b48 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html @@ -1,28 +1,4 @@ <visualize-app class="app-container visEditor visEditor--{{ vis.type.name }}"> - <!-- Linked search. --> - <div - ng-show="isVisible" - ng-if="vis.type.requiresSearch && linked" - class="fullWidth visEditor__linkedMessage" - > - <div class="kuiVerticalRhythmSmall"> - {{ ::'kbn.visualize.linkedToSearchInfoText' | i18n: { defaultMessage: 'Linked to Saved Search' } }} - <a - href="#/discover/{{savedVis.savedSearch.id}}" - > - {{ savedVis.savedSearch.title }} - </a> -   - <a - data-test-subj="unlinkSavedSearch" - href="" - ng-dblclick="unlink()" - tooltip="{{ ::'kbn.visualize.linkedToSearch.unlinkButtonTooltip' | i18n: { defaultMessage: 'Double click to unlink from Saved Search' } }}" - > - <span aria-hidden="true" class="kuiIcon fa-chain-broken"></span> - </a> - </div> - </div> <!-- Local nav. diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index 2137e413451d2..293327f3f72f9 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -551,6 +551,20 @@ function VisualizeAppController( updateStateFromSavedQuery(newSavedQuery); }); + $scope.$watch('linked', linked => { + if (linked && !savedVis.savedSearchId) { + savedVis.savedSearchId = savedVis.searchSource.id; + vis.savedSearchId = savedVis.searchSource.id; + + $scope.$broadcast('render'); + } else if (!linked && savedVis.savedSearchId) { + delete savedVis.savedSearchId; + delete vis.savedSearchId; + + $scope.$broadcast('render'); + } + }); + /** * Called when the user clicks "Save" button. */ @@ -638,9 +652,7 @@ function VisualizeAppController( ); } - $scope.unlink = function() { - if (!$scope.linked) return; - + const unlinkFromSavedSearch = () => { const searchSourceParent = searchSource.getParent(); const searchSourceGrandparent = searchSourceParent.getParent(); @@ -681,6 +693,8 @@ function VisualizeAppController( ); }; + vis.on('unlinkFromSavedSearch', unlinkFromSavedSearch); + addHelpMenuToAppChrome(chrome, docLinks); init(); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js index 65c25b8cf705d..f2d9cbe2ad84c 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js @@ -43,6 +43,7 @@ export function initVisEditorDirective(app, deps) { filters: $scope.filters, query: $scope.query, appState: $scope.appState, + linked: !!$scope.savedObj.savedSearchId, }); }; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts index d95939170419b..8ca603eb11459 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts @@ -59,6 +59,10 @@ export interface EditorRenderProps { uiState: PersistedState; timeRange: TimeRange; query?: Query; + /** + * Flag to determine if visualiztion is linked to the saved search + */ + linked: boolean; } export interface SavedVisualizations { diff --git a/src/legacy/core_plugins/vis_default_editor/public/_sidebar.scss b/src/legacy/core_plugins/vis_default_editor/public/_sidebar.scss index a38c729cb4622..ed92dc1dae884 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/_sidebar.scss +++ b/src/legacy/core_plugins/vis_default_editor/public/_sidebar.scss @@ -33,8 +33,7 @@ // NAVIGATION // -.visEditorSidebar__indexPattern { - @include euiTextTruncate; +.visEditorSidebar__titleContainer { padding: $euiSizeS $euiSizeXL $euiSizeS $euiSizeS; // Extra padding on the right for the collapse button } @@ -43,7 +42,8 @@ border-bottom: $euiBorderThin; } -.visEditorSidebar__nav { +.visEditorSidebar__nav, +.visEditorSidebar__linkedSearch { flex-grow: 0; } diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx index d3b843eaaec9f..425245fe91fed 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx @@ -20,13 +20,16 @@ import React, { useMemo, useState, useCallback, KeyboardEventHandler, useEffect } from 'react'; import { get, isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { keyCodes, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { keyCodes, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + import { Vis } from 'src/legacy/core_plugins/visualizations/public'; import { AggGroupNames } from '../../legacy_imports'; import { DefaultEditorNavBar, OptionTab } from './navbar'; import { DefaultEditorControls } from './controls'; import { setStateParamValue, useEditorReducer, useEditorFormState, discardChanges } from './state'; import { DefaultEditorAggCommonProps } from '../agg_common_props'; +import { SidebarTitle } from './sidebar_title'; +import { SavedSearch } from '../../../../kibana/public/discover/np_ready/types'; import { PersistedState } from '../../../../../../plugins/visualizations/public'; interface DefaultEditorSideBarProps { @@ -35,6 +38,8 @@ interface DefaultEditorSideBarProps { optionTabs: OptionTab[]; uiState: PersistedState; vis: Vis; + isLinkedSearch: boolean; + savedSearch?: SavedSearch; } function DefaultEditorSideBar({ @@ -43,6 +48,8 @@ function DefaultEditorSideBar({ optionTabs, uiState, vis, + isLinkedSearch, + savedSearch, }: DefaultEditorSideBarProps) { const [selectedTab, setSelectedTab] = useState(optionTabs[0].name); const [isDirty, setDirty] = useState(false); @@ -161,21 +168,8 @@ function DefaultEditorSideBar({ name="visualizeEditor" onKeyDownCapture={onSubmit} > - {vis.type.requiresSearch && vis.type.options.showIndexSelection ? ( - <EuiTitle size="xs" className="visEditorSidebar__indexPattern"> - <h2 - title={i18n.translate('visDefaultEditor.sidebar.indexPatternAriaLabel', { - defaultMessage: 'Index pattern: {title}', - values: { - title: vis.indexPattern.title, - }, - })} - > - {vis.indexPattern.title} - </h2> - </EuiTitle> - ) : ( - <div className="visEditorSidebar__indexPatternPlaceholder" /> + {vis.type.requiresSearch && ( + <SidebarTitle isLinkedSearch={isLinkedSearch} savedSearch={savedSearch} vis={vis} /> )} {optionTabs.length > 1 && ( diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx new file mode 100644 index 0000000000000..3fd82f1c4a2b6 --- /dev/null +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx @@ -0,0 +1,175 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPopover, + EuiPopoverTitle, + EuiText, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { Vis } from 'src/legacy/core_plugins/visualizations/public'; +import { SavedSearch } from '../../../../kibana/public/discover/np_ready/types'; + +interface LinkedSearchProps { + savedSearch: SavedSearch; + vis: Vis; +} + +interface SidebarTitleProps { + isLinkedSearch: boolean; + savedSearch?: SavedSearch; + vis: Vis; +} + +export function LinkedSearch({ savedSearch, vis }: LinkedSearchProps) { + const [showPopover, setShowPopover] = useState(false); + const closePopover = useCallback(() => setShowPopover(false), []); + const onClickButtonLink = useCallback(() => setShowPopover(v => !v), []); + const onClickUnlikFromSavedSearch = useCallback(() => { + setShowPopover(false); + vis.emit('unlinkFromSavedSearch'); + }, [vis]); + + const linkButtonAriaLabel = i18n.translate( + 'visDefaultEditor.sidebar.savedSearch.linkButtonAriaLabel', + { + defaultMessage: 'Link to saved search. Click to learn more or break link.', + } + ); + + return ( + <EuiFlexGroup + alignItems="center" + className="visEditorSidebar__titleContainer visEditorSidebar__linkedSearch" + gutterSize="xs" + responsive={false} + > + <EuiFlexItem grow={false}> + <EuiIcon type="search" /> + </EuiFlexItem> + + <EuiFlexItem grow={false} className="eui-textTruncate"> + <EuiTitle size="xs" className="eui-textTruncate"> + <h2 + title={i18n.translate('visDefaultEditor.sidebar.savedSearch.titleAriaLabel', { + defaultMessage: 'Saved search: {title}', + values: { + title: savedSearch.title, + }, + })} + > + {savedSearch.title} + </h2> + </EuiTitle> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiPopover + anchorPosition="downRight" + button={ + <EuiToolTip content={linkButtonAriaLabel}> + <EuiButtonIcon + aria-label={linkButtonAriaLabel} + data-test-subj="showUnlinkSavedSearchPopover" + iconType="link" + onClick={onClickButtonLink} + /> + </EuiToolTip> + } + isOpen={showPopover} + closePopover={closePopover} + panelPaddingSize="s" + > + <EuiPopoverTitle> + <FormattedMessage + id="visDefaultEditor.sidebar.savedSearch.popoverTitle" + defaultMessage="Linked to saved search" + /> + </EuiPopoverTitle> + <div style={{ width: 260 }}> + <EuiText size="s"> + <p> + <EuiButtonEmpty flush="left" href={`#/discover/${savedSearch.id}`} size="xs"> + <FormattedMessage + id="visDefaultEditor.sidebar.savedSearch.goToDiscoverButtonText" + defaultMessage="View this search in Discover" + /> + </EuiButtonEmpty> + </p> + <p> + <FormattedMessage + id="visDefaultEditor.sidebar.savedSearch.popoverHelpText" + defaultMessage="Subsequent modifications to this saved search are reflected in the visualization. To disable automatic updates, remove the link." + /> + </p> + <p> + <EuiButton + color="danger" + data-test-subj="unlinkSavedSearch" + fullWidth + onClick={onClickUnlikFromSavedSearch} + size="s" + > + <FormattedMessage + id="visDefaultEditor.sidebar.savedSearch.unlinkSavedSearchButtonText" + defaultMessage="Remove link to saved search" + /> + </EuiButton> + </p> + </EuiText> + </div> + </EuiPopover> + </EuiFlexItem> + </EuiFlexGroup> + ); +} + +function SidebarTitle({ savedSearch, vis, isLinkedSearch }: SidebarTitleProps) { + return isLinkedSearch && savedSearch ? ( + <LinkedSearch savedSearch={savedSearch} vis={vis} /> + ) : vis.type.options.showIndexSelection ? ( + <EuiTitle size="xs" className="visEditorSidebar__titleContainer eui-textTruncate"> + <h2 + title={i18n.translate('visDefaultEditor.sidebar.indexPatternAriaLabel', { + defaultMessage: 'Index pattern: {title}', + values: { + title: vis.indexPattern.title, + }, + })} + > + {vis.indexPattern.title} + </h2> + </EuiTitle> + ) : ( + <div className="visEditorSidebar__indexPatternPlaceholder" /> + ); +} + +export { SidebarTitle }; diff --git a/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx b/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx index 7eee54006f684..fa3213d244e7e 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx @@ -40,12 +40,13 @@ function DefaultEditor({ appState, optionTabs, query, + linked, }: DefaultEditorControllerState & Omit<EditorRenderProps, 'data' | 'core'>) { const visRef = useRef<HTMLDivElement>(null); const visHandler = useRef<VisualizeEmbeddable | null>(null); const [isCollapsed, setIsCollapsed] = useState(false); const [factory, setFactory] = useState<VisualizeEmbeddableFactory | null>(null); - const { vis } = savedObj; + const { vis, savedSearch } = savedObj; const onClickCollapse = useCallback(() => { setIsCollapsed(value => !value); @@ -117,6 +118,8 @@ function DefaultEditor({ optionTabs={optionTabs} vis={vis} uiState={uiState} + isLinkedSearch={linked} + savedSearch={savedSearch} /> </Panel> </PanelsContainer> diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index e54e3d1d01154..82ef3dc800f6c 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -209,7 +209,8 @@ export function VisualizePageProvider({ getService, getPageObjects }: FtrProvide } public async clickUnlinkSavedSearch() { - await testSubjects.doubleClick('unlinkSavedSearch'); + await testSubjects.click('showUnlinkSavedSearchPopover'); + await testSubjects.click('unlinkSavedSearch'); await header.waitUntilLoadingHasFinished(); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a97cf608abc71..cadebcad93510 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1568,9 +1568,7 @@ "kbn.visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage": "indexPattern または savedSearchId が必要です", "kbn.visualize.editor.createBreadcrumb": "作成", "kbn.visualize.experimentalVisInfoText": "このビジュアライゼーションは実験的なものです。", - "kbn.visualize.linkedToSearch.unlinkButtonTooltip": "保存された検索からリンクを解除するにはダブルクリックします", "kbn.visualize.linkedToSearch.unlinkSuccessNotificationText": "保存された検索「{searchTitle}」からリンクが解除されました", - "kbn.visualize.linkedToSearchInfoText": "保存された検索にリンクされています", "kbn.visualize.listing.betaTitle": "ベータ", "kbn.visualize.listing.betaTooltip": "このビジュアライゼーションはベータ段階で、変更される可能性があります。デザインとコードはオフィシャル GA 機能よりも完成度が低く、現状のまま保証なしで提供されています。ベータ機能にはオフィシャル GA 機能の SLA が適用されません", "kbn.visualize.listing.breadcrumb": "可視化", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e6055680e1240..ff72f20b24864 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1568,9 +1568,7 @@ "kbn.visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage": "必须提供 indexPattern 或 savedSearchId", "kbn.visualize.editor.createBreadcrumb": "创建", "kbn.visualize.experimentalVisInfoText": "此可视化标记为“实验”。", - "kbn.visualize.linkedToSearch.unlinkButtonTooltip": "双击可取消与“已保存搜索”的链接", "kbn.visualize.linkedToSearch.unlinkSuccessNotificationText": "取消与已保存搜索 “{searchTitle}” 的链接", - "kbn.visualize.linkedToSearchInfoText": "链接到“已保存搜索”", "kbn.visualize.listing.betaTitle": "公测版", "kbn.visualize.listing.betaTooltip": "此可视化为公测版,可能会进行更改。设计和代码相对于正式发行版功能还不够成熟,将按原样提供,且不提供任何保证。公测版功能不受正式发行版功能支持 SLA 的约束", "kbn.visualize.listing.breadcrumb": "可视化", From 64ffae3ec5f5b5c977c4b0702ead681866bf4972 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet <pierre.gayvallet@elastic.co> Date: Tue, 3 Mar 2020 10:54:03 +0100 Subject: [PATCH 32/36] Add core metrics service (#58623) * create base service and collectors * wire the service into server, add mock * add collector tests * add main collector test * export metric types from server * add service and server tests * updates generated doc * improve doc * nits and comments * add disconnected requests test --- .../core/server/kibana-plugin-server.md | 5 + ...rver.metricsservicesetup.getopsmetrics_.md | 24 +++ ...ibana-plugin-server.metricsservicesetup.md | 20 ++ ...erver.opsmetrics.concurrent_connections.md | 13 ++ .../server/kibana-plugin-server.opsmetrics.md | 24 +++ .../kibana-plugin-server.opsmetrics.os.md | 13 ++ ...kibana-plugin-server.opsmetrics.process.md | 13 ++ ...ibana-plugin-server.opsmetrics.requests.md | 13 ++ ...plugin-server.opsmetrics.response_times.md | 13 ++ ...ibana-plugin-server.opsosmetrics.distro.md | 13 ++ ...lugin-server.opsosmetrics.distrorelease.md | 13 ++ .../kibana-plugin-server.opsosmetrics.load.md | 17 ++ .../kibana-plugin-server.opsosmetrics.md | 26 +++ ...ibana-plugin-server.opsosmetrics.memory.md | 17 ++ ...ana-plugin-server.opsosmetrics.platform.md | 13 ++ ...gin-server.opsosmetrics.platformrelease.md | 13 ++ ...in-server.opsosmetrics.uptime_in_millis.md | 13 ++ ...rver.opsprocessmetrics.event_loop_delay.md | 13 ++ .../kibana-plugin-server.opsprocessmetrics.md | 23 +++ ...-plugin-server.opsprocessmetrics.memory.md | 20 ++ ...ana-plugin-server.opsprocessmetrics.pid.md | 13 ++ ...rver.opsprocessmetrics.uptime_in_millis.md | 13 ++ ...opsservermetrics.concurrent_connections.md | 13 ++ .../kibana-plugin-server.opsservermetrics.md | 22 +++ ...plugin-server.opsservermetrics.requests.md | 17 ++ ...-server.opsservermetrics.response_times.md | 16 ++ package.json | 1 + src/core/server/index.ts | 8 + src/core/server/internal_types.ts | 2 + src/core/server/legacy/legacy_service.test.ts | 2 + src/core/server/metrics/collectors/index.ts | 23 +++ src/core/server/metrics/collectors/os.test.ts | 99 ++++++++++ src/core/server/metrics/collectors/os.ts | 60 ++++++ .../server/metrics/collectors/process.test.ts | 81 ++++++++ src/core/server/metrics/collectors/process.ts | 52 +++++ src/core/server/metrics/collectors/server.ts | 80 ++++++++ src/core/server/metrics/collectors/types.ts | 110 +++++++++++ src/core/server/metrics/index.ts | 29 +++ .../server_collector.test.ts | 183 ++++++++++++++++++ .../server/metrics/metrics_service.mock.ts | 67 +++++++ .../metrics/metrics_service.test.mocks.ts | 25 +++ .../server/metrics/metrics_service.test.ts | 134 +++++++++++++ src/core/server/metrics/metrics_service.ts | 86 ++++++++ src/core/server/metrics/ops_config.ts | 29 +++ .../ops_metrics_collector.test.mocks.ts | 39 ++++ .../metrics/ops_metrics_collector.test.ts | 59 ++++++ .../server/metrics/ops_metrics_collector.ts | 52 +++++ src/core/server/metrics/types.ts | 66 +++++++ src/core/server/mocks.ts | 5 +- src/core/server/server.api.md | 62 ++++++ src/core/server/server.test.mocks.ts | 6 + src/core/server/server.test.ts | 9 + src/core/server/server.ts | 9 + 53 files changed, 1790 insertions(+), 1 deletion(-) create mode 100644 docs/development/core/server/kibana-plugin-server.metricsservicesetup.getopsmetrics_.md create mode 100644 docs/development/core/server/kibana-plugin-server.metricsservicesetup.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsmetrics.concurrent_connections.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsmetrics.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsmetrics.os.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsmetrics.process.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsmetrics.requests.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsmetrics.response_times.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.distro.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.distrorelease.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.load.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.memory.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.platform.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.platformrelease.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.uptime_in_millis.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsprocessmetrics.event_loop_delay.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsprocessmetrics.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsprocessmetrics.memory.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsprocessmetrics.pid.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsprocessmetrics.uptime_in_millis.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsservermetrics.concurrent_connections.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsservermetrics.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsservermetrics.requests.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsservermetrics.response_times.md create mode 100644 src/core/server/metrics/collectors/index.ts create mode 100644 src/core/server/metrics/collectors/os.test.ts create mode 100644 src/core/server/metrics/collectors/os.ts create mode 100644 src/core/server/metrics/collectors/process.test.ts create mode 100644 src/core/server/metrics/collectors/process.ts create mode 100644 src/core/server/metrics/collectors/server.ts create mode 100644 src/core/server/metrics/collectors/types.ts create mode 100644 src/core/server/metrics/index.ts create mode 100644 src/core/server/metrics/integration_tests/server_collector.test.ts create mode 100644 src/core/server/metrics/metrics_service.mock.ts create mode 100644 src/core/server/metrics/metrics_service.test.mocks.ts create mode 100644 src/core/server/metrics/metrics_service.test.ts create mode 100644 src/core/server/metrics/metrics_service.ts create mode 100644 src/core/server/metrics/ops_config.ts create mode 100644 src/core/server/metrics/ops_metrics_collector.test.mocks.ts create mode 100644 src/core/server/metrics/ops_metrics_collector.test.ts create mode 100644 src/core/server/metrics/ops_metrics_collector.ts create mode 100644 src/core/server/metrics/types.ts diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index 15a1fd0506256..c948c89920796 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -88,11 +88,16 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- --> | [Logger](./kibana-plugin-server.logger.md) | Logger exposes all the necessary methods to log any type of information and this is the interface used by the logging consumers including plugins. | | [LoggerFactory](./kibana-plugin-server.loggerfactory.md) | The single purpose of <code>LoggerFactory</code> interface is to define a way to retrieve a context-based logger instance. | | [LogMeta](./kibana-plugin-server.logmeta.md) | Contextual metadata | +| [MetricsServiceSetup](./kibana-plugin-server.metricsservicesetup.md) | APIs to retrieves metrics gathered and exposed by the core platform. | | [OnPostAuthToolkit](./kibana-plugin-server.onpostauthtoolkit.md) | A tool set defining an outcome of OnPostAuth interceptor for incoming request. | | [OnPreAuthToolkit](./kibana-plugin-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | | [OnPreResponseExtensions](./kibana-plugin-server.onpreresponseextensions.md) | Additional data to extend a response. | | [OnPreResponseInfo](./kibana-plugin-server.onpreresponseinfo.md) | Response status code. | | [OnPreResponseToolkit](./kibana-plugin-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | +| [OpsMetrics](./kibana-plugin-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-server.opsosmetrics.md) | OS related metrics | +| [OpsProcessMetrics](./kibana-plugin-server.opsprocessmetrics.md) | Process related metrics | +| [OpsServerMetrics](./kibana-plugin-server.opsservermetrics.md) | server related metrics | | [PackageInfo](./kibana-plugin-server.packageinfo.md) | | | [Plugin](./kibana-plugin-server.plugin.md) | The interface that should be returned by a <code>PluginInitializer</code>. | | [PluginConfigDescriptor](./kibana-plugin-server.pluginconfigdescriptor.md) | Describes a plugin configuration properties. | diff --git a/docs/development/core/server/kibana-plugin-server.metricsservicesetup.getopsmetrics_.md b/docs/development/core/server/kibana-plugin-server.metricsservicesetup.getopsmetrics_.md new file mode 100644 index 0000000000000..454b8c905451e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.metricsservicesetup.getopsmetrics_.md @@ -0,0 +1,24 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [MetricsServiceSetup](./kibana-plugin-server.metricsservicesetup.md) > [getOpsMetrics$](./kibana-plugin-server.metricsservicesetup.getopsmetrics_.md) + +## MetricsServiceSetup.getOpsMetrics$ property + +Retrieve an observable emitting the [OpsMetrics](./kibana-plugin-server.opsmetrics.md) gathered. The observable will emit an initial value during core's `start` phase, and a new value every fixed interval of time, based on the `opts.interval` configuration property. + +<b>Signature:</b> + +```typescript +getOpsMetrics$: () => Observable<OpsMetrics>; +``` + +## Example + + +```ts +core.metrics.getOpsMetrics$().subscribe(metrics => { + // do something with the metrics +}) + +``` + diff --git a/docs/development/core/server/kibana-plugin-server.metricsservicesetup.md b/docs/development/core/server/kibana-plugin-server.metricsservicesetup.md new file mode 100644 index 0000000000000..270c56402a390 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.metricsservicesetup.md @@ -0,0 +1,20 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [MetricsServiceSetup](./kibana-plugin-server.metricsservicesetup.md) + +## MetricsServiceSetup interface + +APIs to retrieves metrics gathered and exposed by the core platform. + +<b>Signature:</b> + +```typescript +export interface MetricsServiceSetup +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [getOpsMetrics$](./kibana-plugin-server.metricsservicesetup.getopsmetrics_.md) | <code>() => Observable<OpsMetrics></code> | Retrieve an observable emitting the [OpsMetrics](./kibana-plugin-server.opsmetrics.md) gathered. The observable will emit an initial value during core's <code>start</code> phase, and a new value every fixed interval of time, based on the <code>opts.interval</code> configuration property. | + diff --git a/docs/development/core/server/kibana-plugin-server.opsmetrics.concurrent_connections.md b/docs/development/core/server/kibana-plugin-server.opsmetrics.concurrent_connections.md new file mode 100644 index 0000000000000..cfd39a551ad34 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsmetrics.concurrent_connections.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsMetrics](./kibana-plugin-server.opsmetrics.md) > [concurrent\_connections](./kibana-plugin-server.opsmetrics.concurrent_connections.md) + +## OpsMetrics.concurrent\_connections property + +number of current concurrent connections to the server + +<b>Signature:</b> + +```typescript +concurrent_connections: OpsServerMetrics['concurrent_connections']; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsmetrics.md b/docs/development/core/server/kibana-plugin-server.opsmetrics.md new file mode 100644 index 0000000000000..e23bd8d431d3f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsmetrics.md @@ -0,0 +1,24 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsMetrics](./kibana-plugin-server.opsmetrics.md) + +## OpsMetrics interface + +Regroups metrics gathered by all the collectors. This contains metrics about the os/runtime, the kibana process and the http server. + +<b>Signature:</b> + +```typescript +export interface OpsMetrics +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [concurrent\_connections](./kibana-plugin-server.opsmetrics.concurrent_connections.md) | <code>OpsServerMetrics['concurrent_connections']</code> | number of current concurrent connections to the server | +| [os](./kibana-plugin-server.opsmetrics.os.md) | <code>OpsOsMetrics</code> | OS related metrics | +| [process](./kibana-plugin-server.opsmetrics.process.md) | <code>OpsProcessMetrics</code> | Process related metrics | +| [requests](./kibana-plugin-server.opsmetrics.requests.md) | <code>OpsServerMetrics['requests']</code> | server requests stats | +| [response\_times](./kibana-plugin-server.opsmetrics.response_times.md) | <code>OpsServerMetrics['response_times']</code> | server response time stats | + diff --git a/docs/development/core/server/kibana-plugin-server.opsmetrics.os.md b/docs/development/core/server/kibana-plugin-server.opsmetrics.os.md new file mode 100644 index 0000000000000..993a1d7a2d7b7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsmetrics.os.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsMetrics](./kibana-plugin-server.opsmetrics.md) > [os](./kibana-plugin-server.opsmetrics.os.md) + +## OpsMetrics.os property + +OS related metrics + +<b>Signature:</b> + +```typescript +os: OpsOsMetrics; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsmetrics.process.md b/docs/development/core/server/kibana-plugin-server.opsmetrics.process.md new file mode 100644 index 0000000000000..53d3a33d66e06 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsmetrics.process.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsMetrics](./kibana-plugin-server.opsmetrics.md) > [process](./kibana-plugin-server.opsmetrics.process.md) + +## OpsMetrics.process property + +Process related metrics + +<b>Signature:</b> + +```typescript +process: OpsProcessMetrics; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsmetrics.requests.md b/docs/development/core/server/kibana-plugin-server.opsmetrics.requests.md new file mode 100644 index 0000000000000..9cd6b85e507f0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsmetrics.requests.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsMetrics](./kibana-plugin-server.opsmetrics.md) > [requests](./kibana-plugin-server.opsmetrics.requests.md) + +## OpsMetrics.requests property + +server requests stats + +<b>Signature:</b> + +```typescript +requests: OpsServerMetrics['requests']; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsmetrics.response_times.md b/docs/development/core/server/kibana-plugin-server.opsmetrics.response_times.md new file mode 100644 index 0000000000000..358699071b1c3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsmetrics.response_times.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsMetrics](./kibana-plugin-server.opsmetrics.md) > [response\_times](./kibana-plugin-server.opsmetrics.response_times.md) + +## OpsMetrics.response\_times property + +server response time stats + +<b>Signature:</b> + +```typescript +response_times: OpsServerMetrics['response_times']; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.distro.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.distro.md new file mode 100644 index 0000000000000..338164f173d02 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.distro.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [distro](./kibana-plugin-server.opsosmetrics.distro.md) + +## OpsOsMetrics.distro property + +The os distrib. Only present for linux platforms + +<b>Signature:</b> + +```typescript +distro?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.distrorelease.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.distrorelease.md new file mode 100644 index 0000000000000..24c5a1f00b64c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.distrorelease.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [distroRelease](./kibana-plugin-server.opsosmetrics.distrorelease.md) + +## OpsOsMetrics.distroRelease property + +The os distrib release, prefixed by the os distrib. Only present for linux platforms + +<b>Signature:</b> + +```typescript +distroRelease?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.load.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.load.md new file mode 100644 index 0000000000000..0bf17502ce34e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.load.md @@ -0,0 +1,17 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [load](./kibana-plugin-server.opsosmetrics.load.md) + +## OpsOsMetrics.load property + +cpu load metrics + +<b>Signature:</b> + +```typescript +load: { + '1m': number; + '5m': number; + '15m': number; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.md new file mode 100644 index 0000000000000..0fb4e59fdf539 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.md @@ -0,0 +1,26 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) + +## OpsOsMetrics interface + +OS related metrics + +<b>Signature:</b> + +```typescript +export interface OpsOsMetrics +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [distro](./kibana-plugin-server.opsosmetrics.distro.md) | <code>string</code> | The os distrib. Only present for linux platforms | +| [distroRelease](./kibana-plugin-server.opsosmetrics.distrorelease.md) | <code>string</code> | The os distrib release, prefixed by the os distrib. Only present for linux platforms | +| [load](./kibana-plugin-server.opsosmetrics.load.md) | <code>{</code><br/><code> '1m': number;</code><br/><code> '5m': number;</code><br/><code> '15m': number;</code><br/><code> }</code> | cpu load metrics | +| [memory](./kibana-plugin-server.opsosmetrics.memory.md) | <code>{</code><br/><code> total_in_bytes: number;</code><br/><code> free_in_bytes: number;</code><br/><code> used_in_bytes: number;</code><br/><code> }</code> | system memory usage metrics | +| [platform](./kibana-plugin-server.opsosmetrics.platform.md) | <code>NodeJS.Platform</code> | The os platform | +| [platformRelease](./kibana-plugin-server.opsosmetrics.platformrelease.md) | <code>string</code> | The os platform release, prefixed by the platform name | +| [uptime\_in\_millis](./kibana-plugin-server.opsosmetrics.uptime_in_millis.md) | <code>number</code> | the OS uptime | + diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.memory.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.memory.md new file mode 100644 index 0000000000000..4a1becaeeaec7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.memory.md @@ -0,0 +1,17 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [memory](./kibana-plugin-server.opsosmetrics.memory.md) + +## OpsOsMetrics.memory property + +system memory usage metrics + +<b>Signature:</b> + +```typescript +memory: { + total_in_bytes: number; + free_in_bytes: number; + used_in_bytes: number; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.platform.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.platform.md new file mode 100644 index 0000000000000..411d0fc546dc0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.platform.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [platform](./kibana-plugin-server.opsosmetrics.platform.md) + +## OpsOsMetrics.platform property + +The os platform + +<b>Signature:</b> + +```typescript +platform: NodeJS.Platform; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.platformrelease.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.platformrelease.md new file mode 100644 index 0000000000000..1071b4a38f588 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.platformrelease.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [platformRelease](./kibana-plugin-server.opsosmetrics.platformrelease.md) + +## OpsOsMetrics.platformRelease property + +The os platform release, prefixed by the platform name + +<b>Signature:</b> + +```typescript +platformRelease: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.uptime_in_millis.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.uptime_in_millis.md new file mode 100644 index 0000000000000..dfff1a1f1da0b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.uptime_in_millis.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [uptime\_in\_millis](./kibana-plugin-server.opsosmetrics.uptime_in_millis.md) + +## OpsOsMetrics.uptime\_in\_millis property + +the OS uptime + +<b>Signature:</b> + +```typescript +uptime_in_millis: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.event_loop_delay.md b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.event_loop_delay.md new file mode 100644 index 0000000000000..f61c8b0995324 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.event_loop_delay.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsProcessMetrics](./kibana-plugin-server.opsprocessmetrics.md) > [event\_loop\_delay](./kibana-plugin-server.opsprocessmetrics.event_loop_delay.md) + +## OpsProcessMetrics.event\_loop\_delay property + +node event loop delay + +<b>Signature:</b> + +```typescript +event_loop_delay: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.md b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.md new file mode 100644 index 0000000000000..92fd8471cce7d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.md @@ -0,0 +1,23 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsProcessMetrics](./kibana-plugin-server.opsprocessmetrics.md) + +## OpsProcessMetrics interface + +Process related metrics + +<b>Signature:</b> + +```typescript +export interface OpsProcessMetrics +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [event\_loop\_delay](./kibana-plugin-server.opsprocessmetrics.event_loop_delay.md) | <code>number</code> | node event loop delay | +| [memory](./kibana-plugin-server.opsprocessmetrics.memory.md) | <code>{</code><br/><code> heap: {</code><br/><code> total_in_bytes: number;</code><br/><code> used_in_bytes: number;</code><br/><code> size_limit: number;</code><br/><code> };</code><br/><code> resident_set_size_in_bytes: number;</code><br/><code> }</code> | process memory usage | +| [pid](./kibana-plugin-server.opsprocessmetrics.pid.md) | <code>number</code> | pid of the kibana process | +| [uptime\_in\_millis](./kibana-plugin-server.opsprocessmetrics.uptime_in_millis.md) | <code>number</code> | uptime of the kibana process | + diff --git a/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.memory.md b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.memory.md new file mode 100644 index 0000000000000..5c1a8de70dc01 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.memory.md @@ -0,0 +1,20 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsProcessMetrics](./kibana-plugin-server.opsprocessmetrics.md) > [memory](./kibana-plugin-server.opsprocessmetrics.memory.md) + +## OpsProcessMetrics.memory property + +process memory usage + +<b>Signature:</b> + +```typescript +memory: { + heap: { + total_in_bytes: number; + used_in_bytes: number; + size_limit: number; + }; + resident_set_size_in_bytes: number; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.pid.md b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.pid.md new file mode 100644 index 0000000000000..a34187f372018 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.pid.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsProcessMetrics](./kibana-plugin-server.opsprocessmetrics.md) > [pid](./kibana-plugin-server.opsprocessmetrics.pid.md) + +## OpsProcessMetrics.pid property + +pid of the kibana process + +<b>Signature:</b> + +```typescript +pid: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.uptime_in_millis.md b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.uptime_in_millis.md new file mode 100644 index 0000000000000..24db2f017a663 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.uptime_in_millis.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsProcessMetrics](./kibana-plugin-server.opsprocessmetrics.md) > [uptime\_in\_millis](./kibana-plugin-server.opsprocessmetrics.uptime_in_millis.md) + +## OpsProcessMetrics.uptime\_in\_millis property + +uptime of the kibana process + +<b>Signature:</b> + +```typescript +uptime_in_millis: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsservermetrics.concurrent_connections.md b/docs/development/core/server/kibana-plugin-server.opsservermetrics.concurrent_connections.md new file mode 100644 index 0000000000000..ade79fedfa1b5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsservermetrics.concurrent_connections.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsServerMetrics](./kibana-plugin-server.opsservermetrics.md) > [concurrent\_connections](./kibana-plugin-server.opsservermetrics.concurrent_connections.md) + +## OpsServerMetrics.concurrent\_connections property + +number of current concurrent connections to the server + +<b>Signature:</b> + +```typescript +concurrent_connections: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsservermetrics.md b/docs/development/core/server/kibana-plugin-server.opsservermetrics.md new file mode 100644 index 0000000000000..4e35c02bd9f28 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsservermetrics.md @@ -0,0 +1,22 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsServerMetrics](./kibana-plugin-server.opsservermetrics.md) + +## OpsServerMetrics interface + +server related metrics + +<b>Signature:</b> + +```typescript +export interface OpsServerMetrics +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [concurrent\_connections](./kibana-plugin-server.opsservermetrics.concurrent_connections.md) | <code>number</code> | number of current concurrent connections to the server | +| [requests](./kibana-plugin-server.opsservermetrics.requests.md) | <code>{</code><br/><code> disconnects: number;</code><br/><code> total: number;</code><br/><code> statusCodes: Record<number, number>;</code><br/><code> }</code> | server requests stats | +| [response\_times](./kibana-plugin-server.opsservermetrics.response_times.md) | <code>{</code><br/><code> avg_in_millis: number;</code><br/><code> max_in_millis: number;</code><br/><code> }</code> | server response time stats | + diff --git a/docs/development/core/server/kibana-plugin-server.opsservermetrics.requests.md b/docs/development/core/server/kibana-plugin-server.opsservermetrics.requests.md new file mode 100644 index 0000000000000..5ad2abc869557 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsservermetrics.requests.md @@ -0,0 +1,17 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsServerMetrics](./kibana-plugin-server.opsservermetrics.md) > [requests](./kibana-plugin-server.opsservermetrics.requests.md) + +## OpsServerMetrics.requests property + +server requests stats + +<b>Signature:</b> + +```typescript +requests: { + disconnects: number; + total: number; + statusCodes: Record<number, number>; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsservermetrics.response_times.md b/docs/development/core/server/kibana-plugin-server.opsservermetrics.response_times.md new file mode 100644 index 0000000000000..5008efc6ad4da --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsservermetrics.response_times.md @@ -0,0 +1,16 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsServerMetrics](./kibana-plugin-server.opsservermetrics.md) > [response\_times](./kibana-plugin-server.opsservermetrics.response_times.md) + +## OpsServerMetrics.response\_times property + +server response time stats + +<b>Signature:</b> + +```typescript +response_times: { + avg_in_millis: number; + max_in_millis: number; + }; +``` diff --git a/package.json b/package.json index e727d87a83c53..2c401724c72cd 100644 --- a/package.json +++ b/package.json @@ -323,6 +323,7 @@ "@types/fetch-mock": "^7.3.1", "@types/flot": "^0.0.31", "@types/getopts": "^2.0.1", + "@types/getos": "^3.0.0", "@types/glob": "^7.1.1", "@types/globby": "^8.0.0", "@types/graphql": "^0.13.2", diff --git a/src/core/server/index.ts b/src/core/server/index.ts index e45d4f28edcc3..de6cdb2d7acd7 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -245,6 +245,14 @@ export { StringValidationRegexString, } from './ui_settings'; +export { + OpsMetrics, + OpsOsMetrics, + OpsServerMetrics, + OpsProcessMetrics, + MetricsServiceSetup, +} from './metrics'; + export { RecursiveReadonly } from '../utils'; export { diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index ff68d1544d119..37d1061dc618d 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -30,6 +30,7 @@ import { } from './saved_objects'; import { InternalUiSettingsServiceSetup, InternalUiSettingsServiceStart } from './ui_settings'; import { UuidServiceSetup } from './uuid'; +import { InternalMetricsServiceSetup } from './metrics'; /** @internal */ export interface InternalCoreSetup { @@ -40,6 +41,7 @@ export interface InternalCoreSetup { uiSettings: InternalUiSettingsServiceSetup; savedObjects: InternalSavedObjectsServiceSetup; uuid: UuidServiceSetup; + metrics: InternalMetricsServiceSetup; } /** diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index 46436461505c0..50468db8a504d 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -43,6 +43,7 @@ import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service. import { capabilitiesServiceMock } from '../capabilities/capabilities_service.mock'; import { setupMock as renderingServiceMock } from '../rendering/__mocks__/rendering_service'; import { uuidServiceMock } from '../uuid/uuid_service.mock'; +import { metricsServiceMock } from '../metrics/metrics_service.mock'; import { findLegacyPluginSpecs } from './plugins'; import { LegacyVars, LegacyServiceSetupDeps, LegacyServiceStartDeps } from './types'; import { LegacyService } from './legacy_service'; @@ -93,6 +94,7 @@ beforeEach(() => { }, }, rendering: renderingServiceMock, + metrics: metricsServiceMock.createInternalSetupContract(), uuid: uuidSetup, }, plugins: { 'plugin-id': 'plugin-value' }, diff --git a/src/core/server/metrics/collectors/index.ts b/src/core/server/metrics/collectors/index.ts new file mode 100644 index 0000000000000..f58ab02e63881 --- /dev/null +++ b/src/core/server/metrics/collectors/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { OpsProcessMetrics, OpsOsMetrics, OpsServerMetrics, MetricsCollector } from './types'; +export { OsMetricsCollector } from './os'; +export { ProcessMetricsCollector } from './process'; +export { ServerMetricsCollector } from './server'; diff --git a/src/core/server/metrics/collectors/os.test.ts b/src/core/server/metrics/collectors/os.test.ts new file mode 100644 index 0000000000000..7d5a6da90b7d6 --- /dev/null +++ b/src/core/server/metrics/collectors/os.test.ts @@ -0,0 +1,99 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +jest.mock('getos', () => (cb: Function) => cb(null, { dist: 'distrib', release: 'release' })); + +import os from 'os'; +import { OsMetricsCollector } from './os'; + +describe('OsMetricsCollector', () => { + let collector: OsMetricsCollector; + + beforeEach(() => { + collector = new OsMetricsCollector(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('collects platform info from the os package', async () => { + const platform = 'darwin'; + const release = '10.14.1'; + + jest.spyOn(os, 'platform').mockImplementation(() => platform); + jest.spyOn(os, 'release').mockImplementation(() => release); + + const metrics = await collector.collect(); + + expect(metrics.platform).toBe(platform); + expect(metrics.platformRelease).toBe(`${platform}-${release}`); + }); + + it('collects distribution info when platform is linux', async () => { + const platform = 'linux'; + + jest.spyOn(os, 'platform').mockImplementation(() => platform); + + const metrics = await collector.collect(); + + expect(metrics.distro).toBe('distrib'); + expect(metrics.distroRelease).toBe('distrib-release'); + }); + + it('collects memory info from the os package', async () => { + const totalMemory = 1457886; + const freeMemory = 456786; + + jest.spyOn(os, 'totalmem').mockImplementation(() => totalMemory); + jest.spyOn(os, 'freemem').mockImplementation(() => freeMemory); + + const metrics = await collector.collect(); + + expect(metrics.memory.total_in_bytes).toBe(totalMemory); + expect(metrics.memory.free_in_bytes).toBe(freeMemory); + expect(metrics.memory.used_in_bytes).toBe(totalMemory - freeMemory); + }); + + it('collects uptime info from the os package', async () => { + const uptime = 325; + + jest.spyOn(os, 'uptime').mockImplementation(() => uptime); + + const metrics = await collector.collect(); + + expect(metrics.uptime_in_millis).toBe(uptime * 1000); + }); + + it('collects load info from the os package', async () => { + const oneMinLoad = 1; + const fiveMinLoad = 2; + const fifteenMinLoad = 3; + + jest.spyOn(os, 'loadavg').mockImplementation(() => [oneMinLoad, fiveMinLoad, fifteenMinLoad]); + + const metrics = await collector.collect(); + + expect(metrics.load).toEqual({ + '1m': oneMinLoad, + '5m': fiveMinLoad, + '15m': fifteenMinLoad, + }); + }); +}); diff --git a/src/core/server/metrics/collectors/os.ts b/src/core/server/metrics/collectors/os.ts new file mode 100644 index 0000000000000..d3d9bb0be86fa --- /dev/null +++ b/src/core/server/metrics/collectors/os.ts @@ -0,0 +1,60 @@ +/* + * 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 os from 'os'; +import getosAsync, { LinuxOs } from 'getos'; +import { promisify } from 'util'; +import { OpsOsMetrics, MetricsCollector } from './types'; + +const getos = promisify(getosAsync); + +export class OsMetricsCollector implements MetricsCollector<OpsOsMetrics> { + public async collect(): Promise<OpsOsMetrics> { + const platform = os.platform(); + const load = os.loadavg(); + + const metrics: OpsOsMetrics = { + platform, + platformRelease: `${platform}-${os.release()}`, + load: { + '1m': load[0], + '5m': load[1], + '15m': load[2], + }, + memory: { + total_in_bytes: os.totalmem(), + free_in_bytes: os.freemem(), + used_in_bytes: os.totalmem() - os.freemem(), + }, + uptime_in_millis: os.uptime() * 1000, + }; + + if (platform === 'linux') { + try { + const distro = (await getos()) as LinuxOs; + metrics.distro = distro.dist; + metrics.distroRelease = `${distro.dist}-${distro.release}`; + } catch (e) { + // ignore errors + } + } + + return metrics; + } +} diff --git a/src/core/server/metrics/collectors/process.test.ts b/src/core/server/metrics/collectors/process.test.ts new file mode 100644 index 0000000000000..a437d799371f1 --- /dev/null +++ b/src/core/server/metrics/collectors/process.test.ts @@ -0,0 +1,81 @@ +/* + * 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 v8, { HeapInfo } from 'v8'; +import { ProcessMetricsCollector } from './process'; + +describe('ProcessMetricsCollector', () => { + let collector: ProcessMetricsCollector; + + beforeEach(() => { + collector = new ProcessMetricsCollector(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('collects pid from the process', async () => { + const metrics = await collector.collect(); + + expect(metrics.pid).toEqual(process.pid); + }); + + it('collects event loop delay', async () => { + const metrics = await collector.collect(); + + expect(metrics.event_loop_delay).toBeGreaterThan(0); + }); + + it('collects uptime info from the process', async () => { + const uptime = 58986; + jest.spyOn(process, 'uptime').mockImplementation(() => uptime); + + const metrics = await collector.collect(); + + expect(metrics.uptime_in_millis).toEqual(uptime * 1000); + }); + + it('collects memory info from the process', async () => { + const heapTotal = 58986; + const heapUsed = 4688; + const heapSizeLimit = 5788; + const rss = 5865; + jest.spyOn(process, 'memoryUsage').mockImplementation(() => ({ + rss, + heapTotal, + heapUsed, + external: 0, + })); + + jest.spyOn(v8, 'getHeapStatistics').mockImplementation( + () => + ({ + heap_size_limit: heapSizeLimit, + } as HeapInfo) + ); + + const metrics = await collector.collect(); + + expect(metrics.memory.heap.total_in_bytes).toEqual(heapTotal); + expect(metrics.memory.heap.used_in_bytes).toEqual(heapUsed); + expect(metrics.memory.heap.size_limit).toEqual(heapSizeLimit); + expect(metrics.memory.resident_set_size_in_bytes).toEqual(rss); + }); +}); diff --git a/src/core/server/metrics/collectors/process.ts b/src/core/server/metrics/collectors/process.ts new file mode 100644 index 0000000000000..aa68abaf74e41 --- /dev/null +++ b/src/core/server/metrics/collectors/process.ts @@ -0,0 +1,52 @@ +/* + * 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 v8 from 'v8'; +import { Bench } from 'hoek'; +import { OpsProcessMetrics, MetricsCollector } from './types'; + +export class ProcessMetricsCollector implements MetricsCollector<OpsProcessMetrics> { + public async collect(): Promise<OpsProcessMetrics> { + const heapStats = v8.getHeapStatistics(); + const memoryUsage = process.memoryUsage(); + const [eventLoopDelay] = await Promise.all([getEventLoopDelay()]); + return { + memory: { + heap: { + total_in_bytes: memoryUsage.heapTotal, + used_in_bytes: memoryUsage.heapUsed, + size_limit: heapStats.heap_size_limit, + }, + resident_set_size_in_bytes: memoryUsage.rss, + }, + pid: process.pid, + event_loop_delay: eventLoopDelay, + uptime_in_millis: process.uptime() * 1000, + }; + } +} + +const getEventLoopDelay = (): Promise<number> => { + const bench = new Bench(); + return new Promise(resolve => { + setImmediate(() => { + return resolve(bench.elapsed()); + }); + }); +}; diff --git a/src/core/server/metrics/collectors/server.ts b/src/core/server/metrics/collectors/server.ts new file mode 100644 index 0000000000000..e46ac2f653df6 --- /dev/null +++ b/src/core/server/metrics/collectors/server.ts @@ -0,0 +1,80 @@ +/* + * 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 { ResponseObject, Server as HapiServer } from 'hapi'; +import { OpsServerMetrics, MetricsCollector } from './types'; + +interface ServerResponseTime { + count: number; + total: number; + max: number; +} + +export class ServerMetricsCollector implements MetricsCollector<OpsServerMetrics> { + private readonly requests: OpsServerMetrics['requests'] = { + disconnects: 0, + total: 0, + statusCodes: {}, + }; + private readonly responseTimes: ServerResponseTime = { + count: 0, + total: 0, + max: 0, + }; + + constructor(private readonly server: HapiServer) { + this.server.ext('onRequest', (request, h) => { + this.requests.total++; + request.events.once('disconnect', () => { + this.requests.disconnects++; + }); + return h.continue; + }); + this.server.events.on('response', request => { + const statusCode = (request.response as ResponseObject)?.statusCode; + if (statusCode) { + if (!this.requests.statusCodes[statusCode]) { + this.requests.statusCodes[statusCode] = 0; + } + this.requests.statusCodes[statusCode]++; + } + + const duration = Date.now() - request.info.received; + this.responseTimes.count++; + this.responseTimes.total += duration; + this.responseTimes.max = Math.max(this.responseTimes.max, duration); + }); + } + + public async collect(): Promise<OpsServerMetrics> { + const connections = await new Promise<number>(resolve => { + this.server.listener.getConnections((_, count) => { + resolve(count); + }); + }); + + return { + requests: this.requests, + response_times: { + avg_in_millis: this.responseTimes.total / Math.max(this.responseTimes.count, 1), + max_in_millis: this.responseTimes.max, + }, + concurrent_connections: connections, + }; + } +} diff --git a/src/core/server/metrics/collectors/types.ts b/src/core/server/metrics/collectors/types.ts new file mode 100644 index 0000000000000..5a83bc70af3c1 --- /dev/null +++ b/src/core/server/metrics/collectors/types.ts @@ -0,0 +1,110 @@ +/* + * 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. + */ + +/** Base interface for all metrics gatherers */ +export interface MetricsCollector<T> { + collect(): Promise<T>; +} + +/** + * Process related metrics + * @public + */ +export interface OpsProcessMetrics { + /** process memory usage */ + memory: { + /** heap memory usage */ + heap: { + /** total heap available */ + total_in_bytes: number; + /** used heap */ + used_in_bytes: number; + /** v8 heap size limit */ + size_limit: number; + }; + /** node rss */ + resident_set_size_in_bytes: number; + }; + /** node event loop delay */ + event_loop_delay: number; + /** pid of the kibana process */ + pid: number; + /** uptime of the kibana process */ + uptime_in_millis: number; +} + +/** + * OS related metrics + * @public + */ +export interface OpsOsMetrics { + /** The os platform */ + platform: NodeJS.Platform; + /** The os platform release, prefixed by the platform name */ + platformRelease: string; + /** The os distrib. Only present for linux platforms */ + distro?: string; + /** The os distrib release, prefixed by the os distrib. Only present for linux platforms */ + distroRelease?: string; + /** cpu load metrics */ + load: { + /** load for last minute */ + '1m': number; + /** load for last 5 minutes */ + '5m': number; + /** load for last 15 minutes */ + '15m': number; + }; + /** system memory usage metrics */ + memory: { + /** total memory available */ + total_in_bytes: number; + /** current free memory */ + free_in_bytes: number; + /** current used memory */ + used_in_bytes: number; + }; + /** the OS uptime */ + uptime_in_millis: number; +} + +/** + * server related metrics + * @public + */ +export interface OpsServerMetrics { + /** server response time stats */ + response_times: { + /** average response time */ + avg_in_millis: number; + /** maximum response time */ + max_in_millis: number; + }; + /** server requests stats */ + requests: { + /** number of disconnected requests since startup */ + disconnects: number; + /** total number of requests handled since startup */ + total: number; + /** number of request handled per response status code */ + statusCodes: Record<number, number>; + }; + /** number of current concurrent connections to the server */ + concurrent_connections: number; +} diff --git a/src/core/server/metrics/index.ts b/src/core/server/metrics/index.ts new file mode 100644 index 0000000000000..fdcf637c0cd7b --- /dev/null +++ b/src/core/server/metrics/index.ts @@ -0,0 +1,29 @@ +/* + * 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 { + InternalMetricsServiceStart, + InternalMetricsServiceSetup, + MetricsServiceSetup, + MetricsServiceStart, + OpsMetrics, +} from './types'; +export { OpsProcessMetrics, OpsServerMetrics, OpsOsMetrics } from './collectors'; +export { MetricsService } from './metrics_service'; +export { opsConfig } from './ops_config'; diff --git a/src/core/server/metrics/integration_tests/server_collector.test.ts b/src/core/server/metrics/integration_tests/server_collector.test.ts new file mode 100644 index 0000000000000..a387de80212d9 --- /dev/null +++ b/src/core/server/metrics/integration_tests/server_collector.test.ts @@ -0,0 +1,183 @@ +/* + * 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 { Subject } from 'rxjs'; +import { take } from 'rxjs/operators'; +import supertest from 'supertest'; +import { Server as HapiServer } from 'hapi'; +import { createHttpServer } from '../../http/test_utils'; +import { HttpService, IRouter } from '../../http'; +import { contextServiceMock } from '../../context/context_service.mock'; +import { ServerMetricsCollector } from '../collectors/server'; + +describe('ServerMetricsCollector', () => { + let server: HttpService; + let collector: ServerMetricsCollector; + let hapiServer: HapiServer; + let router: IRouter; + + const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + const sendGet = (path: string) => supertest(hapiServer.listener).get(path); + + beforeEach(async () => { + server = createHttpServer(); + const contextSetup = contextServiceMock.createSetupContract(); + const httpSetup = await server.setup({ context: contextSetup }); + hapiServer = httpSetup.server; + router = httpSetup.createRouter('/'); + collector = new ServerMetricsCollector(hapiServer); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('collect requests infos', async () => { + router.get({ path: '/', validate: false }, async (ctx, req, res) => { + return res.ok({ body: '' }); + }); + await server.start(); + + let metrics = await collector.collect(); + + expect(metrics.requests).toEqual({ + total: 0, + disconnects: 0, + statusCodes: {}, + }); + + await sendGet('/'); + await sendGet('/'); + await sendGet('/not-found'); + + metrics = await collector.collect(); + + expect(metrics.requests).toEqual({ + total: 3, + disconnects: 0, + statusCodes: { + '200': 2, + '404': 1, + }, + }); + }); + + it('collect disconnects requests infos', async () => { + const never = new Promise(resolve => undefined); + + router.get({ path: '/', validate: false }, async (ctx, req, res) => { + return res.ok({ body: '' }); + }); + router.get({ path: '/disconnect', validate: false }, async (ctx, req, res) => { + await never; + return res.ok({ body: '' }); + }); + await server.start(); + + await sendGet('/'); + const discoReq1 = sendGet('/disconnect').end(); + const discoReq2 = sendGet('/disconnect').end(); + await delay(20); + + let metrics = await collector.collect(); + expect(metrics.requests).toEqual( + expect.objectContaining({ + total: 3, + disconnects: 0, + }) + ); + + discoReq1.abort(); + await delay(20); + + metrics = await collector.collect(); + expect(metrics.requests).toEqual( + expect.objectContaining({ + total: 3, + disconnects: 1, + }) + ); + + discoReq2.abort(); + await delay(20); + + metrics = await collector.collect(); + expect(metrics.requests).toEqual( + expect.objectContaining({ + total: 3, + disconnects: 2, + }) + ); + }); + + it('collect response times', async () => { + router.get({ path: '/no-delay', validate: false }, async (ctx, req, res) => { + return res.ok({ body: '' }); + }); + router.get({ path: '/500-ms', validate: false }, async (ctx, req, res) => { + await delay(500); + return res.ok({ body: '' }); + }); + router.get({ path: '/250-ms', validate: false }, async (ctx, req, res) => { + await delay(250); + return res.ok({ body: '' }); + }); + await server.start(); + + await Promise.all([sendGet('/no-delay'), sendGet('/250-ms')]); + let metrics = await collector.collect(); + + expect(metrics.response_times.avg_in_millis).toBeGreaterThanOrEqual(125); + expect(metrics.response_times.max_in_millis).toBeGreaterThanOrEqual(250); + + await Promise.all([sendGet('/500-ms'), sendGet('/500-ms')]); + metrics = await collector.collect(); + + expect(metrics.response_times.avg_in_millis).toBeGreaterThanOrEqual(250); + expect(metrics.response_times.max_in_millis).toBeGreaterThanOrEqual(500); + }); + + it('collect connection count', async () => { + const waitSubject = new Subject(); + + router.get({ path: '/', validate: false }, async (ctx, req, res) => { + await waitSubject.pipe(take(1)).toPromise(); + return res.ok({ body: '' }); + }); + await server.start(); + + let metrics = await collector.collect(); + expect(metrics.concurrent_connections).toEqual(0); + + sendGet('/').end(() => null); + await delay(20); + metrics = await collector.collect(); + expect(metrics.concurrent_connections).toEqual(1); + + sendGet('/').end(() => null); + await delay(20); + metrics = await collector.collect(); + expect(metrics.concurrent_connections).toEqual(2); + + waitSubject.next('go'); + await delay(20); + metrics = await collector.collect(); + expect(metrics.concurrent_connections).toEqual(0); + }); +}); diff --git a/src/core/server/metrics/metrics_service.mock.ts b/src/core/server/metrics/metrics_service.mock.ts new file mode 100644 index 0000000000000..cc53a4e27d571 --- /dev/null +++ b/src/core/server/metrics/metrics_service.mock.ts @@ -0,0 +1,67 @@ +/* + * 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 { MetricsService } from './metrics_service'; +import { + InternalMetricsServiceSetup, + InternalMetricsServiceStart, + MetricsServiceSetup, + MetricsServiceStart, +} from './types'; + +const createSetupContractMock = () => { + const setupContract: jest.Mocked<MetricsServiceSetup> = { + getOpsMetrics$: jest.fn(), + }; + return setupContract; +}; + +const createInternalSetupContractMock = () => { + const setupContract: jest.Mocked<InternalMetricsServiceSetup> = createSetupContractMock(); + return setupContract; +}; + +const createStartContractMock = () => { + const startContract: jest.Mocked<MetricsServiceStart> = {}; + return startContract; +}; + +const createInternalStartContractMock = () => { + const startContract: jest.Mocked<InternalMetricsServiceStart> = createStartContractMock(); + return startContract; +}; + +type MetricsServiceContract = PublicMethodsOf<MetricsService>; + +const createMock = () => { + const mocked: jest.Mocked<MetricsServiceContract> = { + setup: jest.fn().mockReturnValue(createInternalSetupContractMock()), + start: jest.fn().mockReturnValue(createInternalStartContractMock()), + stop: jest.fn(), + }; + return mocked; +}; + +export const metricsServiceMock = { + create: createMock, + createSetupContract: createSetupContractMock, + createStartContract: createStartContractMock, + createInternalSetupContract: createInternalSetupContractMock, + createInternalStartContract: createInternalStartContractMock, +}; diff --git a/src/core/server/metrics/metrics_service.test.mocks.ts b/src/core/server/metrics/metrics_service.test.mocks.ts new file mode 100644 index 0000000000000..8e91775283042 --- /dev/null +++ b/src/core/server/metrics/metrics_service.test.mocks.ts @@ -0,0 +1,25 @@ +/* + * 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 const mockOpsCollector = { + collect: jest.fn(), +}; +jest.doMock('./ops_metrics_collector', () => ({ + OpsMetricsCollector: jest.fn().mockImplementation(() => mockOpsCollector), +})); diff --git a/src/core/server/metrics/metrics_service.test.ts b/src/core/server/metrics/metrics_service.test.ts new file mode 100644 index 0000000000000..10d6761adbe7d --- /dev/null +++ b/src/core/server/metrics/metrics_service.test.ts @@ -0,0 +1,134 @@ +/* + * 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 moment from 'moment'; +import { mockOpsCollector } from './metrics_service.test.mocks'; +import { MetricsService } from './metrics_service'; +import { mockCoreContext } from '../core_context.mock'; +import { configServiceMock } from '../config/config_service.mock'; +import { httpServiceMock } from '../http/http_service.mock'; +import { take } from 'rxjs/operators'; + +const testInterval = 100; + +const dummyMetrics = { metricA: 'value', metricB: 'otherValue' }; + +describe('MetricsService', () => { + const httpMock = httpServiceMock.createSetupContract(); + let metricsService: MetricsService; + + beforeEach(() => { + jest.useFakeTimers(); + + const configService = configServiceMock.create({ + atPath: { interval: moment.duration(testInterval) }, + }); + const coreContext = mockCoreContext.create({ configService }); + metricsService = new MetricsService(coreContext); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + describe('#start', () => { + it('invokes setInterval with the configured interval', async () => { + await metricsService.setup({ http: httpMock }); + await metricsService.start(); + + expect(setInterval).toHaveBeenCalledTimes(1); + expect(setInterval).toHaveBeenCalledWith(expect.any(Function), testInterval); + }); + + it('emits the metrics at start', async () => { + mockOpsCollector.collect.mockResolvedValue(dummyMetrics); + + const { getOpsMetrics$ } = await metricsService.setup({ + http: httpMock, + }); + + await metricsService.start(); + + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(1); + expect( + await getOpsMetrics$() + .pipe(take(1)) + .toPromise() + ).toEqual(dummyMetrics); + }); + + it('collects the metrics at every interval', async () => { + mockOpsCollector.collect.mockResolvedValue(dummyMetrics); + + await metricsService.setup({ http: httpMock }); + + await metricsService.start(); + + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(testInterval); + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(2); + + jest.advanceTimersByTime(testInterval); + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(3); + }); + + it('throws when called before setup', async () => { + await expect(metricsService.start()).rejects.toThrowErrorMatchingInlineSnapshot( + `"#setup() needs to be run first"` + ); + }); + }); + + describe('#stop', () => { + it('stops the metrics interval', async () => { + const { getOpsMetrics$ } = await metricsService.setup({ http: httpMock }); + await metricsService.start(); + + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(testInterval); + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(2); + + await metricsService.stop(); + jest.advanceTimersByTime(10 * testInterval); + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(2); + + getOpsMetrics$().subscribe({ complete: () => {} }); + }); + + it('completes the metrics observable', async () => { + const { getOpsMetrics$ } = await metricsService.setup({ http: httpMock }); + await metricsService.start(); + + let completed = false; + + getOpsMetrics$().subscribe({ + complete: () => { + completed = true; + }, + }); + + await metricsService.stop(); + + expect(completed).toEqual(true); + }); + }); +}); diff --git a/src/core/server/metrics/metrics_service.ts b/src/core/server/metrics/metrics_service.ts new file mode 100644 index 0000000000000..1aed89a4aad60 --- /dev/null +++ b/src/core/server/metrics/metrics_service.ts @@ -0,0 +1,86 @@ +/* + * 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 { ReplaySubject } from 'rxjs'; +import { first, shareReplay } from 'rxjs/operators'; +import { CoreService } from '../../types'; +import { CoreContext } from '../core_context'; +import { Logger } from '../logging'; +import { InternalHttpServiceSetup } from '../http'; +import { InternalMetricsServiceSetup, InternalMetricsServiceStart, OpsMetrics } from './types'; +import { OpsMetricsCollector } from './ops_metrics_collector'; +import { opsConfig, OpsConfigType } from './ops_config'; + +interface MetricsServiceSetupDeps { + http: InternalHttpServiceSetup; +} + +/** @internal */ +export class MetricsService + implements CoreService<InternalMetricsServiceSetup, InternalMetricsServiceStart> { + private readonly logger: Logger; + private metricsCollector?: OpsMetricsCollector; + private collectInterval?: NodeJS.Timeout; + private metrics$ = new ReplaySubject<OpsMetrics>(1); + + constructor(private readonly coreContext: CoreContext) { + this.logger = coreContext.logger.get('metrics'); + } + + public async setup({ http }: MetricsServiceSetupDeps): Promise<InternalMetricsServiceSetup> { + this.metricsCollector = new OpsMetricsCollector(http.server); + + const metricsObservable = this.metrics$.pipe(shareReplay(1)); + + return { + getOpsMetrics$: () => metricsObservable, + }; + } + + public async start(): Promise<InternalMetricsServiceStart> { + if (!this.metricsCollector) { + throw new Error('#setup() needs to be run first'); + } + const config = await this.coreContext.configService + .atPath<OpsConfigType>(opsConfig.path) + .pipe(first()) + .toPromise(); + + await this.refreshMetrics(); + + this.collectInterval = setInterval(() => { + this.refreshMetrics(); + }, config.interval.asMilliseconds()); + + return {}; + } + + private async refreshMetrics() { + this.logger.debug('Refreshing metrics'); + const metrics = await this.metricsCollector!.collect(); + this.metrics$.next(metrics); + } + + public async stop() { + if (this.collectInterval) { + clearInterval(this.collectInterval); + } + this.metrics$.complete(); + } +} diff --git a/src/core/server/metrics/ops_config.ts b/src/core/server/metrics/ops_config.ts new file mode 100644 index 0000000000000..bd6ae5cc5474d --- /dev/null +++ b/src/core/server/metrics/ops_config.ts @@ -0,0 +1,29 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +export const opsConfig = { + path: 'ops', + schema: schema.object({ + interval: schema.duration({ defaultValue: '5s' }), + }), +}; + +export type OpsConfigType = TypeOf<typeof opsConfig.schema>; diff --git a/src/core/server/metrics/ops_metrics_collector.test.mocks.ts b/src/core/server/metrics/ops_metrics_collector.test.mocks.ts new file mode 100644 index 0000000000000..8265796d57970 --- /dev/null +++ b/src/core/server/metrics/ops_metrics_collector.test.mocks.ts @@ -0,0 +1,39 @@ +/* + * 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 const mockOsCollector = { + collect: jest.fn(), +}; +jest.doMock('./collectors/os', () => ({ + OsMetricsCollector: jest.fn().mockImplementation(() => mockOsCollector), +})); + +export const mockProcessCollector = { + collect: jest.fn(), +}; +jest.doMock('./collectors/process', () => ({ + ProcessMetricsCollector: jest.fn().mockImplementation(() => mockProcessCollector), +})); + +export const mockServerCollector = { + collect: jest.fn(), +}; +jest.doMock('./collectors/server', () => ({ + ServerMetricsCollector: jest.fn().mockImplementation(() => mockServerCollector), +})); diff --git a/src/core/server/metrics/ops_metrics_collector.test.ts b/src/core/server/metrics/ops_metrics_collector.test.ts new file mode 100644 index 0000000000000..04302a195fb6c --- /dev/null +++ b/src/core/server/metrics/ops_metrics_collector.test.ts @@ -0,0 +1,59 @@ +/* + * 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 { + mockOsCollector, + mockProcessCollector, + mockServerCollector, +} from './ops_metrics_collector.test.mocks'; +import { httpServiceMock } from '../http/http_service.mock'; +import { OpsMetricsCollector } from './ops_metrics_collector'; + +describe('OpsMetricsCollector', () => { + let collector: OpsMetricsCollector; + + beforeEach(() => { + const hapiServer = httpServiceMock.createSetupContract().server; + collector = new OpsMetricsCollector(hapiServer); + + mockOsCollector.collect.mockResolvedValue('osMetrics'); + }); + + it('gathers metrics from the underlying collectors', async () => { + mockOsCollector.collect.mockResolvedValue('osMetrics'); + mockProcessCollector.collect.mockResolvedValue('processMetrics'); + mockServerCollector.collect.mockResolvedValue({ + requests: 'serverRequestsMetrics', + response_times: 'serverTimingMetrics', + }); + + const metrics = await collector.collect(); + + expect(mockOsCollector.collect).toHaveBeenCalledTimes(1); + expect(mockProcessCollector.collect).toHaveBeenCalledTimes(1); + expect(mockServerCollector.collect).toHaveBeenCalledTimes(1); + + expect(metrics).toEqual({ + process: 'processMetrics', + os: 'osMetrics', + requests: 'serverRequestsMetrics', + response_times: 'serverTimingMetrics', + }); + }); +}); diff --git a/src/core/server/metrics/ops_metrics_collector.ts b/src/core/server/metrics/ops_metrics_collector.ts new file mode 100644 index 0000000000000..04344f21f57f7 --- /dev/null +++ b/src/core/server/metrics/ops_metrics_collector.ts @@ -0,0 +1,52 @@ +/* + * 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 { Server as HapiServer } from 'hapi'; +import { + ProcessMetricsCollector, + OsMetricsCollector, + ServerMetricsCollector, + MetricsCollector, +} from './collectors'; +import { OpsMetrics } from './types'; + +export class OpsMetricsCollector implements MetricsCollector<OpsMetrics> { + private readonly processCollector: ProcessMetricsCollector; + private readonly osCollector: OsMetricsCollector; + private readonly serverCollector: ServerMetricsCollector; + + constructor(server: HapiServer) { + this.processCollector = new ProcessMetricsCollector(); + this.osCollector = new OsMetricsCollector(); + this.serverCollector = new ServerMetricsCollector(server); + } + + public async collect(): Promise<OpsMetrics> { + const [process, os, server] = await Promise.all([ + this.processCollector.collect(), + this.osCollector.collect(), + this.serverCollector.collect(), + ]); + return { + process, + os, + ...server, + }; + } +} diff --git a/src/core/server/metrics/types.ts b/src/core/server/metrics/types.ts new file mode 100644 index 0000000000000..5c8f18fff380d --- /dev/null +++ b/src/core/server/metrics/types.ts @@ -0,0 +1,66 @@ +/* + * 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 } from 'rxjs'; +import { OpsProcessMetrics, OpsOsMetrics, OpsServerMetrics } from './collectors'; + +/** + * APIs to retrieves metrics gathered and exposed by the core platform. + * + * @public + */ +export interface MetricsServiceSetup { + /** + * Retrieve an observable emitting the {@link OpsMetrics} gathered. + * The observable will emit an initial value during core's `start` phase, and a new value every fixed interval of time, + * based on the `opts.interval` configuration property. + * + * @example + * ```ts + * core.metrics.getOpsMetrics$().subscribe(metrics => { + * // do something with the metrics + * }) + * ``` + */ + getOpsMetrics$: () => Observable<OpsMetrics>; +} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface MetricsServiceStart {} + +export type InternalMetricsServiceSetup = MetricsServiceSetup; +export type InternalMetricsServiceStart = MetricsServiceStart; + +/** + * Regroups metrics gathered by all the collectors. + * This contains metrics about the os/runtime, the kibana process and the http server. + * + * @public + */ +export interface OpsMetrics { + /** Process related metrics */ + process: OpsProcessMetrics; + /** OS related metrics */ + os: OpsOsMetrics; + /** server response time stats */ + response_times: OpsServerMetrics['response_times']; + /** server requests stats */ + requests: OpsServerMetrics['requests']; + /** number of current concurrent connections to the server */ + concurrent_connections: OpsServerMetrics['concurrent_connections']; +} diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 96b28ab5827e1..037f3bbed67e0 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -30,6 +30,8 @@ import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { SharedGlobalConfig } from './plugins'; import { InternalCoreSetup, InternalCoreStart } from './internal_types'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; +import { metricsServiceMock } from './metrics/metrics_service.mock'; +import { uuidServiceMock } from './uuid/uuid_service.mock'; export { httpServerMock } from './http/http_server.mocks'; export { sessionStorageMock } from './http/cookie_session_storage.mocks'; @@ -40,7 +42,7 @@ export { loggingServiceMock } from './logging/logging_service.mock'; export { savedObjectsRepositoryMock } from './saved_objects/service/lib/repository.mock'; export { typeRegistryMock as savedObjectsTypeRegistryMock } from './saved_objects/saved_objects_type_registry.mock'; export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; -import { uuidServiceMock } from './uuid/uuid_service.mock'; +export { metricsServiceMock } from './metrics/metrics_service.mock'; export function pluginInitializerContextConfigMock<T>(config: T) { const globalConfig: SharedGlobalConfig = { @@ -153,6 +155,7 @@ function createInternalCoreSetupMock() { uiSettings: uiSettingsServiceMock.createSetupContract(), savedObjects: savedObjectsServiceMock.createInternalSetupContract(), uuid: uuidServiceMock.createSetupContract(), + metrics: metricsServiceMock.createInternalSetupContract(), }; return setupDeps; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 42bc1ce214b19..445ed16ec7829 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1176,6 +1176,11 @@ export interface LogRecord { timestamp: Date; } +// @public +export interface MetricsServiceSetup { + getOpsMetrics$: () => Observable<OpsMetrics>; +} + // @public (undocumented) export type MIGRATION_ASSISTANCE_INDEX_ACTION = 'upgrade' | 'reindex'; @@ -1227,6 +1232,63 @@ export interface OnPreResponseToolkit { next: (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult; } +// @public +export interface OpsMetrics { + concurrent_connections: OpsServerMetrics['concurrent_connections']; + os: OpsOsMetrics; + process: OpsProcessMetrics; + requests: OpsServerMetrics['requests']; + response_times: OpsServerMetrics['response_times']; +} + +// @public +export interface OpsOsMetrics { + distro?: string; + distroRelease?: string; + load: { + '1m': number; + '5m': number; + '15m': number; + }; + memory: { + total_in_bytes: number; + free_in_bytes: number; + used_in_bytes: number; + }; + platform: NodeJS.Platform; + platformRelease: string; + uptime_in_millis: number; +} + +// @public +export interface OpsProcessMetrics { + event_loop_delay: number; + memory: { + heap: { + total_in_bytes: number; + used_in_bytes: number; + size_limit: number; + }; + resident_set_size_in_bytes: number; + }; + pid: number; + uptime_in_millis: number; +} + +// @public +export interface OpsServerMetrics { + concurrent_connections: number; + requests: { + disconnects: number; + total: number; + statusCodes: Record<number, number>; + }; + response_times: { + avg_in_millis: number; + max_in_millis: number; + }; +} + // @public (undocumented) export interface PackageInfo { // (undocumented) diff --git a/src/core/server/server.test.mocks.ts b/src/core/server/server.test.mocks.ts index 038c4651ff5a7..53d1b742a6494 100644 --- a/src/core/server/server.test.mocks.ts +++ b/src/core/server/server.test.mocks.ts @@ -79,3 +79,9 @@ export const mockUuidService = uuidServiceMock.create(); jest.doMock('./uuid/uuid_service', () => ({ UuidService: jest.fn(() => mockUuidService), })); + +import { metricsServiceMock } from './metrics/metrics_service.mock'; +export const mockMetricsService = metricsServiceMock.create(); +jest.doMock('./metrics/metrics_service', () => ({ + MetricsService: jest.fn(() => mockMetricsService), +})); diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index 161dd3759a218..a4b5a9d81df20 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -28,6 +28,7 @@ import { mockEnsureValidConfiguration, mockUiSettingsService, mockRenderingService, + mockMetricsService, } from './server.test.mocks'; import { BehaviorSubject } from 'rxjs'; @@ -61,6 +62,7 @@ test('sets up services on "setup"', async () => { expect(mockSavedObjectsService.setup).not.toHaveBeenCalled(); expect(mockUiSettingsService.setup).not.toHaveBeenCalled(); expect(mockRenderingService.setup).not.toHaveBeenCalled(); + expect(mockMetricsService.setup).not.toHaveBeenCalled(); await server.setup(); @@ -71,6 +73,7 @@ test('sets up services on "setup"', async () => { expect(mockSavedObjectsService.setup).toHaveBeenCalledTimes(1); expect(mockUiSettingsService.setup).toHaveBeenCalledTimes(1); expect(mockRenderingService.setup).toHaveBeenCalledTimes(1); + expect(mockMetricsService.setup).toHaveBeenCalledTimes(1); }); test('injects legacy dependency to context#setup()', async () => { @@ -107,6 +110,7 @@ test('runs services on "start"', async () => { expect(mockLegacyService.start).not.toHaveBeenCalled(); expect(mockSavedObjectsService.start).not.toHaveBeenCalled(); expect(mockUiSettingsService.start).not.toHaveBeenCalled(); + expect(mockMetricsService.start).not.toHaveBeenCalled(); await server.start(); @@ -114,6 +118,7 @@ test('runs services on "start"', async () => { expect(mockLegacyService.start).toHaveBeenCalledTimes(1); expect(mockSavedObjectsService.start).toHaveBeenCalledTimes(1); expect(mockUiSettingsService.start).toHaveBeenCalledTimes(1); + expect(mockMetricsService.start).toHaveBeenCalledTimes(1); }); test('does not fail on "setup" if there are unused paths detected', async () => { @@ -135,6 +140,7 @@ test('stops services on "stop"', async () => { expect(mockLegacyService.stop).not.toHaveBeenCalled(); expect(mockSavedObjectsService.stop).not.toHaveBeenCalled(); expect(mockUiSettingsService.stop).not.toHaveBeenCalled(); + expect(mockMetricsService.stop).not.toHaveBeenCalled(); await server.stop(); @@ -144,6 +150,7 @@ test('stops services on "stop"', async () => { expect(mockLegacyService.stop).toHaveBeenCalledTimes(1); expect(mockSavedObjectsService.stop).toHaveBeenCalledTimes(1); expect(mockUiSettingsService.stop).toHaveBeenCalledTimes(1); + expect(mockMetricsService.stop).toHaveBeenCalledTimes(1); }); test(`doesn't setup core services if config validation fails`, async () => { @@ -159,6 +166,7 @@ test(`doesn't setup core services if config validation fails`, async () => { expect(mockLegacyService.setup).not.toHaveBeenCalled(); expect(mockUiSettingsService.setup).not.toHaveBeenCalled(); expect(mockRenderingService.setup).not.toHaveBeenCalled(); + expect(mockMetricsService.setup).not.toHaveBeenCalled(); }); test(`doesn't setup core services if legacy config validation fails`, async () => { @@ -178,4 +186,5 @@ test(`doesn't setup core services if legacy config validation fails`, async () = expect(mockLegacyService.setup).not.toHaveBeenCalled(); expect(mockSavedObjectsService.stop).not.toHaveBeenCalled(); expect(mockUiSettingsService.setup).not.toHaveBeenCalled(); + expect(mockMetricsService.setup).not.toHaveBeenCalled(); }); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index db2493b38d6e0..8603f5fba1da8 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -34,6 +34,7 @@ import { Logger, LoggerFactory } from './logging'; import { UiSettingsService } from './ui_settings'; import { PluginsService, config as pluginsConfig } from './plugins'; import { SavedObjectsService } from '../server/saved_objects'; +import { MetricsService, opsConfig } from './metrics'; import { config as cspConfig } from './csp'; import { config as elasticsearchConfig } from './elasticsearch'; @@ -67,6 +68,7 @@ export class Server { private readonly savedObjects: SavedObjectsService; private readonly uiSettings: UiSettingsService; private readonly uuid: UuidService; + private readonly metrics: MetricsService; private coreStart?: InternalCoreStart; @@ -89,6 +91,7 @@ export class Server { this.uiSettings = new UiSettingsService(core); this.capabilities = new CapabilitiesService(core); this.uuid = new UuidService(core); + this.metrics = new MetricsService(core); } public async setup() { @@ -137,6 +140,8 @@ export class Server { legacyPlugins, }); + const metricsSetup = await this.metrics.setup({ http: httpSetup }); + const coreSetup: InternalCoreSetup = { capabilities: capabilitiesSetup, context: contextServiceSetup, @@ -145,6 +150,7 @@ export class Server { uiSettings: uiSettingsSetup, savedObjects: savedObjectsSetup, uuid: uuidSetup, + metrics: metricsSetup, }; const pluginsSetup = await this.plugins.setup(coreSetup); @@ -193,6 +199,7 @@ export class Server { await this.http.start(); await this.rendering.start(); + await this.metrics.start(); return this.coreStart; } @@ -207,6 +214,7 @@ export class Server { await this.http.stop(); await this.uiSettings.stop(); await this.rendering.stop(); + await this.metrics.stop(); } private registerDefaultRoute(httpSetup: InternalHttpServiceSetup) { @@ -260,6 +268,7 @@ export class Server { [savedObjectsConfig.path, savedObjectsConfig.schema], [savedObjectsMigrationConfig.path, savedObjectsMigrationConfig.schema], [uiSettingsConfig.path, uiSettingsConfig.schema], + [opsConfig.path, opsConfig.schema], ]; this.configService.addDeprecationProvider(rootConfigPath, coreDeprecationProvider); From f7dd5fe4d42e9673256d2c0423fdcfc4e28cdf91 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger <walter@elastic.co> Date: Tue, 3 Mar 2020 11:05:57 +0100 Subject: [PATCH 33/36] [ML] Transforms: Remove beta badges. (#59060) Transforms will be GA in 7.7. This PR removes the beta related UI elements. --- .../clone_transform/clone_transform_section.tsx | 13 ------------- .../create_transform/create_transform_section.tsx | 14 -------------- .../transform_management_section.tsx | 14 -------------- .../plugins/translations/translations/ja-JP.json | 4 ---- .../plugins/translations/translations/zh-CN.json | 4 ---- 5 files changed, 49 deletions(-) diff --git a/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index 8f58bc94e7c12..c5c46dcac6c95 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -11,7 +11,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { - EuiBetaBadge, EuiButtonEmpty, EuiCallOut, EuiFlexGroup, @@ -140,18 +139,6 @@ export const CloneTransformSection: FC<Props> = ({ match }) => { id="xpack.transform.transformsWizard.cloneTransformTitle" defaultMessage="Clone transform" /> - <span> </span> - <EuiBetaBadge - label={i18n.translate('xpack.transform.transformsWizard.betaBadgeLabel', { - defaultMessage: `Beta`, - })} - tooltipContent={i18n.translate( - 'xpack.transform.transformsWizard.betaBadgeTooltipContent', - { - defaultMessage: `Transforms are a beta feature. We'd love to hear your feedback.`, - } - )} - /> </h1> </EuiFlexItem> <EuiFlexItem grow={false}> diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx index e92ba256256a4..5196f281adf0a 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx @@ -8,10 +8,8 @@ import React, { useEffect, FC } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; import { - EuiBetaBadge, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, @@ -52,18 +50,6 @@ export const CreateTransformSection: FC<Props> = ({ match }) => { id="xpack.transform.transformsWizard.createTransformTitle" defaultMessage="Create transform" /> - <span> </span> - <EuiBetaBadge - label={i18n.translate('xpack.transform.transformsWizard.betaBadgeLabel', { - defaultMessage: `Beta`, - })} - tooltipContent={i18n.translate( - 'xpack.transform.transformsWizard.betaBadgeTooltipContent', - { - defaultMessage: `Transforms are a beta feature. We'd love to hear your feedback.`, - } - )} - /> </h1> </EuiFlexItem> <EuiFlexItem grow={false}> diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/legacy/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index 1573d4c53c0cf..8c174098fb623 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -7,10 +7,8 @@ import React, { FC, Fragment, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; import { - EuiBetaBadge, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, @@ -84,18 +82,6 @@ export const TransformManagement: FC = () => { id="xpack.transform.transformList.transformTitle" defaultMessage="Transforms" /> - <span> </span> - <EuiBetaBadge - label={i18n.translate('xpack.transform.transformList.betaBadgeLabel', { - defaultMessage: `Beta`, - })} - tooltipContent={i18n.translate( - 'xpack.transform.transformList.betaBadgeTooltipContent', - { - defaultMessage: `Transforms are a beta feature. We'd love to hear your feedback.`, - } - )} - /> </h1> </EuiFlexItem> <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index cadebcad93510..7fdffbec78311 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12503,8 +12503,6 @@ "xpack.transform.toastText.modalTitle": "詳細を入力", "xpack.transform.toastText.openModalButtonText": "詳細を表示", "xpack.transform.transformForm.sizeNotationPlaceholder": "例: {example1}、{example2}、{example3}、{example4}", - "xpack.transform.transformList.betaBadgeLabel": "ベータ", - "xpack.transform.transformList.betaBadgeTooltipContent": "変換はベータ機能です。フィードバックをお待ちしています。", "xpack.transform.transformList.bulkDeleteModalBody": "{count, plural, one {この} other {これらの}} {count} 件の{count, plural, one {変換} other {変換}}を削除してよろしいですか?変換の送信先インデックスとオプションの Kibana インデックスパターンは削除されません。", "xpack.transform.transformList.bulkDeleteModalTitle": "{count} 件の{count, plural, one {変換} other {変換}}を削除", "xpack.transform.transformList.bulkStartModalTitle": "{count} 件の{count, plural, one {変換} other {変換}}を開始", @@ -12549,8 +12547,6 @@ "xpack.transform.transformList.transformDetails.tabs.transformSettingsLabel": "ジョブの詳細", "xpack.transform.transformList.transformDocsLinkText": "変換ドキュメント", "xpack.transform.transformList.transformTitle": "データフレームジョブ", - "xpack.transform.transformsWizard.betaBadgeLabel": "ベータ", - "xpack.transform.transformsWizard.betaBadgeTooltipContent": "変換はベータ機能です。フィードバックをお待ちしています。", "xpack.transform.transformsWizard.createTransformTitle": "変換の作成", "xpack.transform.transformsWizard.stepCreateTitle": "作成", "xpack.transform.transformsWizard.stepDefineTitle": "ピボットの定義", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ff72f20b24864..438a8f9197508 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12503,8 +12503,6 @@ "xpack.transform.toastText.modalTitle": "错误详细信息", "xpack.transform.toastText.openModalButtonText": "查看详情", "xpack.transform.transformForm.sizeNotationPlaceholder": "示例:{example1}、{example2}、{example3}、{example4}", - "xpack.transform.transformList.betaBadgeLabel": "公测版", - "xpack.transform.transformList.betaBadgeTooltipContent": "转换为公测版功能。我们很乐意听取您的反馈意见。", "xpack.transform.transformList.bulkDeleteModalBody": "是否确定要删除{count, plural, one {这} other {这}} {count} 个 {count, plural, one {转换} other {转换}}?转换的目标索引和可选 Kibana 索引模式将不会删除。", "xpack.transform.transformList.bulkDeleteModalTitle": "删除 {count} 个 {count, plural, one {转换} other {转换}}?", "xpack.transform.transformList.bulkStartModalTitle": "启动 {count} 个 {count, plural, one {转换} other {转换}}?", @@ -12549,8 +12547,6 @@ "xpack.transform.transformList.transformDetails.tabs.transformSettingsLabel": "作业详情", "xpack.transform.transformList.transformDocsLinkText": "转换文档", "xpack.transform.transformList.transformTitle": "数据帧作业", - "xpack.transform.transformsWizard.betaBadgeLabel": "公测版", - "xpack.transform.transformsWizard.betaBadgeTooltipContent": "转换为公测版功能。我们很乐意听取您的反馈意见。", "xpack.transform.transformsWizard.createTransformTitle": "创建转换", "xpack.transform.transformsWizard.stepCreateTitle": "创建", "xpack.transform.transformsWizard.stepDefineTitle": "定义透视", From e5362d36a3bae9d55b9b89a462cd4a0ba3a43210 Mon Sep 17 00:00:00 2001 From: Maryia Lapata <mary.lopato@gmail.com> Date: Tue, 3 Mar 2020 13:58:05 +0300 Subject: [PATCH 34/36] [NP] Remove visualize reference in saved object save modal (#59016) * Remove visualize reference in saved object save modal * Rename attribute Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../top_nav/__snapshots__/save_modal.test.js.snap | 1 + .../public/dashboard/np_ready/top_nav/save_modal.tsx | 1 + .../public/discover/np_ready/angular/discover.js | 1 + .../kibana/public/visualize/np_ready/editor/editor.js | 1 + .../public/save_modal/saved_object_save_modal.test.tsx | 2 ++ .../public/save_modal/saved_object_save_modal.tsx | 10 +++------- .../plugins/graph/public/components/save_modal.tsx | 1 + x-pack/legacy/plugins/lens/public/app_plugin/app.tsx | 1 + .../plugins/maps/public/angular/map_controller.js | 1 + 9 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/save_modal.test.js.snap b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/save_modal.test.js.snap index aa9eaf09c7e0a..7ac2e2d9dd317 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/save_modal.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/save_modal.test.js.snap @@ -59,6 +59,7 @@ exports[`renders DashboardSaveModal 1`] = ` </React.Fragment> } showCopyOnSave={true} + showDescription={false} title="dash title" /> `; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/save_modal.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/save_modal.tsx index 026784fcae06f..4a4fcb7e1adc8 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/save_modal.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/save_modal.tsx @@ -147,6 +147,7 @@ export class DashboardSaveModal extends React.Component<Props, State> { showCopyOnSave={this.props.showCopyOnSave} objectType="dashboard" options={this.renderDashboardSaveOptions()} + showDescription={false} /> ); } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js index 1ac54ad5dabee..bb693ab860221 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js @@ -305,6 +305,7 @@ function discoverController( defaultMessage: 'Save your Discover search so you can use it in visualizations and dashboards', })} + showDescription={false} /> ); showSaveModal(saveModal, core.i18n.Context); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index 293327f3f72f9..2d2552b5e2f30 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -189,6 +189,7 @@ function VisualizeAppController( objectType="visualization" confirmButtonLabel={confirmButtonLabel} description={savedVis.description} + showDescription={true} /> ); showSaveModal(saveModal, I18nContext); diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.test.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.test.tsx index 65bd8e1d48e03..15400087c2641 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.test.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.test.tsx @@ -31,6 +31,7 @@ describe('SavedObjectSaveModal', () => { title={'Saved Object title'} showCopyOnSave={false} objectType="visualization" + showDescription={true} /> ); expect(wrapper).toMatchSnapshot(); @@ -44,6 +45,7 @@ describe('SavedObjectSaveModal', () => { title={'Saved Object title'} showCopyOnSave={false} objectType="visualization" + showDescription={true} confirmButtonLabel="Save and done" /> ); diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx index 275a9da96a2c4..1d145bc97bdb4 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx @@ -40,11 +40,6 @@ import React from 'react'; import { EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -// TODO: can't import from '../../../../legacy/core_plugins/visualizations/public/' directly, -// because yarn build:types fails after trying to emit type declarations for whole visualizations plugin -// Bunch of errors like this: 'Return type of exported function has or is using private name 'SavedVis'' -import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../legacy/core_plugins/visualizations/public/np_ready/public/embeddable/constants'; - export interface OnSaveProps { newTitle: string; newCopyOnSave: boolean; @@ -62,6 +57,7 @@ interface Props { confirmButtonLabel?: React.ReactNode; options?: React.ReactNode; description?: string; + showDescription: boolean; } interface State { @@ -112,7 +108,7 @@ export class SavedObjectSaveModal extends React.Component<Props, State> { {this.renderDuplicateTitleCallout(duplicateWarningId)} <EuiForm> - {this.props.objectType !== VISUALIZE_EMBEDDABLE_TYPE && this.props.description && ( + {!this.props.showDescription && this.props.description && ( <EuiFormRow> <EuiText color="subdued">{this.props.description}</EuiText> </EuiFormRow> @@ -164,7 +160,7 @@ export class SavedObjectSaveModal extends React.Component<Props, State> { } private renderViewDescription = () => { - if (this.props.objectType !== VISUALIZE_EMBEDDABLE_TYPE) { + if (!this.props.showDescription) { return; } diff --git a/x-pack/legacy/plugins/graph/public/components/save_modal.tsx b/x-pack/legacy/plugins/graph/public/components/save_modal.tsx index 3dede69d0ca93..a7329c10e93d7 100644 --- a/x-pack/legacy/plugins/graph/public/components/save_modal.tsx +++ b/x-pack/legacy/plugins/graph/public/components/save_modal.tsx @@ -49,6 +49,7 @@ export function SaveModal({ objectType={i18n.translate('xpack.graph.topNavMenu.save.objectType', { defaultMessage: 'graph', })} + showDescription={false} options={ <> <EuiFormRow diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index a212cb0a1a879..a0c6e4c21a34b 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -374,6 +374,7 @@ export function App({ objectType={i18n.translate('xpack.lens.app.saveModalType', { defaultMessage: 'Lens visualization', })} + showDescription={false} confirmButtonLabel={confirmButton} /> )} diff --git a/x-pack/legacy/plugins/maps/public/angular/map_controller.js b/x-pack/legacy/plugins/maps/public/angular/map_controller.js index a8e9ae46a3b9a..84ead42d3374e 100644 --- a/x-pack/legacy/plugins/maps/public/angular/map_controller.js +++ b/x-pack/legacy/plugins/maps/public/angular/map_controller.js @@ -595,6 +595,7 @@ app.controller( title={savedMap.title} showCopyOnSave={savedMap.id ? true : false} objectType={MAP_SAVED_OBJECT_TYPE} + showDescription={false} /> ); showSaveModal(saveModal, npStart.core.i18n.Context); From 4ef594c20896c84380bb2e6ad1a7730ca2c43608 Mon Sep 17 00:00:00 2001 From: James Gowdy <jgowdy@elastic.co> Date: Tue, 3 Mar 2020 11:59:35 +0000 Subject: [PATCH 35/36] [ML] Adding indices_options to datafeed (#59119) * [ML] Adding indices_options to datafeed * adding extra checks to the schema * updating expand_wildcards --- .../common/job_creator/configs/datafeed.ts | 8 ++++++++ .../ml/server/routes/schemas/datafeeds_schema.ts | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/configs/datafeed.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/configs/datafeed.ts index e35f3056ce434..538b225926f65 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/configs/datafeed.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/configs/datafeed.ts @@ -25,6 +25,7 @@ export interface Datafeed { script_fields?: object; scroll_size?: number; delayed_data_check_config?: object; + indices_options?: IndicesOptions; } export interface ChunkingConfig { @@ -42,3 +43,10 @@ interface Aggregation { aggs?: { [key: string]: any }; }; } + +interface IndicesOptions { + expand_wildcards?: 'all' | 'open' | 'closed' | 'hidden' | 'none'; + ignore_unavailable?: boolean; + allow_no_indices?: boolean; + ignore_throttled?: boolean; +} diff --git a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts index 02677dcb107c2..ee49da6538460 100644 --- a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts @@ -17,7 +17,12 @@ export const datafeedConfigSchema = schema.object({ feed_id: schema.maybe(schema.string()), aggregations: schema.maybe(schema.any()), aggs: schema.maybe(schema.any()), - chunking_config: schema.maybe(schema.any()), + chunking_config: schema.maybe( + schema.object({ + mode: schema.maybe(schema.string()), + time_span: schema.maybe(schema.string()), + }) + ), frequency: schema.maybe(schema.string()), indices: schema.arrayOf(schema.string()), indexes: schema.maybe(schema.arrayOf(schema.string())), @@ -28,4 +33,12 @@ export const datafeedConfigSchema = schema.object({ script_fields: schema.maybe(schema.any()), scroll_size: schema.maybe(schema.number()), delayed_data_check_config: schema.maybe(schema.any()), + indices_options: schema.maybe( + schema.object({ + expand_wildcards: schema.maybe(schema.arrayOf(schema.string())), + ignore_unavailable: schema.maybe(schema.boolean()), + allow_no_indices: schema.maybe(schema.boolean()), + ignore_throttled: schema.maybe(schema.boolean()), + }) + ), }); From bfca202c4f08af4f430e688afa6efde79aa51368 Mon Sep 17 00:00:00 2001 From: Justin Kambic <justin.kambic@elastic.co> Date: Tue, 3 Mar 2020 07:52:57 -0500 Subject: [PATCH 36/36] [Uptime] Add default chart empty state (#57725) * Add default chart empty state. * Delete obsolete translations. Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../chart_empty_state.test.tsx.snap | 49 +++++++ .../__tests__/chart_empty_state.test.tsx | 35 +++++ .../functional/charts/chart_empty_state.tsx | 24 ++++ .../functional/charts/checks_chart.tsx | 124 ------------------ .../functional/charts/duration_chart.tsx | 15 ++- .../charts/duration_chart_empty_state.tsx | 33 ----- .../functional/charts/ping_histogram.tsx | 104 ++++++--------- .../translations/translations/ja-JP.json | 5 - .../translations/translations/zh-CN.json | 5 - 9 files changed, 163 insertions(+), 231 deletions(-) create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/chart_empty_state.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/chart_empty_state.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_empty_state.tsx delete mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/checks_chart.tsx delete mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart_empty_state.tsx diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/chart_empty_state.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/chart_empty_state.test.tsx.snap new file mode 100644 index 0000000000000..79ef7b3b97abd --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/chart_empty_state.test.tsx.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ChartEmptyState renders JSX values 1`] = ` +<EuiEmptyPrompt + body={ + <p> + <FormattedMessage + defaultMessage="This is the default with a {val} included" + id="test.body" + values={ + Object { + "val": <strong> + down + </strong>, + } + } + /> + </p> + } + title={ + <EuiTitle> + <h5> + <FormattedMessage + defaultMessage="The title" + id="test.title" + values={Object {}} + /> + </h5> + </EuiTitle> + } +/> +`; + +exports[`ChartEmptyState renders string values 1`] = ` +<EuiEmptyPrompt + body={ + <p> + This is the body + </p> + } + title={ + <EuiTitle> + <h5> + This is the title + </h5> + </EuiTitle> + } +/> +`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/chart_empty_state.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/chart_empty_state.test.tsx new file mode 100644 index 0000000000000..2e25dddc0b4ed --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/chart_empty_state.test.tsx @@ -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 { ChartEmptyState } from '../chart_empty_state'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +describe('ChartEmptyState', () => { + it('renders string values', () => { + expect( + shallowWithIntl(<ChartEmptyState body="This is the body" title="This is the title" />) + ).toMatchSnapshot(); + }); + + it('renders JSX values', () => { + expect( + shallowWithIntl( + <ChartEmptyState + body={ + <FormattedMessage + id="test.body" + defaultMessage="This is the default with a {val} included" + values={{ val: <strong>down</strong> }} + /> + } + title={<FormattedMessage id="test.title" defaultMessage="The title" />} + /> + ) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_empty_state.tsx new file mode 100644 index 0000000000000..19202822fe737 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_empty_state.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiEmptyPrompt, EuiTitle } from '@elastic/eui'; +import React, { FC } from 'react'; + +interface ChartEmptyStateProps { + title: string | JSX.Element; + body: string | JSX.Element; +} + +export const ChartEmptyState: FC<ChartEmptyStateProps> = ({ title, body }) => ( + <EuiEmptyPrompt + title={ + <EuiTitle> + <h5>{title}</h5> + </EuiTitle> + } + body={<p>{body}</p>} + /> +); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/checks_chart.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/checks_chart.tsx deleted file mode 100644 index a88a9668660f7..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/checks_chart.tsx +++ /dev/null @@ -1,124 +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 { - AreaSeries, - Axis, - Chart, - Position, - Settings, - ScaleType, - timeFormatter, -} from '@elastic/charts'; -import { EuiPanel, EuiTitle } from '@elastic/eui'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { StatusData } from '../../../../common/graphql/types'; -import { getChartDateLabel } from '../../../lib/helper'; -import { useUrlParams } from '../../../hooks'; - -interface ChecksChartProps { - /** - * The color that will be used for the area series displaying "Down" checks. - */ - dangerColor: string; - /** - * The timeseries data displayed in the chart. - */ - status: StatusData[]; - /** - * The color that will be used for the area series displaying "Up" checks. - */ - successColor: string; -} - -/** - * Renders a chart that displays the total count of up/down status checks over time - * as a stacked area chart. - * @param props The props values required by this component. - */ -export const ChecksChart = ({ dangerColor, status, successColor }: ChecksChartProps) => { - const upSeriesSpecId = 'Up'; - const downSeriesSpecId = 'Down'; - const [getUrlParams] = useUrlParams(); - const { absoluteDateRangeStart: min, absoluteDateRangeEnd: max } = getUrlParams(); - - const upString = i18n.translate('xpack.uptime.monitorCharts.checkStatus.series.upCountLabel', { - defaultMessage: 'Up count', - }); - const downString = i18n.translate( - 'xpack.uptime.monitorCharts.checkStatus.series.downCountLabel', - { - defaultMessage: 'Down count', - } - ); - - return ( - <React.Fragment> - <EuiTitle size="xs"> - <h4> - <FormattedMessage - id="xpack.uptime.monitorCharts.checkStatus.title" - defaultMessage="Check status" - /> - </h4> - </EuiTitle> - <EuiPanel> - <Chart> - <Settings xDomain={{ min, max }} showLegend={false} /> - <Axis - id="checksBottom" - position={Position.Bottom} - showOverlappingTicks={true} - tickFormat={timeFormatter(getChartDateLabel(min, max))} - title={i18n.translate('xpack.uptime.monitorChart.checksChart.bottomAxis.title', { - defaultMessage: 'Timestamp', - description: 'The heading of the x-axis of a chart of timeseries data.', - })} - /> - <Axis - id="left" - position={Position.Left} - tickFormat={d => Number(d).toFixed(0)} - title={i18n.translate('xpack.uptime.monitorChart.checksChart.leftAxis.title', { - defaultMessage: 'Number of checks', - description: 'The heading of the y-axis of a chart of timeseries data', - })} - /> - <AreaSeries - customSeriesColors={[successColor]} - data={status.map(({ x, up }) => ({ - x, - [upString]: up || 0, - }))} - id={upSeriesSpecId} - stackAccessors={['x']} - timeZone="local" - xAccessor="x" - xScaleType={ScaleType.Time} - yAccessors={[upString]} - yScaleType={ScaleType.Linear} - /> - <AreaSeries - customSeriesColors={[downSeriesSpecId]} - data={status.map(({ x, down }) => ({ - x, - [downString]: down || 0, - }))} - id={downSeriesSpecId} - stackAccessors={['x']} - timeZone="local" - xAccessor="x" - xScaleType={ScaleType.Time} - yAccessors={[downString]} - yScaleType={ScaleType.Linear} - /> - </Chart> - </EuiPanel> - </React.Fragment> - ); -}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx index 7a6db6d952dd9..0488e2531bc98 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx @@ -13,10 +13,10 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { getChartDateLabel } from '../../../lib/helper'; import { LocationDurationLine } from '../../../../common/graphql/types'; import { DurationLineSeriesList } from './duration_line_series_list'; -import { DurationChartEmptyState } from './duration_chart_empty_state'; import { ChartWrapper } from './chart_wrapper'; import { useUrlParams } from '../../../hooks'; import { getTickFormat } from './get_tick_format'; +import { ChartEmptyState } from './chart_empty_state'; interface DurationChartProps { /** @@ -102,7 +102,18 @@ export const DurationChart = ({ <DurationLineSeriesList lines={locationDurationLines} meanColor={meanColor} /> </Chart> ) : ( - <DurationChartEmptyState /> + <ChartEmptyState + body={ + <FormattedMessage + id="xpack.uptime.durationChart.emptyPrompt.description" + defaultMessage="This monitor has never been {emphasizedText} during the selected time range." + values={{ emphasizedText: <strong>up</strong> }} + /> + } + title={i18n.translate('xpack.uptime.durationChart.emptyPrompt.title', { + defaultMessage: 'No duration data available', + })} + /> )} </ChartWrapper> </EuiPanel> diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart_empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart_empty_state.tsx deleted file mode 100644 index ef4e70bf65898..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart_empty_state.tsx +++ /dev/null @@ -1,33 +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 { FormattedMessage } from '@kbn/i18n/react'; -import { EuiEmptyPrompt, EuiTitle } from '@elastic/eui'; -import React from 'react'; - -export const DurationChartEmptyState = () => ( - <EuiEmptyPrompt - title={ - <EuiTitle> - <h5> - <FormattedMessage - id="xpack.uptime.durationChart.emptyPrompt.title" - defaultMessage="No duration data available" - /> - </h5> - </EuiTitle> - } - body={ - <p> - <FormattedMessage - id="xpack.uptime.durationChart.emptyPrompt.description" - defaultMessage="This monitor has never been {emphasizedText} during the selected time range." - values={{ emphasizedText: <strong>up</strong> }} - /> - </p> - } - /> -); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx index b4989282f854c..6119d897cbf53 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx @@ -5,7 +5,7 @@ */ import { Axis, BarSeries, Chart, Position, Settings, timeFormatter } from '@elastic/charts'; -import { EuiEmptyPrompt, EuiPanel, EuiTitle } from '@elastic/eui'; +import { EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useContext } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -15,6 +15,7 @@ import { ChartWrapper } from './chart_wrapper'; import { UptimeThemeContext } from '../../../contexts'; import { HistogramResult } from '../../../../common/types'; import { useUrlParams } from '../../../hooks'; +import { ChartEmptyState } from './chart_empty_state'; export interface PingHistogramComponentProps { /** @@ -49,71 +50,36 @@ export const PingHistogramComponent: React.FC<PingHistogramComponentProps> = ({ const [, updateUrlParams] = useUrlParams(); - if (!data || !data.histogram) - /** - * TODO: the Fragment, EuiTitle, and EuiPanel should be extracted to a dumb component - * that we can reuse in the subsequent return statement at the bottom of this function. - */ - return ( - <> - <EuiTitle size="xs"> - <h5> - <FormattedMessage - id="xpack.uptime.snapshot.pingsOverTimeTitle" - defaultMessage="Pings over time" - /> - </h5> - </EuiTitle> - <EuiPanel paddingSize="s" style={{ height: 170 }}> - <EuiEmptyPrompt - title={ - <EuiTitle> - <h5> - <FormattedMessage - id="xpack.uptime.snapshot.noDataTitle" - defaultMessage="No histogram data available" - /> - </h5> - </EuiTitle> - } - body={ - <p> - <FormattedMessage - id="xpack.uptime.snapshot.noDataDescription" - defaultMessage="Sorry, there is no data available for the histogram" - /> - </p> - } - /> - </EuiPanel> - </> + let content: JSX.Element | undefined; + if (!data?.histogram?.length) { + content = ( + <ChartEmptyState + title={i18n.translate('xpack.uptime.snapshot.noDataTitle', { + defaultMessage: 'No ping data available', + })} + body={i18n.translate('xpack.uptime.snapshot.noDataDescription', { + defaultMessage: 'There are no pings in the selected time range.', + })} + /> ); - const { histogram } = data; - - const downSpecId = i18n.translate('xpack.uptime.snapshotHistogram.downMonitorsId', { - defaultMessage: 'Down Monitors', - }); + } else { + const { histogram } = data; - const upMonitorsId = i18n.translate('xpack.uptime.snapshotHistogram.series.upLabel', { - defaultMessage: 'Up', - }); + const downSpecId = i18n.translate('xpack.uptime.snapshotHistogram.downMonitorsId', { + defaultMessage: 'Down Monitors', + }); - const onBrushEnd = (min: number, max: number) => { - updateUrlParams({ - dateRangeStart: moment(min).toISOString(), - dateRangeEnd: moment(max).toISOString(), + const upMonitorsId = i18n.translate('xpack.uptime.snapshotHistogram.series.upLabel', { + defaultMessage: 'Up', }); - }; - return ( - <> - <EuiTitle size="xs"> - <h2> - <FormattedMessage - id="xpack.uptime.snapshot.pingsOverTimeTitle" - defaultMessage="Pings over time" - /> - </h2> - </EuiTitle> + + const onBrushEnd = (min: number, max: number) => { + updateUrlParams({ + dateRangeStart: moment(min).toISOString(), + dateRangeEnd: moment(max).toISOString(), + }); + }; + content = ( <ChartWrapper height={height} loading={loading} @@ -183,6 +149,20 @@ export const PingHistogramComponent: React.FC<PingHistogramComponentProps> = ({ /> </Chart> </ChartWrapper> + ); + } + + return ( + <> + <EuiTitle size="xs"> + <h2> + <FormattedMessage + id="xpack.uptime.snapshot.pingsOverTimeTitle" + defaultMessage="Pings over time" + /> + </h2> + </EuiTitle> + {content} </> ); }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7fdffbec78311..75db834a969d0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12689,11 +12689,6 @@ "xpack.uptime.kueryBar.indexPatternMissingWarningMessage": "インデックスパターンの取得中にエラーが発生しました。", "xpack.uptime.kueryBar.searchPlaceholder": "モニター ID、名前、プロトコルタイプなどを検索…", "xpack.uptime.locationName.helpLinkAnnotation": "場所を追加", - "xpack.uptime.monitorChart.checksChart.bottomAxis.title": "タイムスタンプ", - "xpack.uptime.monitorChart.checksChart.leftAxis.title": "チェックの数", - "xpack.uptime.monitorCharts.checkStatus.series.downCountLabel": "ダウンカウント", - "xpack.uptime.monitorCharts.checkStatus.series.upCountLabel": "アップカウント", - "xpack.uptime.monitorCharts.checkStatus.title": "ステータスを確認", "xpack.uptime.monitorCharts.durationChart.bottomAxis.title": "タイムスタンプ", "xpack.uptime.monitorCharts.durationChart.leftAxis.title": "期間ms", "xpack.uptime.monitorCharts.loadingMessage": "読み込み中…", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 438a8f9197508..d67d2054a2da6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12689,11 +12689,6 @@ "xpack.uptime.kueryBar.indexPatternMissingWarningMessage": "检索索引模式时出错。", "xpack.uptime.kueryBar.searchPlaceholder": "搜索监测 ID、名称和协议类型......", "xpack.uptime.locationName.helpLinkAnnotation": "添加位置", - "xpack.uptime.monitorChart.checksChart.bottomAxis.title": "鏃堕棿鎴", - "xpack.uptime.monitorChart.checksChart.leftAxis.title": "检查数目", - "xpack.uptime.monitorCharts.checkStatus.series.downCountLabel": "关闭计数", - "xpack.uptime.monitorCharts.checkStatus.series.upCountLabel": "运行计数", - "xpack.uptime.monitorCharts.checkStatus.title": "检查状态", "xpack.uptime.monitorCharts.durationChart.bottomAxis.title": "鏃堕棿鎴", "xpack.uptime.monitorCharts.durationChart.leftAxis.title": "持续时间 (ms)", "xpack.uptime.monitorCharts.loadingMessage": "正在加载……",