From 7448238444b9e36ae15286aa2897f055f30d42a7 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 12 Apr 2021 17:55:50 +0200 Subject: [PATCH 01/28] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20improve=20UI?= =?UTF-8?q?=20actions=20plugin=20readme=20(#96030)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: ✏️ improve UI actions plugin readme * docs: improve trigger description * docs: remove unnecessary comma --- src/plugins/ui_actions/README.asciidoc | 73 +++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/src/plugins/ui_actions/README.asciidoc b/src/plugins/ui_actions/README.asciidoc index 577aa2eae354b..27b3eae3a52a7 100644 --- a/src/plugins/ui_actions/README.asciidoc +++ b/src/plugins/ui_actions/README.asciidoc @@ -1,14 +1,71 @@ [[uiactions-plugin]] == UI Actions -An API for: - -- creating custom functionality (`actions`) -- creating custom user interaction events (`triggers`) -- attaching and detaching `actions` to `triggers`. -- emitting `trigger` events -- executing `actions` attached to a given `trigger`. -- exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. +UI Actions plugins provides API to manage *triggers* and *actions*. + +*Trigger* is an abstract description of user's intent to perform an action +(like user clicking on a value inside chart). It allows us to do runtime +binding between code from different plugins. For, example one such +trigger is when somebody applies filters on dashboard; another one is when +somebody opens a Dashboard panel context menu. + +*Actions* are pieces of code that execute in response to a trigger. For example, +to the dashboard filtering trigger multiple actions can be attached. Once a user +filters on the dashboard all possible actions are displayed to the user in a +popup menu and the user has to chose one. + +In general this plugin provides: + +- Creating custom functionality (actions). +- Creating custom user interaction events (triggers). +- Attaching and detaching actions to triggers. +- Emitting trigger events. +- Executing actions attached to a given trigger. +- Exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. + +=== Basic usage + +To get started, first you need to know a trigger you will attach your actions to. +You can either pick an existing one, or register your own one: + +[source,typescript jsx] +---- +plugins.uiActions.registerTrigger({ + id: 'MY_APP_PIE_CHART_CLICK', + title: 'Pie chart click', + description: 'When user clicks on a pie chart slice.', +}); +---- + +Now, when user clicks on a pie slice you need to "trigger" your trigger and +provide some context data: + +[source,typescript jsx] +---- +plugins.uiActions.getTrigger('MY_APP_PIE_CHART_CLICK').exec({ + /* Custom context data. */ +}); +---- + +Finally, your code or developers from other plugins can register UI actions that +listen for the above trigger and execute some code when the trigger is triggered. + +[source,typescript jsx] +---- +plugins.uiActions.registerAction({ + id: 'DO_SOMETHING', + isCompatible: async (context) => true, + execute: async (context) => { + // Do something. + }, +}); +plugins.uiActions.attachAction('MY_APP_PIE_CHART_CLICK', 'DO_SOMETHING'); +---- + +Now your `DO_SOMETHING` action will automatically execute when `MY_APP_PIE_CHART_CLICK` +trigger is triggered; or, if more than one compatible action is attached to +that trigger, user will be presented with a context menu popup to select one +action to execute. === Examples From b33022f680db69400b37b359bf8b82e8ed21877a Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Mon, 12 Apr 2021 11:58:19 -0400 Subject: [PATCH 02/28] [Security Solution][Artifacts] Artifact creation for Endpoint Event Filtering (#96499) * generate endpoint event filters artifacts * Add ExperimentalFeature object to the initialization params of ManifestManager * create event filters artifacts if feature flag is on * change artifact migration to be less chatty in the logs (also: don't reference Fleet) --- .../exception_lists/exception_list_client.ts | 13 +++++ .../endpoint/endpoint_app_context_services.ts | 14 +++++ .../server/endpoint/lib/artifacts/common.ts | 3 + .../server/endpoint/lib/artifacts/lists.ts | 41 +++++++++++--- .../migrate_artifacts_to_fleet.test.ts | 6 +- .../artifacts/migrate_artifacts_to_fleet.ts | 10 ++-- .../server/endpoint/mocks.ts | 1 + .../manifest_manager/manifest_manager.mock.ts | 2 + .../manifest_manager/manifest_manager.ts | 55 ++++++++++++++++--- .../security_solution/server/plugin.ts | 26 ++++----- 10 files changed, 135 insertions(+), 36 deletions(-) diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 4b371b6dcb930..84b6de1672cd6 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -52,6 +52,7 @@ import { } from './find_exception_list_items'; import { createEndpointList } from './create_endpoint_list'; import { createEndpointTrustedAppsList } from './create_endpoint_trusted_apps_list'; +import { createEndpointEventFiltersList } from './create_endoint_event_filters_list'; export class ExceptionListClient { private readonly user: string; @@ -108,6 +109,18 @@ export class ExceptionListClient { }); }; + /** + * Create the Endpoint Event Filters Agnostic list if it does not yet exist (`null` is returned if it does exist) + */ + public createEndpointEventFiltersList = async (): Promise => { + const { savedObjectsClient, user } = this; + return createEndpointEventFiltersList({ + savedObjectsClient, + user, + version: 1, + }); + }; + /** * This is the same as "createListItem" except it applies specifically to the agnostic endpoint list and will * auto-call the "createEndpointList" for you so that you have the best chance of the agnostic endpoint diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index f4a5d6add4f41..103e3ae80831a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -37,6 +37,10 @@ import { metadataTransformPrefix } from '../../common/endpoint/constants'; import { AppClientFactory } from '../client'; import { ConfigType } from '../config'; import { LicenseService } from '../../common/license/license'; +import { + ExperimentalFeatures, + parseExperimentalConfigValue, +} from '../../common/experimental_features'; export interface MetadataService { queryStrategy( @@ -107,6 +111,9 @@ export class EndpointAppContextService { private agentPolicyService: AgentPolicyServiceInterface | undefined; private savedObjectsStart: SavedObjectsServiceStart | undefined; private metadataService: MetadataService | undefined; + private config: ConfigType | undefined; + + private experimentalFeatures: ExperimentalFeatures | undefined; public start(dependencies: EndpointAppContextServiceStartContract) { this.agentService = dependencies.agentService; @@ -115,6 +122,9 @@ export class EndpointAppContextService { this.manifestManager = dependencies.manifestManager; this.savedObjectsStart = dependencies.savedObjectsStart; this.metadataService = createMetadataService(dependencies.packageService!); + this.config = dependencies.config; + + this.experimentalFeatures = parseExperimentalConfigValue(this.config.enableExperimental); if (this.manifestManager && dependencies.registerIngestCallback) { dependencies.registerIngestCallback( @@ -140,6 +150,10 @@ export class EndpointAppContextService { public stop() {} + public getExperimentalFeatures(): Readonly | undefined { + return this.experimentalFeatures; + } + public getAgentService(): AgentService | undefined { return this.agentService; } diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts index 65bd6ffd15f5f..7cfcf11379dd8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts @@ -22,6 +22,9 @@ export const ArtifactConstants = { SUPPORTED_OPERATING_SYSTEMS: ['macos', 'windows'], SUPPORTED_TRUSTED_APPS_OPERATING_SYSTEMS: ['macos', 'windows', 'linux'], GLOBAL_TRUSTED_APPS_NAME: 'endpoint-trustlist', + + SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS: ['macos', 'windows', 'linux'], + GLOBAL_EVENT_FILTERS_NAME: 'endpoint-eventfilterlist', }; export const ManifestConstants = { diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index 322bb2ca47a45..1c3c92c50afd3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -14,20 +14,21 @@ import { Entry, EntryNested } from '../../../../../lists/common/schemas/types'; import { ExceptionListClient } from '../../../../../lists/server'; import { ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../common/shared_imports'; import { + internalArtifactCompleteSchema, + InternalArtifactCompleteSchema, InternalArtifactSchema, TranslatedEntry, - WrappedTranslatedExceptionList, - wrappedTranslatedExceptionList, - TranslatedEntryNestedEntry, - translatedEntryNestedEntry, translatedEntry as translatedEntryType, + translatedEntryMatchAnyMatcher, TranslatedEntryMatcher, translatedEntryMatchMatcher, - translatedEntryMatchAnyMatcher, + TranslatedEntryNestedEntry, + translatedEntryNestedEntry, TranslatedExceptionListItem, - internalArtifactCompleteSchema, - InternalArtifactCompleteSchema, + WrappedTranslatedExceptionList, + wrappedTranslatedExceptionList, } from '../../schemas'; +import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '../../../../../lists/common/constants'; export async function buildArtifact( exceptions: WrappedTranslatedExceptionList, @@ -77,7 +78,10 @@ export async function getFilteredEndpointExceptionList( eClient: ExceptionListClient, schemaVersion: string, filter: string, - listId: typeof ENDPOINT_LIST_ID | typeof ENDPOINT_TRUSTED_APPS_LIST_ID + listId: + | typeof ENDPOINT_LIST_ID + | typeof ENDPOINT_TRUSTED_APPS_LIST_ID + | typeof ENDPOINT_EVENT_FILTERS_LIST_ID ): Promise { const exceptions: WrappedTranslatedExceptionList = { entries: [] }; let page = 1; @@ -142,6 +146,27 @@ export async function getEndpointTrustedAppsList( ); } +export async function getEndpointEventFiltersList( + eClient: ExceptionListClient, + schemaVersion: string, + os: string, + policyId?: string +): Promise { + const osFilter = `exception-list-agnostic.attributes.os_types:\"${os}\"`; + const policyFilter = `(exception-list-agnostic.attributes.tags:\"policy:all\"${ + policyId ? ` or exception-list-agnostic.attributes.tags:\"policy:${policyId}\"` : '' + })`; + + await eClient.createEndpointEventFiltersList(); + + return getFilteredEndpointExceptionList( + eClient, + schemaVersion, + `${osFilter} and ${policyFilter}`, + ENDPOINT_EVENT_FILTERS_LIST_ID + ); +} + /** * Translates Exception list items to Exceptions the endpoint can understand * @param exceptions diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.test.ts index d0ad6e4734baf..cf1f178a80e78 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.test.ts @@ -66,8 +66,8 @@ describe('When migrating artifacts to fleet', () => { it('should do nothing if `fleetServerEnabled` flag is false', async () => { await migrateArtifactsToFleet(soClient, artifactClient, logger, false); - expect(logger.info).toHaveBeenCalledWith( - 'Skipping Artifacts migration to fleet. [fleetServerEnabled] flag is off' + expect(logger.debug).toHaveBeenCalledWith( + 'Skipping Artifacts migration. [fleetServerEnabled] flag is off' ); expect(soClient.find).not.toHaveBeenCalled(); }); @@ -94,7 +94,7 @@ describe('When migrating artifacts to fleet', () => { const error = new Error('test: delete failed'); soClient.delete.mockRejectedValue(error); await expect(migrateArtifactsToFleet(soClient, artifactClient, logger, true)).rejects.toThrow( - 'Artifact SO migration to fleet failed' + 'Artifact SO migration failed' ); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.ts index bcbcb7f63e3ca..ba3c15cecf217 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.ts @@ -27,7 +27,7 @@ export const migrateArtifactsToFleet = async ( isFleetServerEnabled: boolean ): Promise => { if (!isFleetServerEnabled) { - logger.info('Skipping Artifacts migration to fleet. [fleetServerEnabled] flag is off'); + logger.debug('Skipping Artifacts migration. [fleetServerEnabled] flag is off'); return; } @@ -49,14 +49,16 @@ export const migrateArtifactsToFleet = async ( if (totalArtifactsMigrated === -1) { totalArtifactsMigrated = total; if (total > 0) { - logger.info(`Migrating artifacts from SavedObject to Fleet`); + logger.info(`Migrating artifacts from SavedObject`); } } // If nothing else to process, then exit out if (total === 0) { hasMore = false; - logger.info(`Total Artifacts migrated to Fleet: ${totalArtifactsMigrated}`); + if (totalArtifactsMigrated > 0) { + logger.info(`Total Artifacts migrated: ${totalArtifactsMigrated}`); + } return; } @@ -78,7 +80,7 @@ export const migrateArtifactsToFleet = async ( } } } catch (e) { - const error = new ArtifactMigrationError('Artifact SO migration to fleet failed', e); + const error = new ArtifactMigrationError('Artifact SO migration failed', e); logger.error(error); throw error; } diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index c82d2b6524773..d1911a39166dc 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -56,6 +56,7 @@ export const createMockEndpointAppContextService = ( return ({ start: jest.fn(), stop: jest.fn(), + getExperimentalFeatures: jest.fn(), getAgentService: jest.fn(), getAgentPolicyService: jest.fn(), getManifestManager: jest.fn().mockReturnValue(mockManifestManager ?? jest.fn()), diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts index ececb425af657..6f41fe3578496 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts @@ -22,6 +22,7 @@ import { } from '../../../lib/artifacts/mocks'; import { createEndpointArtifactClientMock, getManifestClientMock } from '../mocks'; import { ManifestManager, ManifestManagerContext } from './manifest_manager'; +import { parseExperimentalConfigValue } from '../../../../../common/experimental_features'; export const createExceptionListResponse = (data: ExceptionListItemSchema[], total?: number) => ({ data, @@ -85,6 +86,7 @@ export const buildManifestManagerContextMock = ( ...fullOpts, artifactClient: createEndpointArtifactClientMock(), logger: loggingSystemMock.create().get() as jest.Mocked, + experimentalFeatures: parseExperimentalConfigValue([]), }; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 9ed17686fd2bc..b3d8b63687d31 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -22,6 +22,7 @@ import { ArtifactConstants, buildArtifact, getArtifactId, + getEndpointEventFiltersList, getEndpointExceptionList, getEndpointTrustedAppsList, isCompressed, @@ -34,6 +35,7 @@ import { } from '../../../schemas/artifacts'; import { EndpointArtifactClientInterface } from '../artifact_client'; import { ManifestClient } from '../manifest_client'; +import { ExperimentalFeatures } from '../../../../../common/experimental_features'; interface ArtifactsBuildResult { defaultArtifacts: InternalArtifactCompleteSchema[]; @@ -81,6 +83,7 @@ export interface ManifestManagerContext { packagePolicyService: PackagePolicyServiceInterface; logger: Logger; cache: LRU; + experimentalFeatures: ExperimentalFeatures; } const getArtifactIds = (manifest: ManifestSchema) => @@ -99,11 +102,9 @@ export class ManifestManager { protected logger: Logger; protected cache: LRU; protected schemaVersion: ManifestSchemaVersion; + protected experimentalFeatures: ExperimentalFeatures; - constructor( - context: ManifestManagerContext, - private readonly isFleetServerEnabled: boolean = false - ) { + constructor(context: ManifestManagerContext) { this.artifactClient = context.artifactClient; this.exceptionListClient = context.exceptionListClient; this.packagePolicyService = context.packagePolicyService; @@ -111,6 +112,7 @@ export class ManifestManager { this.logger = context.logger; this.cache = context.cache; this.schemaVersion = 'v1'; + this.experimentalFeatures = context.experimentalFeatures; } /** @@ -198,6 +200,41 @@ export class ManifestManager { return { defaultArtifacts, policySpecificArtifacts }; } + /** + * Builds an array of endpoint event filters (one per supported OS) based on the current state of the + * Event Filters list + * @protected + */ + protected async buildEventFiltersArtifacts(): Promise { + const defaultArtifacts: InternalArtifactCompleteSchema[] = []; + const policySpecificArtifacts: Record = {}; + + for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { + defaultArtifacts.push(await this.buildEventFiltersForOs(os)); + } + + await iterateAllListItems( + (page) => this.listEndpointPolicyIds(page), + async (policyId) => { + for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { + policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; + policySpecificArtifacts[policyId].push(await this.buildEventFiltersForOs(os, policyId)); + } + } + ); + + return { defaultArtifacts, policySpecificArtifacts }; + } + + protected async buildEventFiltersForOs(os: string, policyId?: string) { + return buildArtifact( + await getEndpointEventFiltersList(this.exceptionListClient, this.schemaVersion, os, policyId), + this.schemaVersion, + os, + ArtifactConstants.GLOBAL_EVENT_FILTERS_NAME + ); + } + /** * Writes new artifact SO. * @@ -286,7 +323,7 @@ export class ManifestManager { semanticVersion: manifestSo.attributes.semanticVersion, soVersion: manifestSo.version, }, - this.isFleetServerEnabled + this.experimentalFeatures.fleetServerEnabled ); for (const entry of manifestSo.attributes.artifacts) { @@ -327,12 +364,16 @@ export class ManifestManager { public async buildNewManifest( baselineManifest: Manifest = ManifestManager.createDefaultManifest( this.schemaVersion, - this.isFleetServerEnabled + this.experimentalFeatures.fleetServerEnabled ) ): Promise { const results = await Promise.all([ this.buildExceptionListArtifacts(), this.buildTrustedAppsArtifacts(), + // If Endpoint Event Filtering feature is ON, then add in the exceptions for them + ...(this.experimentalFeatures.eventFilteringEnabled + ? [this.buildEventFiltersArtifacts()] + : []), ]); const manifest = new Manifest( @@ -341,7 +382,7 @@ export class ManifestManager { semanticVersion: baselineManifest.getSemanticVersion(), soVersion: baselineManifest.getSavedObjectVersion(), }, - this.isFleetServerEnabled + this.experimentalFeatures.fleetServerEnabled ); for (const result of results) { diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 04f98e53ea9a3..8dab308affad8 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -349,24 +349,22 @@ export class Plugin implements IPlugin { @@ -376,7 +374,7 @@ export class Plugin implements IPlugin { - logger.info('Fleet setup complete - Starting ManifestTask'); + logger.info('Dependent plugin setup complete - Starting ManifestTask'); if (this.manifestTask) { this.manifestTask.start({ From f544d8d458ef1612b5da1950b0e00c4d88ca4225 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Mon, 12 Apr 2021 18:19:42 +0200 Subject: [PATCH 03/28] Migrations v2 ignore fleet agent events (#96690) * migrationsv2: ignore fleet agent events and tsvb telemetry * migrationsv1: ignore tsvb-validation-telemetry * Skip fleet test that depends on fleet-agent-events * Fix typescript errors Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../migrations/core/elastic_index.test.ts | 15 +++- .../migrations/core/elastic_index.ts | 23 +++--- .../migrations/kibana/kibana_migrator.test.ts | 6 +- .../migrationsv2/actions/index.test.ts | 3 +- .../migrationsv2/actions/index.ts | 17 ++++- .../integration_tests/actions.test.ts | 75 +++++++++++++++---- .../migrations_state_action_machine.test.ts | 28 +++++++ .../saved_objects/migrationsv2/model.test.ts | 8 ++ .../saved_objects/migrationsv2/model.ts | 6 ++ .../server/saved_objects/migrationsv2/next.ts | 12 ++- .../saved_objects/migrationsv2/types.ts | 5 ++ .../apis/agents_setup.ts | 2 +- 12 files changed, 164 insertions(+), 36 deletions(-) diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts index 5cb2a88c4733f..2fc78fc619cab 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts @@ -414,11 +414,18 @@ describe('ElasticIndex', () => { size: 100, query: { bool: { - must_not: { - term: { - type: 'fleet-agent-events', + must_not: [ + { + term: { + type: 'fleet-agent-events', + }, }, - }, + { + term: { + type: 'tsvb-validation-telemetry', + }, + }, + ], }, }, }, diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.ts b/src/core/server/saved_objects/migrations/core/elastic_index.ts index a5f3cb36e736b..462425ff6e3e0 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.ts @@ -70,16 +70,19 @@ export function reader( let scrollId: string | undefined; // When migrating from the outdated index we use a read query which excludes - // saved objects which are no longer used. These saved objects will still be - // kept in the outdated index for backup purposes, but won't be availble in - // the upgraded index. - const excludeUnusedTypes = { + // saved object types which are no longer used. These saved objects will + // still be kept in the outdated index for backup purposes, but won't be + // availble in the upgraded index. + const EXCLUDE_UNUSED_TYPES = [ + 'fleet-agent-events', // https://github.com/elastic/kibana/issues/91869 + 'tsvb-validation-telemetry', // https://github.com/elastic/kibana/issues/95617 + ]; + + const excludeUnusedTypesQuery = { bool: { - must_not: { - term: { - type: 'fleet-agent-events', // https://github.com/elastic/kibana/issues/91869 - }, - }, + must_not: EXCLUDE_UNUSED_TYPES.map((type) => ({ + term: { type }, + })), }, }; @@ -92,7 +95,7 @@ export function reader( : client.search>({ body: { size: batchSize, - query: excludeUnusedTypes, + query: excludeUnusedTypesQuery, }, index, scroll, diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index 40d18c3b5063a..221e78e3e12e2 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -321,7 +321,7 @@ describe('KibanaMigrator', () => { options.client.tasks.get.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ completed: true, - error: { type: 'elatsicsearch_exception', reason: 'task failed with an error' }, + error: { type: 'elasticsearch_exception', reason: 'task failed with an error' }, failures: [], task: { description: 'task description' } as any, }) @@ -331,11 +331,11 @@ describe('KibanaMigrator', () => { migrator.prepareMigrations(); await expect(migrator.runMigrations()).rejects.toMatchInlineSnapshot(` [Error: Unable to complete saved object migrations for the [.my-index] index. Error: Reindex failed with the following error: - {"_tag":"Some","value":{"type":"elatsicsearch_exception","reason":"task failed with an error"}}] + {"_tag":"Some","value":{"type":"elasticsearch_exception","reason":"task failed with an error"}}] `); expect(loggingSystemMock.collect(options.logger).error[0][0]).toMatchInlineSnapshot(` [Error: Reindex failed with the following error: - {"_tag":"Some","value":{"type":"elatsicsearch_exception","reason":"task failed with an error"}}] + {"_tag":"Some","value":{"type":"elasticsearch_exception","reason":"task failed with an error"}}] `); }); }); diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/index.test.ts index 14ca73e7fcca0..bee17f42d7bdb 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.test.ts @@ -85,7 +85,8 @@ describe('actions', () => { 'my_source_index', 'my_target_index', Option.none, - false + false, + Option.none ); try { await task(); diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.ts b/src/core/server/saved_objects/migrationsv2/actions/index.ts index 8ac683a29d657..d759c0c9be20e 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.ts @@ -14,6 +14,7 @@ import { errors as EsErrors } from '@elastic/elasticsearch'; import type { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors'; import { pipe } from 'fp-ts/lib/pipeable'; import { flow } from 'fp-ts/lib/function'; +import { QueryContainer } from '@elastic/eui/src/components/search_bar/query/ast_to_es_query_dsl'; import { ElasticsearchClient } from '../../../elasticsearch'; import { IndexMapping } from '../../mappings'; import { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; @@ -436,7 +437,12 @@ export const reindex = ( sourceIndex: string, targetIndex: string, reindexScript: Option.Option, - requireAlias: boolean + requireAlias: boolean, + /* When reindexing we use a source query to exclude saved objects types which + * are no longer used. These saved objects will still be kept in the outdated + * index for backup purposes, but won't be availble in the upgraded index. + */ + unusedTypesToExclude: Option.Option ): TaskEither.TaskEither => () => { return client .reindex({ @@ -450,6 +456,15 @@ export const reindex = ( index: sourceIndex, // Set reindex batch size size: BATCH_SIZE, + // Exclude saved object types + query: Option.fold( + () => undefined, + (types) => ({ + bool: { + must_not: types.map((type) => ({ term: { type } })), + }, + }) + )(unusedTypesToExclude), }, dest: { index: targetIndex, diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts index aa9a5ea92ac11..3ed3ace416990 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts @@ -66,7 +66,8 @@ describe('migration actions', () => { { _source: { title: 'doc 1' } }, { _source: { title: 'doc 2' } }, { _source: { title: 'doc 3' } }, - { _source: { title: 'saved object 4' } }, + { _source: { title: 'saved object 4', type: 'another_unused_type' } }, + { _source: { title: 'f-agent-event 5', type: 'f_agent_event' } }, ] as unknown) as SavedObjectsRawDoc[]; await bulkOverwriteTransformedDocuments(client, 'existing_index_with_docs', sourceDocs)(); @@ -343,7 +344,8 @@ describe('migration actions', () => { 'existing_index_with_docs', 'reindex_target', Option.none, - false + false, + Option.none )()) as Either.Right; const task = waitForReindexTask(client, res.right.taskId, '10s'); await expect(task()).resolves.toMatchInlineSnapshot(` @@ -364,6 +366,37 @@ describe('migration actions', () => { "doc 2", "doc 3", "saved object 4", + "f-agent-event 5", + ] + `); + }); + it('resolves right and excludes all unusedTypesToExclude documents', async () => { + const res = (await reindex( + client, + 'existing_index_with_docs', + 'reindex_target_excluded_docs', + Option.none, + false, + Option.some(['f_agent_event', 'another_unused_type']) + )()) as Either.Right; + const task = waitForReindexTask(client, res.right.taskId, '10s'); + await expect(task()).resolves.toMatchInlineSnapshot(` + Object { + "_tag": "Right", + "right": "reindex_succeeded", + } + `); + + const results = ((await searchForOutdatedDocuments(client, { + batchSize: 1000, + targetIndex: 'reindex_target_excluded_docs', + outdatedDocumentsQuery: undefined, + })()) as Either.Right).right.outdatedDocuments; + expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + Array [ + "doc 1", + "doc 2", + "doc 3", ] `); }); @@ -374,7 +407,8 @@ describe('migration actions', () => { 'existing_index_with_docs', 'reindex_target_2', Option.some(`ctx._source.title = ctx._source.title + '_updated'`), - false + false, + Option.none )()) as Either.Right; const task = waitForReindexTask(client, res.right.taskId, '10s'); await expect(task()).resolves.toMatchInlineSnapshot(` @@ -394,6 +428,7 @@ describe('migration actions', () => { "doc 2_updated", "doc 3_updated", "saved object 4_updated", + "f-agent-event 5_updated", ] `); }); @@ -405,7 +440,8 @@ describe('migration actions', () => { 'existing_index_with_docs', 'reindex_target_3', Option.some(`ctx._source.title = ctx._source.title + '_updated'`), - false + false, + Option.none )()) as Either.Right; let task = waitForReindexTask(client, res.right.taskId, '10s'); await expect(task()).resolves.toMatchInlineSnapshot(` @@ -421,7 +457,8 @@ describe('migration actions', () => { 'existing_index_with_docs', 'reindex_target_3', Option.none, - false + false, + Option.none )()) as Either.Right; task = waitForReindexTask(client, res.right.taskId, '10s'); await expect(task()).resolves.toMatchInlineSnapshot(` @@ -443,6 +480,7 @@ describe('migration actions', () => { "doc 2_updated", "doc 3_updated", "saved object 4_updated", + "f-agent-event 5_updated", ] `); }); @@ -469,7 +507,8 @@ describe('migration actions', () => { 'existing_index_with_docs', 'reindex_target_4', Option.some(`ctx._source.title = ctx._source.title + '_updated'`), - false + false, + Option.none )()) as Either.Right; const task = waitForReindexTask(client, res.right.taskId, '10s'); await expect(task()).resolves.toMatchInlineSnapshot(` @@ -491,6 +530,7 @@ describe('migration actions', () => { "doc 2", "doc 3_updated", "saved object 4_updated", + "f-agent-event 5_updated", ] `); }); @@ -517,7 +557,8 @@ describe('migration actions', () => { 'existing_index_with_docs', 'reindex_target_5', Option.none, - false + false, + Option.none )()) as Either.Right; const task = waitForReindexTask(client, reindexTaskId, '10s'); @@ -551,7 +592,8 @@ describe('migration actions', () => { 'existing_index_with_docs', 'reindex_target_6', Option.none, - false + false, + Option.none )()) as Either.Right; const task = waitForReindexTask(client, reindexTaskId, '10s'); @@ -571,7 +613,8 @@ describe('migration actions', () => { 'no_such_index', 'reindex_target', Option.none, - false + false, + Option.none )()) as Either.Right; const task = waitForReindexTask(client, res.right.taskId, '10s'); await expect(task()).resolves.toMatchInlineSnapshot(` @@ -591,7 +634,8 @@ describe('migration actions', () => { 'existing_index_with_docs', 'existing_index_with_write_block', Option.none, - false + false, + Option.none )()) as Either.Right; const task = waitForReindexTask(client, res.right.taskId, '10s'); @@ -612,7 +656,8 @@ describe('migration actions', () => { 'existing_index_with_docs', 'existing_index_with_write_block', Option.none, - true + true, + Option.none )()) as Either.Right; const task = waitForReindexTask(client, res.right.taskId, '10s'); @@ -633,7 +678,8 @@ describe('migration actions', () => { 'existing_index_with_docs', 'reindex_target', Option.none, - false + false, + Option.none )()) as Either.Right; const task = waitForReindexTask(client, res.right.taskId, '0s'); @@ -659,7 +705,8 @@ describe('migration actions', () => { 'existing_index_with_docs', 'reindex_target_7', Option.none, - false + false, + Option.none )()) as Either.Right; await waitForReindexTask(client, res.right.taskId, '10s')(); @@ -714,7 +761,7 @@ describe('migration actions', () => { targetIndex: 'existing_index_with_docs', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(resultsWithoutQuery.length).toBe(4); + expect(resultsWithoutQuery.length).toBe(5); }); it('resolves with _id, _source, _seq_no and _primary_term', async () => { expect.assertions(1); diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts index d4ce7b74baa5f..2c2cd0032abfd 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts @@ -249,6 +249,13 @@ describe('migrationsStateActionMachine', () => { }, }, }, + "unusedTypesToExclude": Object { + "_tag": "Some", + "value": Array [ + "fleet-agent-events", + "tsvb-validation-telemetry", + ], + }, "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", }, @@ -310,6 +317,13 @@ describe('migrationsStateActionMachine', () => { }, }, }, + "unusedTypesToExclude": Object { + "_tag": "Some", + "value": Array [ + "fleet-agent-events", + "tsvb-validation-telemetry", + ], + }, "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", }, @@ -456,6 +470,13 @@ describe('migrationsStateActionMachine', () => { }, }, }, + "unusedTypesToExclude": Object { + "_tag": "Some", + "value": Array [ + "fleet-agent-events", + "tsvb-validation-telemetry", + ], + }, "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", }, @@ -512,6 +533,13 @@ describe('migrationsStateActionMachine', () => { }, }, }, + "unusedTypesToExclude": Object { + "_tag": "Some", + "value": Array [ + "fleet-agent-events", + "tsvb-validation-telemetry", + ], + }, "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", }, diff --git a/src/core/server/saved_objects/migrationsv2/model.test.ts b/src/core/server/saved_objects/migrationsv2/model.test.ts index f9bf3418c0ab6..4fd9b7cbb3df4 100644 --- a/src/core/server/saved_objects/migrationsv2/model.test.ts +++ b/src/core/server/saved_objects/migrationsv2/model.test.ts @@ -69,6 +69,7 @@ describe('migrations v2 model', () => { versionAlias: '.kibana_7.11.0', versionIndex: '.kibana_7.11.0_001', tempIndex: '.kibana_7.11.0_reindex_temp', + unusedTypesToExclude: Option.some(['unused-fleet-agent-events']), }; describe('exponential retry delays for retryable_es_client_error', () => { @@ -1242,6 +1243,13 @@ describe('migrations v2 model', () => { }, }, }, + "unusedTypesToExclude": Object { + "_tag": "Some", + "value": Array [ + "fleet-agent-events", + "tsvb-validation-telemetry", + ], + }, "versionAlias": ".kibana_task_manager_8.1.0", "versionIndex": ".kibana_task_manager_8.1.0_001", } diff --git a/src/core/server/saved_objects/migrationsv2/model.ts b/src/core/server/saved_objects/migrationsv2/model.ts index e62bd108faea0..2353452a6a51b 100644 --- a/src/core/server/saved_objects/migrationsv2/model.ts +++ b/src/core/server/saved_objects/migrationsv2/model.ts @@ -768,6 +768,11 @@ export const createInitialState = ({ }, }; + const unusedTypesToExclude = Option.some([ + 'fleet-agent-events', // https://github.com/elastic/kibana/issues/91869 + 'tsvb-validation-telemetry', // https://github.com/elastic/kibana/issues/95617 + ]); + const initialState: InitState = { controlState: 'INIT', indexPrefix, @@ -786,6 +791,7 @@ export const createInitialState = ({ retryAttempts: migrationsConfig.retryAttempts, batchSize: migrationsConfig.batchSize, logs: [], + unusedTypesToExclude, }; return initialState; }; diff --git a/src/core/server/saved_objects/migrationsv2/next.ts b/src/core/server/saved_objects/migrationsv2/next.ts index 5c159f4f24e22..67b2004a4b31a 100644 --- a/src/core/server/saved_objects/migrationsv2/next.ts +++ b/src/core/server/saved_objects/migrationsv2/next.ts @@ -61,7 +61,14 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra CREATE_REINDEX_TEMP: (state: CreateReindexTempState) => Actions.createIndex(client, state.tempIndex, state.tempIndexMappings), REINDEX_SOURCE_TO_TEMP: (state: ReindexSourceToTempState) => - Actions.reindex(client, state.sourceIndex.value, state.tempIndex, Option.none, false), + Actions.reindex( + client, + state.sourceIndex.value, + state.tempIndex, + Option.none, + false, + state.unusedTypesToExclude + ), SET_TEMP_WRITE_BLOCK: (state: SetTempWriteBlock) => Actions.setWriteBlock(client, state.tempIndex), REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK: (state: ReindexSourceToTempWaitForTaskState) => @@ -104,7 +111,8 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra state.legacyIndex, state.sourceIndex.value, state.preMigrationScript, - false + false, + state.unusedTypesToExclude ), LEGACY_REINDEX_WAIT_FOR_TASK: (state: LegacyReindexWaitForTaskState) => Actions.waitForReindexTask(client, state.legacyReindexTaskId, '60s'), diff --git a/src/core/server/saved_objects/migrationsv2/types.ts b/src/core/server/saved_objects/migrationsv2/types.ts index 8d6fe3f030eb3..cc4aa18171843 100644 --- a/src/core/server/saved_objects/migrationsv2/types.ts +++ b/src/core/server/saved_objects/migrationsv2/types.ts @@ -89,6 +89,11 @@ export interface BaseState extends ControlState { * prevents lost deletes e.g. `.kibana_7.11.0_reindex`. */ readonly tempIndex: string; + /* When reindexing we use a source query to exclude saved objects types which + * are no longer used. These saved objects will still be kept in the outdated + * index for backup purposes, but won't be availble in the upgraded index. + */ + readonly unusedTypesToExclude: Option.Option; } export type InitState = BaseState & { diff --git a/x-pack/test/fleet_api_integration/apis/agents_setup.ts b/x-pack/test/fleet_api_integration/apis/agents_setup.ts index 91d6ca0119d1d..700a06750d2f4 100644 --- a/x-pack/test/fleet_api_integration/apis/agents_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/agents_setup.ts @@ -101,7 +101,7 @@ export default function (providerContext: FtrProviderContext) { ); }); - it('should create or update the fleet_enroll user if called multiple times with forceRecreate flag', async () => { + it.skip('should create or update the fleet_enroll user if called multiple times with forceRecreate flag', async () => { await supertest.post(`/api/fleet/agents/setup`).set('kbn-xsrf', 'xxxx').expect(200); const { body: userResponseFirstTime } = await es.security.getUser({ From b645fec8b82be0ccfa6fc16378482333a2977afa Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Mon, 12 Apr 2021 12:25:03 -0400 Subject: [PATCH 04/28] [Dashboard] Move all dashboard extract/inject into persistable state (#96095) * Move all dashboard inject/extract to be part of embeddable persistable state * Fixes typescript errors * Remove comments * Fixes test * API Doc changes * Fix integration tests * Fix functional testS * Fix unit tests * Update Dashboard plugin API to get dashboard embeddable renderer * Fix Types Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...ugins-embeddable-server.embeddablestart.md | 11 + ...kibana-plugin-plugins-embeddable-server.md | 6 + .../public/app.tsx | 4 +- .../public/by_value/embeddable.tsx | 4 +- .../public/plugin.tsx | 3 +- src/plugins/dashboard/common/bwc/types.ts | 1 + ...hboard_container_persistable_state.test.ts | 158 +++++ .../dashboard_container_persistable_state.ts | 125 ++++ .../embeddable_saved_object_converters.ts | 2 + src/plugins/dashboard/common/index.ts | 1 + .../common/saved_dashboard_references.test.ts | 132 ++++- .../common/saved_dashboard_references.ts | 195 ++++--- src/plugins/dashboard/common/types.ts | 15 +- .../dashboard_container_factory.tsx | 14 +- src/plugins/dashboard/public/plugin.tsx | 35 +- .../dashboard_container_embeddable_factory.ts | 24 + src/plugins/dashboard/server/plugin.ts | 20 +- .../dashboard_migrations.test.ts | 544 ++++++++++-------- src/plugins/embeddable/server/index.ts | 4 +- src/plugins/embeddable/server/server.api.md | 5 + .../apis/saved_objects/export.ts | 6 +- 21 files changed, 964 insertions(+), 345 deletions(-) create mode 100644 docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablestart.md create mode 100644 src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.test.ts create mode 100644 src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts create mode 100644 src/plugins/dashboard/server/embeddable/dashboard_container_embeddable_factory.ts diff --git a/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablestart.md b/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablestart.md new file mode 100644 index 0000000000000..c69850006e146 --- /dev/null +++ b/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablestart.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-server](./kibana-plugin-plugins-embeddable-server.md) > [EmbeddableStart](./kibana-plugin-plugins-embeddable-server.embeddablestart.md) + +## EmbeddableStart type + +Signature: + +```typescript +export declare type EmbeddableStart = PersistableStateService; +``` diff --git a/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.md b/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.md index 19ee57d677250..5b3083e039847 100644 --- a/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.md +++ b/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.md @@ -18,3 +18,9 @@ | --- | --- | | [plugin](./kibana-plugin-plugins-embeddable-server.plugin.md) | | +## Type Aliases + +| Type Alias | Description | +| --- | --- | +| [EmbeddableStart](./kibana-plugin-plugins-embeddable-server.embeddablestart.md) | | + diff --git a/examples/dashboard_embeddable_examples/public/app.tsx b/examples/dashboard_embeddable_examples/public/app.tsx index 0e21e4421e742..8a6b5a90a22a8 100644 --- a/examples/dashboard_embeddable_examples/public/app.tsx +++ b/examples/dashboard_embeddable_examples/public/app.tsx @@ -55,7 +55,9 @@ const Nav = withRouter(({ history, pages }: NavProps) => { interface Props { basename: string; - DashboardContainerByValueRenderer: DashboardStart['DashboardContainerByValueRenderer']; + DashboardContainerByValueRenderer: ReturnType< + DashboardStart['getDashboardContainerByValueRenderer'] + >; } const DashboardEmbeddableExplorerApp = ({ basename, DashboardContainerByValueRenderer }: Props) => { diff --git a/examples/dashboard_embeddable_examples/public/by_value/embeddable.tsx b/examples/dashboard_embeddable_examples/public/by_value/embeddable.tsx index cba87d466176e..29297341c3016 100644 --- a/examples/dashboard_embeddable_examples/public/by_value/embeddable.tsx +++ b/examples/dashboard_embeddable_examples/public/by_value/embeddable.tsx @@ -96,7 +96,9 @@ const initialInput: DashboardContainerInput = { export const DashboardEmbeddableByValue = ({ DashboardContainerByValueRenderer, }: { - DashboardContainerByValueRenderer: DashboardStart['DashboardContainerByValueRenderer']; + DashboardContainerByValueRenderer: ReturnType< + DashboardStart['getDashboardContainerByValueRenderer'] + >; }) => { const [input, setInput] = useState(initialInput); diff --git a/examples/dashboard_embeddable_examples/public/plugin.tsx b/examples/dashboard_embeddable_examples/public/plugin.tsx index e57c12daaef23..57678f5a2a517 100644 --- a/examples/dashboard_embeddable_examples/public/plugin.tsx +++ b/examples/dashboard_embeddable_examples/public/plugin.tsx @@ -33,8 +33,7 @@ export class DashboardEmbeddableExamples implements Plugin { + it('should inject the extracted saved object panel', () => { + const inject = createInject(persistableStateService); + const references = [extractedSavedObjectPanelRef]; + + const injected = inject( + dashboardWithExtractedPanel, + references + ) as DashboardContainerStateWithType; + + expect(injected).toEqual(unextractedDashboardState); + }); + + it('should extract the saved object panel', () => { + const extract = createExtract(persistableStateService); + const { state: extractedState, references: extractedReferences } = extract( + unextractedDashboardState + ); + + expect(extractedState).toEqual(dashboardWithExtractedPanel); + expect(extractedReferences[0]).toEqual(extractedSavedObjectPanelRef); + }); +}); + +const dashboardWithExtractedByValuePanel: DashboardContainerStateWithType = { + id: 'id', + type: 'dashboard', + panels: { + panel_1: { + type: 'panel_type', + gridData: { w: 0, h: 0, x: 0, y: 0, i: '0' }, + explicitInput: { + id: 'panel_1', + extracted_reference: 'ref', + }, + }, + }, +}; + +const extractedByValueRef = { + id: 'id', + name: 'panel_1:ref', + type: 'panel_type', +}; + +const unextractedDashboardByValueState: DashboardContainerStateWithType = { + id: 'id', + type: 'dashboard', + panels: { + panel_1: { + type: 'panel_type', + gridData: { w: 0, h: 0, x: 0, y: 0, i: '0' }, + explicitInput: { + id: 'panel_1', + value: 'id', + }, + }, + }, +}; + +describe('inject/extract by value panels', () => { + it('should inject the extracted references', () => { + const inject = createInject(persistableStateService); + + persistableStateService.inject.mockImplementationOnce((state, references) => { + const ref = references.find((r) => r.name === 'ref'); + if (!ref) { + return state; + } + + if (('extracted_reference' in state) as any) { + (state as any).value = ref.id; + delete (state as any).extracted_reference; + } + + return state; + }); + + const injectedState = inject(dashboardWithExtractedByValuePanel, [extractedByValueRef]); + + expect(injectedState).toEqual(unextractedDashboardByValueState); + }); + + it('should extract references using persistable state', () => { + const extract = createExtract(persistableStateService); + + persistableStateService.extract.mockImplementationOnce((state) => { + if ((state as any).value === 'id') { + delete (state as any).value; + (state as any).extracted_reference = 'ref'; + + return { + state, + references: [{ id: extractedByValueRef.id, name: 'ref', type: extractedByValueRef.type }], + }; + } + + return { state, references: [] }; + }); + + const { state: extractedState, references: extractedReferences } = extract( + unextractedDashboardByValueState + ); + + expect(extractedState).toEqual(dashboardWithExtractedByValuePanel); + expect(extractedReferences).toEqual([extractedByValueRef]); + }); +}); diff --git a/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts b/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts new file mode 100644 index 0000000000000..6104fcfdbe949 --- /dev/null +++ b/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + EmbeddableInput, + EmbeddablePersistableStateService, + EmbeddableStateWithType, +} from '../../../embeddable/common'; +import { SavedObjectReference } from '../../../../core/types'; +import { DashboardContainerStateWithType, DashboardPanelState } from '../types'; + +const getPanelStatePrefix = (state: DashboardPanelState) => `${state.explicitInput.id}:`; + +export const createInject = ( + persistableStateService: EmbeddablePersistableStateService +): EmbeddablePersistableStateService['inject'] => { + return (state: EmbeddableStateWithType, references: SavedObjectReference[]) => { + const workingState = { ...state } as EmbeddableStateWithType | DashboardContainerStateWithType; + + if ('panels' in workingState) { + workingState.panels = { ...workingState.panels }; + + for (const [key, panel] of Object.entries(workingState.panels)) { + workingState.panels[key] = { ...panel }; + // Find the references for this panel + const prefix = getPanelStatePrefix(panel); + + const filteredReferences = references + .filter((reference) => reference.name.indexOf(prefix) === 0) + .map((reference) => ({ ...reference, name: reference.name.replace(prefix, '') })); + + const panelReferences = filteredReferences.length === 0 ? references : filteredReferences; + + // Inject dashboard references back in + if (panel.panelRefName !== undefined) { + const matchingReference = panelReferences.find( + (reference) => reference.name === panel.panelRefName + ); + + if (!matchingReference) { + throw new Error(`Could not find reference "${panel.panelRefName}"`); + } + + if (matchingReference !== undefined) { + workingState.panels[key] = { + ...panel, + type: matchingReference.type, + explicitInput: { + ...workingState.panels[key].explicitInput, + savedObjectId: matchingReference.id, + }, + }; + + delete workingState.panels[key].panelRefName; + } + } + + const { type, ...injectedState } = persistableStateService.inject( + { ...workingState.panels[key].explicitInput, type: workingState.panels[key].type }, + panelReferences + ); + + workingState.panels[key].explicitInput = injectedState as EmbeddableInput; + } + } + + return workingState as EmbeddableStateWithType; + }; +}; + +export const createExtract = ( + persistableStateService: EmbeddablePersistableStateService +): EmbeddablePersistableStateService['extract'] => { + return (state: EmbeddableStateWithType) => { + const workingState = { ...state } as EmbeddableStateWithType | DashboardContainerStateWithType; + + const references: SavedObjectReference[] = []; + + if ('panels' in workingState) { + workingState.panels = { ...workingState.panels }; + + // Run every panel through the state service to get the nested references + for (const [key, panel] of Object.entries(workingState.panels)) { + const prefix = getPanelStatePrefix(panel); + + // If the panel is a saved object, then we will make the reference for that saved object and change the explicit input + if (panel.explicitInput.savedObjectId) { + panel.panelRefName = `panel_${key}`; + + references.push({ + name: `${prefix}panel_${key}`, + type: panel.type, + id: panel.explicitInput.savedObjectId as string, + }); + + delete panel.explicitInput.savedObjectId; + delete panel.explicitInput.type; + } + + const { state: panelState, references: panelReferences } = persistableStateService.extract({ + ...panel.explicitInput, + type: panel.type, + }); + + // We're going to prefix the names of the references so that we don't end up with dupes (from visualizations for instance) + const prefixedReferences = panelReferences.map((reference) => ({ + ...reference, + name: `${prefix}${reference.name}`, + })); + + references.push(...prefixedReferences); + + const { type, ...restOfState } = panelState; + workingState.panels[key].explicitInput = restOfState as EmbeddableInput; + } + } + + return { state: workingState as EmbeddableStateWithType, references }; + }; +}; diff --git a/src/plugins/dashboard/common/embeddable/embeddable_saved_object_converters.ts b/src/plugins/dashboard/common/embeddable/embeddable_saved_object_converters.ts index 96725d4405112..a06f248eb8125 100644 --- a/src/plugins/dashboard/common/embeddable/embeddable_saved_object_converters.ts +++ b/src/plugins/dashboard/common/embeddable/embeddable_saved_object_converters.ts @@ -16,6 +16,7 @@ export function convertSavedDashboardPanelToPanelState( return { type: savedDashboardPanel.type, gridData: savedDashboardPanel.gridData, + panelRefName: savedDashboardPanel.panelRefName, explicitInput: { id: savedDashboardPanel.panelIndex, ...(savedDashboardPanel.id !== undefined && { savedObjectId: savedDashboardPanel.id }), @@ -38,5 +39,6 @@ export function convertPanelStateToSavedDashboardPanel( embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']), ...(panelState.explicitInput.title !== undefined && { title: panelState.explicitInput.title }), ...(savedObjectId !== undefined && { id: savedObjectId }), + ...(panelState.panelRefName !== undefined && { panelRefName: panelState.panelRefName }), }; } diff --git a/src/plugins/dashboard/common/index.ts b/src/plugins/dashboard/common/index.ts index a1d5487eeb244..017b7d804c872 100644 --- a/src/plugins/dashboard/common/index.ts +++ b/src/plugins/dashboard/common/index.ts @@ -14,6 +14,7 @@ export { DashboardDocPre700, } from './bwc/types'; export { + DashboardContainerStateWithType, SavedDashboardPanelTo60, SavedDashboardPanel610, SavedDashboardPanel620, diff --git a/src/plugins/dashboard/common/saved_dashboard_references.test.ts b/src/plugins/dashboard/common/saved_dashboard_references.test.ts index 584d7e5e63a92..9ab0e7b644496 100644 --- a/src/plugins/dashboard/common/saved_dashboard_references.test.ts +++ b/src/plugins/dashboard/common/saved_dashboard_references.test.ts @@ -12,14 +12,34 @@ import { InjectDeps, ExtractDeps, } from './saved_dashboard_references'; + +import { createExtract, createInject } from './embeddable/dashboard_container_persistable_state'; import { createEmbeddablePersistableStateServiceMock } from '../../embeddable/common/mocks'; const embeddablePersistableStateServiceMock = createEmbeddablePersistableStateServiceMock(); +const dashboardInject = createInject(embeddablePersistableStateServiceMock); +const dashboardExtract = createExtract(embeddablePersistableStateServiceMock); + +embeddablePersistableStateServiceMock.extract.mockImplementation((state) => { + if (state.type === 'dashboard') { + return dashboardExtract(state); + } + + return { state, references: [] }; +}); + +embeddablePersistableStateServiceMock.inject.mockImplementation((state, references) => { + if (state.type === 'dashboard') { + return dashboardInject(state, references); + } + + return state; +}); const deps: InjectDeps & ExtractDeps = { embeddablePersistableStateService: embeddablePersistableStateServiceMock, }; -describe('extractReferences', () => { +describe('legacy extract references', () => { test('extracts references from panelsJSON', () => { const doc = { id: '1', @@ -30,13 +50,13 @@ describe('extractReferences', () => { type: 'visualization', id: '1', title: 'Title 1', - version: '7.9.1', + version: '7.0.0', }, { type: 'visualization', id: '2', title: 'Title 2', - version: '7.9.1', + version: '7.0.0', }, ]), }, @@ -48,7 +68,7 @@ describe('extractReferences', () => { Object { "attributes": Object { "foo": true, - "panelsJSON": "[{\\"version\\":\\"7.9.1\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"panelRefName\\":\\"panel_0\\"},{\\"version\\":\\"7.9.1\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\",\\"panelRefName\\":\\"panel_1\\"}]", + "panelsJSON": "[{\\"title\\":\\"Title 1\\",\\"version\\":\\"7.0.0\\",\\"panelRefName\\":\\"panel_0\\"},{\\"title\\":\\"Title 2\\",\\"version\\":\\"7.0.0\\",\\"panelRefName\\":\\"panel_1\\"}]", }, "references": Array [ Object { @@ -75,7 +95,7 @@ describe('extractReferences', () => { { id: '1', title: 'Title 1', - version: '7.9.1', + version: '7.0.0', }, ]), }, @@ -186,6 +206,102 @@ describe('extractReferences', () => { }); }); +describe('extractReferences', () => { + test('extracts references from panelsJSON', () => { + const doc = { + id: '1', + attributes: { + foo: true, + panelsJSON: JSON.stringify([ + { + panelIndex: 'panel-1', + type: 'visualization', + id: '1', + title: 'Title 1', + version: '7.9.1', + }, + { + panelIndex: 'panel-2', + type: 'visualization', + id: '2', + title: 'Title 2', + version: '7.9.1', + }, + ]), + }, + references: [], + }; + const updatedDoc = extractReferences(doc, deps); + + expect(updatedDoc).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "foo": true, + "panelsJSON": "[{\\"version\\":\\"7.9.1\\",\\"type\\":\\"visualization\\",\\"panelIndex\\":\\"panel-1\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"panelRefName\\":\\"panel_panel-1\\"},{\\"version\\":\\"7.9.1\\",\\"type\\":\\"visualization\\",\\"panelIndex\\":\\"panel-2\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\",\\"panelRefName\\":\\"panel_panel-2\\"}]", + }, + "references": Array [ + Object { + "id": "1", + "name": "panel-1:panel_panel-1", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel-2:panel_panel-2", + "type": "visualization", + }, + ], + } + `); + }); + + test('fails when "type" attribute is missing from a panel', () => { + const doc = { + id: '1', + attributes: { + foo: true, + panelsJSON: JSON.stringify([ + { + id: '1', + title: 'Title 1', + version: '7.9.1', + }, + ]), + }, + references: [], + }; + expect(() => extractReferences(doc, deps)).toThrowErrorMatchingInlineSnapshot( + `"\\"type\\" attribute is missing from panel \\"0\\""` + ); + }); + + test('passes when "id" attribute is missing from a panel', () => { + const doc = { + id: '1', + attributes: { + foo: true, + panelsJSON: JSON.stringify([ + { + type: 'visualization', + title: 'Title 1', + version: '7.9.1', + }, + ]), + }, + references: [], + }; + expect(extractReferences(doc, deps)).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "foo": true, + "panelsJSON": "[{\\"version\\":\\"7.9.1\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\"}]", + }, + "references": Array [], + } + `); + }); +}); + describe('injectReferences', () => { test('returns injected attributes', () => { const attributes = { @@ -195,10 +311,12 @@ describe('injectReferences', () => { { panelRefName: 'panel_0', title: 'Title 1', + version: '7.9.0', }, { panelRefName: 'panel_1', title: 'Title 2', + version: '7.9.0', }, ]), }; @@ -219,7 +337,7 @@ describe('injectReferences', () => { expect(newAttributes).toMatchInlineSnapshot(` Object { "id": "1", - "panelsJSON": "[{\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\"},{\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\",\\"id\\":\\"2\\"}]", + "panelsJSON": "[{\\"version\\":\\"7.9.0\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\"},{\\"version\\":\\"7.9.0\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\",\\"id\\":\\"2\\"}]", "title": "test", } `); @@ -280,7 +398,7 @@ describe('injectReferences', () => { expect(newAttributes).toMatchInlineSnapshot(` Object { "id": "1", - "panelsJSON": "[{\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\"},{\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\"}]", + "panelsJSON": "[{\\"version\\":\\"\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\"},{\\"version\\":\\"\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\"}]", "title": "test", } `); diff --git a/src/plugins/dashboard/common/saved_dashboard_references.ts b/src/plugins/dashboard/common/saved_dashboard_references.ts index f1fea99057f83..16ab470ce7d6f 100644 --- a/src/plugins/dashboard/common/saved_dashboard_references.ts +++ b/src/plugins/dashboard/common/saved_dashboard_references.ts @@ -8,22 +8,71 @@ import semverSatisfies from 'semver/functions/satisfies'; import { SavedObjectAttributes, SavedObjectReference } from '../../../core/types'; -import { - extractPanelsReferences, - injectPanelsReferences, -} from './embeddable/embeddable_references'; -import { SavedDashboardPanel730ToLatest } from './types'; +import { DashboardContainerStateWithType, DashboardPanelState } from './types'; import { EmbeddablePersistableStateService } from '../../embeddable/common/types'; - +import { + convertPanelStateToSavedDashboardPanel, + convertSavedDashboardPanelToPanelState, +} from './embeddable/embeddable_saved_object_converters'; +import { SavedDashboardPanel } from './types'; export interface ExtractDeps { embeddablePersistableStateService: EmbeddablePersistableStateService; } - export interface SavedObjectAttributesAndReferences { attributes: SavedObjectAttributes; references: SavedObjectReference[]; } +const isPre730Panel = (panel: Record): boolean => { + return 'version' in panel ? semverSatisfies(panel.version, '<7.3') : true; +}; + +function dashboardAttributesToState( + attributes: SavedObjectAttributes +): { + state: DashboardContainerStateWithType; + panels: SavedDashboardPanel[]; +} { + let inputPanels = [] as SavedDashboardPanel[]; + if (typeof attributes.panelsJSON === 'string') { + inputPanels = JSON.parse(attributes.panelsJSON) as SavedDashboardPanel[]; + } + + return { + panels: inputPanels, + state: { + id: attributes.id as string, + type: 'dashboard', + panels: inputPanels.reduce>((current, panel, index) => { + const panelIndex = panel.panelIndex || `${index}`; + current[panelIndex] = convertSavedDashboardPanelToPanelState(panel); + return current; + }, {}), + }, + }; +} + +function panelStatesToPanels( + panelStates: DashboardContainerStateWithType['panels'], + originalPanels: SavedDashboardPanel[] +): SavedDashboardPanel[] { + return Object.entries(panelStates).map(([id, panelState]) => { + // Find matching original panel to get the version + let originalPanel = originalPanels.find((p) => p.panelIndex === id); + + if (!originalPanel) { + // Maybe original panel doesn't have a panel index and it's just straight up based on it's index + const numericId = parseInt(id, 10); + originalPanel = isNaN(numericId) ? originalPanel : originalPanels[numericId]; + } + + return convertPanelStateToSavedDashboardPanel( + panelState, + originalPanel?.version ? originalPanel.version : '' + ); + }); +} + export function extractReferences( { attributes, references = [] }: SavedObjectAttributesAndReferences, deps: ExtractDeps @@ -31,64 +80,33 @@ export function extractReferences( if (typeof attributes.panelsJSON !== 'string') { return { attributes, references }; } - const panelReferences: SavedObjectReference[] = []; - let panels: Array> = JSON.parse(String(attributes.panelsJSON)); - const isPre730Panel = (panel: Record): boolean => { - return 'version' in panel ? semverSatisfies(panel.version, '<7.3') : true; - }; + const { panels, state } = dashboardAttributesToState(attributes); - const hasPre730Panel = panels.some(isPre730Panel); - - /** - * `extractPanelsReferences` only knows how to reliably handle "latest" panels - * It is possible that `extractReferences` is run on older dashboard SO with older panels, - * for example, when importing a saved object using saved object UI `extractReferences` is called BEFORE any server side migrations are run. - * - * In this case we skip running `extractPanelsReferences` on such object. - * We also know that there is nothing to extract - * (First possible entity to be extracted by this mechanism is a dashboard drilldown since 7.11) - */ - if (!hasPre730Panel) { - const extractedReferencesResult = extractPanelsReferences( - // it is ~safe~ to cast to `SavedDashboardPanel730ToLatest` because above we've checked that there are only >=7.3 panels - (panels as unknown) as SavedDashboardPanel730ToLatest[], - deps - ); + if (((panels as unknown) as Array>).some(isPre730Panel)) { + return pre730ExtractReferences({ attributes, references }, deps); + } - panels = (extractedReferencesResult.map((res) => res.panel) as unknown) as Array< - Record - >; - extractedReferencesResult.forEach((res) => { - panelReferences.push(...res.references); - }); + const missingTypeIndex = panels.findIndex((panel) => panel.type === undefined); + if (missingTypeIndex >= 0) { + throw new Error(`"type" attribute is missing from panel "${missingTypeIndex}"`); } - // TODO: This extraction should be done by EmbeddablePersistableStateService - // https://github.com/elastic/kibana/issues/82830 - panels.forEach((panel, i) => { - if (!panel.type) { - throw new Error(`"type" attribute is missing from panel "${i}"`); - } - if (!panel.id) { - // Embeddables are not required to be backed off a saved object. - return; - } - panel.panelRefName = `panel_${i}`; - panelReferences.push({ - name: `panel_${i}`, - type: panel.type, - id: panel.id, - }); - delete panel.type; - delete panel.id; - }); + const { + state: extractedState, + references: extractedReferences, + } = deps.embeddablePersistableStateService.extract(state); + + const extractedPanels = panelStatesToPanels( + (extractedState as DashboardContainerStateWithType).panels, + panels + ); return { - references: [...references, ...panelReferences], + references: [...references, ...extractedReferences], attributes: { ...attributes, - panelsJSON: JSON.stringify(panels), + panelsJSON: JSON.stringify(extractedPanels), }, }; } @@ -107,33 +125,60 @@ export function injectReferences( if (typeof attributes.panelsJSON !== 'string') { return attributes; } - let panels = JSON.parse(attributes.panelsJSON); + const parsedPanels = JSON.parse(attributes.panelsJSON); // Same here, prevent failing saved object import if ever panels aren't an array. - if (!Array.isArray(panels)) { + if (!Array.isArray(parsedPanels)) { return attributes; } - // TODO: This injection should be done by EmbeddablePersistableStateService - // https://github.com/elastic/kibana/issues/82830 - panels.forEach((panel) => { - if (!panel.panelRefName) { - return; + const { panels, state } = dashboardAttributesToState(attributes); + + const injectedState = deps.embeddablePersistableStateService.inject(state, references); + const injectedPanels = panelStatesToPanels( + (injectedState as DashboardContainerStateWithType).panels, + panels + ); + + return { + ...attributes, + panelsJSON: JSON.stringify(injectedPanels), + }; +} + +function pre730ExtractReferences( + { attributes, references = [] }: SavedObjectAttributesAndReferences, + deps: ExtractDeps +): SavedObjectAttributesAndReferences { + if (typeof attributes.panelsJSON !== 'string') { + return { attributes, references }; + } + const panelReferences: SavedObjectReference[] = []; + const panels: Array> = JSON.parse(String(attributes.panelsJSON)); + + panels.forEach((panel, i) => { + if (!panel.type) { + throw new Error(`"type" attribute is missing from panel "${i}"`); } - const reference = references.find((ref) => ref.name === panel.panelRefName); - if (!reference) { - // Throw an error since "panelRefName" means the reference exists within - // "references" and in this scenario we have bad data. - throw new Error(`Could not find reference "${panel.panelRefName}"`); + if (!panel.id) { + // Embeddables are not required to be backed off a saved object. + return; } - panel.id = reference.id; - panel.type = reference.type; - delete panel.panelRefName; - }); - panels = injectPanelsReferences(panels, references, deps); + panel.panelRefName = `panel_${i}`; + panelReferences.push({ + name: `panel_${i}`, + type: panel.type, + id: panel.id, + }); + delete panel.type; + delete panel.id; + }); return { - ...attributes, - panelsJSON: JSON.stringify(panels), + references: [...references, ...panelReferences], + attributes: { + ...attributes, + panelsJSON: JSON.stringify(panels), + }, }; } diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index c8ef3c81662c7..9a6d185ef2ac1 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -6,7 +6,11 @@ * Side Public License, v 1. */ -import { EmbeddableInput, PanelState } from '../../../../src/plugins/embeddable/common/types'; +import { + EmbeddableInput, + EmbeddableStateWithType, + PanelState, +} from '../../../../src/plugins/embeddable/common/types'; import { SavedObjectEmbeddableInput } from '../../../../src/plugins/embeddable/common/lib/saved_object_embeddable'; import { RawSavedDashboardPanelTo60, @@ -25,6 +29,7 @@ export interface DashboardPanelState< TEmbeddableInput extends EmbeddableInput | SavedObjectEmbeddableInput = SavedObjectEmbeddableInput > extends PanelState { readonly gridData: GridData; + panelRefName?: string; } /** @@ -80,3 +85,11 @@ export type SavedDashboardPanel730ToLatest = Pick< readonly id?: string; readonly type: string; }; + +// Making this interface because so much of the Container type from embeddable is tied up in public +// Once that is all available from common, we should be able to move the dashboard_container type to our common as well +export interface DashboardContainerStateWithType extends EmbeddableStateWithType { + panels: { + [panelId: string]: DashboardPanelState; + }; +} diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx index 6501f92689d17..9b93f0bbd0711 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx @@ -7,6 +7,7 @@ */ import { i18n } from '@kbn/i18n'; +import { EmbeddablePersistableStateService } from 'src/plugins/embeddable/common'; import { Container, ErrorEmbeddable, @@ -20,6 +21,10 @@ import { DashboardContainerServices, } from './dashboard_container'; import { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants'; +import { + createExtract, + createInject, +} from '../../../common/embeddable/dashboard_container_persistable_state'; export type DashboardContainerFactory = EmbeddableFactory< DashboardContainerInput, @@ -32,7 +37,10 @@ export class DashboardContainerFactoryDefinition public readonly isContainerType = true; public readonly type = DASHBOARD_CONTAINER_TYPE; - constructor(private readonly getStartServices: () => Promise) {} + constructor( + private readonly getStartServices: () => Promise, + private readonly persistableStateService: EmbeddablePersistableStateService + ) {} public isEditable = async () => { // Currently unused for dashboards @@ -62,4 +70,8 @@ export class DashboardContainerFactoryDefinition const services = await this.getStartServices(); return new DashboardContainer(initialInput, services, parent); }; + + public inject = createInject(this.persistableStateService); + + public extract = createExtract(this.persistableStateService); } diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 5bf730996ab4f..e2f52a47455b3 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -121,9 +121,11 @@ export type DashboardSetup = void; export interface DashboardStart { getSavedDashboardLoader: () => SavedObjectLoader; + getDashboardContainerByValueRenderer: () => ReturnType< + typeof createDashboardContainerByValueRenderer + >; dashboardUrlGenerator?: DashboardUrlGenerator; dashboardFeatureFlagConfig: DashboardFeatureFlagConfig; - DashboardContainerByValueRenderer: ReturnType; } export class DashboardPlugin @@ -260,8 +262,16 @@ export class DashboardPlugin }, }); - const dashboardContainerFactory = new DashboardContainerFactoryDefinition(getStartServices); - embeddable.registerEmbeddableFactory(dashboardContainerFactory.type, dashboardContainerFactory); + getStartServices().then((coreStart) => { + const dashboardContainerFactory = new DashboardContainerFactoryDefinition( + getStartServices, + coreStart.embeddable + ); + embeddable.registerEmbeddableFactory( + dashboardContainerFactory.type, + dashboardContainerFactory + ); + }); const placeholderFactory = new PlaceholderEmbeddableFactory(); embeddable.registerEmbeddableFactory(placeholderFactory.type, placeholderFactory); @@ -403,17 +413,24 @@ export class DashboardPlugin savedObjects: plugins.savedObjects, embeddableStart: plugins.embeddable, }); - const dashboardContainerFactory = plugins.embeddable.getEmbeddableFactory( - DASHBOARD_CONTAINER_TYPE - )! as DashboardContainerFactory; return { getSavedDashboardLoader: () => savedDashboardLoader, + getDashboardContainerByValueRenderer: () => { + const dashboardContainerFactory = plugins.embeddable.getEmbeddableFactory( + DASHBOARD_CONTAINER_TYPE + ); + + if (!dashboardContainerFactory) { + throw new Error(`${DASHBOARD_CONTAINER_TYPE} Embeddable Factory not found`); + } + + return createDashboardContainerByValueRenderer({ + factory: dashboardContainerFactory as DashboardContainerFactory, + }); + }, dashboardUrlGenerator: this.dashboardUrlGenerator, dashboardFeatureFlagConfig: this.dashboardFeatureFlagConfig!, - DashboardContainerByValueRenderer: createDashboardContainerByValueRenderer({ - factory: dashboardContainerFactory, - }), }; } diff --git a/src/plugins/dashboard/server/embeddable/dashboard_container_embeddable_factory.ts b/src/plugins/dashboard/server/embeddable/dashboard_container_embeddable_factory.ts new file mode 100644 index 0000000000000..995731341739a --- /dev/null +++ b/src/plugins/dashboard/server/embeddable/dashboard_container_embeddable_factory.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EmbeddablePersistableStateService } from 'src/plugins/embeddable/common'; +import { EmbeddableRegistryDefinition } from '../../../embeddable/server'; +import { + createExtract, + createInject, +} from '../../common/embeddable/dashboard_container_persistable_state'; + +export const dashboardPersistableStateServiceFactory = ( + persistableStateService: EmbeddablePersistableStateService +): EmbeddableRegistryDefinition => { + return { + id: 'dashboard', + extract: createExtract(persistableStateService), + inject: createInject(persistableStateService), + }; +}; diff --git a/src/plugins/dashboard/server/plugin.ts b/src/plugins/dashboard/server/plugin.ts index 020ecfeaa9239..3aeaf31c190bd 100644 --- a/src/plugins/dashboard/server/plugin.ts +++ b/src/plugins/dashboard/server/plugin.ts @@ -18,24 +18,29 @@ import { createDashboardSavedObjectType } from './saved_objects'; import { capabilitiesProvider } from './capabilities_provider'; import { DashboardPluginSetup, DashboardPluginStart } from './types'; -import { EmbeddableSetup } from '../../embeddable/server'; +import { EmbeddableSetup, EmbeddableStart } from '../../embeddable/server'; import { UsageCollectionSetup } from '../../usage_collection/server'; import { registerDashboardUsageCollector } from './usage/register_collector'; +import { dashboardPersistableStateServiceFactory } from './embeddable/dashboard_container_embeddable_factory'; interface SetupDeps { embeddable: EmbeddableSetup; usageCollection: UsageCollectionSetup; } +interface StartDeps { + embeddable: EmbeddableStart; +} + export class DashboardPlugin - implements Plugin { + implements Plugin { private readonly logger: Logger; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); } - public setup(core: CoreSetup, plugins: SetupDeps) { + public setup(core: CoreSetup, plugins: SetupDeps) { this.logger.debug('dashboard: Setup'); core.savedObjects.registerType( @@ -48,6 +53,15 @@ export class DashboardPlugin core.capabilities.registerProvider(capabilitiesProvider); registerDashboardUsageCollector(plugins.usageCollection, plugins.embeddable); + + (async () => { + const [, startPlugins] = await core.getStartServices(); + + plugins.embeddable.registerEmbeddableFactory( + dashboardPersistableStateServiceFactory(startPlugins.embeddable) + ); + })(); + return {}; } diff --git a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts index e2949847bc926..9671a8d847c0a 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts @@ -6,13 +6,39 @@ * Side Public License, v 1. */ -import { SavedObjectUnsanitizedDoc } from 'kibana/server'; +import { SavedObjectReference, SavedObjectUnsanitizedDoc } from 'kibana/server'; import { savedObjectsServiceMock } from '../../../../core/server/mocks'; import { createEmbeddableSetupMock } from '../../../embeddable/server/mocks'; import { createDashboardSavedObjectTypeMigrations } from './dashboard_migrations'; import { DashboardDoc730ToLatest } from '../../common'; +import { + createExtract, + createInject, +} from '../../common/embeddable/dashboard_container_persistable_state'; +import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; const embeddableSetupMock = createEmbeddableSetupMock(); +const extract = createExtract(embeddableSetupMock); +const inject = createInject(embeddableSetupMock); +const extractImplementation = (state: EmbeddableStateWithType) => { + if (state.type === 'dashboard') { + return extract(state); + } + return { state, references: [] }; +}; +const injectImplementation = ( + state: EmbeddableStateWithType, + references: SavedObjectReference[] +) => { + if (state.type === 'dashboard') { + return inject(state, references); + } + + return state; +}; +embeddableSetupMock.extract.mockImplementation(extractImplementation); +embeddableSetupMock.inject.mockImplementation(injectImplementation); + const migrations = createDashboardSavedObjectTypeMigrations({ embeddable: embeddableSetupMock, }); @@ -25,10 +51,10 @@ describe('dashboard', () => { test('skips error on empty object', () => { expect(migration({} as SavedObjectUnsanitizedDoc, contextMock)).toMatchInlineSnapshot(` -Object { - "references": Array [], -} -`); + Object { + "references": Array [], + } + `); }); test('skips errors when searchSourceJSON is null', () => { @@ -45,29 +71,29 @@ Object { }; const migratedDoc = migration(doc, contextMock); expect(migratedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "kibanaSavedObjectMeta": Object { - "searchSourceJSON": null, - }, - "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", - }, - "id": "1", - "references": Array [ - Object { - "id": "1", - "name": "panel_0", - "type": "visualization", - }, - Object { - "id": "2", - "name": "panel_1", - "type": "visualization", - }, - ], - "type": "dashboard", -} -`); + Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": null, + }, + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", + } + `); }); test('skips errors when searchSourceJSON is undefined', () => { @@ -84,29 +110,29 @@ Object { }; const migratedDoc = migration(doc, contextMock); expect(migratedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "kibanaSavedObjectMeta": Object { - "searchSourceJSON": undefined, - }, - "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", - }, - "id": "1", - "references": Array [ - Object { - "id": "1", - "name": "panel_0", - "type": "visualization", - }, - Object { - "id": "2", - "name": "panel_1", - "type": "visualization", - }, - ], - "type": "dashboard", -} -`); + Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": undefined, + }, + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", + } + `); }); test('skips error when searchSourceJSON is not a string', () => { @@ -122,29 +148,29 @@ Object { }, }; expect(migration(doc, contextMock)).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "kibanaSavedObjectMeta": Object { - "searchSourceJSON": 123, - }, - "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", - }, - "id": "1", - "references": Array [ - Object { - "id": "1", - "name": "panel_0", - "type": "visualization", - }, - Object { - "id": "2", - "name": "panel_1", - "type": "visualization", - }, - ], - "type": "dashboard", -} -`); + Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": 123, + }, + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", + } + `); }); test('skips error when searchSourceJSON is invalid json', () => { @@ -160,29 +186,29 @@ Object { }, }; expect(migration(doc, contextMock)).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "{abc123}", - }, - "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", - }, - "id": "1", - "references": Array [ - Object { - "id": "1", - "name": "panel_0", - "type": "visualization", - }, - Object { - "id": "2", - "name": "panel_1", - "type": "visualization", - }, - ], - "type": "dashboard", -} -`); + Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{abc123}", + }, + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", + } + `); }); test('skips error when "index" and "filter" is missing from searchSourceJSON', () => { @@ -199,29 +225,29 @@ Object { }; const migratedDoc = migration(doc, contextMock); expect(migratedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "{\\"bar\\":true}", - }, - "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", - }, - "id": "1", - "references": Array [ - Object { - "id": "1", - "name": "panel_0", - "type": "visualization", - }, - Object { - "id": "2", - "name": "panel_1", - "type": "visualization", - }, - ], - "type": "dashboard", -} -`); + Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"bar\\":true}", + }, + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", + } + `); }); test('extracts "index" attribute from doc', () => { @@ -238,34 +264,34 @@ Object { }; const migratedDoc = migration(doc, contextMock); expect(migratedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "{\\"bar\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}", - }, - "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", - }, - "id": "1", - "references": Array [ - Object { - "id": "pattern*", - "name": "kibanaSavedObjectMeta.searchSourceJSON.index", - "type": "index-pattern", - }, - Object { - "id": "1", - "name": "panel_0", - "type": "visualization", - }, - Object { - "id": "2", - "name": "panel_1", - "type": "visualization", - }, - ], - "type": "dashboard", -} -`); + Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"bar\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}", + }, + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "pattern*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern", + }, + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", + } + `); }); test('extracts index patterns from filter', () => { @@ -293,34 +319,34 @@ Object { const migratedDoc = migration(doc, contextMock); expect(migratedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "{\\"bar\\":true,\\"filter\\":[{\\"meta\\":{\\"foo\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\\"}}]}", - }, - "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", - }, - "id": "1", - "references": Array [ - Object { - "id": "my-index", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern", - }, - Object { - "id": "1", - "name": "panel_0", - "type": "visualization", - }, - Object { - "id": "2", - "name": "panel_1", - "type": "visualization", - }, - ], - "type": "dashboard", -} -`); + Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"bar\\":true,\\"filter\\":[{\\"meta\\":{\\"foo\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\\"}}]}", + }, + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "my-index", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern", + }, + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", + } + `); }); test('skips error when panelsJSON is not a string', () => { @@ -331,14 +357,14 @@ Object { }, } as SavedObjectUnsanitizedDoc; expect(migration(doc, contextMock)).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "panelsJSON": 123, - }, - "id": "1", - "references": Array [], -} -`); + Object { + "attributes": Object { + "panelsJSON": 123, + }, + "id": "1", + "references": Array [], + } + `); }); test('skips error when panelsJSON is not valid JSON', () => { @@ -349,14 +375,14 @@ Object { }, } as SavedObjectUnsanitizedDoc; expect(migration(doc, contextMock)).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "panelsJSON": "{123abc}", - }, - "id": "1", - "references": Array [], -} -`); + Object { + "attributes": Object { + "panelsJSON": "{123abc}", + }, + "id": "1", + "references": Array [], + } + `); }); test('skips panelsJSON when its not an array', () => { @@ -367,14 +393,14 @@ Object { }, } as SavedObjectUnsanitizedDoc; expect(migration(doc, contextMock)).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "panelsJSON": "{}", - }, - "id": "1", - "references": Array [], -} -`); + Object { + "attributes": Object { + "panelsJSON": "{}", + }, + "id": "1", + "references": Array [], + } + `); }); test('skips error when a panel is missing "type" attribute', () => { @@ -385,14 +411,14 @@ Object { }, } as SavedObjectUnsanitizedDoc; expect(migration(doc, contextMock)).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "panelsJSON": "[{\\"id\\":\\"123\\"}]", - }, - "id": "1", - "references": Array [], -} -`); + Object { + "attributes": Object { + "panelsJSON": "[{\\"id\\":\\"123\\"}]", + }, + "id": "1", + "references": Array [], + } + `); }); test('skips error when a panel is missing "id" attribute', () => { @@ -403,14 +429,14 @@ Object { }, } as SavedObjectUnsanitizedDoc; expect(migration(doc, contextMock)).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "panelsJSON": "[{\\"type\\":\\"visualization\\"}]", - }, - "id": "1", - "references": Array [], -} -`); + Object { + "attributes": Object { + "panelsJSON": "[{\\"type\\":\\"visualization\\"}]", + }, + "id": "1", + "references": Array [], + } + `); }); test('extract panel references from doc', () => { @@ -423,25 +449,25 @@ Object { } as SavedObjectUnsanitizedDoc; const migratedDoc = migration(doc, contextMock); expect(migratedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", - }, - "id": "1", - "references": Array [ - Object { - "id": "1", - "name": "panel_0", - "type": "visualization", - }, - Object { - "id": "2", - "name": "panel_1", - "type": "visualization", - }, - ], -} -`); + Object { + "attributes": Object { + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + } + `); }); }); @@ -475,19 +501,57 @@ Object { test('should migrate 7.3.0 doc without embeddable state to extract', () => { const newDoc = migration(doc, contextMock); - expect(newDoc).toEqual(doc); + expect(newDoc).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "description": "", + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"query\\":{\\"language\\":\\"kuery\\",\\"query\\":\\"\\"},\\"filter\\":[{\\"query\\":{\\"match_phrase\\":{\\"machine.os.keyword\\":\\"osx\\"}},\\"$state\\":{\\"store\\":\\"appState\\"},\\"meta\\":{\\"type\\":\\"phrase\\",\\"key\\":\\"machine.os.keyword\\",\\"params\\":{\\"query\\":\\"osx\\"},\\"disabled\\":false,\\"negate\\":false,\\"alias\\":null,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\\"}}]}", + }, + "optionsJSON": "{\\"useMargins\\":true,\\"hidePanelTitles\\":false}", + "panelsJSON": "[{\\"version\\":\\"7.9.3\\",\\"type\\":\\"visualization\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\"},\\"panelIndex\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\",\\"embeddableConfig\\":{\\"enhancements\\":{\\"dynamicActions\\":{\\"events\\":[]}}},\\"panelRefName\\":\\"panel_82fa0882-9f9e-476a-bbb9-03555e5ced91\\"}]", + "timeRestore": false, + "title": "Dashboard A", + "version": 1, + }, + "id": "376e6260-1f5e-11eb-91aa-7b6d5f8a61d6", + "references": Array [ + Object { + "id": "90943e30-9a47-11e8-b64d-95841ca0b247", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern", + }, + Object { + "id": "14e2e710-4258-11e8-b3aa-73fdaf54bfc9", + "name": "82fa0882-9f9e-476a-bbb9-03555e5ced91:panel_82fa0882-9f9e-476a-bbb9-03555e5ced91", + "type": "visualization", + }, + ], + "type": "dashboard", + } + `); }); test('should migrate 7.3.0 doc and extract embeddable state', () => { - embeddableSetupMock.extract.mockImplementationOnce((state) => ({ - state: { ...state, __extracted: true }, - references: [{ id: '__new', name: '__newRefName', type: '__newType' }], - })); + embeddableSetupMock.extract.mockImplementation((state) => { + const stateAndReferences = extractImplementation(state); + const { references } = stateAndReferences; + let { state: newState } = stateAndReferences; + + if (state.enhancements !== undefined && Object.keys(state.enhancements).length !== 0) { + newState = { ...state, __extracted: true } as any; + references.push({ id: '__new', name: '__newRefName', type: '__newType' }); + } + + return { state: newState, references }; + }); const newDoc = migration(doc, contextMock); expect(newDoc).not.toEqual(doc); expect(newDoc.references).toHaveLength(doc.references.length + 1); expect(JSON.parse(newDoc.attributes.panelsJSON)[0].embeddableConfig.__extracted).toBe(true); + + embeddableSetupMock.extract.mockImplementation(extractImplementation); }); }); }); diff --git a/src/plugins/embeddable/server/index.ts b/src/plugins/embeddable/server/index.ts index 33eaaca9dd69b..aac081f9467b6 100644 --- a/src/plugins/embeddable/server/index.ts +++ b/src/plugins/embeddable/server/index.ts @@ -6,9 +6,9 @@ * Side Public License, v 1. */ -import { EmbeddableServerPlugin, EmbeddableSetup } from './plugin'; +import { EmbeddableServerPlugin, EmbeddableSetup, EmbeddableStart } from './plugin'; -export { EmbeddableSetup }; +export { EmbeddableSetup, EmbeddableStart }; export { EnhancementRegistryDefinition, EmbeddableRegistryDefinition } from './types'; diff --git a/src/plugins/embeddable/server/server.api.md b/src/plugins/embeddable/server/server.api.md index d3921ab11457c..5c7efec57e93b 100644 --- a/src/plugins/embeddable/server/server.api.md +++ b/src/plugins/embeddable/server/server.api.md @@ -29,6 +29,11 @@ export interface EmbeddableSetup extends PersistableStateService void; } +// Warning: (ae-missing-release-tag) "EmbeddableStart" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type EmbeddableStart = PersistableStateService; + // Warning: (ae-forgotten-export) The symbol "SerializableState" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "EnhancementRegistryDefinition" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/test/api_integration/apis/saved_objects/export.ts b/test/api_integration/apis/saved_objects/export.ts index 87cdf5a8b0c46..c02ce76340da8 100644 --- a/test/api_integration/apis/saved_objects/export.ts +++ b/test/api_integration/apis/saved_objects/export.ts @@ -324,7 +324,7 @@ export default function ({ getService }: FtrProviderContext) { references: [ { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - name: 'panel_0', + name: '1:panel_1', type: 'visualization', }, ], @@ -384,7 +384,7 @@ export default function ({ getService }: FtrProviderContext) { references: [ { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - name: 'panel_0', + name: '1:panel_1', type: 'visualization', }, ], @@ -449,7 +449,7 @@ export default function ({ getService }: FtrProviderContext) { references: [ { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - name: 'panel_0', + name: '1:panel_1', type: 'visualization', }, ], From 7e2ffc054e532fcbd47029fdc4eed78cf6650269 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Mon, 12 Apr 2021 12:27:56 -0400 Subject: [PATCH 05/28] RFC: Object level security, Phase 1 (#93115) Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> --- rfcs/images/ols_phase_1_auth.png | Bin 0 -> 252971 bytes rfcs/text/0016_ols_phase_1.md | 323 +++++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 rfcs/images/ols_phase_1_auth.png create mode 100644 rfcs/text/0016_ols_phase_1.md diff --git a/rfcs/images/ols_phase_1_auth.png b/rfcs/images/ols_phase_1_auth.png new file mode 100644 index 0000000000000000000000000000000000000000..5bf4b210bee9e9fd82155c006835a3076c8086f4 GIT binary patch literal 252971 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

6&A+HTYFO>}NP<9^HjISMlTrM7 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 E8+Y-c-T(jq literal 0 HcmV?d00001 diff --git a/rfcs/text/0016_ols_phase_1.md b/rfcs/text/0016_ols_phase_1.md new file mode 100644 index 0000000000000..c1f65111df328 --- /dev/null +++ b/rfcs/text/0016_ols_phase_1.md @@ -0,0 +1,323 @@ +- Start Date: 2020-03-01 +- RFC PR: (leave this empty) +- Kibana Issue: (leave this empty) + +--- +- [1. Summary](#1-summary) +- [2. Motivation](#2-motivation) +- [3. Detailed design](#3-detailed-design) +- [4. Drawbacks](#4-drawbacks) +- [5. Alternatives](#5-alternatives) +- [6. Adoption strategy](#6-adoption-strategy) +- [7. How we teach this](#7-how-we-teach-this) +- [8. Unresolved questions](#8-unresolved-questions) + +# 1. Summary + +Object-level security ("OLS") authorizes Saved Object CRUD operations on a per-object basis. +This RFC focuses on the [phase 1](https://github.com/elastic/kibana/issues/82725), which introduces "private" saved object types. These private types +are owned by individual users, and are _generally_ only accessible by their owners. + +This RFC does not address any [followup phases](https://github.com/elastic/kibana/issues/39259), which may support sharing, and ownership of "public" objects. + +# 2. Motivation + +OLS allows saved objects to be owned by individual users. This allows Kibana to store information that is specific +to each user, which enables further customization and collaboration throughout our solutions. + +The most immediate feature this unlocks is [User settings and preferences (#17888)](https://github.com/elastic/kibana/issues/17888), +which is a very popular and long-standing request. + +# 3. Detailed design + +Phase 1 of OLS allows consumers to register "private" saved object types. +These saved objects are owned by individual end users, and are subject to additional security controls. + +Public (non-private) saved object types are not impacted by this RFC. This proposal does not allow types to transition to/from `public`/`private`, and is considered out of scope for phase 1. + +## 3.1 Saved Objects Service + +### 3.1.1 Type registry +The [saved objects type registry](https://github.com/elastic/kibana/blob/701697cc4a34d07c0508c3bdf01dca6f9d40a636/src/core/server/saved_objects/saved_objects_type_registry.ts) will allow consumers to register "private" saved object types via a new `accessClassification` property: + +```ts +/** + * The accessClassification dictates the protection level of the saved object: + * * public (default): instances of this saved object type will be accessible to all users within the given namespace, who are authorized to act on objects of this type. + * * private: instances of this saved object type will belong to the user who created them, and will not be accessible by other users, except for administrators. + */ +export type SavedObjectsAccessClassification = 'public' | 'private'; + +// Note: some existing properties have been omitted for brevity. +export interface SavedObjectsType { + name: string; + hidden: boolean; + namespaceType: SavedObjectsNamespaceType; + mappings: SavedObjectsTypeMappingDefinition; + + /** + * The {@link SavedObjectsAccessClassification | accessClassification} for the type. + */ + accessClassification?: SavedObjectsAccessClassification; +} + +// Example consumer +class MyPlugin { + setup(core: CoreSetup) { + core.savedObjects.registerType({ + name: 'user-settings', + accessClassification: 'private', + namespaceType: 'single', + hidden: false, + mappings, + }) + } +} +``` + +### 3.1.2 Schema +Saved object ownership will be recorded as metadata within each `private` saved object. We do so by adding a top-level `accessControl` object with a singular `owner` property. See [unresolved question 1](#81-accessControl.owner) for details on the `owner` property. + +```ts +/** + * Describes which users should be authorized to access this SavedObject. + * + * @public + */ +export interface SavedObjectAccessControl { + /** The owner of this SavedObject. */ + owner: string; +} + +// Note: some existing fields have been omitted for brevity +export interface SavedObject { + id: string; + type: string; + attributes: T; + references: SavedObjectReference[]; + namespaces?: string[]; + /** Describes which users should be authorized to access this SavedObject. */ + accessControl?: SavedObjectAccessControl; +} +``` + +### 3.1.3 Saved Objects Client: Security wrapper + +The [security wrapper](https://github.com/elastic/kibana/blob/701697cc4a34d07c0508c3bdf01dca6f9d40a636/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts) authorizes and audits operations against saved objects. + +There are two primary changes to this wrapper: + +#### Attaching Access Controls + +This wrapper will be responsible for attaching an access control specification to all private objects before they are created in Elasticsearch. +It will also allow users to provide their own access control specification in order to support the import/create use cases. + +Similar to the way we treat `namespaces`, it will not be possible to change an access control specification via the `update`/`bulk_update` functions in this first phase. We may consider adding a dedicated function to update the access control specification, similar to what we've done for sharing to spaces. + +#### Authorization changes + +This wrapper will be updated to ensure that access to private objects is only granted to authorized users. A user is authorized to operate on a private saved object if **all of the following** are true: +Step 1) The user is authorized to perform the operation on saved objects of the requested type, within the requested space. (Example: `update` a `user-settings` saved object in the `marketing` space) +Step 2) The user is authorized to access this specific instance of the saved object, as described by that object's access control specification. For this first phase, the `accessControl.owner` is allowed to perform all operations. The only other users who are allowed to access this object are administrators (see [resolved question 2](#92-authorization-for-private-objects)) + +Step 1 of this authorization check is the same check we perform today for all existing saved object types. Step 2 is a new authorization check, and **introduces additional overhead and complexity**. We explore the logic for this step in more detail later in this RFC. Alternatives to this approach are discussed in [alternatives, section 5.2](#52-re-using-the-repositorys-pre-flight-checks). + +![High-level authorization model for private objects](../images/ols_phase_1_auth.png) + +## 3.2 Saved Objects API + +OLS Phase 1 does not introduce any new APIs, but rather augments the existing Saved Object APIs. + +APIs which return saved objects are augmented to include the top-level `accessControl` property when it exists. This includes the `export` API. + +APIs that create saved objects are augmented to accept an `accessControl` property. This includes the `import` API. + +### `get` / `bulk_get` + +The security wrapper will ensure the user is authorized to access private objects before returning them to the consumer. + +#### Performance considerations +None. The retrieved object contains all of the necessary information to authorize the current user, with no additional round trips to Elasticsearch. + +### `create` / `bulk_create` + +The security wrapper will ensure that an access control specification is attached to all private objects. + +If the caller has requested to overwrite existing `private` objects, then the security wrapper must ensure that the user is authorized to do so. + +#### Performance considerations +When overwriting existing objects, the security wrapper must first retrieve all of the existing `private` objects to ensure that the user is authorized. This requires another round-trip to `get`/`bulk-get` all `private` objects so we can authorize the operation. + +This overhead does not impact overwriting "public" objects. We only need to retrieve objects that are registered as `private`. As such, we do not expect any meaningful performance hit initially, but this will grow over time as the feature is used. + +### `update` / `bulk_update` + +The security wrapper will ensure that the user is authorized to update all existing `private` objects. It will also ensure that an access control specification is not provided, as updates to the access control specification are not permitted via `update`/`bulk_update`. + +#### Performance considerations +Similar to the "create / override" scenario above, the security wrapper must first retrieve all of the existing `private` objects to ensure that the user is authorized. This requires another round-trip to `get`/`bulk-get` all `private` objects so we can authorize the operation. + +This overhead does not impact updating "public" objects. We only need to retrieve objects that are registered as `private`. As such, we do not expect any meaningful performance hit initially, but this will grow over time as the feature is used. + +### `delete` + +The security wrapper will first retrieve the requested `private` object to ensure the user is authorized. + +#### Performance considerations +The security wrapper must first retrieve the existing `private` object to ensure that the user is authorized. This requires another round-trip to `get` the `private` object so we can authorize the operation. + +This overhead does not impact deleting "public" objects. We only need to retrieve objects that are registered as `private`. As such, we do not expect any meaningful performance hit initially, but this will grow over time as the feature is used. + + +### `find` +The security wrapper will supply or augment a [KQL `filter`](https://github.com/elastic/kibana/blob/701697cc4a34d07c0508c3bdf01dca6f9d40a636/src/core/server/saved_objects/types.ts#L118) which describes the objects the current user is authorized to see. + +```ts +// Sample KQL filter +const filterClauses = typesToFind.reduce((acc, type) => { + if (this.typeRegistry.isPrivate(type)) { + return [ + ...acc, + // note: this relies on specific behavior of the SO service's `filter_utils`, + // which automatically wraps this in an `and` node to ensure the type is accounted for. + // we have added additional safeguards there, and functional tests will ensure that changes + // to this logic will not accidentally alter our authorization model. + + // This is equivalent to writing the following, if this syntax was allowed by the SO `filter` option: + // esKuery.nodeTypes.function.buildNode('and', [ + // esKuery.nodeTypes.function.buildNode('is', `accessControl.owner`, this.getOwner()), + // esKuery.nodeTypes.function.buildNode('is', `type`, type), + // ]) + esKuery.nodeTypes.function.buildNode('is', `${type}.accessControl.owner`, this.getOwner()), + ]; + } + return acc; +}, []); + +const privateObjectsFilter = + filterClauses.length > 0 ? esKuery.nodeTypes.function.buildNode('or', filterClauses) : null; +``` + +#### Performance considerations +We are sending a more complex query to Elasticsearch for any find request which requests a `private` saved object. This has the potential to hurt query performance, but at this point it hasn't been quantified. + +Since we are only requesting saved objects that the user is authorized to see, there is no additional overhead for Kibana once Elasticsearch has returned the results of the query. + + +### `addToNamespaces` / `deleteFromNamespaces` + +The security wrapper will ensure that the user is authorized to share/unshare all existing `private` objects. +#### Performance considerations +Similar to the "create / override" scenario above, the security wrapper must first retrieve all of the existing `private` objects to ensure that the user is authorized. This requires another round-trip to `get`/`bulk-get` all `private` objects so we can authorize the operation. + +This overhead does not impact sharing/unsharing "public" objects. We only need to retrieve objects that are registered as `private`. As such, we do not expect any meaningful performance hit initially, but this will grow over time as the feature is used. + + +## 3.3 Behavior with various plugin configurations +Kibana can run with and without security enabled. When security is disabled, +`private` saved objects will be accessible to all users. + +| **Plugin Configuration** | Security | Security & Spaces | Spaces | +| ---- | ------ | ------ | --- | +|| ✅ Enforced | ✅ Enforced | 🚫 Not enforced: objects will be accessible to all + +### Alternative +If this behavior is not desired, we can prevent `private` saved objects from being accessed whenever security is disabled. + +See [unresolved question 3](#83-behavior-when-security-is-disabled) + +## 3.4 Impacts on telemetry + +The proposed design does not have any impacts on telemetry collection or reporting. Telemetry collectors run in the background against an "unwrapped" saved objects client. That is to say, they run without space-awareness, and without security. Since the security enforcement for private objects exists within the security wrapper, telemetry collection can continue as it currently exists. + +# 4. Drawbacks + +As outlined above, this approach introduces additional overhead to many of the saved object APIs. We minimize this by denoting which saved object types require this additional authorization. + +This first phase also does not allow a public object to become private. Search sessions may migrate to OLS in the future, but this will likely be a coordinated effort with Elasticsearch, due to the differing ownership models between OLS and async searches. + +# 5. Alternatives + +## 5.1 Document level security +OLS can be thought of as a Kibana-specific implementation of [Document level security](https://www.elastic.co/guide/en/elasticsearch/reference/current/document-level-security.html) ("DLS"). As such, we could consider enhancing the existing DLS feature to fit our needs (DLS doesn't prevent writes at the moment, only reads). This would involve considerable work from the Elasticsearch security team before we could consider this, and may not scale to subsequent phases of OLS. + +## 5.2 Re-using the repository's pre-flight checks +The Saved Objects Repository uses pre-flight checks to ensure that operations against multi-namespace saved objects are adhering the user's current space. The currently proposed implementation has the security wrapper performing pre-flight checks for `private` objects. + +If we have `private` multi-namespace saved objects, then we will end up performing two pre-flight requests, which is excessive. We could explore re-using the repository's pre-flight checks instead of introducing new checks. + +The primary concern with this approach is audit logging. Currently, we audit create/update/delete events before they happen, so that we can record that the operation was attempted, even in the event of a network outage or other transient event. + +If we re-use the repository's pre-flight checks, then the repository will need a way to signal that audit logging should occur. We have a couple of options to explore in this regard: + +### 5.2.1 Move audit logging code into the repository +Now that we no longer ship an OSS distribution, we could move the audit logging code directly into the repository. The implementation could still be provided by the security plugin, so we could still record information about the current user, and respect the current license. + +If we take this approach, then we will need a way to create a repository without audit logging. Certain features rely on the fact that the repository does not perform its own audit logging (such as Alerting, and the background repair jobs for ML). + +Core originally provided an [`audit_trail_service`](https://github.com/elastic/kibana/blob/v7.9.3/src/core/server/audit_trail/audit_trail_service.ts) for this type of functionality, with the thinking that OSS features could take advantage of this if needed. This was abandoned when we discovered that we had no such usages at the time, so we simplified the architecture. We could re-introduce this if desired, in order to support this initiative. + +Not all saved object audit events can be recorded by the repository. When users are not authorized at the type level (e.g., user can't `create` `dashboards`), then the wrapper will record this and not allow the operation to proceed. This shared-responsibility model will likely be even more confusing to reason about, so I'm not sure it's worth the small performance optimization we would get in return. + +### 5.2.2 Pluggable authorization +This inverts the current model. Instead of security wrapping the saved objects client, security could instead provide an authorization module to the repository. The repository could decide when to perform authorization (including audit logging), passing along the results of any pre-flight operations as necessary. + +This arguably a lot of work, but worth consideration as we evolve both our persistence and authorization mechanisms to support our maturing solutions. + +Similar to alternative `5.2.1`, we would need a way to create a repository without authorization/auditing to support specific use cases. + +### 5.2.3 Repository callbacks + +A more rudimentary approach would be to provide callbacks via each saved object operation's `options` property. This callback would be provided by the security wrapper, and called by the repository when it was "safe" to perform the audit operation. + +This is a very simplistic approach, and probably not an architecture that we want to encourage or support long-term. + +### 5.2.4 Pass down preflight objects + +Any client wrapper could fetch the object/s on its own and pass that down to the repository in an `options` field (preflightObject/s?) so the repository can reuse that result if it's defined, instead of initiating an entire additional preflight check. That resolves our problem without much additional complexity. +Of course we don't want consumers (mis)using this field, we can either mark it as `@internal` or we could explore creating a separate "internal SOC" interface that is only meant to be used by the SOC wrappers. + + +# 6. Adoption strategy + +Adoption for net-new features is hopefully straightforward. Like most saved object features, the saved objects service will transparently handle all authorization and auditing of these objects, so long as they are properly registered. + +Adoption for existing features (public saved object types) is not addressed in this first phase. + +# 7. How we teach this + +Updates to the saved object service's documentation to describe the different `accessClassification`s would be required. Like other saved object security controls, we want to ensure that engineers understand that this only "works" when the security wrapper is applied. Creating a bespoke instance of the saved objects client, or using the raw repository will intentionally bypass these authorization checks. + +# 8. Unresolved questions + +## 8.1 `accessControl.owner` + +The `accessControl.owner` property will uniquely identify the owner of each `private` saved object. We are still iterating with the Elasticsearch security team on what this value will ultimately look like. It is highly likely that this will not be a human-readable piece of text, but rather a GUID-style identifier. + +## 8.2 Authorization for private objects + +This has been [resolved](#92-authorization-for-private-objects). + +The user identified by `accessControl.owner` will be authorized for all operations against that instance, provided they pass the existing type/space/action authorization checks. + +In addition to the object owner, we also need to allow administrators to manage these saved objects. This is beneficial if they need to perform a bulk import/export of private objects, or if they wish to remove private objects from users that no longer exist. The open question is: **who counts as an administrator?** + +We have historically used the `Saved Objects Management` feature for these administrative tasks. This feature grants access to all saved objects, even if you're not authorized to access the "owning" application. Do we consider this privilege sufficient to see and potentially manipulate private saved objects? + +## 8.3 Behavior when security is disabled + +This has been [resolved](#93-behavior-when-security-is-disabled). + +When security is disabled, should `private` saved objects still be accessible via the Saved Objects Client? + + +# 9. Resolved Questions + +## 9.2 Authorization for private objects + +Users with the `Saved Objects Management` privilege will be authorized to access private saved objects belonging to other users. +Additionally, we will introduce a sub-feature privilege which will allow administrators to control which of their users with `Saved Objects Management` access are authorized to access these private objects. + +## 9.3 Behavior when security is disabled + +When security is disabled, `private` objects will still be accessible via the Saved Objects Client. \ No newline at end of file From 987e9b879ed86e3ef8ee5cd6831f8297b64be3fb Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Mon, 12 Apr 2021 12:29:05 -0400 Subject: [PATCH 06/28] fix training quick filters (#96500) --- .../exploration_page_wrapper.tsx | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx index 60c5a1db9b93b..6c158f103aade 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx @@ -33,24 +33,32 @@ import { FeatureImportanceSummaryPanelProps } from '../total_feature_importance_ import { useExplorationUrlState } from '../../hooks/use_exploration_url_state'; import { ExplorationQueryBarProps } from '../exploration_query_bar/exploration_query_bar'; -const filters = { - options: [ - { - id: 'training', - label: i18n.translate('xpack.ml.dataframe.analytics.explorationResults.trainingSubsetLabel', { - defaultMessage: 'Training', - }), - }, - { - id: 'testing', - label: i18n.translate('xpack.ml.dataframe.analytics.explorationResults.testingSubsetLabel', { - defaultMessage: 'Testing', - }), - }, - ], - columnId: 'ml.is_training', - key: { training: true, testing: false }, -}; +function getFilters(resultsField: string) { + return { + options: [ + { + id: 'training', + label: i18n.translate( + 'xpack.ml.dataframe.analytics.explorationResults.trainingSubsetLabel', + { + defaultMessage: 'Training', + } + ), + }, + { + id: 'testing', + label: i18n.translate( + 'xpack.ml.dataframe.analytics.explorationResults.testingSubsetLabel', + { + defaultMessage: 'Testing', + } + ), + }, + ], + columnId: `${resultsField}.is_training`, + key: { training: true, testing: false }, + }; +} export interface EvaluatePanelProps { jobConfig: DataFrameAnalyticsConfig; @@ -151,7 +159,7 @@ export const ExplorationPageWrapper: FC = ({ )} - {indexPattern !== undefined && ( + {indexPattern !== undefined && jobConfig && ( <> @@ -162,7 +170,7 @@ export const ExplorationPageWrapper: FC = ({ indexPattern={indexPattern} setSearchQuery={searchQueryUpdateHandler} query={query} - filters={filters} + filters={getFilters(jobConfig.dest.results_field)} /> From 5879d1fdf79bfaf12f1be5876a527d38fed3a506 Mon Sep 17 00:00:00 2001 From: spalger Date: Mon, 12 Apr 2021 09:35:44 -0700 Subject: [PATCH 07/28] =?UTF-8?q?Revert=20"docs:=20=E2=9C=8F=EF=B8=8F=20im?= =?UTF-8?q?prove=20UI=20actions=20plugin=20readme=20(#96030)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 7448238444b9e36ae15286aa2897f055f30d42a7. --- src/plugins/ui_actions/README.asciidoc | 73 +++----------------------- 1 file changed, 8 insertions(+), 65 deletions(-) diff --git a/src/plugins/ui_actions/README.asciidoc b/src/plugins/ui_actions/README.asciidoc index 27b3eae3a52a7..577aa2eae354b 100644 --- a/src/plugins/ui_actions/README.asciidoc +++ b/src/plugins/ui_actions/README.asciidoc @@ -1,71 +1,14 @@ [[uiactions-plugin]] == UI Actions -UI Actions plugins provides API to manage *triggers* and *actions*. - -*Trigger* is an abstract description of user's intent to perform an action -(like user clicking on a value inside chart). It allows us to do runtime -binding between code from different plugins. For, example one such -trigger is when somebody applies filters on dashboard; another one is when -somebody opens a Dashboard panel context menu. - -*Actions* are pieces of code that execute in response to a trigger. For example, -to the dashboard filtering trigger multiple actions can be attached. Once a user -filters on the dashboard all possible actions are displayed to the user in a -popup menu and the user has to chose one. - -In general this plugin provides: - -- Creating custom functionality (actions). -- Creating custom user interaction events (triggers). -- Attaching and detaching actions to triggers. -- Emitting trigger events. -- Executing actions attached to a given trigger. -- Exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. - -=== Basic usage - -To get started, first you need to know a trigger you will attach your actions to. -You can either pick an existing one, or register your own one: - -[source,typescript jsx] ----- -plugins.uiActions.registerTrigger({ - id: 'MY_APP_PIE_CHART_CLICK', - title: 'Pie chart click', - description: 'When user clicks on a pie chart slice.', -}); ----- - -Now, when user clicks on a pie slice you need to "trigger" your trigger and -provide some context data: - -[source,typescript jsx] ----- -plugins.uiActions.getTrigger('MY_APP_PIE_CHART_CLICK').exec({ - /* Custom context data. */ -}); ----- - -Finally, your code or developers from other plugins can register UI actions that -listen for the above trigger and execute some code when the trigger is triggered. - -[source,typescript jsx] ----- -plugins.uiActions.registerAction({ - id: 'DO_SOMETHING', - isCompatible: async (context) => true, - execute: async (context) => { - // Do something. - }, -}); -plugins.uiActions.attachAction('MY_APP_PIE_CHART_CLICK', 'DO_SOMETHING'); ----- - -Now your `DO_SOMETHING` action will automatically execute when `MY_APP_PIE_CHART_CLICK` -trigger is triggered; or, if more than one compatible action is attached to -that trigger, user will be presented with a context menu popup to select one -action to execute. +An API for: + +- creating custom functionality (`actions`) +- creating custom user interaction events (`triggers`) +- attaching and detaching `actions` to `triggers`. +- emitting `trigger` events +- executing `actions` attached to a given `trigger`. +- exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. === Examples From c9cd4a0a99a10b5ca9f10c86f27ac22e7a524035 Mon Sep 17 00:00:00 2001 From: Luke Elmers Date: Mon, 12 Apr 2021 10:55:44 -0600 Subject: [PATCH 08/28] [telemetry] Adds cloud provider metadata. (#95131) --- .../server/__snapshots__/index.test.ts.snap | 8 +- .../cloud_provider_collector.test.mocks.ts | 18 + .../cloud/cloud_provider_collector.test.ts | 78 +++++ .../cloud/cloud_provider_collector.ts | 69 ++++ .../collectors/cloud/detector/aws.test.ts | 311 ++++++++++++++++++ .../server/collectors/cloud/detector/aws.ts | 151 +++++++++ .../collectors/cloud/detector/azure.test.ts | 71 ++-- .../server/collectors/cloud/detector/azure.ts | 103 ++++++ .../cloud/detector/cloud_detector.mock.ts | 18 + .../cloud/detector/cloud_detector.test.ts | 50 +-- .../cloud/detector/cloud_detector.ts | 76 +++++ .../cloud/detector/cloud_response.test.ts | 5 +- .../cloud/detector/cloud_response.ts | 62 ++-- .../cloud/detector/cloud_service.test.ts | 66 ++-- .../cloud/detector/cloud_service.ts | 130 ++++++++ .../collectors/cloud/detector/gcp.test.ts | 99 +++--- .../server/collectors/cloud/detector/gcp.ts | 127 +++++++ .../server/collectors/cloud/detector/index.ts | 6 +- .../server/collectors/cloud/index.ts | 9 + .../server/collectors/index.ts | 1 + .../server/index.test.mocks.ts | 18 + .../server/index.test.ts | 11 + .../kibana_usage_collection/server/plugin.ts | 2 + src/plugins/telemetry/schema/oss_plugins.json | 28 ++ x-pack/plugins/monitoring/common/constants.ts | 17 - x-pack/plugins/monitoring/server/cloud/aws.js | 127 ------- .../monitoring/server/cloud/aws.test.js | 237 ------------- .../plugins/monitoring/server/cloud/azure.js | 99 ------ .../monitoring/server/cloud/cloud_detector.js | 64 ---- .../monitoring/server/cloud/cloud_service.js | 115 ------- .../monitoring/server/cloud/cloud_services.js | 17 - .../server/cloud/cloud_services.test.js | 22 -- x-pack/plugins/monitoring/server/cloud/gcp.js | 136 -------- 33 files changed, 1348 insertions(+), 1003 deletions(-) create mode 100644 src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.mocks.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.test.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.ts rename x-pack/plugins/monitoring/server/cloud/azure.test.js => src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.test.ts (71%) create mode 100644 src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.mock.ts rename x-pack/plugins/monitoring/server/cloud/cloud_detector.test.js => src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.test.ts (56%) create mode 100644 src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.ts rename x-pack/plugins/monitoring/server/cloud/cloud_response.test.js => src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_response.test.ts (87%) rename x-pack/plugins/monitoring/server/cloud/cloud_response.js => src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_response.ts (52%) rename x-pack/plugins/monitoring/server/cloud/cloud_service.test.js => src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.test.ts (65%) create mode 100644 src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.ts rename x-pack/plugins/monitoring/server/cloud/gcp.test.js => src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.test.ts (66%) create mode 100644 src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.ts rename x-pack/plugins/monitoring/server/cloud/index.js => src/plugins/kibana_usage_collection/server/collectors/cloud/detector/index.ts (53%) create mode 100644 src/plugins/kibana_usage_collection/server/collectors/cloud/index.ts create mode 100644 src/plugins/kibana_usage_collection/server/index.test.mocks.ts delete mode 100644 x-pack/plugins/monitoring/server/cloud/aws.js delete mode 100644 x-pack/plugins/monitoring/server/cloud/aws.test.js delete mode 100644 x-pack/plugins/monitoring/server/cloud/azure.js delete mode 100644 x-pack/plugins/monitoring/server/cloud/cloud_detector.js delete mode 100644 x-pack/plugins/monitoring/server/cloud/cloud_service.js delete mode 100644 x-pack/plugins/monitoring/server/cloud/cloud_services.js delete mode 100644 x-pack/plugins/monitoring/server/cloud/cloud_services.test.js delete mode 100644 x-pack/plugins/monitoring/server/cloud/gcp.js diff --git a/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap b/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap index 2180d6a0fcc4e..939e90d2f2583 100644 --- a/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap +++ b/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap @@ -12,8 +12,10 @@ exports[`kibana_usage_collection Runs the setup method without issues 5`] = `fal exports[`kibana_usage_collection Runs the setup method without issues 6`] = `false`; -exports[`kibana_usage_collection Runs the setup method without issues 7`] = `true`; +exports[`kibana_usage_collection Runs the setup method without issues 7`] = `false`; -exports[`kibana_usage_collection Runs the setup method without issues 8`] = `false`; +exports[`kibana_usage_collection Runs the setup method without issues 8`] = `true`; -exports[`kibana_usage_collection Runs the setup method without issues 9`] = `true`; +exports[`kibana_usage_collection Runs the setup method without issues 9`] = `false`; + +exports[`kibana_usage_collection Runs the setup method without issues 10`] = `true`; diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.mocks.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.mocks.ts new file mode 100644 index 0000000000000..4a8f269fe5098 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.mocks.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { cloudDetectorMock } from './detector/cloud_detector.mock'; + +const mock = cloudDetectorMock.create(); + +export const cloudDetailsMock = mock.getCloudDetails; +export const detectCloudServiceMock = mock.detectCloudService; + +jest.doMock('./detector', () => ({ + CloudDetector: jest.fn().mockImplementation(() => mock), +})); diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.ts new file mode 100644 index 0000000000000..1f7617a0e69ce --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { cloudDetailsMock, detectCloudServiceMock } from './cloud_provider_collector.test.mocks'; +import { loggingSystemMock } from '../../../../../core/server/mocks'; +import { + Collector, + createUsageCollectionSetupMock, + createCollectorFetchContextMock, +} from '../../../../usage_collection/server/usage_collection.mock'; + +import { registerCloudProviderUsageCollector } from './cloud_provider_collector'; + +describe('registerCloudProviderUsageCollector', () => { + let collector: Collector; + const logger = loggingSystemMock.createLogger(); + + const usageCollectionMock = createUsageCollectionSetupMock(); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = new Collector(logger, config); + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); + + const mockedFetchContext = createCollectorFetchContextMock(); + + beforeEach(() => { + cloudDetailsMock.mockClear(); + detectCloudServiceMock.mockClear(); + registerCloudProviderUsageCollector(usageCollectionMock); + }); + + test('registered collector is set', () => { + expect(collector).not.toBeUndefined(); + }); + + test('isReady() => false when cloud details are not available', () => { + cloudDetailsMock.mockReturnValueOnce(undefined); + expect(collector.isReady()).toBe(false); + }); + + test('isReady() => true when cloud details are available', () => { + cloudDetailsMock.mockReturnValueOnce({ foo: true }); + expect(collector.isReady()).toBe(true); + }); + + test('initiates CloudDetector.detectCloudDetails when called', () => { + expect(detectCloudServiceMock).toHaveBeenCalledTimes(1); + }); + + describe('fetch()', () => { + test('returns undefined when no details are available', async () => { + cloudDetailsMock.mockReturnValueOnce(undefined); + await expect(collector.fetch(mockedFetchContext)).resolves.toBeUndefined(); + }); + + test('returns cloud details when defined', async () => { + const mockDetails = { + name: 'aws', + vm_type: 't2.micro', + region: 'us-west-2', + zone: 'us-west-2a', + }; + + cloudDetailsMock.mockReturnValueOnce(mockDetails); + await expect(collector.fetch(mockedFetchContext)).resolves.toEqual(mockDetails); + }); + + test('should not fail if invoked when not ready', async () => { + cloudDetailsMock.mockReturnValueOnce(undefined); + await expect(collector.fetch(mockedFetchContext)).resolves.toBe(undefined); + }); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.ts new file mode 100644 index 0000000000000..eafce56d7cf2e --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { CloudDetector } from './detector'; + +interface Usage { + name: string; + vm_type?: string; + region?: string; + zone?: string; +} + +export function registerCloudProviderUsageCollector(usageCollection: UsageCollectionSetup) { + const cloudDetector = new CloudDetector(); + // determine the cloud service in the background + cloudDetector.detectCloudService(); + + const collector = usageCollection.makeUsageCollector({ + type: 'cloud_provider', + isReady: () => Boolean(cloudDetector.getCloudDetails()), + async fetch() { + const details = cloudDetector.getCloudDetails(); + if (!details) { + return; + } + + return { + name: details.name, + vm_type: details.vm_type, + region: details.region, + zone: details.zone, + }; + }, + schema: { + name: { + type: 'keyword', + _meta: { + description: 'The name of the cloud provider', + }, + }, + vm_type: { + type: 'keyword', + _meta: { + description: 'The VM instance type', + }, + }, + region: { + type: 'keyword', + _meta: { + description: 'The cloud provider region', + }, + }, + zone: { + type: 'keyword', + _meta: { + description: 'The availability zone within the region', + }, + }, + }, + }); + + usageCollection.registerCollector(collector); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.test.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.test.ts new file mode 100644 index 0000000000000..0bba64823a3e2 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.test.ts @@ -0,0 +1,311 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import fs from 'fs'; +import type { Request, RequestOptions } from './cloud_service'; +import { AWSCloudService, AWSResponse } from './aws'; + +type Callback = (err: unknown, res: unknown) => void; + +const AWS = new AWSCloudService(); + +describe('AWS', () => { + const expectedFilenames = ['/sys/hypervisor/uuid', '/sys/devices/virtual/dmi/id/product_uuid']; + const expectedEncoding = 'utf8'; + // mixed case to ensure we check for ec2 after lowercasing + const ec2Uuid = 'eC2abcdef-ghijk\n'; + const ec2FileSystem = { + readFile: (filename: string, encoding: string, callback: Callback) => { + expect(expectedFilenames).toContain(filename); + expect(encoding).toEqual(expectedEncoding); + + callback(null, ec2Uuid); + }, + } as typeof fs; + + it('is named "aws"', () => { + expect(AWS.getName()).toEqual('aws'); + }); + + describe('_checkIfService', () => { + it('handles expected response', async () => { + const id = 'abcdef'; + const request = ((req: RequestOptions, callback: Callback) => { + expect(req.method).toEqual('GET'); + expect(req.uri).toEqual( + 'http://169.254.169.254/2016-09-02/dynamic/instance-identity/document' + ); + expect(req.json).toEqual(true); + + const body = `{"instanceId": "${id}","availabilityZone":"us-fake-2c", "imageId" : "ami-6df1e514"}`; + + callback(null, { statusCode: 200, body }); + }) as Request; + // ensure it does not use the fs to trump the body + const awsCheckedFileSystem = new AWSCloudService({ + _fs: ec2FileSystem, + _isWindows: false, + }); + + const response = await awsCheckedFileSystem._checkIfService(request); + + expect(response.isConfirmed()).toEqual(true); + expect(response.toJSON()).toEqual({ + name: AWS.getName(), + id, + region: undefined, + vm_type: undefined, + zone: 'us-fake-2c', + metadata: { + imageId: 'ami-6df1e514', + }, + }); + }); + + it('handles request without a usable body by downgrading to UUID detection', async () => { + const request = ((_req: RequestOptions, callback: Callback) => + callback(null, { statusCode: 404 })) as Request; + const awsCheckedFileSystem = new AWSCloudService({ + _fs: ec2FileSystem, + _isWindows: false, + }); + + const response = await awsCheckedFileSystem._checkIfService(request); + + expect(response.isConfirmed()).toBe(true); + expect(response.toJSON()).toEqual({ + name: AWS.getName(), + id: ec2Uuid.trim().toLowerCase(), + region: undefined, + vm_type: undefined, + zone: undefined, + metadata: undefined, + }); + }); + + it('handles request failure by downgrading to UUID detection', async () => { + const failedRequest = ((_req: RequestOptions, callback: Callback) => + callback(new Error('expected: request failed'), null)) as Request; + const awsCheckedFileSystem = new AWSCloudService({ + _fs: ec2FileSystem, + _isWindows: false, + }); + + const response = await awsCheckedFileSystem._checkIfService(failedRequest); + + expect(response.isConfirmed()).toBe(true); + expect(response.toJSON()).toEqual({ + name: AWS.getName(), + id: ec2Uuid.trim().toLowerCase(), + region: undefined, + vm_type: undefined, + zone: undefined, + metadata: undefined, + }); + }); + + it('handles not running on AWS', async () => { + const failedRequest = ((_req: RequestOptions, callback: Callback) => + callback(null, null)) as Request; + const awsIgnoredFileSystem = new AWSCloudService({ + _fs: ec2FileSystem, + _isWindows: true, + }); + + const response = await awsIgnoredFileSystem._checkIfService(failedRequest); + + expect(response.getName()).toEqual(AWS.getName()); + expect(response.isConfirmed()).toBe(false); + }); + }); + + describe('parseBody', () => { + it('parses object in expected format', () => { + const body: AWSResponse = { + devpayProductCodes: null, + privateIp: '10.0.0.38', + availabilityZone: 'us-west-2c', + version: '2010-08-31', + instanceId: 'i-0c7a5b7590a4d811c', + billingProducts: null, + instanceType: 't2.micro', + accountId: '1234567890', + architecture: 'x86_64', + kernelId: null, + ramdiskId: null, + imageId: 'ami-6df1e514', + pendingTime: '2017-07-06T02:09:12Z', + region: 'us-west-2', + marketplaceProductCodes: null, + }; + + const response = AWSCloudService.parseBody(AWS.getName(), body)!; + expect(response).not.toBeNull(); + + expect(response.getName()).toEqual(AWS.getName()); + expect(response.isConfirmed()).toEqual(true); + expect(response.toJSON()).toEqual({ + name: 'aws', + id: 'i-0c7a5b7590a4d811c', + vm_type: 't2.micro', + region: 'us-west-2', + zone: 'us-west-2c', + metadata: { + version: '2010-08-31', + architecture: 'x86_64', + kernelId: null, + marketplaceProductCodes: null, + ramdiskId: null, + imageId: 'ami-6df1e514', + pendingTime: '2017-07-06T02:09:12Z', + }, + }); + }); + + it('ignores unexpected response body', () => { + // @ts-expect-error + expect(AWSCloudService.parseBody(AWS.getName(), undefined)).toBe(null); + // @ts-expect-error + expect(AWSCloudService.parseBody(AWS.getName(), null)).toBe(null); + // @ts-expect-error + expect(AWSCloudService.parseBody(AWS.getName(), {})).toBe(null); + // @ts-expect-error + expect(AWSCloudService.parseBody(AWS.getName(), { privateIp: 'a.b.c.d' })).toBe(null); + }); + }); + + describe('_tryToDetectUuid', () => { + describe('checks the file system for UUID if not Windows', () => { + it('checks /sys/hypervisor/uuid', async () => { + const awsCheckedFileSystem = new AWSCloudService({ + _fs: { + readFile: (filename: string, encoding: string, callback: Callback) => { + expect(expectedFilenames).toContain(filename); + expect(encoding).toEqual(expectedEncoding); + + callback(null, ec2Uuid); + }, + } as typeof fs, + _isWindows: false, + }); + + const response = await awsCheckedFileSystem._tryToDetectUuid(); + + expect(response.isConfirmed()).toEqual(true); + expect(response.toJSON()).toEqual({ + name: AWS.getName(), + id: ec2Uuid.trim().toLowerCase(), + region: undefined, + zone: undefined, + vm_type: undefined, + metadata: undefined, + }); + }); + + it('checks /sys/devices/virtual/dmi/id/product_uuid', async () => { + const awsCheckedFileSystem = new AWSCloudService({ + _fs: { + readFile: (filename: string, encoding: string, callback: Callback) => { + expect(expectedFilenames).toContain(filename); + expect(encoding).toEqual(expectedEncoding); + + callback(null, ec2Uuid); + }, + } as typeof fs, + _isWindows: false, + }); + + const response = await awsCheckedFileSystem._tryToDetectUuid(); + + expect(response.isConfirmed()).toEqual(true); + expect(response.toJSON()).toEqual({ + name: AWS.getName(), + id: ec2Uuid.trim().toLowerCase(), + region: undefined, + zone: undefined, + vm_type: undefined, + metadata: undefined, + }); + }); + + it('returns confirmed if only one file exists', async () => { + let callCount = 0; + const awsCheckedFileSystem = new AWSCloudService({ + _fs: { + readFile: (filename: string, encoding: string, callback: Callback) => { + if (callCount === 0) { + callCount++; + throw new Error('oops'); + } + callback(null, ec2Uuid); + }, + } as typeof fs, + _isWindows: false, + }); + + const response = await awsCheckedFileSystem._tryToDetectUuid(); + + expect(response.isConfirmed()).toEqual(true); + expect(response.toJSON()).toEqual({ + name: AWS.getName(), + id: ec2Uuid.trim().toLowerCase(), + region: undefined, + zone: undefined, + vm_type: undefined, + metadata: undefined, + }); + }); + + it('returns unconfirmed if all files return errors', async () => { + const awsFailedFileSystem = new AWSCloudService({ + _fs: ({ + readFile: () => { + throw new Error('oops'); + }, + } as unknown) as typeof fs, + _isWindows: false, + }); + + const response = await awsFailedFileSystem._tryToDetectUuid(); + + expect(response.isConfirmed()).toEqual(false); + }); + }); + + it('ignores UUID if it does not start with ec2', async () => { + const notEC2FileSystem = { + readFile: (filename: string, encoding: string, callback: Callback) => { + expect(expectedFilenames).toContain(filename); + expect(encoding).toEqual(expectedEncoding); + + callback(null, 'notEC2'); + }, + } as typeof fs; + + const awsCheckedFileSystem = new AWSCloudService({ + _fs: notEC2FileSystem, + _isWindows: false, + }); + + const response = await awsCheckedFileSystem._tryToDetectUuid(); + + expect(response.isConfirmed()).toEqual(false); + }); + + it('does NOT check the file system for UUID on Windows', async () => { + const awsUncheckedFileSystem = new AWSCloudService({ + _fs: ec2FileSystem, + _isWindows: true, + }); + + const response = await awsUncheckedFileSystem._tryToDetectUuid(); + + expect(response.isConfirmed()).toEqual(false); + }); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.ts new file mode 100644 index 0000000000000..69e5698489b30 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import fs from 'fs'; +import { get, isString, omit } from 'lodash'; +import { promisify } from 'util'; +import { CloudService, CloudServiceOptions, Request, RequestOptions } from './cloud_service'; +import { CloudServiceResponse } from './cloud_response'; + +// We explicitly call out the version, 2016-09-02, rather than 'latest' to avoid unexpected changes +const SERVICE_ENDPOINT = 'http://169.254.169.254/2016-09-02/dynamic/instance-identity/document'; + +/** @internal */ +export interface AWSResponse { + accountId: string; + architecture: string; + availabilityZone: string; + billingProducts: unknown; + devpayProductCodes: unknown; + marketplaceProductCodes: unknown; + imageId: string; + instanceId: string; + instanceType: string; + kernelId: unknown; + pendingTime: string; + privateIp: string; + ramdiskId: unknown; + region: string; + version: string; +} + +/** + * Checks and loads the service metadata for an Amazon Web Service VM if it is available. + * + * @internal + */ +export class AWSCloudService extends CloudService { + private readonly _isWindows: boolean; + private readonly _fs: typeof fs; + + /** + * Parse the AWS response, if possible. + * + * Example payload: + * { + * "accountId" : "1234567890", + * "architecture" : "x86_64", + * "availabilityZone" : "us-west-2c", + * "billingProducts" : null, + * "devpayProductCodes" : null, + * "imageId" : "ami-6df1e514", + * "instanceId" : "i-0c7a5b7590a4d811c", + * "instanceType" : "t2.micro", + * "kernelId" : null, + * "pendingTime" : "2017-07-06T02:09:12Z", + * "privateIp" : "10.0.0.38", + * "ramdiskId" : null, + * "region" : "us-west-2" + * "version" : "2010-08-31", + * } + */ + static parseBody(name: string, body: AWSResponse): CloudServiceResponse | null { + const id: string | undefined = get(body, 'instanceId'); + const vmType: string | undefined = get(body, 'instanceType'); + const region: string | undefined = get(body, 'region'); + const zone: string | undefined = get(body, 'availabilityZone'); + const metadata = omit(body, [ + // remove keys we already have + 'instanceId', + 'instanceType', + 'region', + 'availabilityZone', + // remove keys that give too much detail + 'accountId', + 'billingProducts', + 'devpayProductCodes', + 'privateIp', + ]); + + // ensure we actually have some data + if (id || vmType || region || zone) { + return new CloudServiceResponse(name, true, { id, vmType, region, zone, metadata }); + } + + return null; + } + + constructor(options: CloudServiceOptions = {}) { + super('aws', options); + + // Allow the file system handler to be swapped out for tests + const { _fs = fs, _isWindows = process.platform.startsWith('win') } = options; + + this._fs = _fs; + this._isWindows = _isWindows; + } + + async _checkIfService(request: Request) { + const req: RequestOptions = { + method: 'GET', + uri: SERVICE_ENDPOINT, + json: true, + }; + + return promisify(request)(req) + .then((response) => + this._parseResponse(response.body, (body) => + AWSCloudService.parseBody(this.getName(), body) + ) + ) + .catch(() => this._tryToDetectUuid()); + } + + /** + * Attempt to load the UUID by checking `/sys/hypervisor/uuid`. + * + * This is a fallback option if the metadata service is unavailable for some reason. + */ + _tryToDetectUuid() { + // Windows does not have an easy way to check + if (!this._isWindows) { + const pathsToCheck = ['/sys/hypervisor/uuid', '/sys/devices/virtual/dmi/id/product_uuid']; + const promises = pathsToCheck.map((path) => promisify(this._fs.readFile)(path, 'utf8')); + + return Promise.allSettled(promises).then((responses) => { + for (const response of responses) { + let uuid; + if (response.status === 'fulfilled' && isString(response.value)) { + // Some AWS APIs return it lowercase (like the file did in testing), while others return it uppercase + uuid = response.value.trim().toLowerCase(); + + // There is a small chance of a false positive here in the unlikely event that a uuid which doesn't + // belong to ec2 happens to be generated with `ec2` as the first three characters. + if (uuid.startsWith('ec2')) { + return new CloudServiceResponse(this._name, true, { id: uuid }); + } + } + } + + return this._createUnconfirmedResponse(); + }); + } + + return Promise.resolve(this._createUnconfirmedResponse()); + } +} diff --git a/x-pack/plugins/monitoring/server/cloud/azure.test.js b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.test.ts similarity index 71% rename from x-pack/plugins/monitoring/server/cloud/azure.test.js rename to src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.test.ts index cb56c89f1d64a..17205562fa335 100644 --- a/x-pack/plugins/monitoring/server/cloud/azure.test.js +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.test.ts @@ -1,11 +1,17 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ -import { AZURE } from './azure'; +import type { Request, RequestOptions } from './cloud_service'; +import { AzureCloudService } from './azure'; + +type Callback = (err: unknown, res: unknown) => void; + +const AZURE = new AzureCloudService(); describe('Azure', () => { it('is named "azure"', () => { @@ -15,16 +21,16 @@ describe('Azure', () => { describe('_checkIfService', () => { it('handles expected response', async () => { const id = 'abcdef'; - const request = (req, callback) => { + const request = ((req: RequestOptions, callback: Callback) => { expect(req.method).toEqual('GET'); expect(req.uri).toEqual('http://169.254.169.254/metadata/instance?api-version=2017-04-02'); - expect(req.headers.Metadata).toEqual('true'); + expect(req.headers?.Metadata).toEqual('true'); expect(req.json).toEqual(true); const body = `{"compute":{"vmId": "${id}","location":"fakeus","availabilityZone":"fakeus-2"}}`; - callback(null, { statusCode: 200, body }, body); - }; + callback(null, { statusCode: 200, body }); + }) as Request; const response = await AZURE._checkIfService(request); expect(response.isConfirmed()).toEqual(true); @@ -43,39 +49,30 @@ describe('Azure', () => { // NOTE: the CloudService method, checkIfService, catches the errors that follow it('handles not running on Azure with error by rethrowing it', async () => { const someError = new Error('expected: request failed'); - const failedRequest = (_req, callback) => callback(someError, null); + const failedRequest = ((_req: RequestOptions, callback: Callback) => + callback(someError, null)) as Request; - try { + expect(async () => { await AZURE._checkIfService(failedRequest); - - expect().fail('Method should throw exception (Promise.reject)'); - } catch (err) { - expect(err.message).toEqual(someError.message); - } + }).rejects.toThrowError(someError.message); }); it('handles not running on Azure with 404 response by throwing error', async () => { - const failedRequest = (_req, callback) => callback(null, { statusCode: 404 }); + const failedRequest = ((_req: RequestOptions, callback: Callback) => + callback(null, { statusCode: 404 })) as Request; - try { + expect(async () => { await AZURE._checkIfService(failedRequest); - - expect().fail('Method should throw exception (Promise.reject)'); - } catch (ignoredErr) { - // ignored - } + }).rejects.toThrowErrorMatchingInlineSnapshot(`"Azure request failed"`); }); it('handles not running on Azure with unexpected response by throwing error', async () => { - const failedRequest = (_req, callback) => callback(null, null); + const failedRequest = ((_req: RequestOptions, callback: Callback) => + callback(null, null)) as Request; - try { + expect(async () => { await AZURE._checkIfService(failedRequest); - - expect().fail('Method should throw exception (Promise.reject)'); - } catch (ignoredErr) { - // ignored - } + }).rejects.toThrowErrorMatchingInlineSnapshot(`"Azure request failed"`); }); }); @@ -122,7 +119,8 @@ describe('Azure', () => { }, }; - const response = AZURE._parseBody(body); + const response = AzureCloudService.parseBody(AZURE.getName(), body)!; + expect(response).not.toBeNull(); expect(response.getName()).toEqual(AZURE.getName()); expect(response.isConfirmed()).toEqual(true); @@ -174,7 +172,8 @@ describe('Azure', () => { }, }; - const response = AZURE._parseBody(body); + const response = AzureCloudService.parseBody(AZURE.getName(), body)!; + expect(response).not.toBeNull(); expect(response.getName()).toEqual(AZURE.getName()); expect(response.isConfirmed()).toEqual(true); @@ -191,10 +190,14 @@ describe('Azure', () => { }); it('ignores unexpected response body', () => { - expect(AZURE._parseBody(undefined)).toBe(null); - expect(AZURE._parseBody(null)).toBe(null); - expect(AZURE._parseBody({})).toBe(null); - expect(AZURE._parseBody({ privateIp: 'a.b.c.d' })).toBe(null); + // @ts-expect-error + expect(AzureCloudService.parseBody(AZURE.getName(), undefined)).toBe(null); + // @ts-expect-error + expect(AzureCloudService.parseBody(AZURE.getName(), null)).toBe(null); + // @ts-expect-error + expect(AzureCloudService.parseBody(AZURE.getName(), {})).toBe(null); + // @ts-expect-error + expect(AzureCloudService.parseBody(AZURE.getName(), { privateIp: 'a.b.c.d' })).toBe(null); }); }); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.ts new file mode 100644 index 0000000000000..b846636f0ce6c --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { get, omit } from 'lodash'; +import { promisify } from 'util'; +import { CloudService, Request } from './cloud_service'; +import { CloudServiceResponse } from './cloud_response'; + +// 2017-04-02 is the first GA release of this API +const SERVICE_ENDPOINT = 'http://169.254.169.254/metadata/instance?api-version=2017-04-02'; + +interface AzureResponse { + compute?: Record; + network: unknown; +} + +/** + * Checks and loads the service metadata for an Azure VM if it is available. + * + * @internal + */ +export class AzureCloudService extends CloudService { + /** + * Parse the Azure response, if possible. + * + * Azure VMs created using the "classic" method, as opposed to the resource manager, + * do not provide a "compute" field / object. However, both report the "network" field / object. + * + * Example payload (with network object ignored): + * { + * "compute": { + * "location": "eastus", + * "name": "my-ubuntu-vm", + * "offer": "UbuntuServer", + * "osType": "Linux", + * "platformFaultDomain": "0", + * "platformUpdateDomain": "0", + * "publisher": "Canonical", + * "sku": "16.04-LTS", + * "version": "16.04.201706191", + * "vmId": "d4c57456-2b3b-437a-9f1f-7082cfce02d4", + * "vmSize": "Standard_A1" + * }, + * "network": { + * ... + * } + * } + */ + static parseBody(name: string, body: AzureResponse): CloudServiceResponse | null { + const compute: Record | undefined = get(body, 'compute'); + const id = get, string>(compute, 'vmId'); + const vmType = get, string>(compute, 'vmSize'); + const region = get, string>(compute, 'location'); + + // remove keys that we already have; explicitly undefined so we don't send it when empty + const metadata = compute ? omit(compute, ['vmId', 'vmSize', 'location']) : undefined; + + // we don't actually use network, but we check for its existence to see if this is a response from Azure + const network = get(body, 'network'); + + // ensure we actually have some data + if (id || vmType || region) { + return new CloudServiceResponse(name, true, { id, vmType, region, metadata }); + } else if (network) { + // classic-managed VMs in Azure don't provide compute so we highlight the lack of info + return new CloudServiceResponse(name, true, { metadata: { classic: true } }); + } + + return null; + } + + constructor(options = {}) { + super('azure', options); + } + + async _checkIfService(request: Request) { + const req = { + method: 'GET', + uri: SERVICE_ENDPOINT, + headers: { + // Azure requires this header + Metadata: 'true', + }, + json: true, + }; + + const response = await promisify(request)(req); + + // Note: there is no fallback option for Azure + if (!response || response.statusCode === 404) { + throw new Error('Azure request failed'); + } + + return this._parseResponse(response.body, (body) => + AzureCloudService.parseBody(this.getName(), body) + ); + } +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.mock.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.mock.ts new file mode 100644 index 0000000000000..82e321c93783d --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.mock.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const create = () => { + const mock = { + detectCloudService: jest.fn(), + getCloudDetails: jest.fn(), + }; + + return mock; +}; + +export const cloudDetectorMock = { create }; diff --git a/x-pack/plugins/monitoring/server/cloud/cloud_detector.test.js b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.test.ts similarity index 56% rename from x-pack/plugins/monitoring/server/cloud/cloud_detector.test.js rename to src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.test.ts index 3c4d0dfa724c8..4b88ed5b4064f 100644 --- a/x-pack/plugins/monitoring/server/cloud/cloud_detector.test.js +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.test.ts @@ -1,11 +1,13 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { CloudDetector } from './cloud_detector'; +import type { CloudService } from './cloud_service'; describe('CloudDetector', () => { const cloudService1 = { @@ -28,8 +30,10 @@ describe('CloudDetector', () => { }; }, }; - // this service is theoretically a better match for the current server, but order dictates that it should - // never be checked (at least until we have some sort of "confidence" metric returned, if we ever run into this problem) + // this service is theoretically a better match for the current server, + // but order dictates that it should never be checked (at least until + // we have some sort of "confidence" metric returned, if we ever run + // into this problem) const cloudService4 = { checkIfService: () => { return { @@ -40,7 +44,12 @@ describe('CloudDetector', () => { }; }, }; - const cloudServices = [cloudService1, cloudService2, cloudService3, cloudService4]; + const cloudServices = ([ + cloudService1, + cloudService2, + cloudService3, + cloudService4, + ] as unknown) as CloudService[]; describe('getCloudDetails', () => { it('returns undefined by default', () => { @@ -51,35 +60,34 @@ describe('CloudDetector', () => { }); describe('detectCloudService', () => { - it('awaits _getCloudService', async () => { + it('returns first match', async () => { const detector = new CloudDetector({ cloudServices }); - expect(detector.getCloudDetails()).toBe(undefined); + expect(detector.getCloudDetails()).toBeUndefined(); await detector.detectCloudService(); - expect(detector.getCloudDetails()).toEqual({ name: 'good-match' }); - }); - }); - - describe('_getCloudService', () => { - it('returns first match', async () => { - const detector = new CloudDetector(); - // note: should never use better-match - expect(await detector._getCloudService(cloudServices)).toEqual({ name: 'good-match' }); + expect(detector.getCloudDetails()).toEqual({ name: 'good-match' }); }); it('returns undefined if none match', async () => { - const detector = new CloudDetector(); + const services = ([cloudService1, cloudService2] as unknown) as CloudService[]; - expect(await detector._getCloudService([cloudService1, cloudService2])).toBe(undefined); - expect(await detector._getCloudService([])).toBe(undefined); + const detector1 = new CloudDetector({ cloudServices: services }); + await detector1.detectCloudService(); + expect(detector1.getCloudDetails()).toBeUndefined(); + + const detector2 = new CloudDetector({ cloudServices: [] }); + await detector2.detectCloudService(); + expect(detector2.getCloudDetails()).toBeUndefined(); }); // this is already tested above, but this just tests it explicitly it('ignores exceptions from cloud services', async () => { - const detector = new CloudDetector(); + const services = ([cloudService2] as unknown) as CloudService[]; + const detector = new CloudDetector({ cloudServices: services }); - expect(await detector._getCloudService([cloudService2])).toBe(undefined); + await detector.detectCloudService(); + expect(detector.getCloudDetails()).toBeUndefined(); }); }); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.ts new file mode 100644 index 0000000000000..6f6405d9852b6 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { CloudService } from './cloud_service'; +import type { CloudServiceResponseJson } from './cloud_response'; + +import { AWSCloudService } from './aws'; +import { AzureCloudService } from './azure'; +import { GCPCloudService } from './gcp'; + +const SUPPORTED_SERVICES = [AWSCloudService, AzureCloudService, GCPCloudService]; + +interface CloudDetectorOptions { + cloudServices?: CloudService[]; +} + +/** + * The `CloudDetector` can be used to asynchronously detect the + * cloud service that Kibana is running within. + * + * @internal + */ +export class CloudDetector { + private readonly cloudServices: CloudService[]; + private cloudDetails?: CloudServiceResponseJson; + + constructor(options: CloudDetectorOptions = {}) { + this.cloudServices = + options.cloudServices ?? SUPPORTED_SERVICES.map((Service) => new Service()); + } + + /** + * Get any cloud details that we have detected. + */ + getCloudDetails() { + return this.cloudDetails; + } + + /** + * Asynchronously detect the cloud service. + * + * Callers are _not_ expected to await this method, which allows the + * caller to trigger the lookup and then simply use it whenever we + * determine it. + */ + async detectCloudService() { + this.cloudDetails = await this.getCloudService(); + } + + /** + * Check every cloud service until the first one reports success from detection. + */ + private async getCloudService() { + // check each service until we find one that is confirmed to match; + // order is assumed to matter + for (const service of this.cloudServices) { + try { + const serviceResponse = await service.checkIfService(); + + if (serviceResponse.isConfirmed()) { + return serviceResponse.toJSON(); + } + } catch (ignoredError) { + // ignored until we make wider use of this in the UI + } + } + + // explicitly undefined rather than null so that it can be ignored in JSON + return undefined; + } +} diff --git a/x-pack/plugins/monitoring/server/cloud/cloud_response.test.js b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_response.test.ts similarity index 87% rename from x-pack/plugins/monitoring/server/cloud/cloud_response.test.js rename to src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_response.test.ts index fbc0d857ebd02..5fc721929ee85 100644 --- a/x-pack/plugins/monitoring/server/cloud/cloud_response.test.js +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_response.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { CloudServiceResponse } from './cloud_response'; diff --git a/x-pack/plugins/monitoring/server/cloud/cloud_response.js b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_response.ts similarity index 52% rename from x-pack/plugins/monitoring/server/cloud/cloud_response.js rename to src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_response.ts index 5744744dd214e..48291ebff22e7 100644 --- a/x-pack/plugins/monitoring/server/cloud/cloud_response.js +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_response.ts @@ -1,36 +1,63 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ +interface CloudServiceResponseOptions { + id?: string; + vmType?: string; + region?: string; + zone?: string; + metadata?: Record; +} + +export interface CloudServiceResponseJson { + name: string; + id?: string; + vm_type?: string; + region?: string; + zone?: string; + metadata?: Record; +} + /** - * {@code CloudServiceResponse} represents a single response from any individual {@code CloudService}. + * Represents a single response from any individual CloudService. */ export class CloudServiceResponse { + private readonly _name: string; + private readonly _confirmed: boolean; + private readonly _id?: string; + private readonly _vmType?: string; + private readonly _region?: string; + private readonly _zone?: string; + private readonly _metadata?: Record; + /** - * Create an unconfirmed {@code CloudServiceResponse} by the {@code name}. - * - * @param {String} name The name of the {@code CloudService}. - * @return {CloudServiceResponse} Never {@code null}. + * Create an unconfirmed CloudServiceResponse by the name. */ - static unconfirmed(name) { + static unconfirmed(name: string) { return new CloudServiceResponse(name, false, {}); } /** - * Create a new {@code CloudServiceResponse}. + * Create a new CloudServiceResponse. * - * @param {String} name The name of the {@code CloudService}. - * @param {Boolean} confirmed Confirmed to be the current {@code CloudService}. + * @param {String} name The name of the CloudService. + * @param {Boolean} confirmed Confirmed to be the current CloudService. * @param {String} id The optional ID of the VM (depends on the cloud service). * @param {String} vmType The optional type of VM (depends on the cloud service). * @param {String} region The optional region of the VM (depends on the cloud service). * @param {String} availabilityZone The optional availability zone within the region (depends on the cloud service). * @param {Object} metadata The optional metadata associated with the VM. */ - constructor(name, confirmed, { id, vmType, region, zone, metadata }) { + constructor( + name: string, + confirmed: boolean, + { id, vmType, region, zone, metadata }: CloudServiceResponseOptions + ) { this._name = name; this._confirmed = confirmed; this._id = id; @@ -41,9 +68,7 @@ export class CloudServiceResponse { } /** - * Get the name of the {@code CloudService} associated with the current response. - * - * @return {String} The cloud service that created this response. + * Get the name of the CloudService associated with the current response. */ getName() { return this._name; @@ -51,8 +76,6 @@ export class CloudServiceResponse { /** * Determine if the Cloud Service is confirmed to exist. - * - * @return {Boolean} {@code true} to indicate that Kibana is running in this cloud environment. */ isConfirmed() { return this._confirmed; @@ -60,11 +83,8 @@ export class CloudServiceResponse { /** * Create a plain JSON object that can be indexed that represents the response. - * - * @return {Object} Never {@code null} object. - * @throws {Error} if this response is not {@code confirmed}. */ - toJSON() { + toJSON(): CloudServiceResponseJson { if (!this._confirmed) { throw new Error(`[${this._name}] is not confirmed`); } diff --git a/x-pack/plugins/monitoring/server/cloud/cloud_service.test.js b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.test.ts similarity index 65% rename from x-pack/plugins/monitoring/server/cloud/cloud_service.test.js rename to src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.test.ts index 5a0186d9f9b59..0a7d5899486ab 100644 --- a/x-pack/plugins/monitoring/server/cloud/cloud_service.test.js +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.test.ts @@ -1,14 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ -import { CloudService } from './cloud_service'; +import { CloudService, Response } from './cloud_service'; import { CloudServiceResponse } from './cloud_response'; describe('CloudService', () => { + // @ts-expect-error Creating an instance of an abstract class for testing const service = new CloudService('xyz'); describe('getName', () => { @@ -28,13 +30,9 @@ describe('CloudService', () => { describe('_checkIfService', () => { it('throws an exception unless overridden', async () => { - const request = jest.fn(); - - try { - await service._checkIfService(request); - } catch (err) { - expect(err.message).toEqual('not implemented'); - } + expect(async () => { + await service._checkIfService(undefined); + }).rejects.toThrowErrorMatchingInlineSnapshot(`"not implemented"`); }); }); @@ -89,42 +87,46 @@ describe('CloudService', () => { describe('_parseResponse', () => { const body = { some: { body: {} } }; - const tryParseResponse = async (...args) => { - try { - await service._parseResponse(...args); - } catch (err) { - // expected - return; - } - - expect().fail('Should throw exception'); - }; it('throws error upon failure to parse body as object', async () => { - // missing body - await tryParseResponse(); - await tryParseResponse(null); - await tryParseResponse({}); - await tryParseResponse(123); - await tryParseResponse('raw string'); - // malformed JSON object - await tryParseResponse('{{}'); + expect(async () => { + await service._parseResponse(); + }).rejects.toMatchInlineSnapshot(`undefined`); + expect(async () => { + await service._parseResponse(null); + }).rejects.toMatchInlineSnapshot(`undefined`); + expect(async () => { + await service._parseResponse({}); + }).rejects.toMatchInlineSnapshot(`undefined`); + expect(async () => { + await service._parseResponse(123); + }).rejects.toMatchInlineSnapshot(`undefined`); + expect(async () => { + await service._parseResponse('raw string'); + }).rejects.toMatchInlineSnapshot(`[Error: 'raw string' is not a JSON object]`); + expect(async () => { + await service._parseResponse('{{}'); + }).rejects.toMatchInlineSnapshot(`[Error: '{{}' is not a JSON object]`); }); it('expects unusable bodies', async () => { - const parseBody = (parsedBody) => { + const parseBody = (parsedBody: Response['body']) => { expect(parsedBody).toEqual(body); return null; }; - await tryParseResponse(JSON.stringify(body), parseBody); - await tryParseResponse(body, parseBody); + expect(async () => { + await service._parseResponse(JSON.stringify(body), parseBody); + }).rejects.toMatchInlineSnapshot(`undefined`); + expect(async () => { + await service._parseResponse(body, parseBody); + }).rejects.toMatchInlineSnapshot(`undefined`); }); it('uses parsed object to create response', async () => { const serviceResponse = new CloudServiceResponse('a123', true, { id: 'xyz' }); - const parseBody = (parsedBody) => { + const parseBody = (parsedBody: Response['body']) => { expect(parsedBody).toEqual(body); return serviceResponse; diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.ts new file mode 100644 index 0000000000000..768a46a457d7d --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import fs from 'fs'; +import { isObject, isString, isPlainObject } from 'lodash'; +import defaultRequest from 'request'; +import type { OptionsWithUri, Response as DefaultResponse } from 'request'; +import { CloudServiceResponse } from './cloud_response'; + +/** @internal */ +export type Request = typeof defaultRequest; + +/** @internal */ +export type RequestOptions = OptionsWithUri; + +/** @internal */ +export type Response = DefaultResponse; + +/** @internal */ +export interface CloudServiceOptions { + _request?: Request; + _fs?: typeof fs; + _isWindows?: boolean; +} + +/** + * CloudService provides a mechanism for cloud services to be checked for + * metadata that may help to determine the best defaults and priorities. + */ +export abstract class CloudService { + private readonly _request: Request; + protected readonly _name: string; + + constructor(name: string, options: CloudServiceOptions = {}) { + this._name = name.toLowerCase(); + + // Allow the HTTP handler to be swapped out for tests + const { _request = defaultRequest } = options; + + this._request = _request; + } + + /** + * Get the search-friendly name of the Cloud Service. + */ + getName() { + return this._name; + } + + /** + * Using whatever mechanism is required by the current Cloud Service, + * determine if Kibana is running in it and return relevant metadata. + */ + async checkIfService() { + try { + return await this._checkIfService(this._request); + } catch (e) { + return this._createUnconfirmedResponse(); + } + } + + _checkIfService(request: Request): Promise { + // should always be overridden by a subclass + return Promise.reject(new Error('not implemented')); + } + + /** + * Create a new CloudServiceResponse that denotes that this cloud service + * is not being used by the current machine / VM. + */ + _createUnconfirmedResponse() { + return CloudServiceResponse.unconfirmed(this._name); + } + + /** + * Strictly parse JSON. + */ + _stringToJson(value: string) { + // note: this will throw an error if this is not a string + value = value.trim(); + + try { + const json = JSON.parse(value); + // we don't want to return scalar values, arrays, etc. + if (!isPlainObject(json)) { + throw new Error('not a plain object'); + } + return json; + } catch (e) { + throw new Error(`'${value}' is not a JSON object`); + } + } + + /** + * Convert the response to a JSON object and attempt to parse it using the + * parseBody function. + * + * If the response cannot be parsed as a JSON object, or if it fails to be + * useful, then parseBody should return null. + */ + _parseResponse( + body: Response['body'], + parseBody?: (body: Response['body']) => CloudServiceResponse | null + ): Promise { + // parse it if necessary + if (isString(body)) { + try { + body = this._stringToJson(body); + } catch (err) { + return Promise.reject(err); + } + } + + if (isObject(body) && parseBody) { + const response = parseBody(body); + + if (response) { + return Promise.resolve(response); + } + } + + // use default handling + return Promise.reject(); + } +} diff --git a/x-pack/plugins/monitoring/server/cloud/gcp.test.js b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.test.ts similarity index 66% rename from x-pack/plugins/monitoring/server/cloud/gcp.test.js rename to src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.test.ts index 803c6f31af3b9..fd0b3331b4ad1 100644 --- a/x-pack/plugins/monitoring/server/cloud/gcp.test.js +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.test.ts @@ -1,11 +1,17 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ -import { GCP } from './gcp'; +import type { Request, RequestOptions } from './cloud_service'; +import { GCPCloudService } from './gcp'; + +type Callback = (err: unknown, res: unknown) => void; + +const GCP = new GCPCloudService(); describe('GCP', () => { it('is named "gcp"', () => { @@ -17,30 +23,28 @@ describe('GCP', () => { const headers = { 'metadata-flavor': 'Google' }; it('handles expected responses', async () => { - const metadata = { + const metadata: Record = { id: 'abcdef', 'machine-type': 'projects/441331612345/machineTypes/f1-micro', zone: 'projects/441331612345/zones/us-fake4-c', }; - const request = (req, callback) => { + const request = ((req: RequestOptions, callback: Callback) => { const basePath = 'http://169.254.169.254/computeMetadata/v1/instance/'; expect(req.method).toEqual('GET'); - expect(req.uri.startsWith(basePath)).toBe(true); - expect(req.headers['Metadata-Flavor']).toEqual('Google'); + expect((req.uri as string).startsWith(basePath)).toBe(true); + expect(req.headers!['Metadata-Flavor']).toEqual('Google'); expect(req.json).toEqual(false); - const requestKey = req.uri.substring(basePath.length); + const requestKey = (req.uri as string).substring(basePath.length); let body = null; if (metadata[requestKey]) { body = metadata[requestKey]; - } else { - expect().fail(`Unknown field requested [${requestKey}]`); } - callback(null, { statusCode: 200, body, headers }, body); - }; + callback(null, { statusCode: 200, body, headers }); + }) as Request; const response = await GCP._checkIfService(request); expect(response.isConfirmed()).toEqual(true); @@ -56,79 +60,63 @@ describe('GCP', () => { // NOTE: the CloudService method, checkIfService, catches the errors that follow it('handles unexpected responses', async () => { - const request = (_req, callback) => callback(null, { statusCode: 200, headers }); + const request = ((_req: RequestOptions, callback: Callback) => + callback(null, { statusCode: 200, headers })) as Request; - try { + expect(async () => { await GCP._checkIfService(request); - } catch (err) { - // ignored - return; - } - - expect().fail('Method should throw exception (Promise.reject)'); + }).rejects.toThrowErrorMatchingInlineSnapshot(`"unrecognized responses"`); }); it('handles unexpected responses without response header', async () => { const body = 'xyz'; - const request = (_req, callback) => callback(null, { statusCode: 200, body }, body); - - try { - await GCP._checkIfService(request); - } catch (err) { - // ignored - return; - } + const failedRequest = ((_req: RequestOptions, callback: Callback) => + callback(null, { statusCode: 200, body })) as Request; - expect().fail('Method should throw exception (Promise.reject)'); + expect(async () => { + await GCP._checkIfService(failedRequest); + }).rejects.toThrowErrorMatchingInlineSnapshot(`"unrecognized responses"`); }); it('handles not running on GCP with error by rethrowing it', async () => { const someError = new Error('expected: request failed'); - const failedRequest = (_req, callback) => callback(someError, null); + const failedRequest = ((_req: RequestOptions, callback: Callback) => + callback(someError, null)) as Request; - try { + expect(async () => { await GCP._checkIfService(failedRequest); - - expect().fail('Method should throw exception (Promise.reject)'); - } catch (err) { - expect(err.message).toEqual(someError.message); - } + }).rejects.toThrowError(someError); }); it('handles not running on GCP with 404 response by throwing error', async () => { const body = 'This is some random error text'; - const failedRequest = (_req, callback) => - callback(null, { statusCode: 404, headers, body }, body); + const failedRequest = ((_req: RequestOptions, callback: Callback) => + callback(null, { statusCode: 404, headers, body })) as Request; - try { + expect(async () => { await GCP._checkIfService(failedRequest); - } catch (err) { - // ignored - return; - } - - expect().fail('Method should throw exception (Promise.reject)'); + }).rejects.toThrowErrorMatchingInlineSnapshot(`"GCP request failed"`); }); it('handles not running on GCP with unexpected response by throwing error', async () => { - const failedRequest = (_req, callback) => callback(null, null); + const failedRequest = ((_req: RequestOptions, callback: Callback) => + callback(null, null)) as Request; - try { + expect(async () => { await GCP._checkIfService(failedRequest); - } catch (err) { - // ignored - return; - } - - expect().fail('Method should throw exception (Promise.reject)'); + }).rejects.toThrowErrorMatchingInlineSnapshot(`"GCP request failed"`); }); }); describe('_extractValue', () => { it('only handles strings', () => { + // @ts-expect-error expect(GCP._extractValue()).toBe(undefined); + // @ts-expect-error expect(GCP._extractValue(null, null)).toBe(undefined); + // @ts-expect-error expect(GCP._extractValue('abc', { field: 'abcxyz' })).toBe(undefined); + // @ts-expect-error expect(GCP._extractValue('abc', 1234)).toBe(undefined); expect(GCP._extractValue('abc/', 'abc/xyz')).toEqual('xyz'); }); @@ -179,12 +167,17 @@ describe('GCP', () => { }); it('ignores unexpected response body', () => { + // @ts-expect-error expect(() => GCP._combineResponses()).toThrow(); + // @ts-expect-error expect(() => GCP._combineResponses(undefined, undefined, undefined)).toThrow(); + // @ts-expect-error expect(() => GCP._combineResponses(null, null, null)).toThrow(); expect(() => + // @ts-expect-error GCP._combineResponses({ id: 'x' }, { machineType: 'a' }, { zone: 'b' }) ).toThrow(); + // @ts-expect-error expect(() => GCP._combineResponses({ privateIp: 'a.b.c.d' })).toThrow(); }); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.ts new file mode 100644 index 0000000000000..565c07abd1d2c --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isString } from 'lodash'; +import { promisify } from 'util'; +import { CloudService, CloudServiceOptions, Request, Response } from './cloud_service'; +import { CloudServiceResponse } from './cloud_response'; + +// GCP documentation shows both 'metadata.google.internal' (mostly) and '169.254.169.254' (sometimes) +// To bypass potential DNS changes, the IP was used because it's shared with other cloud services +const SERVICE_ENDPOINT = 'http://169.254.169.254/computeMetadata/v1/instance'; + +/** + * Checks and loads the service metadata for an Google Cloud Platform VM if it is available. + * + * @internal + */ +export class GCPCloudService extends CloudService { + constructor(options: CloudServiceOptions = {}) { + super('gcp', options); + } + + _checkIfService(request: Request) { + // we need to call GCP individually for each field we want metadata for + const fields = ['id', 'machine-type', 'zone']; + + const create = this._createRequestForField; + const allRequests = fields.map((field) => promisify(request)(create(field))); + return ( + Promise.all(allRequests) + // Note: there is no fallback option for GCP; + // responses are arrays containing [fullResponse, body]; + // because GCP returns plaintext, we have no way of validating + // without using the response code. + .then((responses) => { + return responses.map((response) => { + if (!response || response.statusCode === 404) { + throw new Error('GCP request failed'); + } + return this._extractBody(response, response.body); + }); + }) + .then(([id, machineType, zone]) => this._combineResponses(id, machineType, zone)) + ); + } + + _createRequestForField(field: string) { + return { + method: 'GET', + uri: `${SERVICE_ENDPOINT}/${field}`, + headers: { + // GCP requires this header + 'Metadata-Flavor': 'Google', + }, + // GCP does _not_ return JSON + json: false, + }; + } + + /** + * Extract the body if the response is valid and it came from GCP. + */ + _extractBody(response: Response, body?: Response['body']) { + if ( + response?.statusCode === 200 && + response.headers && + response.headers['metadata-flavor'] === 'Google' + ) { + return body; + } + + return null; + } + + /** + * Parse the GCP responses, if possible. + * + * Example values for each parameter: + * + * vmId: '5702733457649812345' + * machineType: 'projects/441331612345/machineTypes/f1-micro' + * zone: 'projects/441331612345/zones/us-east4-c' + */ + _combineResponses(id: string, machineType: string, zone: string) { + const vmId = isString(id) ? id.trim() : undefined; + const vmType = this._extractValue('machineTypes/', machineType); + const vmZone = this._extractValue('zones/', zone); + + let region; + + if (vmZone) { + // converts 'us-east4-c' into 'us-east4' + region = vmZone.substring(0, vmZone.lastIndexOf('-')); + } + + // ensure we actually have some data + if (vmId || vmType || region || vmZone) { + return new CloudServiceResponse(this._name, true, { id: vmId, vmType, region, zone: vmZone }); + } + + throw new Error('unrecognized responses'); + } + + /** + * Extract the useful information returned from GCP while discarding + * unwanted account details (the project ID). + * + * For example, this turns something like + * 'projects/441331612345/machineTypes/f1-micro' into 'f1-micro'. + */ + _extractValue(fieldPrefix: string, value: string) { + if (isString(value)) { + const index = value.lastIndexOf(fieldPrefix); + + if (index !== -1) { + return value.substring(index + fieldPrefix.length).trim(); + } + } + + return undefined; + } +} diff --git a/x-pack/plugins/monitoring/server/cloud/index.js b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/index.ts similarity index 53% rename from x-pack/plugins/monitoring/server/cloud/index.js rename to src/plugins/kibana_usage_collection/server/collectors/cloud/detector/index.ts index 5b64a0be96216..ce82cadb15ad5 100644 --- a/x-pack/plugins/monitoring/server/cloud/index.js +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/index.ts @@ -1,9 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ export { CloudDetector } from './cloud_detector'; -export { CLOUD_SERVICES } from './cloud_services'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/index.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/index.ts new file mode 100644 index 0000000000000..7e2c7c891305f --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { registerCloudProviderUsageCollector } from './cloud_provider_collector'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/index.ts b/src/plugins/kibana_usage_collection/server/collectors/index.ts index 10156b51ac183..89e1e6e79482c 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/index.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/index.ts @@ -11,6 +11,7 @@ export { registerManagementUsageCollector } from './management'; export { registerApplicationUsageCollector } from './application_usage'; export { registerKibanaUsageCollector } from './kibana'; export { registerOpsStatsCollector } from './ops_stats'; +export { registerCloudProviderUsageCollector } from './cloud'; export { registerCspCollector } from './csp'; export { registerCoreUsageCollector } from './core'; export { registerLocalizationUsageCollector } from './localization'; diff --git a/src/plugins/kibana_usage_collection/server/index.test.mocks.ts b/src/plugins/kibana_usage_collection/server/index.test.mocks.ts new file mode 100644 index 0000000000000..7df27a3719e92 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/index.test.mocks.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { cloudDetectorMock } from './collectors/cloud/detector/cloud_detector.mock'; + +const mock = cloudDetectorMock.create(); + +export const cloudDetailsMock = mock.getCloudDetails; +export const detectCloudServiceMock = mock.detectCloudService; + +jest.doMock('./collectors/cloud/detector', () => ({ + CloudDetector: jest.fn().mockImplementation(() => mock), +})); diff --git a/src/plugins/kibana_usage_collection/server/index.test.ts b/src/plugins/kibana_usage_collection/server/index.test.ts index ee6df366b788f..b4c52f8353d79 100644 --- a/src/plugins/kibana_usage_collection/server/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/index.test.ts @@ -15,6 +15,7 @@ import { CollectorOptions, createUsageCollectionSetupMock, } from '../../usage_collection/server/usage_collection.mock'; +import { cloudDetailsMock } from './index.test.mocks'; import { plugin } from './'; @@ -33,6 +34,10 @@ describe('kibana_usage_collection', () => { return createUsageCollectionSetupMock().makeStatsCollector(opts); }); + beforeEach(() => { + cloudDetailsMock.mockClear(); + }); + test('Runs the setup method without issues', () => { const coreSetup = coreMock.createSetup(); @@ -50,6 +55,12 @@ describe('kibana_usage_collection', () => { coreStart.uiSettings.asScopedToClient.mockImplementation(() => uiSettingsServiceMock.createClient() ); + cloudDetailsMock.mockReturnValueOnce({ + name: 'my-cloud', + vm_type: 'big', + region: 'my-home', + zone: 'my-home-office', + }); expect(pluginInstance.start(coreStart)).toBe(undefined); usageCollectors.forEach(({ isReady }) => { diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index 5b903489e3ff3..74d2d281ff8f6 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -28,6 +28,7 @@ import { registerManagementUsageCollector, registerOpsStatsCollector, registerUiMetricUsageCollector, + registerCloudProviderUsageCollector, registerCspCollector, registerCoreUsageCollector, registerLocalizationUsageCollector, @@ -102,6 +103,7 @@ export class KibanaUsageCollectionPlugin implements Plugin { registerType, getSavedObjectsClient ); + registerCloudProviderUsageCollector(usageCollection); registerCspCollector(usageCollection, coreSetup.http); registerCoreUsageCollector(usageCollection, getCoreUsageDataService); registerLocalizationUsageCollector(usageCollection, coreSetup.i18n); diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index d8bcf150ac167..41b75824e992d 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -6445,6 +6445,34 @@ } } }, + "cloud_provider": { + "properties": { + "name": { + "type": "keyword", + "_meta": { + "description": "The name of the cloud provider" + } + }, + "vm_type": { + "type": "keyword", + "_meta": { + "description": "The VM instance type" + } + }, + "region": { + "type": "keyword", + "_meta": { + "description": "The cloud provider region" + } + }, + "zone": { + "type": "keyword", + "_meta": { + "description": "The availability zone within the region" + } + } + } + }, "core": { "properties": { "config": { diff --git a/x-pack/plugins/monitoring/common/constants.ts b/x-pack/plugins/monitoring/common/constants.ts index a6184261350b7..bf6e32af0dc39 100644 --- a/x-pack/plugins/monitoring/common/constants.ts +++ b/x-pack/plugins/monitoring/common/constants.ts @@ -97,23 +97,6 @@ export const CALCULATE_DURATION_UNTIL = 'until'; */ export const ML_SUPPORTED_LICENSES = ['trial', 'platinum', 'enterprise']; -/** - * Metadata service URLs for the different cloud services that have constant URLs (e.g., unlike GCP, which is a constant prefix). - * - * @type {Object} - */ -export const CLOUD_METADATA_SERVICES = { - // We explicitly call out the version, 2016-09-02, rather than 'latest' to avoid unexpected changes - AWS_URL: 'http://169.254.169.254/2016-09-02/dynamic/instance-identity/document', - - // 2017-04-02 is the first GA release of this API - AZURE_URL: 'http://169.254.169.254/metadata/instance?api-version=2017-04-02', - - // GCP documentation shows both 'metadata.google.internal' (mostly) and '169.254.169.254' (sometimes) - // To bypass potential DNS changes, the IP was used because it's shared with other cloud services - GCP_URL_PREFIX: 'http://169.254.169.254/computeMetadata/v1/instance', -}; - /** * Constants used by Logstash monitoring code */ diff --git a/x-pack/plugins/monitoring/server/cloud/aws.js b/x-pack/plugins/monitoring/server/cloud/aws.js deleted file mode 100644 index 45b3b80162875..0000000000000 --- a/x-pack/plugins/monitoring/server/cloud/aws.js +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get, isString, omit } from 'lodash'; -import { promisify } from 'util'; -import { CloudService } from './cloud_service'; -import { CloudServiceResponse } from './cloud_response'; -import fs from 'fs'; -import { CLOUD_METADATA_SERVICES } from '../../common/constants'; - -/** - * {@code AWSCloudService} will check and load the service metadata for an Amazon Web Service VM if it is available. - * - * This is exported for testing purposes. Use the {@code AWS} singleton. - */ -export class AWSCloudService extends CloudService { - constructor(options = {}) { - super('aws', options); - - // Allow the file system handler to be swapped out for tests - const { _fs = fs, _isWindows = process.platform.startsWith('win') } = options; - - this._fs = _fs; - this._isWindows = _isWindows; - } - - _checkIfService(request) { - const req = { - method: 'GET', - uri: CLOUD_METADATA_SERVICES.AWS_URL, - json: true, - }; - - return ( - promisify(request)(req) - .then((response) => this._parseResponse(response.body, (body) => this._parseBody(body))) - // fall back to file detection - .catch(() => this._tryToDetectUuid()) - ); - } - - /** - * Parse the AWS response, if possible. Example payload (with fake accountId value): - * - * { - * "devpayProductCodes" : null, - * "privateIp" : "10.0.0.38", - * "availabilityZone" : "us-west-2c", - * "version" : "2010-08-31", - * "instanceId" : "i-0c7a5b7590a4d811c", - * "billingProducts" : null, - * "instanceType" : "t2.micro", - * "imageId" : "ami-6df1e514", - * "accountId" : "1234567890", - * "architecture" : "x86_64", - * "kernelId" : null, - * "ramdiskId" : null, - * "pendingTime" : "2017-07-06T02:09:12Z", - * "region" : "us-west-2" - * } - * - * @param {Object} body The response from the VM web service. - * @return {CloudServiceResponse} {@code null} if not confirmed. Otherwise the response. - */ - _parseBody(body) { - const id = get(body, 'instanceId'); - const vmType = get(body, 'instanceType'); - const region = get(body, 'region'); - const zone = get(body, 'availabilityZone'); - const metadata = omit(body, [ - // remove keys we already have - 'instanceId', - 'instanceType', - 'region', - 'availabilityZone', - // remove keys that give too much detail - 'accountId', - 'billingProducts', - 'devpayProductCodes', - 'privateIp', - ]); - - // ensure we actually have some data - if (id || vmType || region || zone) { - return new CloudServiceResponse(this._name, true, { id, vmType, region, zone, metadata }); - } - - return null; - } - - /** - * Attempt to load the UUID by checking `/sys/hypervisor/uuid`. This is a fallback option if the metadata service is - * unavailable for some reason. - * - * @return {Promise} Never {@code null} {@code CloudServiceResponse}. - */ - _tryToDetectUuid() { - // Windows does not have an easy way to check - if (!this._isWindows) { - return promisify(this._fs.readFile)('/sys/hypervisor/uuid', 'utf8').then((uuid) => { - if (isString(uuid)) { - // Some AWS APIs return it lowercase (like the file did in testing), while others return it uppercase - uuid = uuid.trim().toLowerCase(); - - if (uuid.startsWith('ec2')) { - return new CloudServiceResponse(this._name, true, { id: uuid }); - } - } - - return this._createUnconfirmedResponse(); - }); - } - - return Promise.resolve(this._createUnconfirmedResponse()); - } -} - -/** - * Singleton instance of {@code AWSCloudService}. - * - * @type {AWSCloudService} - */ -export const AWS = new AWSCloudService(); diff --git a/x-pack/plugins/monitoring/server/cloud/aws.test.js b/x-pack/plugins/monitoring/server/cloud/aws.test.js deleted file mode 100644 index 877a1958f0096..0000000000000 --- a/x-pack/plugins/monitoring/server/cloud/aws.test.js +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AWS, AWSCloudService } from './aws'; - -describe('AWS', () => { - const expectedFilename = '/sys/hypervisor/uuid'; - const expectedEncoding = 'utf8'; - // mixed case to ensure we check for ec2 after lowercasing - const ec2Uuid = 'eC2abcdef-ghijk\n'; - const ec2FileSystem = { - readFile: (filename, encoding, callback) => { - expect(filename).toEqual(expectedFilename); - expect(encoding).toEqual(expectedEncoding); - - callback(null, ec2Uuid); - }, - }; - - it('is named "aws"', () => { - expect(AWS.getName()).toEqual('aws'); - }); - - describe('_checkIfService', () => { - it('handles expected response', async () => { - const id = 'abcdef'; - const request = (req, callback) => { - expect(req.method).toEqual('GET'); - expect(req.uri).toEqual( - 'http://169.254.169.254/2016-09-02/dynamic/instance-identity/document' - ); - expect(req.json).toEqual(true); - - const body = `{"instanceId": "${id}","availabilityZone":"us-fake-2c", "imageId" : "ami-6df1e514"}`; - - callback(null, { statusCode: 200, body }, body); - }; - // ensure it does not use the fs to trump the body - const awsCheckedFileSystem = new AWSCloudService({ - _fs: ec2FileSystem, - _isWindows: false, - }); - - const response = await awsCheckedFileSystem._checkIfService(request); - - expect(response.isConfirmed()).toEqual(true); - expect(response.toJSON()).toEqual({ - name: AWS.getName(), - id, - region: undefined, - vm_type: undefined, - zone: 'us-fake-2c', - metadata: { - imageId: 'ami-6df1e514', - }, - }); - }); - - it('handles request without a usable body by downgrading to UUID detection', async () => { - const request = (_req, callback) => callback(null, { statusCode: 404 }); - const awsCheckedFileSystem = new AWSCloudService({ - _fs: ec2FileSystem, - _isWindows: false, - }); - - const response = await awsCheckedFileSystem._checkIfService(request); - - expect(response.isConfirmed()).toBe(true); - expect(response.toJSON()).toEqual({ - name: AWS.getName(), - id: ec2Uuid.trim().toLowerCase(), - region: undefined, - vm_type: undefined, - zone: undefined, - metadata: undefined, - }); - }); - - it('handles request failure by downgrading to UUID detection', async () => { - const failedRequest = (_req, callback) => - callback(new Error('expected: request failed'), null); - const awsCheckedFileSystem = new AWSCloudService({ - _fs: ec2FileSystem, - _isWindows: false, - }); - - const response = await awsCheckedFileSystem._checkIfService(failedRequest); - - expect(response.isConfirmed()).toBe(true); - expect(response.toJSON()).toEqual({ - name: AWS.getName(), - id: ec2Uuid.trim().toLowerCase(), - region: undefined, - vm_type: undefined, - zone: undefined, - metadata: undefined, - }); - }); - - it('handles not running on AWS', async () => { - const failedRequest = (_req, callback) => callback(null, null); - const awsIgnoredFileSystem = new AWSCloudService({ - _fs: ec2FileSystem, - _isWindows: true, - }); - - const response = await awsIgnoredFileSystem._checkIfService(failedRequest); - - expect(response.getName()).toEqual(AWS.getName()); - expect(response.isConfirmed()).toBe(false); - }); - }); - - describe('_parseBody', () => { - it('parses object in expected format', () => { - const body = { - devpayProductCodes: null, - privateIp: '10.0.0.38', - availabilityZone: 'us-west-2c', - version: '2010-08-31', - instanceId: 'i-0c7a5b7590a4d811c', - billingProducts: null, - instanceType: 't2.micro', - accountId: '1234567890', - architecture: 'x86_64', - kernelId: null, - ramdiskId: null, - imageId: 'ami-6df1e514', - pendingTime: '2017-07-06T02:09:12Z', - region: 'us-west-2', - }; - - const response = AWS._parseBody(body); - - expect(response.getName()).toEqual(AWS.getName()); - expect(response.isConfirmed()).toEqual(true); - expect(response.toJSON()).toEqual({ - name: 'aws', - id: 'i-0c7a5b7590a4d811c', - vm_type: 't2.micro', - region: 'us-west-2', - zone: 'us-west-2c', - metadata: { - version: '2010-08-31', - architecture: 'x86_64', - kernelId: null, - ramdiskId: null, - imageId: 'ami-6df1e514', - pendingTime: '2017-07-06T02:09:12Z', - }, - }); - }); - - it('ignores unexpected response body', () => { - expect(AWS._parseBody(undefined)).toBe(null); - expect(AWS._parseBody(null)).toBe(null); - expect(AWS._parseBody({})).toBe(null); - expect(AWS._parseBody({ privateIp: 'a.b.c.d' })).toBe(null); - }); - }); - - describe('_tryToDetectUuid', () => { - it('checks the file system for UUID if not Windows', async () => { - const awsCheckedFileSystem = new AWSCloudService({ - _fs: ec2FileSystem, - _isWindows: false, - }); - - const response = await awsCheckedFileSystem._tryToDetectUuid(); - - expect(response.isConfirmed()).toEqual(true); - expect(response.toJSON()).toEqual({ - name: AWS.getName(), - id: ec2Uuid.trim().toLowerCase(), - region: undefined, - zone: undefined, - vm_type: undefined, - metadata: undefined, - }); - }); - - it('ignores UUID if it does not start with ec2', async () => { - const notEC2FileSystem = { - readFile: (filename, encoding, callback) => { - expect(filename).toEqual(expectedFilename); - expect(encoding).toEqual(expectedEncoding); - - callback(null, 'notEC2'); - }, - }; - - const awsCheckedFileSystem = new AWSCloudService({ - _fs: notEC2FileSystem, - _isWindows: false, - }); - - const response = await awsCheckedFileSystem._tryToDetectUuid(); - - expect(response.isConfirmed()).toEqual(false); - }); - - it('does NOT check the file system for UUID on Windows', async () => { - const awsUncheckedFileSystem = new AWSCloudService({ - _fs: ec2FileSystem, - _isWindows: true, - }); - - const response = await awsUncheckedFileSystem._tryToDetectUuid(); - - expect(response.isConfirmed()).toEqual(false); - }); - - it('does NOT handle file system exceptions', async () => { - const fileDNE = new Error('File DNE'); - const awsFailedFileSystem = new AWSCloudService({ - _fs: { - readFile: () => { - throw fileDNE; - }, - }, - _isWindows: false, - }); - - try { - await awsFailedFileSystem._tryToDetectUuid(); - - expect().fail('Method should throw exception (Promise.reject)'); - } catch (err) { - expect(err).toBe(fileDNE); - } - }); - }); -}); diff --git a/x-pack/plugins/monitoring/server/cloud/azure.js b/x-pack/plugins/monitoring/server/cloud/azure.js deleted file mode 100644 index 4d026441d6840..0000000000000 --- a/x-pack/plugins/monitoring/server/cloud/azure.js +++ /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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get, omit } from 'lodash'; -import { promisify } from 'util'; -import { CloudService } from './cloud_service'; -import { CloudServiceResponse } from './cloud_response'; -import { CLOUD_METADATA_SERVICES } from '../../common/constants'; - -/** - * {@code AzureCloudService} will check and load the service metadata for an Azure VM if it is available. - */ -class AzureCloudService extends CloudService { - constructor(options = {}) { - super('azure', options); - } - - _checkIfService(request) { - const req = { - method: 'GET', - uri: CLOUD_METADATA_SERVICES.AZURE_URL, - headers: { - // Azure requires this header - Metadata: 'true', - }, - json: true, - }; - - return ( - promisify(request)(req) - // Note: there is no fallback option for Azure - .then((response) => { - return this._parseResponse(response.body, (body) => this._parseBody(body)); - }) - ); - } - - /** - * Parse the Azure response, if possible. Example payload (with network object ignored): - * - * { - * "compute": { - * "location": "eastus", - * "name": "my-ubuntu-vm", - * "offer": "UbuntuServer", - * "osType": "Linux", - * "platformFaultDomain": "0", - * "platformUpdateDomain": "0", - * "publisher": "Canonical", - * "sku": "16.04-LTS", - * "version": "16.04.201706191", - * "vmId": "d4c57456-2b3b-437a-9f1f-7082cfce02d4", - * "vmSize": "Standard_A1" - * }, - * "network": { - * ... - * } - * } - * - * Note: Azure VMs created using the "classic" method, as opposed to the resource manager, - * do not provide a "compute" field / object. However, both report the "network" field / object. - * - * @param {Object} body The response from the VM web service. - * @return {CloudServiceResponse} {@code null} for default fallback. - */ - _parseBody(body) { - const compute = get(body, 'compute'); - const id = get(compute, 'vmId'); - const vmType = get(compute, 'vmSize'); - const region = get(compute, 'location'); - - // remove keys that we already have; explicitly undefined so we don't send it when empty - const metadata = compute ? omit(compute, ['vmId', 'vmSize', 'location']) : undefined; - - // we don't actually use network, but we check for its existence to see if this is a response from Azure - const network = get(body, 'network'); - - // ensure we actually have some data - if (id || vmType || region) { - return new CloudServiceResponse(this._name, true, { id, vmType, region, metadata }); - } else if (network) { - // classic-managed VMs in Azure don't provide compute so we highlight the lack of info - return new CloudServiceResponse(this._name, true, { metadata: { classic: true } }); - } - - return null; - } -} - -/** - * Singleton instance of {@code AzureCloudService}. - * - * @type {AzureCloudService} - */ -export const AZURE = new AzureCloudService(); diff --git a/x-pack/plugins/monitoring/server/cloud/cloud_detector.js b/x-pack/plugins/monitoring/server/cloud/cloud_detector.js deleted file mode 100644 index 2cd2b26daab5b..0000000000000 --- a/x-pack/plugins/monitoring/server/cloud/cloud_detector.js +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CLOUD_SERVICES } from './cloud_services'; - -/** - * {@code CloudDetector} can be used to asynchronously detect the cloud service that Kibana is running within. - */ -export class CloudDetector { - constructor(options = {}) { - const { cloudServices = CLOUD_SERVICES } = options; - - this._cloudServices = cloudServices; - // Explicitly undefined. If the value is never updated, then the property will be dropped when the data is serialized. - this._cloudDetails = undefined; - } - - /** - * Get any cloud details that we have detected. - * - * @return {Object} {@code undefined} if unknown. Otherwise plain JSON. - */ - getCloudDetails() { - return this._cloudDetails; - } - - /** - * Asynchronously detect the cloud service. - * - * Callers are _not_ expected to {@code await} this method, which allows the caller to trigger the lookup and then simply use it - * whenever we determine it. - */ - async detectCloudService() { - this._cloudDetails = await this._getCloudService(this._cloudServices); - } - - /** - * Check every cloud service until the first one reports success from detection. - * - * @param {Array} cloudServices The {@code CloudService} objects listed in priority order - * @return {Promise} {@code undefined} if none match. Otherwise the plain JSON {@code Object} from the {@code CloudServiceResponse}. - */ - async _getCloudService(cloudServices) { - // check each service until we find one that is confirmed to match; order is assumed to matter - for (const service of cloudServices) { - try { - const serviceResponse = await service.checkIfService(); - - if (serviceResponse.isConfirmed()) { - return serviceResponse.toJSON(); - } - } catch (ignoredError) { - // ignored until we make wider use of this in the UI - } - } - - // explicitly undefined rather than null so that it can be ignored in JSON - return undefined; - } -} diff --git a/x-pack/plugins/monitoring/server/cloud/cloud_service.js b/x-pack/plugins/monitoring/server/cloud/cloud_service.js deleted file mode 100644 index ea0eb9534cf30..0000000000000 --- a/x-pack/plugins/monitoring/server/cloud/cloud_service.js +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { isObject, isString } from 'lodash'; -import request from 'request'; -import { CloudServiceResponse } from './cloud_response'; - -/** - * {@code CloudService} provides a mechanism for cloud services to be checked for metadata - * that may help to determine the best defaults and priorities. - */ -export class CloudService { - constructor(name, options = {}) { - this._name = name.toLowerCase(); - - // Allow the HTTP handler to be swapped out for tests - const { _request = request } = options; - - this._request = _request; - } - - /** - * Get the search-friendly name of the Cloud Service. - * - * @return {String} Never {@code null}. - */ - getName() { - return this._name; - } - - /** - * Using whatever mechanism is required by the current Cloud Service, determine - * Kibana is running in it and return relevant metadata. - * - * @return {Promise} Never {@code null} {@code CloudServiceResponse}. - */ - checkIfService() { - return this._checkIfService(this._request).catch(() => this._createUnconfirmedResponse()); - } - - /** - * Using whatever mechanism is required by the current Cloud Service, determine - * Kibana is running in it and return relevant metadata. - * - * @param {Object} _request 'request' HTTP handler. - * @return {Promise} Never {@code null} {@code CloudServiceResponse}. - */ - _checkIfService() { - return Promise.reject(new Error('not implemented')); - } - - /** - * Create a new {@code CloudServiceResponse} that denotes that this cloud service is not being used by the current machine / VM. - * - * @return {CloudServiceResponse} Never {@code null}. - */ - _createUnconfirmedResponse() { - return CloudServiceResponse.unconfirmed(this._name); - } - - /** - * Strictly parse JSON. - * - * @param {String} value The string to parse as a JSON object - * @return {Object} The result of {@code JSON.parse} if it's an object. - * @throws {Error} if the {@code value} is not a String that can be converted into an Object - */ - _stringToJson(value) { - // note: this will throw an error if this is not a string - value = value.trim(); - - // we don't want to return scalar values, arrays, etc. - if (value.startsWith('{') && value.endsWith('}')) { - return JSON.parse(value); - } - - throw new Error(`'${value}' is not a JSON object`); - } - - /** - * Convert the {@code response} to a JSON object and attempt to parse it using the {@code parseBody} function. - * - * If the {@code response} cannot be parsed as a JSON object, or if it fails to be useful, then {@code parseBody} should return - * {@code null}. - * - * @param {Object} body The body from the response from the VM web service. - * @param {Function} parseBody Single argument function that accepts parsed JSON body from the response. - * @return {Promise} Never {@code null} {@code CloudServiceResponse} or rejection. - */ - _parseResponse(body, parseBody) { - // parse it if necessary - if (isString(body)) { - try { - body = this._stringToJson(body); - } catch (err) { - return Promise.reject(err); - } - } - - if (isObject(body)) { - const response = parseBody(body); - - if (response) { - return Promise.resolve(response); - } - } - - // use default handling - return Promise.reject(); - } -} diff --git a/x-pack/plugins/monitoring/server/cloud/cloud_services.js b/x-pack/plugins/monitoring/server/cloud/cloud_services.js deleted file mode 100644 index 23be0d0e20e25..0000000000000 --- a/x-pack/plugins/monitoring/server/cloud/cloud_services.js +++ /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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AWS } from './aws'; -import { AZURE } from './azure'; -import { GCP } from './gcp'; - -/** - * An iteratable that can be used to loop across all known cloud services to detect them. - * - * @type {Array} - */ -export const CLOUD_SERVICES = [AWS, GCP, AZURE]; diff --git a/x-pack/plugins/monitoring/server/cloud/cloud_services.test.js b/x-pack/plugins/monitoring/server/cloud/cloud_services.test.js deleted file mode 100644 index adf4bf2bb0f0f..0000000000000 --- a/x-pack/plugins/monitoring/server/cloud/cloud_services.test.js +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CLOUD_SERVICES } from './cloud_services'; -import { AWS } from './aws'; -import { AZURE } from './azure'; -import { GCP } from './gcp'; - -describe('cloudServices', () => { - const expectedOrder = [AWS, GCP, AZURE]; - - it('iterates in expected order', () => { - let i = 0; - for (const service of CLOUD_SERVICES) { - expect(service).toBe(expectedOrder[i++]); - } - }); -}); diff --git a/x-pack/plugins/monitoring/server/cloud/gcp.js b/x-pack/plugins/monitoring/server/cloud/gcp.js deleted file mode 100644 index ab8935769b312..0000000000000 --- a/x-pack/plugins/monitoring/server/cloud/gcp.js +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { isString } from 'lodash'; -import { promisify } from 'util'; -import { CloudService } from './cloud_service'; -import { CloudServiceResponse } from './cloud_response'; -import { CLOUD_METADATA_SERVICES } from '../../common/constants'; - -/** - * {@code GCPCloudService} will check and load the service metadata for an Google Cloud Platform VM if it is available. - */ -class GCPCloudService extends CloudService { - constructor(options = {}) { - super('gcp', options); - } - - _checkIfService(request) { - // we need to call GCP individually for each field - const fields = ['id', 'machine-type', 'zone']; - - const create = this._createRequestForField; - const allRequests = fields.map((field) => promisify(request)(create(field))); - return ( - Promise.all(allRequests) - /* - Note: there is no fallback option for GCP; - responses are arrays containing [fullResponse, body]; - because GCP returns plaintext, we have no way of validating without using the response code - */ - .then((responses) => { - return responses.map((response) => { - return this._extractBody(response, response.body); - }); - }) - .then(([id, machineType, zone]) => this._combineResponses(id, machineType, zone)) - ); - } - - _createRequestForField(field) { - return { - method: 'GET', - uri: `${CLOUD_METADATA_SERVICES.GCP_URL_PREFIX}/${field}`, - headers: { - // GCP requires this header - 'Metadata-Flavor': 'Google', - }, - // GCP does _not_ return JSON - json: false, - }; - } - - /** - * Extract the body if the response is valid and it came from GCP. - * - * @param {Object} response The response object - * @param {Object} body The response body, if any - * @return {Object} {@code body} (probably actually a String) if the response came from GCP. Otherwise {@code null}. - */ - _extractBody(response, body) { - if ( - response && - response.statusCode === 200 && - response.headers && - response.headers['metadata-flavor'] === 'Google' - ) { - return body; - } - - return null; - } - - /** - * Parse the GCP responses, if possible. Example values for each parameter: - * - * {@code vmId}: '5702733457649812345' - * {@code machineType}: 'projects/441331612345/machineTypes/f1-micro' - * {@code zone}: 'projects/441331612345/zones/us-east4-c' - * - * @param {String} vmId The ID of the VM - * @param {String} machineType The machine type, prefixed by unwanted account info. - * @param {String} zone The zone (e.g., availability zone), implicitly showing the region, prefixed by unwanted account info. - * @return {CloudServiceResponse} Never {@code null}. - * @throws {Error} if the responses do not make a valid response - */ - _combineResponses(id, machineType, zone) { - const vmId = isString(id) ? id.trim() : null; - const vmType = this._extractValue('machineTypes/', machineType); - const vmZone = this._extractValue('zones/', zone); - - let region; - - if (vmZone) { - // converts 'us-east4-c' into 'us-east4' - region = vmZone.substring(0, vmZone.lastIndexOf('-')); - } - - // ensure we actually have some data - if (vmId || vmType || region || vmZone) { - return new CloudServiceResponse(this._name, true, { id: vmId, vmType, region, zone: vmZone }); - } - - throw new Error('unrecognized responses'); - } - - /** - * Extract the useful information returned from GCP while discarding unwanted account details (the project ID). For example, - * this turns something like 'projects/441331612345/machineTypes/f1-micro' into 'f1-micro'. - * - * @param {String} fieldPrefix The value prefixing the actual value of interest. - * @param {String} value The entire value returned from GCP. - * @return {String} {@code undefined} if the value could not be extracted. Otherwise just the desired value. - */ - _extractValue(fieldPrefix, value) { - if (isString(value)) { - const index = value.lastIndexOf(fieldPrefix); - - if (index !== -1) { - return value.substring(index + fieldPrefix.length).trim(); - } - } - - return undefined; - } -} - -/** - * Singleton instance of {@code GCPCloudService}. - * - * @type {GCPCloudService} - */ -export const GCP = new GCPCloudService(); From eb25c693409d38809fa919a29b6967bf47bf9ba0 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Mon, 12 Apr 2021 12:57:22 -0400 Subject: [PATCH 09/28] [APM] Moves the transaction type selector to the search bar (#96685) * [APM] Moves the Transaction type selector to the search bar (#91131) * - Replaces the prepend label on the search bar with the transaction type selector - Adds the transaction type selector to the service overview page - Removes title from the Transactions list page * removes unused i18n items Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/app/service_overview/index.tsx | 14 +--------- .../components/app/trace_overview/index.tsx | 2 +- .../app/transaction_details/index.tsx | 2 +- .../app/transaction_overview/index.tsx | 28 +------------------ .../public/components/shared/search_bar.tsx | 12 ++++++-- .../translations/translations/ja-JP.json | 2 -- .../translations/translations/zh-CN.json | 2 -- 7 files changed, 13 insertions(+), 49 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index f6ec2fb24018f..78c8f151b82d9 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -6,12 +6,10 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; import { AnnotationsContextProvider } from '../../../context/annotations/annotations_context'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useBreakPoints } from '../../../hooks/use_break_points'; import { LatencyChart } from '../../shared/charts/latency_chart'; @@ -46,22 +44,12 @@ export function ServiceOverview({ // observe the window width and set the flex directions of rows accordingly const { isMedium } = useBreakPoints(); const rowDirection = isMedium ? 'column' : 'row'; - - const { transactionType } = useApmServiceContext(); - const transactionTypeLabel = i18n.translate( - 'xpack.apm.serviceOverview.searchBar.transactionTypeLabel', - { defaultMessage: 'Type: {transactionType}', values: { transactionType } } - ); const isRumAgent = isRumAgentName(agentName); return ( - + diff --git a/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx index 6d7edcd0a1e35..364266d277482 100644 --- a/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx @@ -49,7 +49,7 @@ export function TraceOverview() { return ( <> - + diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx index 0a322cfc9c80b..d6f45a4a45cc8 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx @@ -95,7 +95,7 @@ export function TransactionDetails({

{transactionName}

- + diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index 0814c6d95b96a..9e2743d7b5986 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -9,7 +9,6 @@ import { EuiCallOut, EuiCode, EuiFlexGroup, - EuiFlexItem, EuiPage, EuiPanel, EuiSpacer, @@ -28,7 +27,6 @@ import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; import { SearchBar } from '../../shared/search_bar'; -import { TransactionTypeSelect } from '../../shared/transaction_type_select'; import { TransactionList } from './TransactionList'; import { useRedirect } from './useRedirect'; import { useTransactionListFetcher } from './use_transaction_list'; @@ -82,33 +80,9 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { return ( <> - + - - - - - - -

- {i18n.translate('xpack.apm.transactionOverviewTitle', { - defaultMessage: 'Transactions', - })} -

-
-
- - - -
- -
-
diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.tsx index aeb2a2c6390fc..ed9a196bbcd9d 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -20,6 +20,7 @@ import { TimeComparison } from './time_comparison'; import { useBreakPoints } from '../../hooks/use_break_points'; import { useKibanaUrl } from '../../hooks/useKibanaUrl'; import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; +import { TransactionTypeSelect } from './transaction_type_select'; const EuiFlexGroupSpaced = euiStyled(EuiFlexGroup)` margin: ${({ theme }) => @@ -29,7 +30,7 @@ const EuiFlexGroupSpaced = euiStyled(EuiFlexGroup)` interface Props { prepend?: React.ReactNode | string; showTimeComparison?: boolean; - showCorrelations?: boolean; + showTransactionTypeSelector?: boolean; } function getRowDirection(showColumn: boolean) { @@ -85,7 +86,7 @@ function DebugQueryCallout() { export function SearchBar({ prepend, showTimeComparison = false, - showCorrelations = false, + showTransactionTypeSelector = false, }: Props) { const { isMedium, isLarge } = useBreakPoints(); const itemsStyle = { marginBottom: isLarge ? px(unit) : 0 }; @@ -94,8 +95,13 @@ export function SearchBar({ <> + {showTransactionTypeSelector && ( + + + + )} - + Date: Mon, 12 Apr 2021 14:04:06 -0300 Subject: [PATCH 10/28] [Enterprise Search] Add missing and remove redundant breadcrumbs (#96636) * Workplace Search: Remove redundant Overview breadcrumb from Sources There is "Source name" breadcrumb that is used for Overview page * App Search: remove "Overview" breadcrumb from Engine page So instead of `engines / national-parks-demo / overview (greyed)` we will have just `engines / national-parks-demo (greyed)` * App Search: Add "Engines" breadcrumb to the main App Search page This needs to be added to 3 states of the page: Normal, Empty and Loading * Fix failing WS test * App Search: DRY out SetPageChrome declaration by putting it in header * Fix failed test "ShallowWrapper::dive() can only be called on components" Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/engine/engine_router.tsx | 3 +- .../engines/components/empty_state.tsx | 2 - .../engines/components/header.test.tsx | 3 + .../components/engines/components/header.tsx | 56 ++++++++++--------- .../engines/components/loading_state.tsx | 3 - .../components/engines/engines_overview.tsx | 2 - .../content_sources/source_router.test.tsx | 2 +- .../views/content_sources/source_router.tsx | 2 +- 8 files changed, 37 insertions(+), 36 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 88a24755070ec..818245bd50978 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -37,7 +37,6 @@ import { AnalyticsRouter } from '../analytics'; import { ApiLogs } from '../api_logs'; import { CurationsRouter } from '../curations'; import { DocumentDetail, Documents } from '../documents'; -import { OVERVIEW_TITLE } from '../engine_overview'; import { EngineOverview } from '../engine_overview'; import { ENGINES_TITLE } from '../engines'; import { RelevanceTuning } from '../relevance_tuning'; @@ -122,7 +121,7 @@ export const EngineRouter: React.FC = () => { )} - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx index 56fe3b97274ea..6911015e39d4a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx @@ -12,7 +12,6 @@ import { useValues, useActions } from 'kea'; import { EuiPageContent, EuiEmptyPrompt, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; import { EuiButtonTo } from '../../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../../shared/telemetry'; import { AppLogic } from '../../../app_logic'; @@ -32,7 +31,6 @@ export const EmptyState: React.FC = () => { return ( <> - {canManageEngines ? ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx index 3ffe2f3d43a77..8cb26713cb840 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx @@ -12,10 +12,13 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { EuiPageHeader } from '@elastic/eui'; + import { EnginesOverviewHeader } from './'; describe('EnginesOverviewHeader', () => { const wrapper = shallow() + .find(EuiPageHeader) .dive() .children() .dive(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx index df87f2e5230db..bab67fd0e4bb5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx @@ -13,36 +13,42 @@ import { EuiPageHeader, EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { getAppSearchUrl } from '../../../../shared/enterprise_search_url'; +import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; import { TelemetryLogic } from '../../../../shared/telemetry'; +import { ENGINES_TITLE } from '../constants'; + export const EnginesOverviewHeader: React.FC = () => { const { sendAppSearchTelemetry } = useActions(TelemetryLogic); return ( - - sendAppSearchTelemetry({ - action: 'clicked', - metric: 'header_launch_button', - }) - } - data-test-subj="launchButton" - > - {i18n.translate('xpack.enterpriseSearch.appSearch.productCta', { - defaultMessage: 'Launch App Search', - })} - , - ]} - /> + <> + + + sendAppSearchTelemetry({ + action: 'clicked', + metric: 'header_launch_button', + }) + } + data-test-subj="launchButton" + > + {i18n.translate('xpack.enterpriseSearch.appSearch.productCta', { + defaultMessage: 'Launch App Search', + })} + , + ]} + /> + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/loading_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/loading_state.tsx index 56be0a5562742..875c47378d1fb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/loading_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/loading_state.tsx @@ -9,14 +9,11 @@ import React from 'react'; import { EuiPageContent, EuiSpacer, EuiLoadingContent } from '@elastic/eui'; -import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; - import { EnginesOverviewHeader } from './header'; export const LoadingState: React.FC = () => { return ( <> - diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx index 0712b990159a4..4d51012f2aa2a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx @@ -20,7 +20,6 @@ import { } from '@elastic/eui'; import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { LicensingLogic } from '../../../shared/licensing'; import { EuiButtonTo } from '../../../shared/react_router_helpers'; import { convertMetaToPagination, handlePageChange } from '../../../shared/table_pagination'; @@ -80,7 +79,6 @@ export const EnginesOverview: React.FC = () => { return ( <> - diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx index 004f7e5e45bfa..463468d1304b6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx @@ -88,7 +88,7 @@ describe('SourceRouter', () => { const contentBreadCrumb = wrapper.find(SetPageChrome).at(1); const settingsBreadCrumb = wrapper.find(SetPageChrome).at(2); - expect(overviewBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.OVERVIEW]); + expect(overviewBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs]); expect(contentBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.CONTENT]); expect(settingsBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.SETTINGS]); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx index ef9788efbdaf2..b844c86abb919 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx @@ -98,7 +98,7 @@ export const SourceRouter: React.FC = () => { - + From 9b239f64cd5151f3752930b37cd83cfa41b1e595 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Mon, 12 Apr 2021 19:04:37 +0200 Subject: [PATCH 11/28] [ML] Data Frame Analytics: Fix scatterplot matrix boilerplate visibility with no fields selected. (#96590) Fixes the problem where deselecting all fields for the scatterplot would also hide the UI to do the actual selection. Now, when all fields are removed from the combo box, the UI stays visible, just the scatterplot itself will be hidden. --- .../scatterplot_matrix.test.tsx | 87 +++++++++++++++++++ .../scatterplot_matrix/scatterplot_matrix.tsx | 4 +- 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.test.tsx diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.test.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.test.tsx new file mode 100644 index 0000000000000..10deaa1c2d489 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, waitFor, screen } from '@testing-library/react'; + +import { IntlProvider } from 'react-intl'; + +import euiThemeLight from '@elastic/eui/dist/eui_theme_light.json'; + +import { ScatterplotMatrix } from './scatterplot_matrix'; + +const mockEsSearch = jest.fn((body) => ({ + hits: { hits: [{ fields: { x: [1], y: [2] } }, { fields: { x: [2], y: [3] } }] }, +})); +jest.mock('../../contexts/kibana', () => ({ + useMlApiContext: () => ({ + esSearch: mockEsSearch, + }), +})); + +const mockEuiTheme = euiThemeLight; +jest.mock('../color_range_legend', () => ({ + useCurrentEuiTheme: () => ({ + euiTheme: mockEuiTheme, + }), +})); + +// Mocking VegaChart to avoid a jest/canvas related error +jest.mock('../vega_chart', () => ({ + VegaChart: () =>
, +})); + +describe('Data Frame Analytics: ', () => { + it('renders the scatterplot matrix wrapper with options but not the chart itself', async () => { + // prepare + render( + + + + ); + + // assert + await waitFor(() => { + expect(mockEsSearch).toHaveBeenCalledTimes(0); + // should hide the loading indicator and render the wrapping options boilerplate + expect(screen.queryByTestId('mlScatterplotMatrix loaded')).toBeInTheDocument(); + // should not render the scatterplot matrix itself because there's no data items. + expect(screen.queryByTestId('mlVegaChart')).not.toBeInTheDocument(); + }); + }); + + it('renders the scatterplot matrix wrapper with options and the chart itself', async () => { + // prepare + render( + + + + ); + + // assert + await waitFor(() => { + expect(mockEsSearch).toHaveBeenCalledWith({ + body: { _source: false, fields: ['x', 'y'], from: 0, query: undefined, size: 1000 }, + index: 'the-index-name', + }); + // should hide the loading indicator and render the wrapping options boilerplate + expect(screen.queryByTestId('mlScatterplotMatrix loaded')).toBeInTheDocument(); + // should render the scatterplot matrix. + expect(screen.queryByTestId('mlVegaChart')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx index 540fa65bf6c18..b83965b52befc 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx @@ -108,7 +108,7 @@ export const ScatterplotMatrix: FC = ({ // are sized according to outlier_score const [dynamicSize, setDynamicSize] = useState(false); - // used to give the use the option to customize the fields used for the matrix axes + // used to give the user the option to customize the fields used for the matrix axes const [fields, setFields] = useState([]); useEffect(() => { @@ -165,7 +165,7 @@ export const ScatterplotMatrix: FC = ({ useEffect(() => { if (fields.length === 0) { - setSplom(undefined); + setSplom({ columns: [], items: [], messages: [] }); setIsLoading(false); return; } From 0836e4d67b61c871bb3f32a86a39f508bed48ceb Mon Sep 17 00:00:00 2001 From: Luca Belluccini Date: Mon, 12 Apr 2021 18:14:03 +0100 Subject: [PATCH 12/28] [DOC] Index pattern and cluster exclusion examples with CCS (#61256) * [DOC] Index pattern and cluster exclusion examples with CCS Providing some examples of using Index Pattern and cluster exclusions with CCS * Update docs/management/index-patterns.asciidoc * Update docs/management/index-patterns.asciidoc * Update docs/management/index-patterns.asciidoc Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/management/index-patterns.asciidoc | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/management/index-patterns.asciidoc b/docs/management/index-patterns.asciidoc index 88dbf6ec8761f..3d9253025d3cc 100644 --- a/docs/management/index-patterns.asciidoc +++ b/docs/management/index-patterns.asciidoc @@ -125,6 +125,11 @@ pattern: *:logstash-* ``` +You can use exclusions to exclude indices that might contain mapping errors. +To match indices starting with `logstash-`, and exclude those starting with `logstash-old` from +all clusters having a name starting with `cluster_`, you can use `cluster_*:logstash-*,cluster*:logstash-old*`. +To exclude a cluster, use `cluster_*:logstash-*,cluster_one:-*`. + Once an index pattern is configured using the {ccs} syntax, all searches and aggregations using that index pattern in {kib} take advantage of {ccs}. From baac478ff37f75fe1d43b418a262bf5f44afe628 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Mon, 12 Apr 2021 13:33:45 -0400 Subject: [PATCH 13/28] [Enterprise Search] Allow jest script to run on individual files (#96589) --- x-pack/plugins/enterprise_search/README.md | 4 +++- x-pack/plugins/enterprise_search/jest.sh | 20 ++++++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md index 0caea251ec6fb..0b067e25e32e8 100644 --- a/x-pack/plugins/enterprise_search/README.md +++ b/x-pack/plugins/enterprise_search/README.md @@ -38,7 +38,7 @@ yarn test:jest yarn test:jest --watch ``` -Unfortunately coverage collection does not work as automatically, and requires using our handy jest.sh script if you want to run tests on a specific folder and only get coverage numbers for that folder: +Unfortunately coverage collection does not work as automatically, and requires using our handy jest.sh script if you want to run tests on a specific file or folder and only get coverage numbers for that file or folder: ```bash # Running the jest.sh script from the `x-pack/plugins/enterprise_search` folder (vs. kibana root) @@ -46,6 +46,8 @@ Unfortunately coverage collection does not work as automatically, and requires u sh jest.sh {YOUR_COMPONENT_DIR} sh jest.sh public/applications/shared/kibana sh jest.sh server/routes/app_search +# When testing an individual file, remember to pass the path of the test file, not the source file. +sh jest.sh public/applications/shared/flash_messages/flash_messages_logic.test.ts ``` ### E2E tests diff --git a/x-pack/plugins/enterprise_search/jest.sh b/x-pack/plugins/enterprise_search/jest.sh index d7aa0b07fb89c..8bc3134a62d8e 100644 --- a/x-pack/plugins/enterprise_search/jest.sh +++ b/x-pack/plugins/enterprise_search/jest.sh @@ -1,13 +1,21 @@ #! /bin/bash # Whether to run Jest on the entire enterprise_search plugin or a specific component/folder -FOLDER="${1:-all}" -if [[ $FOLDER && $FOLDER != "all" ]] + +TARGET="${1:-all}" +if [[ $TARGET && $TARGET != "all" ]] then - FOLDER=${FOLDER%/} # Strip any trailing slash - FOLDER="${FOLDER}/ --collectCoverageFrom='/x-pack/plugins/enterprise_search/${FOLDER}/**/*.{ts,tsx}'" + # If this is a file + if [[ "$TARGET" == *".ts"* ]]; then + PATH_WITHOUT_EXTENSION=${1%%.*} + TARGET="${TARGET} --collectCoverageFrom='/x-pack/plugins/enterprise_search/${PATH_WITHOUT_EXTENSION}.{ts,tsx}'" + # If this is a folder + else + TARGET=${TARGET%/} # Strip any trailing slash + TARGET="${TARGET}/ --collectCoverageFrom='/x-pack/plugins/enterprise_search/${TARGET}/**/*.{ts,tsx}'" + fi else - FOLDER='' + TARGET='' fi # Pass all remaining arguments (e.g., ...rest) from the 2nd arg onwards @@ -15,4 +23,4 @@ fi # @see https://jestjs.io/docs/en/cli#options ARGS="${*:2}" -yarn test:jest $FOLDER $ARGS +yarn test:jest $TARGET $ARGS From b4d330219a18cfddfe05e1be471f03b02056131e Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Mon, 12 Apr 2021 13:38:44 -0400 Subject: [PATCH 14/28] [App Search] Add header details to the Result Settings page (#96623) --- .../relevance_tuning_layout.test.tsx | 10 +- .../relevance_tuning_layout.tsx | 2 +- .../relevance_tuning_logic.test.ts | 5 +- .../result_settings/result_settings.test.tsx | 39 +++++- .../result_settings/result_settings.tsx | 47 ++++++- .../result_settings_logic.test.ts | 124 ++++++++---------- .../result_settings/result_settings_logic.ts | 100 +++++++------- .../components/result_settings/types.ts | 5 - 8 files changed, 198 insertions(+), 134 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx index edd417cc1ffe8..9ed6e17c2bcd9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx @@ -9,7 +9,7 @@ import { setMockActions, setMockValues } from '../../../__mocks__/kea.mock'; import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; import { EuiPageHeader } from '@elastic/eui'; @@ -33,9 +33,11 @@ describe('RelevanceTuningLayout', () => { }); const subject = () => shallow(); + const findButtons = (wrapper: ShallowWrapper) => + wrapper.find(EuiPageHeader).prop('rightSideItems') as React.ReactElement[]; it('renders a Save button that will save the current changes', () => { - const buttons = subject().find(EuiPageHeader).prop('rightSideItems') as React.ReactElement[]; + const buttons = findButtons(subject()); expect(buttons.length).toBe(2); const saveButton = shallow(buttons[0]); saveButton.simulate('click'); @@ -43,7 +45,7 @@ describe('RelevanceTuningLayout', () => { }); it('renders a Reset button that will remove all weights and boosts', () => { - const buttons = subject().find(EuiPageHeader).prop('rightSideItems') as React.ReactElement[]; + const buttons = findButtons(subject()); expect(buttons.length).toBe(2); const resetButton = shallow(buttons[1]); resetButton.simulate('click'); @@ -55,7 +57,7 @@ describe('RelevanceTuningLayout', () => { ...values, engineHasSchemaFields: false, }); - const buttons = subject().find(EuiPageHeader).prop('rightSideItems') as React.ReactElement[]; + const buttons = findButtons(subject()); expect(buttons.length).toBe(0); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx index 0ea38b0d9fa36..f29cc12f20a98 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx @@ -37,7 +37,7 @@ export const RelevanceTuningLayout: React.FC = ({ engineBreadcrumb, child description={i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.description', { - defaultMessage: 'Set field weights and boosts', + defaultMessage: 'Set field weights and boosts.', } )} rightSideItems={ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts index ca9b0a886fdd1..4ec38d314a259 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts @@ -586,10 +586,9 @@ describe('RelevanceTuningLogic', () => { confirmSpy.mockImplementation(() => false); RelevanceTuningLogic.actions.resetSearchSettings(); + await nextTick(); - expect(http.post).not.toHaveBeenCalledWith( - '/api/app_search/engines/test-engine/search_settings/reset' - ); + expect(http.post).not.toHaveBeenCalled(); }); it('handles errors', async () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx index 9eda1362e04fc..5365cc0f029f8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx @@ -11,7 +11,9 @@ import { setMockValues, setMockActions } from '../../../__mocks__'; import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiPageHeader } from '@elastic/eui'; import { ResultSettings } from './result_settings'; import { ResultSettingsTable } from './result_settings_table'; @@ -24,6 +26,9 @@ describe('RelevanceTuning', () => { const actions = { initializeResultSettingsData: jest.fn(), + saveResultSettings: jest.fn(), + confirmResetAllFields: jest.fn(), + clearAllFields: jest.fn(), }; beforeEach(() => { @@ -32,8 +37,12 @@ describe('RelevanceTuning', () => { jest.clearAllMocks(); }); + const subject = () => shallow(); + const findButtons = (wrapper: ShallowWrapper) => + wrapper.find(EuiPageHeader).prop('rightSideItems') as React.ReactElement[]; + it('renders', () => { - const wrapper = shallow(); + const wrapper = subject(); expect(wrapper.find(ResultSettingsTable).exists()).toBe(true); expect(wrapper.find(SampleResponse).exists()).toBe(true); }); @@ -47,8 +56,32 @@ describe('RelevanceTuning', () => { setMockValues({ dataLoading: true, }); - const wrapper = shallow(); + const wrapper = subject(); expect(wrapper.find(ResultSettingsTable).exists()).toBe(false); expect(wrapper.find(SampleResponse).exists()).toBe(false); }); + + it('renders a "save" button that will save the current changes', () => { + const buttons = findButtons(subject()); + expect(buttons.length).toBe(3); + const saveButton = shallow(buttons[0]); + saveButton.simulate('click'); + expect(actions.saveResultSettings).toHaveBeenCalled(); + }); + + it('renders a "restore defaults" button that will reset all values to their defaults', () => { + const buttons = findButtons(subject()); + expect(buttons.length).toBe(3); + const resetButton = shallow(buttons[1]); + resetButton.simulate('click'); + expect(actions.confirmResetAllFields).toHaveBeenCalled(); + }); + + it('renders a "clear" button that will remove all selected options', () => { + const buttons = findButtons(subject()); + expect(buttons.length).toBe(3); + const clearButton = shallow(buttons[2]); + clearButton.simulate('click'); + expect(actions.clearAllFields).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx index 336f3f663119f..a513d0c1b9f34 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx @@ -9,12 +9,15 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiPageHeader, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiPageHeader, EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { SAVE_BUTTON_LABEL } from '../../../shared/constants'; import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; - import { Loading } from '../../../shared/loading'; +import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../constants'; import { RESULT_SETTINGS_TITLE } from './constants'; import { ResultSettingsTable } from './result_settings_table'; @@ -23,13 +26,23 @@ import { SampleResponse } from './sample_response'; import { ResultSettingsLogic } from '.'; +const CLEAR_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.clearButtonLabel', + { defaultMessage: 'Clear all values' } +); + interface Props { engineBreadcrumb: string[]; } export const ResultSettings: React.FC = ({ engineBreadcrumb }) => { const { dataLoading } = useValues(ResultSettingsLogic); - const { initializeResultSettingsData } = useActions(ResultSettingsLogic); + const { + initializeResultSettingsData, + saveResultSettings, + confirmResetAllFields, + clearAllFields, + } = useActions(ResultSettingsLogic); useEffect(() => { initializeResultSettingsData(); @@ -40,7 +53,33 @@ export const ResultSettings: React.FC = ({ engineBreadcrumb }) => { return ( <> - + + {SAVE_BUTTON_LABEL} + , + + {RESTORE_DEFAULTS_BUTTON_LABEL} + , + + {CLEAR_BUTTON_LABEL} + , + ]} + /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts index a9c161b2bb5be..8d9c33e3c9e68 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts @@ -15,7 +15,7 @@ import { nextTick } from '@kbn/test/jest'; import { Schema, SchemaConflicts, SchemaTypes } from '../../../shared/types'; -import { OpenModal, ServerFieldResultSettingObject } from './types'; +import { ServerFieldResultSettingObject } from './types'; import { ResultSettingsLogic } from '.'; @@ -25,7 +25,6 @@ describe('ResultSettingsLogic', () => { const DEFAULT_VALUES = { dataLoading: true, saving: false, - openModal: OpenModal.None, resultFields: {}, lastSavedResultFields: {}, schema: {}, @@ -83,7 +82,6 @@ describe('ResultSettingsLogic', () => { mount({ dataLoading: true, saving: true, - openModal: OpenModal.ConfirmSaveModal, }); ResultSettingsLogic.actions.initializeResultFields( @@ -139,8 +137,6 @@ describe('ResultSettingsLogic', () => { snippetFallback: false, }, }, - // The modal should be reset back to closed if it had been opened previously - openModal: OpenModal.None, // Stores the provided schema details schema, schemaConflicts, @@ -156,47 +152,6 @@ describe('ResultSettingsLogic', () => { }); }); - describe('openConfirmSaveModal', () => { - mount({ - openModal: OpenModal.None, - }); - - ResultSettingsLogic.actions.openConfirmSaveModal(); - - expect(resultSettingLogicValues()).toEqual({ - ...DEFAULT_VALUES, - openModal: OpenModal.ConfirmSaveModal, - }); - }); - - describe('openConfirmResetModal', () => { - mount({ - openModal: OpenModal.None, - }); - - ResultSettingsLogic.actions.openConfirmResetModal(); - - expect(resultSettingLogicValues()).toEqual({ - ...DEFAULT_VALUES, - openModal: OpenModal.ConfirmResetModal, - }); - }); - - describe('closeModals', () => { - it('should close open modals', () => { - mount({ - openModal: OpenModal.ConfirmSaveModal, - }); - - ResultSettingsLogic.actions.closeModals(); - - expect(resultSettingLogicValues()).toEqual({ - ...DEFAULT_VALUES, - openModal: OpenModal.None, - }); - }); - }); - describe('clearAllFields', () => { it('should remove all settings that have been set for each field', () => { mount({ @@ -237,19 +192,6 @@ describe('ResultSettingsLogic', () => { }, }); }); - - it('should close open modals', () => { - mount({ - openModal: OpenModal.ConfirmSaveModal, - }); - - ResultSettingsLogic.actions.resetAllFields(); - - expect(resultSettingLogicValues()).toEqual({ - ...DEFAULT_VALUES, - openModal: OpenModal.None, - }); - }); }); describe('updateField', () => { @@ -297,7 +239,7 @@ describe('ResultSettingsLogic', () => { }); describe('saving', () => { - it('sets saving to true and close any open modals', () => { + it('sets saving to true', () => { mount({ saving: false, }); @@ -307,7 +249,6 @@ describe('ResultSettingsLogic', () => { expect(resultSettingLogicValues()).toEqual({ ...DEFAULT_VALUES, saving: true, - openModal: OpenModal.None, }); }); }); @@ -563,6 +504,12 @@ describe('ResultSettingsLogic', () => { describe('listeners', () => { const { http } = mockHttpValues; const { flashAPIErrors } = mockFlashMessageHelpers; + let confirmSpy: jest.SpyInstance; + + beforeAll(() => { + confirmSpy = jest.spyOn(window, 'confirm'); + }); + afterAll(() => confirmSpy.mockRestore()); const serverFieldResultSettings = { foo: { @@ -864,20 +811,55 @@ describe('ResultSettingsLogic', () => { }); }); + describe('confirmResetAllFields', () => { + it('will reset all fields as long as the user confirms the action', async () => { + mount(); + confirmSpy.mockImplementation(() => true); + jest.spyOn(ResultSettingsLogic.actions, 'resetAllFields'); + + ResultSettingsLogic.actions.confirmResetAllFields(); + + expect(ResultSettingsLogic.actions.resetAllFields).toHaveBeenCalled(); + }); + + it('will do nothing if the user cancels the action', async () => { + mount(); + confirmSpy.mockImplementation(() => false); + jest.spyOn(ResultSettingsLogic.actions, 'resetAllFields'); + + ResultSettingsLogic.actions.confirmResetAllFields(); + + expect(ResultSettingsLogic.actions.resetAllFields).not.toHaveBeenCalled(); + }); + }); + describe('saveResultSettings', () => { + beforeEach(() => { + confirmSpy.mockImplementation(() => true); + }); + it('should make an API call to update result settings and update state accordingly', async () => { + const resultFields = { + foo: { raw: true, rawSize: 100 }, + }; + + const serverResultFields = { + foo: { raw: { size: 100 } }, + }; + mount({ schema, + resultFields, }); http.put.mockReturnValueOnce( Promise.resolve({ - result_fields: serverFieldResultSettings, + result_fields: serverResultFields, }) ); jest.spyOn(ResultSettingsLogic.actions, 'saving'); jest.spyOn(ResultSettingsLogic.actions, 'initializeResultFields'); - ResultSettingsLogic.actions.saveResultSettings(serverFieldResultSettings); + ResultSettingsLogic.actions.saveResultSettings(); expect(ResultSettingsLogic.actions.saving).toHaveBeenCalled(); @@ -887,12 +869,12 @@ describe('ResultSettingsLogic', () => { '/api/app_search/engines/test-engine/result_settings', { body: JSON.stringify({ - result_fields: serverFieldResultSettings, + result_fields: serverResultFields, }), } ); expect(ResultSettingsLogic.actions.initializeResultFields).toHaveBeenCalledWith( - serverFieldResultSettings, + serverResultFields, schema ); }); @@ -901,11 +883,21 @@ describe('ResultSettingsLogic', () => { mount(); http.put.mockReturnValueOnce(Promise.reject('error')); - ResultSettingsLogic.actions.saveResultSettings(serverFieldResultSettings); + ResultSettingsLogic.actions.saveResultSettings(); await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('error'); }); + + it('does nothing if the user does not confirm', async () => { + mount(); + confirmSpy.mockImplementation(() => false); + + ResultSettingsLogic.actions.saveResultSettings(); + await nextTick(); + + expect(http.put).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts index c345ae7e02e8d..f518fc945bfbf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts @@ -19,7 +19,6 @@ import { DEFAULT_SNIPPET_SIZE } from './constants'; import { FieldResultSetting, FieldResultSettingObject, - OpenModal, ServerFieldResultSettingObject, } from './types'; @@ -34,9 +33,6 @@ import { } from './utils'; interface ResultSettingsActions { - openConfirmResetModal(): void; - openConfirmSaveModal(): void; - closeModals(): void; initializeResultFields( serverResultFields: ServerFieldResultSettingObject, schema: Schema, @@ -62,15 +58,13 @@ interface ResultSettingsActions { updateRawSizeForField(fieldName: string, size: number): { fieldName: string; size: number }; updateSnippetSizeForField(fieldName: string, size: number): { fieldName: string; size: number }; initializeResultSettingsData(): void; - saveResultSettings( - resultFields: ServerFieldResultSettingObject - ): { resultFields: ServerFieldResultSettingObject }; + confirmResetAllFields(): void; + saveResultSettings(): void; } interface ResultSettingsValues { dataLoading: boolean; saving: boolean; - openModal: OpenModal; resultFields: FieldResultSettingObject; lastSavedResultFields: FieldResultSettingObject; schema: Schema; @@ -86,12 +80,25 @@ interface ResultSettingsValues { queryPerformanceScore: number; } +const SAVE_CONFIRMATION_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.confirmSaveMessage', + { + defaultMessage: + 'The changes will start immediately. Make sure your applications are ready to accept the new search results!', + } +); + +const RESET_CONFIRMATION_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.confirmResetMessage', + { + defaultMessage: + 'This will revert your settings back to the default: all fields set to raw. The default will take over immediately and impact your search results.', + } +); + export const ResultSettingsLogic = kea>({ path: ['enterprise_search', 'app_search', 'result_settings_logic'], actions: () => ({ - openConfirmResetModal: () => true, - openConfirmSaveModal: () => true, - closeModals: () => true, initializeResultFields: (serverResultFields, schema, schemaConflicts) => { const resultFields = convertServerResultFieldsToResultFields(serverResultFields, schema); @@ -113,7 +120,8 @@ export const ResultSettingsLogic = kea ({ fieldName, size }), updateSnippetSizeForField: (fieldName, size) => ({ fieldName, size }), initializeResultSettingsData: () => true, - saveResultSettings: (resultFields) => ({ resultFields }), + confirmResetAllFields: () => true, + saveResultSettings: () => true, }), reducers: () => ({ dataLoading: [ @@ -129,17 +137,6 @@ export const ResultSettingsLogic = kea true, }, ], - openModal: [ - OpenModal.None, - { - initializeResultFields: () => OpenModal.None, - closeModals: () => OpenModal.None, - resetAllFields: () => OpenModal.None, - openConfirmResetModal: () => OpenModal.ConfirmResetModal, - openConfirmSaveModal: () => OpenModal.ConfirmSaveModal, - saving: () => OpenModal.None, - }, - ], resultFields: [ {}, { @@ -308,35 +305,42 @@ export const ResultSettingsLogic = kea { - actions.saving(); + confirmResetAllFields: () => { + if (window.confirm(RESET_CONFIRMATION_MESSAGE)) { + actions.resetAllFields(); + } + }, + saveResultSettings: async () => { + if (window.confirm(SAVE_CONFIRMATION_MESSAGE)) { + actions.saving(); - const { http } = HttpLogic.values; - const { engineName } = EngineLogic.values; - const url = `/api/app_search/engines/${engineName}/result_settings`; + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + const url = `/api/app_search/engines/${engineName}/result_settings`; - actions.saving(); + actions.saving(); - let response; - try { - response = await http.put(url, { - body: JSON.stringify({ - result_fields: resultFields, - }), - }); - } catch (e) { - flashAPIErrors(e); - } + let response; + try { + response = await http.put(url, { + body: JSON.stringify({ + result_fields: values.reducedServerResultFields, + }), + }); + } catch (e) { + flashAPIErrors(e); + } - actions.initializeResultFields(response.result_fields, values.schema); - setSuccessMessage( - i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.resultSettings.saveSuccessMessage', - { - defaultMessage: 'Result settings have been saved successfully.', - } - ) - ); + actions.initializeResultFields(response.result_fields, values.schema); + setSuccessMessage( + i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.saveSuccessMessage', + { + defaultMessage: 'Result settings have been saved successfully.', + } + ) + ); + } }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/types.ts index 18843112f46bf..1174f65523d99 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/types.ts @@ -7,11 +7,6 @@ import { FieldValue } from '../result/types'; -export enum OpenModal { - None, - ConfirmResetModal, - ConfirmSaveModal, -} export interface ServerFieldResultSetting { raw?: | { From 92b98e740f5daf97d94c018aba8d93bd8c51dba3 Mon Sep 17 00:00:00 2001 From: Luca Belluccini Date: Mon, 12 Apr 2021 18:52:04 +0100 Subject: [PATCH 15/28] [DOC] Painless lab enable/disable flag (#95392) * [DOC] Painless lab enable/disable flag * Update docs/settings/dev-settings.asciidoc * Update docs/settings/dev-settings.asciidoc Co-authored-by: Kaarina Tungseth Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/settings/dev-settings.asciidoc | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/settings/dev-settings.asciidoc b/docs/settings/dev-settings.asciidoc index 62553293a7d03..810694f46b317 100644 --- a/docs/settings/dev-settings.asciidoc +++ b/docs/settings/dev-settings.asciidoc @@ -29,3 +29,14 @@ They are enabled by default. | Set to `true` to enable the <>. Defaults to `true`. |=== + +[float] +[[painless_lab-settings]] +==== Painless Lab settings + +[cols="2*<"] +|=== +| `xpack.painless_lab.enabled` + | When set to `true`, enables the <>. Defaults to `true`. + +|=== From e7f5d079636f10c8469ceda8c9a8daba01494106 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Mon, 12 Apr 2021 12:59:57 -0500 Subject: [PATCH 16/28] [ML] Add runtime support for anomaly charts & add composite validations (#96348) --- .../types/anomaly_detection_jobs/datafeed.ts | 14 +-- .../plugins/ml/common/util/datafeed_utils.ts | 7 -- x-pack/plugins/ml/common/util/job_utils.ts | 117 +++++++++++------- .../ml/common/util/object_utils.test.ts | 16 ++- x-pack/plugins/ml/common/util/object_utils.ts | 11 ++ .../components/job_actions/results.js | 12 +- .../new_job/common/job_creator/job_creator.ts | 8 +- .../anomaly_explorer_charts_service.ts | 6 +- .../results_service/result_service_rx.ts | 8 +- .../translations/translations/ja-JP.json | 4 +- .../translations/translations/zh-CN.json | 4 +- 11 files changed, 115 insertions(+), 92 deletions(-) diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts index 77d453b68edc5..5d7f3f934700b 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { estypes } from '@elastic/elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; // import { IndexPatternTitle } from '../kibana'; // import { RuntimeMappings } from '../fields'; // import { JobId } from './job'; @@ -41,17 +41,7 @@ export type ChunkingConfig = estypes.ChunkingConfig; // time_span?: string; // } -export type Aggregation = Record< - string, - { - date_histogram: { - field: string; - fixed_interval: string; - }; - aggregations?: { [key: string]: any }; - aggs?: { [key: string]: any }; - } ->; +export type Aggregation = Record; export type IndicesOptions = estypes.IndicesOptions; // export interface IndicesOptions { diff --git a/x-pack/plugins/ml/common/util/datafeed_utils.ts b/x-pack/plugins/ml/common/util/datafeed_utils.ts index c0579ce947992..58038feddb98b 100644 --- a/x-pack/plugins/ml/common/util/datafeed_utils.ts +++ b/x-pack/plugins/ml/common/util/datafeed_utils.ts @@ -18,10 +18,3 @@ export const getDatafeedAggregations = ( ): Aggregation | undefined => { return getAggregations(datafeedConfig); }; - -export const getAggregationBucketsName = (aggregations: any): string | undefined => { - if (aggregations !== null && typeof aggregations === 'object') { - const keys = Object.keys(aggregations); - return keys.length > 0 ? keys[0] : undefined; - } -}; diff --git a/x-pack/plugins/ml/common/util/job_utils.ts b/x-pack/plugins/ml/common/util/job_utils.ts index 10f5fb975ef5e..da340d4413849 100644 --- a/x-pack/plugins/ml/common/util/job_utils.ts +++ b/x-pack/plugins/ml/common/util/job_utils.ts @@ -8,9 +8,9 @@ import { each, isEmpty, isEqual, pick } from 'lodash'; import semverGte from 'semver/functions/gte'; import moment, { Duration } from 'moment'; +import type { estypes } from '@elastic/elasticsearch'; // @ts-ignore import numeral from '@elastic/numeral'; - import { i18n } from '@kbn/i18n'; import { ALLOWED_DATA_UNITS, JOB_ID_MAX_LENGTH } from '../constants/validation'; import { parseInterval } from './parse_interval'; @@ -22,13 +22,9 @@ import { MlServerLimits } from '../types/ml_server_info'; import { JobValidationMessage, JobValidationMessageId } from '../constants/messages'; import { ES_AGGREGATION, ML_JOB_AGGREGATION } from '../constants/aggregation_types'; import { MLCATEGORY } from '../constants/field_types'; -import { - getAggregationBucketsName, - getAggregations, - getDatafeedAggregations, -} from './datafeed_utils'; +import { getAggregations, getDatafeedAggregations } from './datafeed_utils'; import { findAggField } from './validation_utils'; -import { isPopulatedObject } from './object_utils'; +import { getFirstKeyInObject, isPopulatedObject } from './object_utils'; import { isDefined } from '../types/guards'; export interface ValidationResults { @@ -52,14 +48,6 @@ export function calculateDatafeedFrequencyDefaultSeconds(bucketSpanSeconds: numb return freq; } -export function hasRuntimeMappings(job: CombinedJob): boolean { - const hasDatafeed = isPopulatedObject(job.datafeed_config); - if (hasDatafeed) { - return isPopulatedObject(job.datafeed_config.runtime_mappings); - } - return false; -} - export function isTimeSeriesViewJob(job: CombinedJob): boolean { return getSingleMetricViewerJobErrorMessage(job) === undefined; } @@ -85,6 +73,34 @@ export function isMappableJob(job: CombinedJob, detectorIndex: number): boolean return isMappable; } +/** + * Validates that composite definition only have sources that are only terms and date_histogram + * if composite is defined. + * @param buckets + */ +export function hasValidComposite(buckets: estypes.AggregationContainer) { + if ( + isPopulatedObject(buckets, ['composite']) && + isPopulatedObject(buckets.composite, ['sources']) && + Array.isArray(buckets.composite.sources) + ) { + const sources = buckets.composite.sources; + return !sources.some((source) => { + const sourceName = getFirstKeyInObject(source); + if (sourceName !== undefined && isPopulatedObject(source[sourceName])) { + const sourceTypes = Object.keys(source[sourceName]); + return ( + sourceTypes.length === 1 && + sourceTypes[0] !== 'date_histogram' && + sourceTypes[0] !== 'terms' + ); + } + return false; + }); + } + return true; +} + // Returns a flag to indicate whether the source data can be plotted in a time // series chart for the specified detector. export function isSourceDataChartableForDetector(job: CombinedJob, detectorIndex: number): boolean { @@ -105,42 +121,42 @@ export function isSourceDataChartableForDetector(job: CombinedJob, detectorIndex dtr.partition_field_name !== MLCATEGORY && dtr.over_field_name !== MLCATEGORY; - // If the datafeed uses script fields, we can only plot the time series if - // model plot is enabled. Without model plot it will be very difficult or impossible - // to invert to a reverse search of the underlying metric data. - if ( - isSourceDataChartable === true && - job.datafeed_config?.script_fields !== null && - typeof job.datafeed_config?.script_fields === 'object' - ) { + const hasDatafeed = isPopulatedObject(job.datafeed_config); + + if (isSourceDataChartable && hasDatafeed) { // Perform extra check to see if the detector is using a scripted field. - const scriptFields = Object.keys(job.datafeed_config.script_fields); - isSourceDataChartable = - scriptFields.indexOf(dtr.partition_field_name!) === -1 && - scriptFields.indexOf(dtr.by_field_name!) === -1 && - scriptFields.indexOf(dtr.over_field_name!) === -1; - } + if (isPopulatedObject(job.datafeed_config.script_fields)) { + // If the datafeed uses script fields, we can only plot the time series if + // model plot is enabled. Without model plot it will be very difficult or impossible + // to invert to a reverse search of the underlying metric data. + + const scriptFields = Object.keys(job.datafeed_config.script_fields); + return ( + scriptFields.indexOf(dtr.partition_field_name!) === -1 && + scriptFields.indexOf(dtr.by_field_name!) === -1 && + scriptFields.indexOf(dtr.over_field_name!) === -1 + ); + } - const hasDatafeed = isPopulatedObject(job.datafeed_config); - if (hasDatafeed) { // We cannot plot the source data for some specific aggregation configurations const aggs = getDatafeedAggregations(job.datafeed_config); - if (aggs !== undefined) { - const aggBucketsName = getAggregationBucketsName(aggs); + if (isPopulatedObject(aggs)) { + const aggBucketsName = getFirstKeyInObject(aggs); if (aggBucketsName !== undefined) { - // if fieldName is a aggregated field under nested terms using bucket_script - const aggregations = getAggregations<{ [key: string]: any }>(aggs[aggBucketsName]) ?? {}; + // if fieldName is an aggregated field under nested terms using bucket_script + const aggregations = + getAggregations(aggs[aggBucketsName]) ?? {}; const foundField = findAggField(aggregations, dtr.field_name, false); if (foundField?.bucket_script !== undefined) { return false; } + + // composite sources should be terms and date_histogram only for now + return hasValidComposite(aggregations); } } - // We also cannot plot the source data if they datafeed uses any field defined by runtime_mappings - if (hasRuntimeMappings(job)) { - return false; - } + return true; } } @@ -180,11 +196,22 @@ export function isModelPlotChartableForDetector(job: Job, detectorIndex: number) // Returns a reason to indicate why the job configuration is not supported // if the result is undefined, that means the single metric job should be viewable export function getSingleMetricViewerJobErrorMessage(job: CombinedJob): string | undefined { - // if job has runtime mappings with no model plot - if (hasRuntimeMappings(job) && !job.model_plot_config?.enabled) { - return i18n.translate('xpack.ml.timeSeriesJob.jobWithRunTimeMessage', { - defaultMessage: 'the datafeed contains runtime fields and model plot is disabled', - }); + // if job has at least one composite source that is not terms or date_histogram + const aggs = getDatafeedAggregations(job.datafeed_config); + if (isPopulatedObject(aggs)) { + const aggBucketsName = getFirstKeyInObject(aggs); + if (aggBucketsName !== undefined && aggs[aggBucketsName] !== undefined) { + // if fieldName is an aggregated field under nested terms using bucket_script + + if (!hasValidComposite(aggs[aggBucketsName])) { + return i18n.translate( + 'xpack.ml.timeSeriesJob.jobWithUnsupportedCompositeAggregationMessage', + { + defaultMessage: 'Disabled because the datafeed contains unsupported composite sources.', + } + ); + } + } } // only allow jobs with at least one detector whose function corresponds to // an ES aggregation which can be viewed in the single metric view and which @@ -196,7 +223,7 @@ export function getSingleMetricViewerJobErrorMessage(job: CombinedJob): string | if (isChartableTimeSeriesViewJob === false) { return i18n.translate('xpack.ml.timeSeriesJob.notViewableTimeSeriesJobMessage', { - defaultMessage: 'not a viewable time series job', + defaultMessage: 'Disabled because not a viewable time series job.', }); } } diff --git a/x-pack/plugins/ml/common/util/object_utils.test.ts b/x-pack/plugins/ml/common/util/object_utils.test.ts index 8e4196ed4d826..d6d500cdb82c6 100644 --- a/x-pack/plugins/ml/common/util/object_utils.test.ts +++ b/x-pack/plugins/ml/common/util/object_utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { isPopulatedObject } from './object_utils'; +import { getFirstKeyInObject, isPopulatedObject } from './object_utils'; describe('object_utils', () => { describe('isPopulatedObject()', () => { @@ -47,4 +47,18 @@ describe('object_utils', () => { ).toBe(false); }); }); + + describe('getFirstKeyInObject()', () => { + it('gets the first key in object', () => { + expect(getFirstKeyInObject({ attribute1: 'value', attribute2: 'value2' })).toBe('attribute1'); + }); + + it('returns undefined with invalid argument', () => { + expect(getFirstKeyInObject(undefined)).toBe(undefined); + expect(getFirstKeyInObject(null)).toBe(undefined); + expect(getFirstKeyInObject({})).toBe(undefined); + expect(getFirstKeyInObject('value')).toBe(undefined); + expect(getFirstKeyInObject(5)).toBe(undefined); + }); + }); }); diff --git a/x-pack/plugins/ml/common/util/object_utils.ts b/x-pack/plugins/ml/common/util/object_utils.ts index 537ee9202b4de..cd62ca006725e 100644 --- a/x-pack/plugins/ml/common/util/object_utils.ts +++ b/x-pack/plugins/ml/common/util/object_utils.ts @@ -34,3 +34,14 @@ export const isPopulatedObject = ( requiredAttributes.every((d) => ({}.hasOwnProperty.call(arg, d)))) ); }; + +/** + * Get the first key in the object + * getFirstKeyInObject({ firstKey: {}, secondKey: {}}) -> firstKey + */ +export const getFirstKeyInObject = (arg: unknown): string | undefined => { + if (isPopulatedObject(arg)) { + const keys = Object.keys(arg); + return keys.length > 0 ? keys[0] : undefined; + } +}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js index 251b1b24087fa..f8195f5747f7e 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js @@ -39,16 +39,6 @@ export function ResultLinks({ jobs }) { const singleMetricDisabledMessage = jobs.length === 1 && jobs[0].isNotSingleMetricViewerJobMessage; - const singleMetricDisabledMessageText = - singleMetricDisabledMessage !== undefined - ? i18n.translate('xpack.ml.jobsList.resultActions.singleMetricDisabledMessageText', { - defaultMessage: 'Disabled because {reason}.', - values: { - reason: singleMetricDisabledMessage, - }, - }) - : undefined; - const jobActionsDisabled = jobs.length === 1 && jobs[0].deleting === true; const { createLinkWithUserDefaults } = useCreateADLinks(); const timeSeriesExplorerLink = useMemo( @@ -62,7 +52,7 @@ export function ResultLinks({ jobs }) { {singleMetricVisible && ( 0 && records.length > 0) { + if (records.length > 0) { const filterField = records[0].by_field_value || records[0].over_field_value; - chartData = eventDistribution.filter((d: { entity: any }) => d.entity !== filterField); + if (eventDistribution.length > 0) { + chartData = eventDistribution.filter((d: { entity: any }) => d.entity !== filterField); + } map(metricData, (value, time) => { // The filtering for rare/event_distribution charts needs to be handled // differently because of how the source data is structured. diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index caa0e20c3230d..c31194b58d589 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -27,6 +27,7 @@ import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { isPopulatedObject } from '../../../../common/util/object_utils'; import { InfluencersFilterQuery } from '../../../../common/types/es_client'; import { RecordForInfluencer } from './results_service'; +import { isRuntimeMappings } from '../../../../common'; interface ResultResponse { success: boolean; @@ -140,9 +141,7 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { }, }, size: 0, - _source: { - excludes: [], - }, + _source: false, aggs: { byTime: { date_histogram: { @@ -152,6 +151,9 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { }, }, }, + ...(isRuntimeMappings(datafeedConfig?.runtime_mappings) + ? { runtime_mappings: datafeedConfig?.runtime_mappings } + : {}), }; if (shouldCriteria.length > 0) { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8b125394eb612..527f32828979a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -14207,7 +14207,6 @@ "xpack.ml.jobsList.refreshButtonLabel": "更新", "xpack.ml.jobsList.resultActions.openJobsInAnomalyExplorerText": "{jobsCount, plural, one {{jobId}} other {# 件のジョブ}} を異常エクスプローラーで開く", "xpack.ml.jobsList.resultActions.openJobsInSingleMetricViewerText": "シングルメトリックビューアーで {jobsCount, plural, one {{jobId}} other {# 件のジョブ}} を開く", - "xpack.ml.jobsList.resultActions.singleMetricDisabledMessageText": "{reason}のため無効です。", "xpack.ml.jobsList.selectRowForJobMessage": "ジョブID {jobId} の行を選択", "xpack.ml.jobsList.showDetailsColumn.screenReaderDescription": "このカラムには各ジョブの詳細を示すクリック可能なコントロールが含まれます", "xpack.ml.jobsList.spacesLabel": "スペース", @@ -15074,7 +15073,6 @@ "xpack.ml.timeSeriesExplorer.timeSeriesChart.zoomLabel": "ズーム:", "xpack.ml.timeSeriesExplorer.tryWideningTheTimeSelectionDescription": "時間範囲を広げるか、さらに過去に遡ってみてください。", "xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage": "このダッシュボードでは 1 度に 1 つのジョブしか表示できません", - "xpack.ml.timeSeriesJob.jobWithRunTimeMessage": "データフィードにはランタイムフィールドが含まれ、モデルプロットが無効です", "xpack.ml.timeSeriesJob.notViewableTimeSeriesJobMessage": "表示可能な時系列ジョブではありません", "xpack.ml.timeSeriesJob.sourceDataModelPlotNotChartableMessage": "この検出器ではソースデータとモデルプロットの両方をグラフ化できません", "xpack.ml.timeSeriesJob.sourceDataNotChartableWithDisabledModelPlotMessage": "この検出器ではソースデータを表示できません。モデルプロットが無効です", @@ -23570,4 +23568,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "フィールドを選択してください。", "xpack.watcher.watcherDescription": "アラートの作成、管理、監視によりデータへの変更を検知します。" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f79a095277bd7..f8c8ee753942c 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -14404,7 +14404,6 @@ "xpack.ml.jobsList.refreshButtonLabel": "刷新", "xpack.ml.jobsList.resultActions.openJobsInAnomalyExplorerText": "在 Anomaly Explorer 中打开 {jobsCount, plural, one {{jobId}} other {# 个作业}}", "xpack.ml.jobsList.resultActions.openJobsInSingleMetricViewerText": "在 Single Metric Viewer 中打开 {jobsCount, plural, one {{jobId}} other {# 个作业}}", - "xpack.ml.jobsList.resultActions.singleMetricDisabledMessageText": "由于{reason},已禁用。", "xpack.ml.jobsList.selectRowForJobMessage": "选择作业 ID {jobId} 的行", "xpack.ml.jobsList.showDetailsColumn.screenReaderDescription": "此列包含可单击控件,用于显示每个作业的更多详情", "xpack.ml.jobsList.spacesLabel": "工作区", @@ -15292,7 +15291,6 @@ "xpack.ml.timeSeriesExplorer.timeSeriesChart.zoomLabel": "缩放:", "xpack.ml.timeSeriesExplorer.tryWideningTheTimeSelectionDescription": "请尝试扩大时间选择范围或进一步向后追溯。", "xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage": "在此仪表板中,一次仅可以查看一个作业", - "xpack.ml.timeSeriesJob.jobWithRunTimeMessage": "数据馈送包含运行时字段,模型绘图已禁用", "xpack.ml.timeSeriesJob.notViewableTimeSeriesJobMessage": "不是可查看的时间序列作业", "xpack.ml.timeSeriesJob.sourceDataModelPlotNotChartableMessage": "此检测器的源数据和模型绘图均无法绘制", "xpack.ml.timeSeriesJob.sourceDataNotChartableWithDisabledModelPlotMessage": "此检测器的源数据无法查看,且模型绘图处于禁用状态", @@ -23939,4 +23937,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "此字段必填。", "xpack.watcher.watcherDescription": "通过创建、管理和监测警报来检测数据中的更改。" } -} \ No newline at end of file +} From cb3c4e3a212255a9e9b8c89e784e0e452b661233 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Mon, 12 Apr 2021 14:05:39 -0400 Subject: [PATCH 17/28] [Fleet] support force flag to add/remove package_policies (#96713) ## Summary Can now pass a `force=true` parameter to add & remove integrations on hosted policies as originally intended [1] & [2] * Add `force` param for `POST` `/api/fleet/package_policies` & `/api/fleet/package_policies/delete` to a policy. Update tests to confirm * Not strictly required, but "while I was in there" * Updated a few places to throw `IngestManagerError` vs `Error` for `400` response vs `500`. Updated tests. * removed a few unnecessary `await`s of sync function [1] https://github.com/elastic/kibana/issues/92426#issuecomment-785092670 [2] https://github.com/elastic/kibana/issues/90445 ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/routes/package_policy/handlers.ts | 12 +- .../fleet/server/services/agent_policy.ts | 10 +- .../fleet/server/services/package_policy.ts | 14 +- .../server/types/models/package_policy.ts | 1 + .../server/types/rest_spec/package_policy.ts | 1 + .../apis/package_policy/create.ts | 422 +++++++++--------- .../apis/package_policy/delete.ts | 14 +- 7 files changed, 247 insertions(+), 227 deletions(-) diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts index 5e8abd5966e3a..4427ba714ad6a 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts @@ -79,11 +79,12 @@ export const createPackagePolicyHandler: RequestHandler< > = async (context, request, response) => { const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asCurrentUser; - const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; + const user = appContextService.getSecurity()?.authc.getCurrentUser(request) || undefined; + const { force, ...newPolicy } = request.body; try { const newData = await packagePolicyService.runExternalCallbacks( 'packagePolicyCreate', - { ...request.body }, + newPolicy, context, request ); @@ -91,6 +92,7 @@ export const createPackagePolicyHandler: RequestHandler< // Create package policy const packagePolicy = await packagePolicyService.create(soClient, esClient, newData, { user, + force, }); const body: CreatePackagePolicyResponse = { item: packagePolicy }; return response.ok({ @@ -114,7 +116,7 @@ export const updatePackagePolicyHandler: RequestHandler< > = async (context, request, response) => { const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asCurrentUser; - const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; + const user = appContextService.getSecurity()?.authc.getCurrentUser(request) || undefined; const packagePolicy = await packagePolicyService.get(soClient, request.params.packagePolicyId); if (!packagePolicy) { @@ -155,13 +157,13 @@ export const deletePackagePolicyHandler: RequestHandler< > = async (context, request, response) => { const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asCurrentUser; - const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; + const user = appContextService.getSecurity()?.authc.getCurrentUser(request) || undefined; try { const body: DeletePackagePoliciesResponse = await packagePolicyService.delete( soClient, esClient, request.body.packagePolicyIds, - { user } + { user, force: request.body.force } ); return response.ok({ body, diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index be61a70154b11..7f793a41ab985 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -466,7 +466,9 @@ class AgentPolicyService { esClient: ElasticsearchClient, id: string, packagePolicyIds: string[], - options: { user?: AuthenticatedUser; bumpRevision: boolean } = { bumpRevision: true } + options: { user?: AuthenticatedUser; bumpRevision: boolean; force?: boolean } = { + bumpRevision: true, + } ): Promise { const oldAgentPolicy = await this.get(soClient, id, false); @@ -474,7 +476,7 @@ class AgentPolicyService { throw new Error('Agent policy not found'); } - if (oldAgentPolicy.is_managed) { + if (oldAgentPolicy.is_managed && !options?.force) { throw new IngestManagerError(`Cannot update integrations of managed policy ${id}`); } @@ -497,7 +499,7 @@ class AgentPolicyService { esClient: ElasticsearchClient, id: string, packagePolicyIds: string[], - options?: { user?: AuthenticatedUser } + options?: { user?: AuthenticatedUser; force?: boolean } ): Promise { const oldAgentPolicy = await this.get(soClient, id, false); @@ -505,7 +507,7 @@ class AgentPolicyService { throw new Error('Agent policy not found'); } - if (oldAgentPolicy.is_managed) { + if (oldAgentPolicy.is_managed && !options?.force) { throw new IngestManagerError(`Cannot remove integrations of managed policy ${id}`); } diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 210c9128b1ec7..7d12aad6f32b5 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -60,14 +60,14 @@ class PackagePolicyService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, packagePolicy: NewPackagePolicy, - options?: { id?: string; user?: AuthenticatedUser; bumpRevision?: boolean } + options?: { id?: string; user?: AuthenticatedUser; bumpRevision?: boolean; force?: boolean } ): Promise { // Check that its agent policy does not have a package policy with the same name const parentAgentPolicy = await agentPolicyService.get(soClient, packagePolicy.policy_id); if (!parentAgentPolicy) { throw new Error('Agent policy not found'); } - if (parentAgentPolicy.is_managed) { + if (parentAgentPolicy.is_managed && !options?.force) { throw new IngestManagerError( `Cannot add integrations to managed policy ${parentAgentPolicy.id}` ); @@ -77,7 +77,9 @@ class PackagePolicyService { (siblingPackagePolicy) => siblingPackagePolicy.name === packagePolicy.name ) ) { - throw new Error('There is already a package with the same name on this agent policy'); + throw new IngestManagerError( + 'There is already a package with the same name on this agent policy' + ); } // Add ids to stream @@ -106,7 +108,7 @@ class PackagePolicyService { if (isPackageLimited(pkgInfo)) { const agentPolicy = await agentPolicyService.get(soClient, packagePolicy.policy_id, true); if (agentPolicy && doesAgentPolicyAlreadyIncludePackage(agentPolicy, pkgInfo.name)) { - throw new Error( + throw new IngestManagerError( `Unable to create package policy. Package '${pkgInfo.name}' already exists on this agent policy.` ); } @@ -140,6 +142,7 @@ class PackagePolicyService { { user: options?.user, bumpRevision: options?.bumpRevision ?? true, + force: options?.force, } ); @@ -367,7 +370,7 @@ class PackagePolicyService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, ids: string[], - options?: { user?: AuthenticatedUser; skipUnassignFromAgentPolicies?: boolean } + options?: { user?: AuthenticatedUser; skipUnassignFromAgentPolicies?: boolean; force?: boolean } ): Promise { const result: DeletePackagePoliciesResponse = []; @@ -385,6 +388,7 @@ class PackagePolicyService { [packagePolicy.id], { user: options?.user, + force: options?.force, } ); } diff --git a/x-pack/plugins/fleet/server/types/models/package_policy.ts b/x-pack/plugins/fleet/server/types/models/package_policy.ts index 6248b375f8edb..1f39b3135cb3f 100644 --- a/x-pack/plugins/fleet/server/types/models/package_policy.ts +++ b/x-pack/plugins/fleet/server/types/models/package_policy.ts @@ -78,6 +78,7 @@ const PackagePolicyBaseSchema = { export const NewPackagePolicySchema = schema.object({ ...PackagePolicyBaseSchema, + force: schema.maybe(schema.boolean()), }); export const UpdatePackagePolicySchema = schema.object({ diff --git a/x-pack/plugins/fleet/server/types/rest_spec/package_policy.ts b/x-pack/plugins/fleet/server/types/rest_spec/package_policy.ts index 3c6f54177096e..6086d1f0e00fb 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/package_policy.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/package_policy.ts @@ -33,5 +33,6 @@ export const UpdatePackagePolicyRequestSchema = { export const DeletePackagePoliciesRequestSchema = { body: schema.object({ packagePolicyIds: schema.arrayOf(schema.string()), + force: schema.maybe(schema.boolean()), }), }; diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts index 3764bdbc20d03..e2e1cc2f584bb 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts @@ -6,19 +6,18 @@ */ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; -import { warnAndSkipTest } from '../../helpers'; +import { skipIfNoDockerRegistry } from '../../helpers'; -export default function ({ getService }: FtrProviderContext) { - const log = getService('log'); +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; const supertest = getService('supertest'); - const dockerServers = getService('dockerServers'); - const server = dockerServers.get('registry'); // use function () {} and not () => {} here // because `this` has to point to the Mocha context // see https://mochajs.org/#arrow-functions describe('Package Policy - create', async function () { + skipIfNoDockerRegistry(providerContext); let agentPolicyId: string; before(async () => { await getService('esArchiver').load('empty_kibana'); @@ -47,230 +46,229 @@ export default function ({ getService }: FtrProviderContext) { .send({ agentPolicyId }); }); - it('should fail for managed agent policies', async function () { - if (server.enabled) { - // get a managed policy - const { - body: { item: managedPolicy }, - } = await supertest - .post(`/api/fleet/agent_policies`) - .set('kbn-xsrf', 'xxxx') - .send({ - name: `Managed policy from ${Date.now()}`, - namespace: 'default', - is_managed: true, - }); + it('can only add to managed agent policies using the force parameter', async function () { + // get a managed policy + const { + body: { item: managedPolicy }, + } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Managed policy from ${Date.now()}`, + namespace: 'default', + is_managed: true, + }); - // try to add an integration to the managed policy - const { body } = await supertest - .post(`/api/fleet/package_policies`) - .set('kbn-xsrf', 'xxxx') - .send({ - name: 'filetest-1', - description: '', - namespace: 'default', - policy_id: managedPolicy.id, - enabled: true, - output_id: '', - inputs: [], - package: { - name: 'filetest', - title: 'For File Tests', - version: '0.1.0', - }, - }) - .expect(400); + // try to add an integration to the managed policy + const { body: responseWithoutForce } = await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'default', + policy_id: managedPolicy.id, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(400); - expect(body.statusCode).to.be(400); - expect(body.message).to.contain('Cannot add integrations to managed policy'); + expect(responseWithoutForce.statusCode).to.be(400); + expect(responseWithoutForce.message).to.contain('Cannot add integrations to managed policy'); - // delete policy we just made - await supertest.post(`/api/fleet/agent_policies/delete`).set('kbn-xsrf', 'xxxx').send({ - agentPolicyId: managedPolicy.id, - }); - } else { - warnAndSkipTest(this, log); - } + // try same request with `force: true` + const { body: responseWithForce } = await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + force: true, + name: 'filetest-1', + description: '', + namespace: 'default', + policy_id: managedPolicy.id, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(200); + + expect(responseWithForce.item.name).to.eql('filetest-1'); + + // delete policy we just made + await supertest.post(`/api/fleet/agent_policies/delete`).set('kbn-xsrf', 'xxxx').send({ + agentPolicyId: managedPolicy.id, + }); }); it('should work with valid values', async function () { - if (server.enabled) { - await supertest - .post(`/api/fleet/package_policies`) - .set('kbn-xsrf', 'xxxx') - .send({ - name: 'filetest-1', - description: '', - namespace: 'default', - policy_id: agentPolicyId, - enabled: true, - output_id: '', - inputs: [], - package: { - name: 'filetest', - title: 'For File Tests', - version: '0.1.0', - }, - }) - .expect(200); - } else { - warnAndSkipTest(this, log); - } + await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'default', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(200); }); it('should return a 400 with an empty namespace', async function () { - if (server.enabled) { - await supertest - .post(`/api/fleet/package_policies`) - .set('kbn-xsrf', 'xxxx') - .send({ - name: 'filetest-1', - description: '', - namespace: '', - policy_id: agentPolicyId, - enabled: true, - output_id: '', - inputs: [], - package: { - name: 'filetest', - title: 'For File Tests', - version: '0.1.0', - }, - }) - .expect(400); - } else { - warnAndSkipTest(this, log); - } + await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: '', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(400); }); it('should return a 400 with an invalid namespace', async function () { - if (server.enabled) { - await supertest - .post(`/api/fleet/package_policies`) - .set('kbn-xsrf', 'xxxx') - .send({ - name: 'filetest-1', - description: '', - namespace: 'InvalidNamespace', - policy_id: agentPolicyId, - enabled: true, - output_id: '', - inputs: [], - package: { - name: 'filetest', - title: 'For File Tests', - version: '0.1.0', - }, - }) - .expect(400); - await supertest - .post(`/api/fleet/package_policies`) - .set('kbn-xsrf', 'xxxx') - .send({ - name: 'filetest-1', - description: '', - namespace: - 'testlength😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀', - policy_id: agentPolicyId, - enabled: true, - output_id: '', - inputs: [], - package: { - name: 'filetest', - title: 'For File Tests', - version: '0.1.0', - }, - }) - .expect(400); - } else { - warnAndSkipTest(this, log); - } + await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'InvalidNamespace', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(400); + await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: + 'testlength😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(400); }); it('should not allow multiple limited packages on the same agent policy', async function () { - if (server.enabled) { - await supertest - .post(`/api/fleet/package_policies`) - .set('kbn-xsrf', 'xxxx') - .send({ - name: 'endpoint-1', - description: '', - namespace: 'default', - policy_id: agentPolicyId, - enabled: true, - output_id: '', - inputs: [], - package: { - name: 'endpoint', - title: 'Endpoint', - version: '0.13.0', - }, - }) - .expect(200); - await supertest - .post(`/api/fleet/package_policies`) - .set('kbn-xsrf', 'xxxx') - .send({ - name: 'endpoint-2', - description: '', - namespace: 'default', - policy_id: agentPolicyId, - enabled: true, - output_id: '', - inputs: [], - package: { - name: 'endpoint', - title: 'Endpoint', - version: '0.13.0', - }, - }) - .expect(500); - } else { - warnAndSkipTest(this, log); - } + await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'endpoint-1', + description: '', + namespace: 'default', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'endpoint', + title: 'Endpoint', + version: '0.13.0', + }, + }) + .expect(200); + await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'endpoint-2', + description: '', + namespace: 'default', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'endpoint', + title: 'Endpoint', + version: '0.13.0', + }, + }) + .expect(400); }); - it('should return a 500 if there is another package policy with the same name', async function () { - if (server.enabled) { - await supertest - .post(`/api/fleet/package_policies`) - .set('kbn-xsrf', 'xxxx') - .send({ - name: 'same-name-test-1', - description: '', - namespace: 'default', - policy_id: agentPolicyId, - enabled: true, - output_id: '', - inputs: [], - package: { - name: 'filetest', - title: 'For File Tests', - version: '0.1.0', - }, - }) - .expect(200); - await supertest - .post(`/api/fleet/package_policies`) - .set('kbn-xsrf', 'xxxx') - .send({ - name: 'same-name-test-1', - description: '', - namespace: 'default', - policy_id: agentPolicyId, - enabled: true, - output_id: '', - inputs: [], - package: { - name: 'filetest', - title: 'For File Tests', - version: '0.1.0', - }, - }) - .expect(500); - } else { - warnAndSkipTest(this, log); - } + it('should return a 400 if there is another package policy with the same name', async function () { + await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'same-name-test-1', + description: '', + namespace: 'default', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(200); + await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'same-name-test-1', + description: '', + namespace: 'default', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(400); }); }); } diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts index 85e6f5ab92b74..15aba758c85d0 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts @@ -80,7 +80,7 @@ export default function (providerContext: FtrProviderContext) { await supertest .post(`/api/fleet/package_policies/delete`) .set('kbn-xsrf', 'xxxx') - .send({ packagePolicyIds: [packagePolicy.id] }); + .send({ force: true, packagePolicyIds: [packagePolicy.id] }); }); after(async () => { await getService('esArchiver').unload('empty_kibana'); @@ -112,6 +112,18 @@ export default function (providerContext: FtrProviderContext) { expect(results[0].success).to.be(false); expect(results[0].body.message).to.contain('Cannot remove integrations of managed policy'); + // same, but with force + const { body: resultsWithForce } = await supertest + .post(`/api/fleet/package_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true, packagePolicyIds: [packagePolicy.id] }) + .expect(200); + + // delete always succeeds (returns 200) with Array<{success: boolean}> + expect(Array.isArray(resultsWithForce)); + expect(resultsWithForce.length).to.be(1); + expect(resultsWithForce[0].success).to.be(true); + // revert existing policy to unmanaged await supertest .put(`/api/fleet/agent_policies/${agentPolicy.id}`) From c2b17696879171858a028bdf4ddfcac6faaf11d9 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Mon, 12 Apr 2021 20:38:47 +0200 Subject: [PATCH 18/28] Migration v2 waits for yellow cluster (#96788) * migrator waits for source index to be yellow otherwise the next request to Elasticsearch can fail * unskip integration tests that failed due to a red cluster * log how much the every step lasts * use Date.now instead of performance.now migration cannot finish in ms * update tests * clean log file before running tests * fix wrong type * add an integration test for waitForIndexStatusYellow --- .../migrationsv2/actions/index.ts | 4 +- .../integration_tests/actions.test.ts | 46 ++++ .../integration_tests/migration.test.ts | 23 +- .../migration_7.7.2_xpack_100k.test.ts | 18 +- .../migrations_state_action_machine.test.ts | 13 +- .../migrations_state_action_machine.ts | 23 +- .../saved_objects/migrationsv2/model.test.ts | 228 +++++++----------- .../saved_objects/migrationsv2/model.ts | 41 ++-- .../server/saved_objects/migrationsv2/next.ts | 5 +- .../saved_objects/migrationsv2/types.ts | 8 + 10 files changed, 225 insertions(+), 184 deletions(-) diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.ts b/src/core/server/saved_objects/migrationsv2/actions/index.ts index d759c0c9be20e..9d6afbd3b0d87 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.ts @@ -185,10 +185,10 @@ export const removeWriteBlock = ( * yellow at any point in the future. So ultimately data-redundancy is up to * users to maintain. */ -const waitForIndexStatusYellow = ( +export const waitForIndexStatusYellow = ( client: ElasticsearchClient, index: string, - timeout: string + timeout = DEFAULT_TIMEOUT ): TaskEither.TaskEither => () => { return client.cluster .health({ index, wait_for_status: 'yellow', timeout }) diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts index 3ed3ace416990..21c05d22b0581 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts @@ -30,6 +30,7 @@ import { UpdateAndPickupMappingsResponse, verifyReindex, removeWriteBlock, + waitForIndexStatusYellow, } from '../actions'; import * as Either from 'fp-ts/lib/Either'; import * as Option from 'fp-ts/lib/Option'; @@ -207,6 +208,51 @@ describe('migration actions', () => { }); }); + describe('waitForIndexStatusYellow', () => { + afterAll(async () => { + await client.indices.delete({ index: 'red_then_yellow_index' }); + }); + it('resolves right after waiting for an index status to be yellow if the index already existed', async () => { + // Create a red index + await client.indices.create( + { + index: 'red_then_yellow_index', + timeout: '5s', + body: { + mappings: { properties: {} }, + settings: { + // Allocate 1 replica so that this index stays yellow + number_of_replicas: '1', + // Disable all shard allocation so that the index status is red + index: { routing: { allocation: { enable: 'none' } } }, + }, + }, + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ); + + // Start tracking the index status + const indexStatusPromise = waitForIndexStatusYellow(client, 'red_then_yellow_index')(); + + const redStatusResponse = await client.cluster.health({ index: 'red_then_yellow_index' }); + expect(redStatusResponse.body.status).toBe('red'); + + client.indices.putSettings({ + index: 'red_then_yellow_index', + body: { + // Enable all shard allocation so that the index status turns yellow + index: { routing: { allocation: { enable: 'all' } } }, + }, + }); + + await indexStatusPromise; + // Assert that the promise didn't resolve before the index became yellow + + const yellowStatusResponse = await client.cluster.health({ index: 'red_then_yellow_index' }); + expect(yellowStatusResponse.body.status).toBe('yellow'); + }); + }); + describe('cloneIndex', () => { afterAll(async () => { try { diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts index 4d41a147bc0ef..1f8c3a535a902 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts @@ -6,7 +6,9 @@ * Side Public License, v 1. */ -import { join } from 'path'; +import Path from 'path'; +import Fs from 'fs'; +import Util from 'util'; import Semver from 'semver'; import { REPO_ROOT } from '@kbn/dev-utils'; import { Env } from '@kbn/config'; @@ -19,8 +21,15 @@ import { Root } from '../../../root'; const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo.version; -// FLAKY: https://github.com/elastic/kibana/issues/91107 -describe.skip('migration v2', () => { +const logFilePath = Path.join(__dirname, 'migration_test_kibana.log'); + +const asyncUnlink = Util.promisify(Fs.unlink); +async function removeLogFile() { + // ignore errors if it doesn't exist + await asyncUnlink(logFilePath).catch(() => void 0); +} + +describe('migration v2', () => { let esServer: kbnTestServer.TestElasticsearchUtils; let root: Root; let coreStart: InternalCoreStart; @@ -47,7 +56,7 @@ describe.skip('migration v2', () => { appenders: { file: { type: 'file', - fileName: join(__dirname, 'migration_test_kibana.log'), + fileName: logFilePath, layout: { type: 'json', }, @@ -122,9 +131,10 @@ describe.skip('migration v2', () => { const migratedIndex = `.kibana_${kibanaVersion}_001`; beforeAll(async () => { + await removeLogFile(); await startServers({ oss: false, - dataArchive: join(__dirname, 'archives', '7.3.0_xpack_sample_saved_objects.zip'), + dataArchive: Path.join(__dirname, 'archives', '7.3.0_xpack_sample_saved_objects.zip'), }); }); @@ -179,9 +189,10 @@ describe.skip('migration v2', () => { const migratedIndex = `.kibana_${kibanaVersion}_001`; beforeAll(async () => { + await removeLogFile(); await startServers({ oss: true, - dataArchive: join(__dirname, 'archives', '8.0.0_oss_sample_saved_objects.zip'), + dataArchive: Path.join(__dirname, 'archives', '8.0.0_oss_sample_saved_objects.zip'), }); }); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts index c26d4593bede1..0e51c886f7f30 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts @@ -6,7 +6,9 @@ * Side Public License, v 1. */ -import { join } from 'path'; +import Path from 'path'; +import Fs from 'fs'; +import Util from 'util'; import { REPO_ROOT } from '@kbn/dev-utils'; import { Env } from '@kbn/config'; import { getEnvOptions } from '@kbn/config/target/mocks'; @@ -16,8 +18,15 @@ import { InternalCoreStart } from '../../../internal_types'; import { Root } from '../../../root'; const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo.version; +const logFilePath = Path.join(__dirname, 'migration_test_kibana.log'); -describe.skip('migration from 7.7.2-xpack with 100k objects', () => { +const asyncUnlink = Util.promisify(Fs.unlink); +async function removeLogFile() { + // ignore errors if it doesn't exist + await asyncUnlink(logFilePath).catch(() => void 0); +} + +describe('migration from 7.7.2-xpack with 100k objects', () => { let esServer: kbnTestServer.TestElasticsearchUtils; let root: Root; let coreStart: InternalCoreStart; @@ -48,7 +57,7 @@ describe.skip('migration from 7.7.2-xpack with 100k objects', () => { appenders: { file: { type: 'file', - fileName: join(__dirname, 'migration_test_kibana.log'), + fileName: logFilePath, layout: { type: 'json', }, @@ -93,9 +102,10 @@ describe.skip('migration from 7.7.2-xpack with 100k objects', () => { const migratedIndex = `.kibana_${kibanaVersion}_001`; beforeAll(async () => { + await removeLogFile(); await startServers({ oss: false, - dataArchive: join(__dirname, 'archives', '7.7.2_xpack_100k_obj.zip'), + dataArchive: Path.join(__dirname, 'archives', '7.7.2_xpack_100k_obj.zip'), }); }); diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts index 2c2cd0032abfd..4d93abcc4018f 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts @@ -16,6 +16,11 @@ import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { elasticsearchClientMock } from '../../elasticsearch/client/mocks'; describe('migrationsStateActionMachine', () => { + beforeAll(() => { + jest + .spyOn(global.Date, 'now') + .mockImplementation(() => new Date('2021-04-12T16:00:00.000Z').valueOf()); + }); beforeEach(() => { jest.clearAllMocks(); }); @@ -112,25 +117,25 @@ describe('migrationsStateActionMachine', () => { "[.my-so-index] Log from LEGACY_REINDEX control state", ], Array [ - "[.my-so-index] INIT -> LEGACY_REINDEX", + "[.my-so-index] INIT -> LEGACY_REINDEX. took: 0ms.", ], Array [ "[.my-so-index] Log from LEGACY_DELETE control state", ], Array [ - "[.my-so-index] LEGACY_REINDEX -> LEGACY_DELETE", + "[.my-so-index] LEGACY_REINDEX -> LEGACY_DELETE. took: 0ms.", ], Array [ "[.my-so-index] Log from LEGACY_DELETE control state", ], Array [ - "[.my-so-index] LEGACY_DELETE -> LEGACY_DELETE", + "[.my-so-index] LEGACY_DELETE -> LEGACY_DELETE. took: 0ms.", ], Array [ "[.my-so-index] Log from DONE control state", ], Array [ - "[.my-so-index] LEGACY_DELETE -> DONE", + "[.my-so-index] LEGACY_DELETE -> DONE. took: 0ms.", ], ], "log": Array [], diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts index dddc66d68ad20..e35e21421ac1f 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts @@ -8,7 +8,6 @@ import { errors as EsErrors } from '@elastic/elasticsearch'; import * as Option from 'fp-ts/lib/Option'; -import { performance } from 'perf_hooks'; import { Logger, LogMeta } from '../../logging'; import { CorruptSavedObjectError } from '../migrations/core/migrate_raw_docs'; import { Model, Next, stateActionMachine } from './state_action_machine'; @@ -32,7 +31,8 @@ const logStateTransition = ( logger: Logger, logMessagePrefix: string, oldState: State, - newState: State + newState: State, + tookMs: number ) => { if (newState.logs.length > oldState.logs.length) { newState.logs @@ -40,7 +40,9 @@ const logStateTransition = ( .forEach((log) => logger[log.level](logMessagePrefix + log.message)); } - logger.info(logMessagePrefix + `${oldState.controlState} -> ${newState.controlState}`); + logger.info( + logMessagePrefix + `${oldState.controlState} -> ${newState.controlState}. took: ${tookMs}ms.` + ); }; const logActionResponse = ( @@ -85,11 +87,12 @@ export async function migrationStateActionMachine({ model: Model; }) { const executionLog: ExecutionLog = []; - const starteTime = performance.now(); + const startTime = Date.now(); // Since saved object index names usually start with a `.` and can be // configured by users to include several `.`'s we can't use a logger tag to // indicate which messages come from which index upgrade. const logMessagePrefix = `[${initialState.indexPrefix}] `; + let prevTimestamp = startTime; try { const finalState = await stateActionMachine( initialState, @@ -116,12 +119,20 @@ export async function migrationStateActionMachine({ controlState: newState.controlState, prevControlState: state.controlState, }); - logStateTransition(logger, logMessagePrefix, state, redactedNewState as State); + const now = Date.now(); + logStateTransition( + logger, + logMessagePrefix, + state, + redactedNewState as State, + now - prevTimestamp + ); + prevTimestamp = now; return newState; } ); - const elapsedMs = performance.now() - starteTime; + const elapsedMs = Date.now() - startTime; if (finalState.controlState === 'DONE') { logger.info(logMessagePrefix + `Migration completed after ${Math.round(elapsedMs)}ms`); if (finalState.sourceIndex != null && Option.isSome(finalState.sourceIndex)) { diff --git a/src/core/server/saved_objects/migrationsv2/model.test.ts b/src/core/server/saved_objects/migrationsv2/model.test.ts index 4fd9b7cbb3df4..8aad62f13b8fe 100644 --- a/src/core/server/saved_objects/migrationsv2/model.test.ts +++ b/src/core/server/saved_objects/migrationsv2/model.test.ts @@ -8,7 +8,7 @@ import * as Either from 'fp-ts/lib/Either'; import * as Option from 'fp-ts/lib/Option'; -import { +import type { FatalState, State, LegacySetWriteBlockState, @@ -30,6 +30,7 @@ import { CreateNewTargetState, CloneTempToSource, SetTempWriteBlock, + WaitForYellowSourceState, } from './types'; import { SavedObjectsRawDoc } from '..'; import { AliasAction, RetryableEsClientError } from './actions'; @@ -265,7 +266,7 @@ describe('migrations v2 model', () => { `"The .kibana alias is pointing to a newer version of Kibana: v7.12.0"` ); }); - test('INIT -> SET_SOURCE_WRITE_BLOCK when .kibana points to an index with an invalid version', () => { + test('INIT -> WAIT_FOR_YELLOW_SOURCE when .kibana points to an index with an invalid version', () => { // If users tamper with our index version naming scheme we can no // longer accurately detect a newer version. Older Kibana versions // will have indices like `.kibana_10` and users might choose an @@ -290,39 +291,13 @@ describe('migrations v2 model', () => { }); const newState = model(initState, res) as FatalState; - expect(newState.controlState).toEqual('SET_SOURCE_WRITE_BLOCK'); + expect(newState.controlState).toEqual('WAIT_FOR_YELLOW_SOURCE'); expect(newState).toMatchObject({ - controlState: 'SET_SOURCE_WRITE_BLOCK', - sourceIndex: Option.some('.kibana_7.invalid.0_001'), - targetIndex: '.kibana_7.11.0_001', + controlState: 'WAIT_FOR_YELLOW_SOURCE', + sourceIndex: '.kibana_7.invalid.0_001', }); - // This snapshot asserts that we disable the unknown saved object - // type. Because it's mappings are disabled, we also don't copy the - // `_meta.migrationMappingPropertyHashes` for the disabled type. - expect(newState.targetIndexMappings).toMatchInlineSnapshot(` - Object { - "_meta": Object { - "migrationMappingPropertyHashes": Object { - "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", - }, - }, - "properties": Object { - "disabled_saved_object_type": Object { - "dynamic": false, - "properties": Object {}, - }, - "new_saved_object_type": Object { - "properties": Object { - "value": Object { - "type": "text", - }, - }, - }, - }, - } - `); }); - test('INIT -> SET_SOURCE_WRITE_BLOCK when migrating from a v2 migrations index (>= 7.11.0)', () => { + test('INIT -> WAIT_FOR_YELLOW_SOURCE when migrating from a v2 migrations index (>= 7.11.0)', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_7.11.0_001': { aliases: { '.kibana': {}, '.kibana_7.11.0': {} }, @@ -348,39 +323,13 @@ describe('migrations v2 model', () => { ); expect(newState).toMatchObject({ - controlState: 'SET_SOURCE_WRITE_BLOCK', - sourceIndex: Option.some('.kibana_7.11.0_001'), - targetIndex: '.kibana_7.12.0_001', + controlState: 'WAIT_FOR_YELLOW_SOURCE', + sourceIndex: '.kibana_7.11.0_001', }); - // This snapshot asserts that we disable the unknown saved object - // type. Because it's mappings are disabled, we also don't copy the - // `_meta.migrationMappingPropertyHashes` for the disabled type. - expect(newState.targetIndexMappings).toMatchInlineSnapshot(` - Object { - "_meta": Object { - "migrationMappingPropertyHashes": Object { - "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", - }, - }, - "properties": Object { - "disabled_saved_object_type": Object { - "dynamic": false, - "properties": Object {}, - }, - "new_saved_object_type": Object { - "properties": Object { - "value": Object { - "type": "text", - }, - }, - }, - }, - } - `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); - test('INIT -> SET_SOURCE_WRITE_BLOCK when migrating from a v1 migrations index (>= 6.5 < 7.11.0)', () => { + test('INIT -> WAIT_FOR_YELLOW_SOURCE when migrating from a v1 migrations index (>= 6.5 < 7.11.0)', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_3': { aliases: { @@ -393,35 +342,9 @@ describe('migrations v2 model', () => { const newState = model(initState, res); expect(newState).toMatchObject({ - controlState: 'SET_SOURCE_WRITE_BLOCK', - sourceIndex: Option.some('.kibana_3'), - targetIndex: '.kibana_7.11.0_001', + controlState: 'WAIT_FOR_YELLOW_SOURCE', + sourceIndex: '.kibana_3', }); - // This snapshot asserts that we disable the unknown saved object - // type. Because it's mappings are disabled, we also don't copy the - // `_meta.migrationMappingPropertyHashes` for the disabled type. - expect(newState.targetIndexMappings).toMatchInlineSnapshot(` - Object { - "_meta": Object { - "migrationMappingPropertyHashes": Object { - "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", - }, - }, - "properties": Object { - "disabled_saved_object_type": Object { - "dynamic": false, - "properties": Object {}, - }, - "new_saved_object_type": Object { - "properties": Object { - "value": Object { - "type": "text", - }, - }, - }, - }, - } - `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -468,7 +391,7 @@ describe('migrations v2 model', () => { expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); - test('INIT -> SET_SOURCE_WRITE_BLOCK when migrating from a custom kibana.index name (>= 6.5 < 7.11.0)', () => { + test('INIT -> WAIT_FOR_YELLOW_SOURCE when migrating from a custom kibana.index name (>= 6.5 < 7.11.0)', () => { const res: ResponseType<'INIT'> = Either.right({ 'my-saved-objects_3': { aliases: { @@ -490,39 +413,13 @@ describe('migrations v2 model', () => { ); expect(newState).toMatchObject({ - controlState: 'SET_SOURCE_WRITE_BLOCK', - sourceIndex: Option.some('my-saved-objects_3'), - targetIndex: 'my-saved-objects_7.11.0_001', + controlState: 'WAIT_FOR_YELLOW_SOURCE', + sourceIndex: 'my-saved-objects_3', }); - // This snapshot asserts that we disable the unknown saved object - // type. Because it's mappings are disabled, we also don't copy the - // `_meta.migrationMappingPropertyHashes` for the disabled type. - expect(newState.targetIndexMappings).toMatchInlineSnapshot(` - Object { - "_meta": Object { - "migrationMappingPropertyHashes": Object { - "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", - }, - }, - "properties": Object { - "disabled_saved_object_type": Object { - "dynamic": false, - "properties": Object {}, - }, - "new_saved_object_type": Object { - "properties": Object { - "value": Object { - "type": "text", - }, - }, - }, - }, - } - `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); - test('INIT -> SET_SOURCE_WRITE_BLOCK when migrating from a custom kibana.index v2 migrations index (>= 7.11.0)', () => { + test('INIT -> WAIT_FOR_YELLOW_SOURCE when migrating from a custom kibana.index v2 migrations index (>= 7.11.0)', () => { const res: ResponseType<'INIT'> = Either.right({ 'my-saved-objects_7.11.0': { aliases: { @@ -545,35 +442,9 @@ describe('migrations v2 model', () => { ); expect(newState).toMatchObject({ - controlState: 'SET_SOURCE_WRITE_BLOCK', - sourceIndex: Option.some('my-saved-objects_7.11.0'), - targetIndex: 'my-saved-objects_7.12.0_001', + controlState: 'WAIT_FOR_YELLOW_SOURCE', + sourceIndex: 'my-saved-objects_7.11.0', }); - // This snapshot asserts that we disable the unknown saved object - // type. Because it's mappings are disabled, we also don't copy the - // `_meta.migrationMappingPropertyHashes` for the disabled type. - expect(newState.targetIndexMappings).toMatchInlineSnapshot(` - Object { - "_meta": Object { - "migrationMappingPropertyHashes": Object { - "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", - }, - }, - "properties": Object { - "disabled_saved_object_type": Object { - "dynamic": false, - "properties": Object {}, - }, - "new_saved_object_type": Object { - "properties": Object { - "value": Object { - "type": "text", - }, - }, - }, - }, - } - `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -761,6 +632,69 @@ describe('migrations v2 model', () => { expect(newState.retryDelay).toEqual(0); }); }); + + describe('WAIT_FOR_YELLOW_SOURCE', () => { + const mappingsWithUnknownType = { + properties: { + disabled_saved_object_type: { + properties: { + value: { type: 'keyword' }, + }, + }, + }, + _meta: { + migrationMappingPropertyHashes: { + disabled_saved_object_type: '7997cf5a56cc02bdc9c93361bde732b0', + }, + }, + }; + + const waitForYellowSourceState: WaitForYellowSourceState = { + ...baseState, + controlState: 'WAIT_FOR_YELLOW_SOURCE', + sourceIndex: '.kibana_3', + sourceIndexMappings: mappingsWithUnknownType, + }; + + test('WAIT_FOR_YELLOW_SOURCE -> SET_SOURCE_WRITE_BLOCK if action succeeds', () => { + const res: ResponseType<'WAIT_FOR_YELLOW_SOURCE'> = Either.right({}); + const newState = model(waitForYellowSourceState, res); + expect(newState.controlState).toEqual('SET_SOURCE_WRITE_BLOCK'); + + expect(newState).toMatchObject({ + controlState: 'SET_SOURCE_WRITE_BLOCK', + sourceIndex: Option.some('.kibana_3'), + targetIndex: '.kibana_7.11.0_001', + }); + + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); + }); + }); + describe('SET_SOURCE_WRITE_BLOCK', () => { const setWriteBlockState: SetSourceWriteBlockState = { ...baseState, diff --git a/src/core/server/saved_objects/migrationsv2/model.ts b/src/core/server/saved_objects/migrationsv2/model.ts index 2353452a6a51b..ee78692a7044f 100644 --- a/src/core/server/saved_objects/migrationsv2/model.ts +++ b/src/core/server/saved_objects/migrationsv2/model.ts @@ -222,22 +222,11 @@ export const model = (currentState: State, resW: ResponseType): ) { // The source index is the index the `.kibana` alias points to const source = aliases[stateP.currentAlias]; - const target = stateP.versionIndex; return { ...stateP, - controlState: 'SET_SOURCE_WRITE_BLOCK', - sourceIndex: Option.some(source) as Option.Some, - targetIndex: target, - targetIndexMappings: disableUnknownTypeMappingFields( - stateP.targetIndexMappings, - indices[source].mappings - ), - versionIndexReadyActions: Option.some([ - { remove: { index: source, alias: stateP.currentAlias, must_exist: true } }, - { add: { index: target, alias: stateP.currentAlias } }, - { add: { index: target, alias: stateP.versionAlias } }, - { remove_index: { index: stateP.tempIndex } }, - ]), + controlState: 'WAIT_FOR_YELLOW_SOURCE', + sourceIndex: source, + sourceIndexMappings: indices[source].mappings, }; } else if (indices[stateP.legacyIndex] != null) { // Migrate from a legacy index @@ -432,6 +421,30 @@ export const model = (currentState: State, resW: ResponseType): } else { throwBadResponse(stateP, res); } + } else if (stateP.controlState === 'WAIT_FOR_YELLOW_SOURCE') { + const res = resW as ExcludeRetryableEsError>; + if (Either.isRight(res)) { + const source = stateP.sourceIndex; + const target = stateP.versionIndex; + return { + ...stateP, + controlState: 'SET_SOURCE_WRITE_BLOCK', + sourceIndex: Option.some(source) as Option.Some, + targetIndex: target, + targetIndexMappings: disableUnknownTypeMappingFields( + stateP.targetIndexMappings, + stateP.sourceIndexMappings + ), + versionIndexReadyActions: Option.some([ + { remove: { index: source, alias: stateP.currentAlias, must_exist: true } }, + { add: { index: target, alias: stateP.currentAlias } }, + { add: { index: target, alias: stateP.versionAlias } }, + { remove_index: { index: stateP.tempIndex } }, + ]), + }; + } else { + return throwBadResponse(stateP, res); + } } else if (stateP.controlState === 'SET_SOURCE_WRITE_BLOCK') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { diff --git a/src/core/server/saved_objects/migrationsv2/next.ts b/src/core/server/saved_objects/migrationsv2/next.ts index 67b2004a4b31a..5cbda741a0ce5 100644 --- a/src/core/server/saved_objects/migrationsv2/next.ts +++ b/src/core/server/saved_objects/migrationsv2/next.ts @@ -10,7 +10,7 @@ import * as TaskEither from 'fp-ts/lib/TaskEither'; import * as Option from 'fp-ts/lib/Option'; import { UnwrapPromise } from '@kbn/utility-types'; import { pipe } from 'fp-ts/lib/pipeable'; -import { +import type { AllActionStates, ReindexSourceToTempState, MarkVersionIndexReady, @@ -32,6 +32,7 @@ import { CreateNewTargetState, CloneTempToSource, SetTempWriteBlock, + WaitForYellowSourceState, } from './types'; import * as Actions from './actions'; import { ElasticsearchClient } from '../../elasticsearch'; @@ -54,6 +55,8 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra return { INIT: (state: InitState) => Actions.fetchIndices(client, [state.currentAlias, state.versionAlias]), + WAIT_FOR_YELLOW_SOURCE: (state: WaitForYellowSourceState) => + Actions.waitForIndexStatusYellow(client, state.sourceIndex), SET_SOURCE_WRITE_BLOCK: (state: SetSourceWriteBlockState) => Actions.setWriteBlock(client, state.sourceIndex.value), CREATE_NEW_TARGET: (state: CreateNewTargetState) => diff --git a/src/core/server/saved_objects/migrationsv2/types.ts b/src/core/server/saved_objects/migrationsv2/types.ts index cc4aa18171843..e9b351c0152fc 100644 --- a/src/core/server/saved_objects/migrationsv2/types.ts +++ b/src/core/server/saved_objects/migrationsv2/types.ts @@ -128,6 +128,13 @@ export type FatalState = BaseState & { readonly reason: string; }; +export interface WaitForYellowSourceState extends BaseState { + /** Wait for the source index to be yellow before requesting it. */ + readonly controlState: 'WAIT_FOR_YELLOW_SOURCE'; + readonly sourceIndex: string; + readonly sourceIndexMappings: IndexMapping; +} + export type SetSourceWriteBlockState = PostInitState & { /** Set a write block on the source index to prevent any further writes */ readonly controlState: 'SET_SOURCE_WRITE_BLOCK'; @@ -290,6 +297,7 @@ export type State = | FatalState | InitState | DoneState + | WaitForYellowSourceState | SetSourceWriteBlockState | CreateNewTargetState | CreateReindexTempState From 171f39821a063587a2db1f27b84cd4b05b857d26 Mon Sep 17 00:00:00 2001 From: Constance Date: Mon, 12 Apr 2021 12:12:33 -0700 Subject: [PATCH 19/28] [App Search] Results follow-up (#96709) * CSS cleanup * Refactor ResultActions component + DRY out link behavior - Create new separate ResultActions component - Pass actions array through to header and have haeder in charge of conditional visibility / FlexItem wrapper (this matches the other header items) - shouldLinkToDetailPage: instead of generating custom JSX, just have it be a standard action and append it to the actions array Link behavior: - ResultHeaderItem - switch to EuiLinkTo, no need for extra wrapper - ResultHeader - DRY out unnecessary extra path generation - instead pass down a conditional documentLink instead of a bool * PR feedback: Fix test name * PR feedback: unshift Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../app_search/components/result/result.scss | 31 +------ .../components/result/result.test.tsx | 83 ++++++++----------- .../app_search/components/result/result.tsx | 62 +++++--------- .../components/result/result_actions.test.tsx | 55 ++++++++++++ .../components/result/result_actions.tsx | 34 ++++++++ .../components/result/result_header.scss | 25 +----- .../components/result/result_header.test.tsx | 68 +++++++-------- .../components/result/result_header.tsx | 35 ++++---- .../components/result/result_header_item.scss | 10 +-- .../result/result_header_item.test.tsx | 4 +- .../components/result/result_header_item.tsx | 10 +-- 11 files changed, 208 insertions(+), 209 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss index 3132894ddc7a1..93bace1d77775 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss @@ -1,10 +1,10 @@ .appSearchResult { display: grid; - grid-template-columns: auto 1fr auto; - grid-template-rows: auto 1fr auto; + grid-template-columns: auto 1fr; + grid-template-rows: auto 1fr; grid-template-areas: - 'drag content actions' - 'drag toggle actions'; + 'drag content' + 'drag toggle'; overflow: hidden; // Prevents child background-colors from clipping outside of panel border-radius border: $euiBorderThin; // TODO: Remove after EUI version is bumped beyond 31.8.0 @@ -35,29 +35,6 @@ } } - &__actionButtons { - grid-area: actions; - display: flex; - flex-wrap: no-wrap; - } - - &__actionButton { - display: flex; - justify-content: center; - align-items: center; - width: $euiSize * 2; - border-left: $euiBorderThin; - - &:first-child { - border-left: none; - } - - &:hover, - &:focus { - background-color: $euiPageBackgroundColor; - } - } - &__dragHandle { grid-area: drag; display: flex; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx index 3e83717bf9355..ba9944744e5c7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx @@ -5,12 +5,14 @@ * 2.0. */ +import { mockKibanaValues } from '../../../__mocks__'; + import React from 'react'; import { DraggableProvidedDragHandleProps } from 'react-beautiful-dnd'; import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiButtonIcon, EuiPanel, EuiButtonIconColor } from '@elastic/eui'; +import { EuiPanel } from '@elastic/eui'; import { SchemaTypes } from '../../../shared/types'; @@ -63,18 +65,28 @@ describe('Result', () => { ]); }); - it('renders a header', () => { - const wrapper = shallow(); - const header = wrapper.find(ResultHeader); - expect(header.exists()).toBe(true); - expect(header.prop('isMetaEngine')).toBe(true); // passed through from props - expect(header.prop('showScore')).toBe(true); // passed through from props - expect(header.prop('shouldLinkToDetailPage')).toBe(false); // passed through from props - expect(header.prop('resultMeta')).toEqual({ - id: '1', - score: 100, - engine: 'my-engine', - }); // passed through from meta in result prop + describe('header', () => { + it('renders a header', () => { + const wrapper = shallow(); + const header = wrapper.find(ResultHeader); + + expect(header.exists()).toBe(true); + expect(header.prop('isMetaEngine')).toBe(true); // passed through from props + expect(header.prop('showScore')).toBe(true); // passed through from props + expect(header.prop('resultMeta')).toEqual({ + id: '1', + score: 100, + engine: 'my-engine', + }); // passed through from meta in result prop + expect(header.prop('documentLink')).toBe(undefined); // based on shouldLinkToDetailPage prop + }); + + it('passes documentLink when shouldLinkToDetailPage is true', () => { + const wrapper = shallow(); + const header = wrapper.find(ResultHeader); + + expect(header.prop('documentLink')).toBe('/engines/my-engine/documents/1'); + }); }); describe('actions', () => { @@ -83,53 +95,30 @@ describe('Result', () => { title: 'Hide', onClick: jest.fn(), iconType: 'eyeClosed', - iconColor: 'danger' as EuiButtonIconColor, }, { title: 'Bookmark', onClick: jest.fn(), iconType: 'starFilled', - iconColor: undefined, }, ]; - it('will render an action button in the header for each action passed', () => { + it('passes actions to the header', () => { const wrapper = shallow(); - const header = wrapper.find(ResultHeader); - const renderedActions = shallow(header.prop('actions') as any); - const buttons = renderedActions.find(EuiButtonIcon); - expect(buttons).toHaveLength(2); - - expect(buttons.first().prop('iconType')).toEqual('eyeClosed'); - expect(buttons.first().prop('color')).toEqual('danger'); - buttons.first().simulate('click'); - expect(actions[0].onClick).toHaveBeenCalled(); - - expect(buttons.last().prop('iconType')).toEqual('starFilled'); - // Note that no iconColor was passed so it was defaulted to primary - expect(buttons.last().prop('color')).toEqual('primary'); - buttons.last().simulate('click'); - expect(actions[1].onClick).toHaveBeenCalled(); + expect(wrapper.find(ResultHeader).prop('actions')).toEqual(actions); }); - it('will render a document detail link as the first action if shouldLinkToDetailPage is passed', () => { + it('adds a link action to the start of the actions array if shouldLinkToDetailPage is passed', () => { const wrapper = shallow(); - const header = wrapper.find(ResultHeader); - const renderedActions = shallow(header.prop('actions') as any); - const buttons = renderedActions.find(EuiButtonIcon); - // In addition to the 2 actions passed, we also have a link action - expect(buttons).toHaveLength(3); + const passedActions = wrapper.find(ResultHeader).prop('actions'); + expect(passedActions.length).toEqual(3); // In addition to the 2 actions passed, we also have a link action - expect(buttons.first().prop('data-test-subj')).toEqual('DocumentDetailLink'); - }); + const linkAction = passedActions[0]; + expect(linkAction.title).toEqual('Visit document details'); - it('will not render anything if no actions are passed and shouldLinkToDetailPage is false', () => { - const wrapper = shallow(); - const header = wrapper.find(ResultHeader); - const renderedActions = shallow(header.prop('actions') as any); - const buttons = renderedActions.find(EuiButtonIcon); - expect(buttons).toHaveLength(0); + linkAction.onClick(); + expect(mockKibanaValues.navigateToUrl).toHaveBeenCalledWith('/engines/my-engine/documents/1'); }); }); @@ -148,9 +137,7 @@ describe('Result', () => { }); it('will render field details with type highlights if schemaForTypeHighlights has been provided', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find(ResultField).map((rf) => rf.prop('type'))).toEqual([ 'text', 'text', diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx index 71d9f39d802d5..d9c16a877dc59 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx @@ -10,12 +10,11 @@ import { DraggableProvidedDragHandleProps } from 'react-beautiful-dnd'; import './result.scss'; -import { EuiButtonIcon, EuiPanel, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { EuiPanel, EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { ReactRouterHelper } from '../../../shared/react_router_helpers/eui_components'; - +import { KibanaLogic } from '../../../shared/kibana'; import { Schema } from '../../../shared/types'; import { ENGINE_DOCUMENT_DETAIL_PATH } from '../../routes'; @@ -56,48 +55,27 @@ export const Result: React.FC = ({ [result] ); const numResults = resultFields.length; - const documentLink = generateEncodedPath(ENGINE_DOCUMENT_DETAIL_PATH, { - engineName: resultMeta.engine, - documentId: resultMeta.id, - }); const typeForField = (fieldName: string) => { if (schemaForTypeHighlights) return schemaForTypeHighlights[fieldName]; }; - const ResultActions = () => { - if (!shouldLinkToDetailPage && !actions.length) return null; - return ( - - - {shouldLinkToDetailPage && ( - - - - - - )} - {actions.map(({ onClick, title, iconType, iconColor }) => ( - - - - ))} - - - ); - }; + const documentLink = shouldLinkToDetailPage + ? generateEncodedPath(ENGINE_DOCUMENT_DETAIL_PATH, { + engineName: resultMeta.engine, + documentId: resultMeta.id, + }) + : undefined; + if (shouldLinkToDetailPage && documentLink) { + const linkAction = { + onClick: () => KibanaLogic.values.navigateToUrl(documentLink), + title: i18n.translate('xpack.enterpriseSearch.appSearch.result.documentDetailLink', { + defaultMessage: 'Visit document details', + }), + iconType: 'eye', + }; + actions = [linkAction, ...actions]; + } return ( = ({ resultMeta={resultMeta} showScore={!!showScore} isMetaEngine={isMetaEngine} - shouldLinkToDetailPage={shouldLinkToDetailPage} - actions={} + documentLink={documentLink} + actions={actions} /> {resultFields .slice(0, isOpen ? resultFields.length : RESULT_CUTOFF) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.test.tsx new file mode 100644 index 0000000000000..4aae1e07f0f8c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButtonIcon, EuiButtonIconColor } from '@elastic/eui'; + +import { ResultActions } from './result_actions'; + +describe('ResultActions', () => { + const actions = [ + { + title: 'Hide', + onClick: jest.fn(), + iconType: 'eyeClosed', + iconColor: 'danger' as EuiButtonIconColor, + }, + { + title: 'Bookmark', + onClick: jest.fn(), + iconType: 'starFilled', + iconColor: undefined, + }, + ]; + + const wrapper = shallow(); + const buttons = wrapper.find(EuiButtonIcon); + + it('renders an action button for each action passed', () => { + expect(buttons).toHaveLength(2); + }); + + it('passes icon props correctly', () => { + expect(buttons.first().prop('iconType')).toEqual('eyeClosed'); + expect(buttons.first().prop('color')).toEqual('danger'); + + expect(buttons.last().prop('iconType')).toEqual('starFilled'); + // Note that no iconColor was passed so it was defaulted to primary + expect(buttons.last().prop('color')).toEqual('primary'); + }); + + it('passes click events', () => { + buttons.first().simulate('click'); + expect(actions[0].onClick).toHaveBeenCalled(); + + buttons.last().simulate('click'); + expect(actions[1].onClick).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.tsx new file mode 100644 index 0000000000000..52fbee90fe31a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { ResultAction } from './types'; + +interface Props { + actions: ResultAction[]; +} + +export const ResultActions: React.FC = ({ actions }) => { + return ( + + {actions.map(({ onClick, title, iconType, iconColor }) => ( + + + + ))} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss index cd1042998dd34..ebae11ee8ad33 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss @@ -1,26 +1,3 @@ .appSearchResultHeader { - display: flex; - margin-bottom: $euiSizeS; - - @include euiBreakpoint('xs') { - flex-direction: column; - } - - &__column { - display: flex; - flex-wrap: wrap; - - @include euiBreakpoint('xs') { - flex-direction: column; - } - - & + &, - .appSearchResultHeaderItem + .appSearchResultHeaderItem { - margin-left: $euiSizeL; - - @include euiBreakpoint('xs') { - margin-left: 0; - } - } - } + margin-bottom: $euiSizeM; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx index 80cff9b96a3ca..cdd43c3efd97a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { ResultActions } from './result_actions'; import { ResultHeader } from './result_header'; describe('ResultHeader', () => { @@ -17,30 +18,27 @@ describe('ResultHeader', () => { score: 100, engine: 'my-engine', }; + const props = { + showScore: false, + isMetaEngine: false, + resultMeta, + actions: [], + }; it('renders', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.isEmptyRender()).toBe(false); }); it('always renders an id', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find('[data-test-subj="ResultId"]').prop('value')).toEqual('1'); expect(wrapper.find('[data-test-subj="ResultId"]').prop('href')).toBeUndefined(); }); - it('renders id as a link if shouldLinkToDetailPage is true', () => { + it('renders id as a link if a documentLink has been passed', () => { const wrapper = shallow( - + ); expect(wrapper.find('[data-test-subj="ResultId"]').prop('value')).toEqual('1'); expect(wrapper.find('[data-test-subj="ResultId"]').prop('href')).toEqual( @@ -50,47 +48,39 @@ describe('ResultHeader', () => { describe('score', () => { it('renders score if showScore is true ', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find('[data-test-subj="ResultScore"]').prop('value')).toEqual(100); }); it('does not render score if showScore is false', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find('[data-test-subj="ResultScore"]').exists()).toBe(false); }); }); describe('engine', () => { it('renders engine name if this is a meta engine', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find('[data-test-subj="ResultEngine"]').prop('value')).toBe('my-engine'); }); it('does not render an engine if this is not a meta engine', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find('[data-test-subj="ResultEngine"]').exists()).toBe(false); }); }); + + describe('actions', () => { + const actions = [{ title: 'View document', onClick: () => {}, iconType: 'eye' }]; + + it('renders ResultActions if actions have been passed', () => { + const wrapper = shallow(); + expect(wrapper.find(ResultActions).exists()).toBe(true); + }); + + it('does not render ResultActions if no actions are passed', () => { + const wrapper = shallow(); + expect(wrapper.find(ResultActions).exists()).toBe(false); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx index 93a684b1968a2..f577b481b39cf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx @@ -9,11 +9,9 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { ENGINE_DOCUMENT_DETAIL_PATH } from '../../routes'; -import { generateEncodedPath } from '../../utils/encode_path_params'; - +import { ResultActions } from './result_actions'; import { ResultHeaderItem } from './result_header_item'; -import { ResultMeta } from './types'; +import { ResultMeta, ResultAction } from './types'; import './result_header.scss'; @@ -21,8 +19,8 @@ interface Props { showScore: boolean; isMetaEngine: boolean; resultMeta: ResultMeta; - actions?: React.ReactNode; - shouldLinkToDetailPage?: boolean; + actions: ResultAction[]; + documentLink?: string; } export const ResultHeader: React.FC = ({ @@ -30,19 +28,20 @@ export const ResultHeader: React.FC = ({ resultMeta, isMetaEngine, actions, - shouldLinkToDetailPage = false, + documentLink, }) => { - const documentLink = generateEncodedPath(ENGINE_DOCUMENT_DETAIL_PATH, { - engineName: resultMeta.engine, - documentId: resultMeta.id, - }); - return ( -
- +
+ = ({ /> )} - {actions} + {actions.length > 0 && ( + + + + )}
); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss index df3e2ec241106..94367ae634b7c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss @@ -1,12 +1,12 @@ -.euiFlexItem:not(:first-child):not(:last-child) .appSearchResultHeaderItem { - padding-right: .75rem; - box-shadow: inset -1px 0 0 0 $euiBorderColor; -} - .appSearchResultHeaderItem { @include euiCodeFont; &__score { color: $euiColorSuccessText; } + + .euiFlexItem:not(:first-child):not(:last-child) & { + padding-right: $euiSizeS; + box-shadow: inset (-$euiBorderWidthThin) 0 0 0 $euiBorderColor; + } } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx index e0407b4db7f25..d45eb8856d118 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx @@ -69,7 +69,7 @@ describe('ResultHeaderItem', () => { const wrapper = shallow( ); - expect(wrapper.find('ReactRouterHelper').exists()).toBe(true); - expect(wrapper.find('ReactRouterHelper').prop('to')).toBe('http://www.example.com'); + expect(wrapper.find('EuiLinkTo').exists()).toBe(true); + expect(wrapper.find('EuiLinkTo').prop('to')).toBe('http://www.example.com'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.tsx index 545b85c17a529..cf3b385fd9257 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.tsx @@ -9,7 +9,7 @@ import React from 'react'; import './result_header_item.scss'; -import { ReactRouterHelper } from '../../../shared/react_router_helpers/eui_components'; +import { EuiLinkTo } from '../../../shared/react_router_helpers/eui_components'; import { TruncatedContent } from '../../../shared/truncate'; @@ -48,11 +48,9 @@ export const ResultHeaderItem: React.FC = ({ field, type, value, href })   {href ? ( - -
- - - + + + ) : ( )} From c4b3dfddcdd9b280434b8c135e0ccad806753fbe Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 12 Apr 2021 21:23:22 +0200 Subject: [PATCH 20/28] [Search Sessions] Implement cancel on search session monitoring task, fetch and process sessions page by page (#96321) --- x-pack/plugins/data_enhanced/config.ts | 7 + .../session/check_running_sessions.test.ts | 137 +++++++++++- .../search/session/check_running_sessions.ts | 205 +++++++++--------- .../server/search/session/monitoring_task.ts | 16 +- .../search/session/session_service.test.ts | 2 + 5 files changed, 262 insertions(+), 105 deletions(-) diff --git a/x-pack/plugins/data_enhanced/config.ts b/x-pack/plugins/data_enhanced/config.ts index 8cbf930fe87bd..c895e586a6931 100644 --- a/x-pack/plugins/data_enhanced/config.ts +++ b/x-pack/plugins/data_enhanced/config.ts @@ -23,6 +23,13 @@ export const configSchema = schema.object({ * trackingInterval controls how often we track search session objects progress */ trackingInterval: schema.duration({ defaultValue: '10s' }), + + /** + * monitoringTaskTimeout controls for how long task manager waits for search session monitoring task to complete before considering it timed out, + * If tasks timeouts it receives cancel signal and next task starts in "trackingInterval" time + */ + monitoringTaskTimeout: schema.duration({ defaultValue: '5m' }), + /** * notTouchedTimeout controls how long do we store unpersisted search session results, * after the last search in the session has completed diff --git a/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.test.ts b/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.test.ts index 2611f6c9da19f..eba463662e26d 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.test.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { checkRunningSessions } from './check_running_sessions'; +import { + checkRunningSessions as checkRunningSessions$, + CheckRunningSessionsDeps, +} from './check_running_sessions'; import { SearchSessionStatus, SearchSessionSavedObjectAttributes, @@ -20,6 +23,13 @@ import { SavedObjectsDeleteOptions, SavedObjectsClientContract, } from '../../../../../../src/core/server'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +jest.useFakeTimers(); + +const checkRunningSessions = (deps: CheckRunningSessionsDeps, config: SearchSessionsConfig) => + checkRunningSessions$(deps, config).toPromise(); describe('getSearchStatus', () => { let mockClient: any; @@ -32,6 +42,7 @@ describe('getSearchStatus', () => { maxUpdateRetries: 3, defaultExpiration: moment.duration(7, 'd'), trackingInterval: moment.duration(10, 's'), + monitoringTaskTimeout: moment.duration(5, 'm'), management: {} as any, }; const mockLogger: any = { @@ -41,11 +52,13 @@ describe('getSearchStatus', () => { }; const emptySO = { - persisted: false, - status: SearchSessionStatus.IN_PROGRESS, - created: moment().subtract(moment.duration(3, 'm')), - touched: moment().subtract(moment.duration(10, 's')), - idMapping: {}, + attributes: { + persisted: false, + status: SearchSessionStatus.IN_PROGRESS, + created: moment().subtract(moment.duration(3, 'm')), + touched: moment().subtract(moment.duration(10, 's')), + idMapping: {}, + }, }; beforeEach(() => { @@ -171,6 +184,118 @@ describe('getSearchStatus', () => { expect(savedObjectsClient.find).toHaveBeenCalledTimes(2); }); + + test('fetching is abortable', async () => { + let i = 0; + const abort$ = new Subject(); + savedObjectsClient.find.mockImplementation(() => { + return new Promise((resolve) => { + if (++i === 2) { + abort$.next(); + } + resolve({ + saved_objects: i <= 5 ? [emptySO, emptySO, emptySO, emptySO, emptySO] : [], + total: 25, + page: i, + } as any); + }); + }); + + await checkRunningSessions$( + { + savedObjectsClient, + client: mockClient, + logger: mockLogger, + }, + config + ) + .pipe(takeUntil(abort$)) + .toPromise(); + + jest.runAllTimers(); + + // if not for `abort$` then this would be called 6 times! + expect(savedObjectsClient.find).toHaveBeenCalledTimes(2); + }); + + test('sorting is by "touched"', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + saved_objects: [], + total: 0, + } as any); + + await checkRunningSessions( + { + savedObjectsClient, + client: mockClient, + logger: mockLogger, + }, + config + ); + + expect(savedObjectsClient.find).toHaveBeenCalledWith( + expect.objectContaining({ sortField: 'touched', sortOrder: 'asc' }) + ); + }); + + test('sessions fetched in the beginning are processed even if sessions in the end fail', async () => { + let i = 0; + savedObjectsClient.find.mockImplementation(() => { + return new Promise((resolve, reject) => { + if (++i === 2) { + reject(new Error('Fake find error...')); + } + resolve({ + saved_objects: + i <= 5 + ? [ + i === 1 + ? { + id: '123', + attributes: { + persisted: false, + status: SearchSessionStatus.IN_PROGRESS, + created: moment().subtract(moment.duration(3, 'm')), + touched: moment().subtract(moment.duration(2, 'm')), + idMapping: { + 'map-key': { + strategy: ENHANCED_ES_SEARCH_STRATEGY, + id: 'async-id', + }, + }, + }, + } + : emptySO, + emptySO, + emptySO, + emptySO, + emptySO, + ] + : [], + total: 25, + page: i, + } as any); + }); + }); + + await checkRunningSessions$( + { + savedObjectsClient, + client: mockClient, + logger: mockLogger, + }, + config + ).toPromise(); + + jest.runAllTimers(); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(2); + + // by checking that delete was called we validate that sessions from session that were successfully fetched were processed + expect(mockClient.asyncSearch.delete).toBeCalled(); + const { id } = mockClient.asyncSearch.delete.mock.calls[0][0]; + expect(id).toBe('async-id'); + }); }); describe('delete', () => { diff --git a/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.ts b/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.ts index 60c7283320d0c..bb1e9643cd0d5 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.ts @@ -13,8 +13,8 @@ import { SavedObjectsUpdateResponse, } from 'kibana/server'; import moment from 'moment'; -import { EMPTY, from } from 'rxjs'; -import { expand, concatMap } from 'rxjs/operators'; +import { EMPTY, from, Observable } from 'rxjs'; +import { catchError, concatMap } from 'rxjs/operators'; import { nodeBuilder } from '../../../../../../src/plugins/data/common'; import { ENHANCED_ES_SEARCH_STRATEGY, @@ -120,6 +120,9 @@ function getSavedSearchSessionsPage$( perPage: config.pageSize, type: SEARCH_SESSION_TYPE, namespaces: ['*'], + // process older sessions first + sortField: 'touched', + sortOrder: 'asc', filter: nodeBuilder.or([ nodeBuilder.and([ nodeBuilder.is( @@ -134,113 +137,121 @@ function getSavedSearchSessionsPage$( ); } -function getAllSavedSearchSessions$(deps: CheckRunningSessionsDeps, config: SearchSessionsConfig) { - return getSavedSearchSessionsPage$(deps, config, 1).pipe( - expand((result) => { - if (!result || !result.saved_objects || result.saved_objects.length < config.pageSize) - return EMPTY; - else { - return getSavedSearchSessionsPage$(deps, config, result.page + 1); - } - }) - ); -} - -export async function checkRunningSessions( +function checkRunningSessionsPage( deps: CheckRunningSessionsDeps, - config: SearchSessionsConfig -): Promise { + config: SearchSessionsConfig, + page: number +) { const { logger, client, savedObjectsClient } = deps; - try { - await getAllSavedSearchSessions$(deps, config) - .pipe( - concatMap(async (runningSearchSessionsResponse) => { - if (!runningSearchSessionsResponse.total) return; - - logger.debug(`Found ${runningSearchSessionsResponse.total} running sessions`); - - const updatedSessions = new Array< - SavedObjectsFindResult - >(); - - await Promise.all( - runningSearchSessionsResponse.saved_objects.map(async (session) => { - const updated = await updateSessionStatus(session, client, logger); - let deleted = false; - - if (!session.attributes.persisted) { - if (isSessionStale(session, config, logger)) { - // delete saved object to free up memory - // TODO: there's a potential rare edge case of deleting an object and then receiving a new trackId for that same session! - // Maybe we want to change state to deleted and cleanup later? - logger.debug(`Deleting stale session | ${session.id}`); + return getSavedSearchSessionsPage$(deps, config, page).pipe( + concatMap(async (runningSearchSessionsResponse) => { + if (!runningSearchSessionsResponse.total) return; + + logger.debug( + `Found ${runningSearchSessionsResponse.total} running sessions, processing ${runningSearchSessionsResponse.saved_objects.length} sessions from page ${page}` + ); + + const updatedSessions = new Array< + SavedObjectsFindResult + >(); + + await Promise.all( + runningSearchSessionsResponse.saved_objects.map(async (session) => { + const updated = await updateSessionStatus(session, client, logger); + let deleted = false; + + if (!session.attributes.persisted) { + if (isSessionStale(session, config, logger)) { + // delete saved object to free up memory + // TODO: there's a potential rare edge case of deleting an object and then receiving a new trackId for that same session! + // Maybe we want to change state to deleted and cleanup later? + logger.debug(`Deleting stale session | ${session.id}`); + try { + await savedObjectsClient.delete(SEARCH_SESSION_TYPE, session.id, { + namespace: session.namespaces?.[0], + }); + deleted = true; + } catch (e) { + logger.error( + `Error while deleting stale search session ${session.id}: ${e.message}` + ); + } + + // Send a delete request for each async search to ES + Object.keys(session.attributes.idMapping).map(async (searchKey: string) => { + const searchInfo = session.attributes.idMapping[searchKey]; + if (searchInfo.strategy === ENHANCED_ES_SEARCH_STRATEGY) { try { - await savedObjectsClient.delete(SEARCH_SESSION_TYPE, session.id, { - namespace: session.namespaces?.[0], - }); - deleted = true; + await client.asyncSearch.delete({ id: searchInfo.id }); } catch (e) { logger.error( - `Error while deleting stale search session ${session.id}: ${e.message}` + `Error while deleting async_search ${searchInfo.id}: ${e.message}` ); } - - // Send a delete request for each async search to ES - Object.keys(session.attributes.idMapping).map(async (searchKey: string) => { - const searchInfo = session.attributes.idMapping[searchKey]; - if (searchInfo.strategy === ENHANCED_ES_SEARCH_STRATEGY) { - try { - await client.asyncSearch.delete({ id: searchInfo.id }); - } catch (e) { - logger.error( - `Error while deleting async_search ${searchInfo.id}: ${e.message}` - ); - } - } - }); } - } + }); + } + } - if (updated && !deleted) { - updatedSessions.push(session); - } - }) - ); - - // Do a bulk update - if (updatedSessions.length) { - // If there's an error, we'll try again in the next iteration, so there's no need to check the output. - const updatedResponse = await savedObjectsClient.bulkUpdate( - updatedSessions.map((session) => ({ - ...session, - namespace: session.namespaces?.[0], - })) - ); + if (updated && !deleted) { + updatedSessions.push(session); + } + }) + ); - const success: Array< - SavedObjectsUpdateResponse - > = []; - const fail: Array> = []; + // Do a bulk update + if (updatedSessions.length) { + // If there's an error, we'll try again in the next iteration, so there's no need to check the output. + const updatedResponse = await savedObjectsClient.bulkUpdate( + updatedSessions.map((session) => ({ + ...session, + namespace: session.namespaces?.[0], + })) + ); - updatedResponse.saved_objects.forEach((savedObjectResponse) => { - if ('error' in savedObjectResponse) { - fail.push(savedObjectResponse); - logger.error( - `Error while updating search session ${savedObjectResponse?.id}: ${savedObjectResponse.error?.message}` - ); - } else { - success.push(savedObjectResponse); - } - }); + const success: Array> = []; + const fail: Array> = []; - logger.debug( - `Updating search sessions: success: ${success.length}, fail: ${fail.length}` + updatedResponse.saved_objects.forEach((savedObjectResponse) => { + if ('error' in savedObjectResponse) { + fail.push(savedObjectResponse); + logger.error( + `Error while updating search session ${savedObjectResponse?.id}: ${savedObjectResponse.error?.message}` ); + } else { + success.push(savedObjectResponse); } - }) - ) - .toPromise(); - } catch (err) { - logger.error(err); - } + }); + + logger.debug(`Updating search sessions: success: ${success.length}, fail: ${fail.length}`); + } + + return runningSearchSessionsResponse; + }) + ); +} + +export function checkRunningSessions(deps: CheckRunningSessionsDeps, config: SearchSessionsConfig) { + const { logger } = deps; + + const checkRunningSessionsByPage = (nextPage = 1): Observable => + checkRunningSessionsPage(deps, config, nextPage).pipe( + concatMap((result) => { + if (!result || !result.saved_objects || result.saved_objects.length < config.pageSize) { + return EMPTY; + } else { + // TODO: while processing previous page session list might have been changed and we might skip a session, + // because it would appear now on a different "page". + // This isn't critical, as we would pick it up on a next task iteration, but maybe we could improve this somehow + return checkRunningSessionsByPage(result.page + 1); + } + }) + ); + + return checkRunningSessionsByPage().pipe( + catchError((e) => { + logger.error(`Error while processing search sessions: ${e?.message}`); + return EMPTY; + }) + ); } diff --git a/x-pack/plugins/data_enhanced/server/search/session/monitoring_task.ts b/x-pack/plugins/data_enhanced/server/search/session/monitoring_task.ts index 101ccb14edf67..c0dc69dfc307b 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/monitoring_task.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/monitoring_task.ts @@ -6,10 +6,13 @@ */ import { Duration } from 'moment'; +import { filter, takeUntil } from 'rxjs/operators'; +import { BehaviorSubject } from 'rxjs'; import { TaskManagerSetupContract, TaskManagerStartContract, RunContext, + TaskRunCreatorFunction, } from '../../../../task_manager/server'; import { checkRunningSessions } from './check_running_sessions'; import { CoreSetup, SavedObjectsClient, Logger } from '../../../../../../src/core/server'; @@ -29,8 +32,9 @@ interface SearchSessionTaskDeps { function searchSessionRunner( core: CoreSetup, { logger, config }: SearchSessionTaskDeps -) { +): TaskRunCreatorFunction { return ({ taskInstance }: RunContext) => { + const aborted$ = new BehaviorSubject(false); return { async run() { const sessionConfig = config.search.sessions; @@ -39,6 +43,8 @@ function searchSessionRunner( logger.debug('Search sessions are disabled. Skipping task.'); return; } + if (aborted$.getValue()) return; + const internalRepo = coreStart.savedObjects.createInternalRepository([SEARCH_SESSION_TYPE]); const internalSavedObjectsClient = new SavedObjectsClient(internalRepo); await checkRunningSessions( @@ -48,12 +54,17 @@ function searchSessionRunner( logger, }, sessionConfig - ); + ) + .pipe(takeUntil(aborted$.pipe(filter((aborted) => aborted)))) + .toPromise(); return { state: {}, }; }, + cancel: async () => { + aborted$.next(true); + }, }; }; } @@ -66,6 +77,7 @@ export function registerSearchSessionsTask( [SEARCH_SESSIONS_TASK_TYPE]: { title: 'Search Sessions Monitor', createTaskRunner: searchSessionRunner(core, deps), + timeout: `${deps.config.search.sessions.monitoringTaskTimeout.asSeconds()}s`, }, }); } diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts index 9344ab973c636..f1f8805a28884 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts @@ -75,6 +75,7 @@ describe('SearchSessionService', () => { notTouchedTimeout: moment.duration(2, 'm'), maxUpdateRetries: MAX_UPDATE_RETRIES, defaultExpiration: moment.duration(7, 'd'), + monitoringTaskTimeout: moment.duration(5, 'm'), trackingInterval: moment.duration(10, 's'), management: {} as any, }, @@ -153,6 +154,7 @@ describe('SearchSessionService', () => { maxUpdateRetries: MAX_UPDATE_RETRIES, defaultExpiration: moment.duration(7, 'd'), trackingInterval: moment.duration(10, 's'), + monitoringTaskTimeout: moment.duration(5, 'm'), management: {} as any, }, }, From cf2c62edf885165721228a6b6c417a5ddd60a330 Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Mon, 12 Apr 2021 12:23:46 -0700 Subject: [PATCH 21/28] ccs_discover additional tests (#96669) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apps/ccs/{ccs.js => ccs_discover.js} | 35 +++++++++++++++++-- .../apps/ccs/index.js | 2 +- 2 files changed, 34 insertions(+), 3 deletions(-) rename x-pack/test/stack_functional_integration/apps/ccs/{ccs.js => ccs_discover.js} (77%) diff --git a/x-pack/test/stack_functional_integration/apps/ccs/ccs.js b/x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js similarity index 77% rename from x-pack/test/stack_functional_integration/apps/ccs/ccs.js rename to x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js index c335680fbc6f9..588ff9a6e9f92 100644 --- a/x-pack/test/stack_functional_integration/apps/ccs/ccs.js +++ b/x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; export default ({ getService, getPageObjects }) => { - describe('Cross cluster search test', async () => { + describe('Cross cluster search test in discover', async () => { const PageObjects = getPageObjects([ 'common', 'settings', @@ -22,10 +22,12 @@ export default ({ getService, getPageObjects }) => { const browser = getService('browser'); const appsMenu = getService('appsMenu'); const kibanaServer = getService('kibanaServer'); + const queryBar = getService('queryBar'); + const filterBar = getService('filterBar'); before(async () => { await browser.setWindowSize(1200, 800); - // pincking relative time in timepicker isn't working. This is also faster. + // picking relative time in timepicker isn't working. This is also faster. // It's the default set, plus new "makelogs" +/- 3 days from now await kibanaServer.uiSettings.replace({ 'timepicker:quickRanges': `[ @@ -172,5 +174,34 @@ export default ({ getService, getPageObjects }) => { expect(hitCount).to.be('28,010'); }); }); + + it('should reload the saved search with persisted query to show the initial hit count', async function () { + await PageObjects.discover.selectIndexPattern('data:makelogs工程-*,local:makelogs工程-*'); + // apply query some changes + await queryBar.setQuery('success'); + await queryBar.submitQuery(); + await retry.try(async () => { + const hitCountNumber = await PageObjects.discover.getHitCount(); + const hitCount = parseInt(hitCountNumber.replace(/\,/g, '')); + log.debug('### hit count = ' + hitCount); + expect(hitCount).to.be.greaterThan(25000); + expect(hitCount).to.be.lessThan(28000); + }); + }); + + it('should add a phrases filter', async function () { + await PageObjects.discover.selectIndexPattern('data:makelogs工程-*,local:makelogs工程-*'); + const hitCountNumber = await PageObjects.discover.getHitCount(); + const originalHitCount = parseInt(hitCountNumber.replace(/\,/g, '')); + await filterBar.addFilter('extension.keyword', 'is', 'jpg'); + expect(await filterBar.hasFilter('extension.keyword', 'jpg')).to.be(true); + await retry.try(async () => { + const hitCountNumber = await PageObjects.discover.getHitCount(); + const hitCount = parseInt(hitCountNumber.replace(/\,/g, '')); + log.debug('### hit count = ' + hitCount); + expect(hitCount).to.be.greaterThan(15000); + expect(hitCount).to.be.lessThan(originalHitCount); + }); + }); }); }; diff --git a/x-pack/test/stack_functional_integration/apps/ccs/index.js b/x-pack/test/stack_functional_integration/apps/ccs/index.js index dd87414c2b9f0..ac82ca0dfda65 100644 --- a/x-pack/test/stack_functional_integration/apps/ccs/index.js +++ b/x-pack/test/stack_functional_integration/apps/ccs/index.js @@ -7,6 +7,6 @@ export default function ({ loadTestFile }) { describe('ccs test', function () { - loadTestFile(require.resolve('./ccs')); + loadTestFile(require.resolve('./ccs_discover')); }); } From 31c1a0838481fcadeffdce5bbe50c4affa1cab28 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Mon, 12 Apr 2021 14:28:12 -0500 Subject: [PATCH 22/28] [Workplace Search] Design polish: Configure and connect source (#96851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update ‘How to add’ view * Update config completed view * Update add source connect page * Remove padding on how to add card Original had no padding. --- .../components/add_source/add_source.scss | 10 - .../add_source/config_completed.tsx | 199 +++++++++-------- .../add_source/configuration_intro.tsx | 205 +++++++++--------- .../add_source/connect_instance.tsx | 8 +- .../components/add_source/source_features.tsx | 2 +- 5 files changed, 216 insertions(+), 208 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.scss index fbc10b5e8ed0f..fe772000f78f7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.scss +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.scss @@ -43,16 +43,6 @@ } } - &__outer-box { - border: 1px solid #DBE2EB; - padding-right: 16px; - border-radius: 6px; - overflow: hidden; - background-color: #FFFFFF; - box-shadow: 0 2px 2px -1px rgba(152, 162, 179, .3), - 0 1px 5px -2px rgba(152, 162, 179, .3); - } - &__intro-image { background-color: #22272E; display: flex; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx index 8edef425f414c..965d71abd5101 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx @@ -13,6 +13,7 @@ import { EuiFlexItem, EuiIcon, EuiLink, + EuiPanel, EuiSpacer, EuiText, EuiTextAlign, @@ -51,116 +52,122 @@ export const ConfigCompleted: React.FC = ({ <> {header} - - - - - - - - - -

- {i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.configCompleted.heading', - { - defaultMessage: '{name} Configured', - values: { name }, - } - )} -

-
-
- - - {!accountContextOnly ? ( -

+ + + + + + + + + + +

{i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.configCompleted.orgCanConnect.message', + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configCompleted.heading', { - defaultMessage: '{name} can now be connected to Workplace Search', + defaultMessage: '{name} Configured', values: { name }, } )} -

- ) : ( - -

+

+
+
+ + + {!accountContextOnly ? ( +

{i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.configCompleted.personalConnectLink.message', + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configCompleted.orgCanConnect.message', { - defaultMessage: - 'Users can now link their {name} accounts from their personal dashboards.', + defaultMessage: '{name} can now be connected to Workplace Search', values: { name }, } )}

- {!privateSourcesEnabled && ( -

- - enable private source connection - - ), - }} - /> + ) : ( + +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configCompleted.personalConnectLink.message', + { + defaultMessage: + 'Users can now link their {name} accounts from their personal dashboards.', + values: { name }, + } + )}

- )} -

- - {CONFIG_COMPLETED_PRIVATE_SOURCES_DOCS_LINK} - -

-
- )} - - -
-
-
-
- - - - - {CONFIG_COMPLETED_CONFIGURE_NEW_BUTTON} - - - {!accountContextOnly && ( + {!privateSourcesEnabled && ( +

+ + enable private source connection + + ), + }} + /> +

+ )} +

+ + {CONFIG_COMPLETED_PRIVATE_SOURCES_DOCS_LINK} + +

+ + )} + + + +
+ + + + - - {i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.configCompleted.connect.button', - { - defaultMessage: 'Connect {name}', - values: { name }, - } - )} - + {CONFIG_COMPLETED_CONFIGURE_NEW_BUTTON} + - )} - + {!accountContextOnly && ( + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configCompleted.connect.button', + { + defaultMessage: 'Connect {name}', + values: { name }, + } + )} + + + )} + + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx index 8a1cdf0b84274..23bd34cfeb944 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx @@ -13,6 +13,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiPanel, EuiSpacer, EuiText, EuiTitle, @@ -52,105 +53,115 @@ export const ConfigurationIntro: React.FC = ({ direction="row" responsive={false} > - - - -
- {CONFIG_INTRO_ALT_TEXT} -
-
- - - - - -

- {i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.configIntro.steps.title', - { - defaultMessage: 'How to add {name}', - values: { name }, - } - )} -

-
- - -

{CONFIG_INTRO_STEPS_TEXT}

-
- -
- - - -
- -

{CONFIG_INTRO_STEP1_HEADING}

-
-
-
- - -

- One-Time Action, - }} - /> -

-

{CONFIG_INTRO_STEP1_TEXT}

-
-
-
-
- - - -
- -

{CONFIG_INTRO_STEP2_HEADING}

+ + + + +
+ {CONFIG_INTRO_ALT_TEXT} +
+
+ + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configIntro.steps.title', + { + defaultMessage: 'How to add {name}', + values: { name }, + } + )} +

+
+ + +

{CONFIG_INTRO_STEPS_TEXT}

+
+ +
+ + + +
+ +

{CONFIG_INTRO_STEP1_HEADING}

+
+
+
+ + +

+ One-Time Action, + }} + /> +

+

{CONFIG_INTRO_STEP1_TEXT}

-
-
- - -

{CONFIG_INTRO_STEP2_TITLE}

-

{CONFIG_INTRO_STEP2_TEXT}

-
-
-
-
- - - - +
+
+ + - {i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.configIntro.configure.button', - { - defaultMessage: 'Configure {name}', - values: { name }, - } - )} - - - - -
-
- + +
+ +

{CONFIG_INTRO_STEP2_HEADING}

+
+
+
+ + +

{CONFIG_INTRO_STEP2_TITLE}

+

{CONFIG_INTRO_STEP2_TEXT}

+
+
+ + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configIntro.configure.button', + { + defaultMessage: 'Configure {name}', + values: { name }, + } + )} + + + + + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx index a34641784b162..fd45d779e6f2a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx @@ -160,7 +160,7 @@ export const ConnectInstance: React.FC = ({ const permissionField = ( <> - +

{CONNECT_DOC_PERMISSIONS_TITLE} @@ -272,12 +272,12 @@ export const ConnectInstance: React.FC = ({ responsive={false} > - - + + {header} - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx index ad16260b1de7c..7a66efe4ba5f4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx @@ -187,7 +187,7 @@ export const SourceFeatures: React.FC = ({ features, objTy {includedFeatures.map((featureId, i) => ( - + From 8bf9e8694248e4c1295c1f39fd79eac3b085ff69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ece=20=C3=96zalp?= Date: Mon, 12 Apr 2021 15:31:39 -0400 Subject: [PATCH 23/28] [Security-Solution] Adds Threat Summary and Threat Details tabs to Alert Side Panel (#909) (#95604) [Security Solution] Adds Threat Summary and Threat Info views to Alert Side Panel (elastic/security-team/909) --- .../utils/field_formatters.test.ts} | 6 +- .../utils/field_formatters.ts} | 8 +- .../utils/mock_event_details.ts} | 0 .../helpers => common/utils}/to_array.ts | 0 .../event_details/__mocks__/index.ts | 12 + ...w.test.tsx => alert_summary_view.test.tsx} | 10 +- .../event_details/alert_summary_view.tsx | 200 +++++++++++++++ .../event_details/event_details.test.tsx | 31 ++- .../event_details/event_details.tsx | 116 +++++++-- .../components/event_details/helpers.tsx | 64 +++++ .../components/event_details/summary_view.tsx | 241 ++---------------- .../threat_details_view.test.tsx | 44 ++++ .../event_details/threat_details_view.tsx | 89 +++++++ .../threat_summary_view.test.tsx | 44 ++++ .../event_details/threat_summary_view.tsx | 89 +++++++ .../components/event_details/translations.ts | 8 + .../__snapshots__/index.test.tsx.snap | 6 +- .../event_details/expandable_event.tsx | 14 +- .../side_panel/event_details/index.tsx | 2 +- .../body/renderers/formatted_field.tsx | 2 +- .../helpers/format_response_object_values.ts | 2 +- .../factory/hosts/all/helpers.ts | 3 +- .../factory/hosts/authentications/helpers.ts | 2 +- .../factory/hosts/details/helpers.ts | 2 +- .../hosts/uncommon_processes/helpers.ts | 2 +- .../factory/network/details/helpers.ts | 2 +- .../factory/events/all/helpers.test.ts | 2 +- .../timeline/factory/events/all/helpers.ts | 7 +- .../timeline/factory/events/details/index.ts | 6 +- 29 files changed, 731 insertions(+), 283 deletions(-) rename x-pack/plugins/security_solution/{server/search_strategy/timeline/factory/events/details/helpers.test.ts => common/utils/field_formatters.test.ts} (97%) rename x-pack/plugins/security_solution/{server/search_strategy/timeline/factory/events/details/helpers.ts => common/utils/field_formatters.ts} (96%) rename x-pack/plugins/security_solution/{server/search_strategy/timeline/factory/events/mocks.ts => common/utils/mock_event_details.ts} (100%) rename x-pack/plugins/security_solution/{server/search_strategy/helpers => common/utils}/to_array.ts (100%) rename x-pack/plugins/security_solution/public/common/components/event_details/{summary_view.test.tsx => alert_summary_view.test.tsx} (90%) create mode 100644 x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/event_details/threat_details_view.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/event_details/threat_details_view.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/event_details/threat_summary_view.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/event_details/threat_summary_view.tsx diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.test.ts b/x-pack/plugins/security_solution/common/utils/field_formatters.test.ts similarity index 97% rename from x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.test.ts rename to x-pack/plugins/security_solution/common/utils/field_formatters.test.ts index dc3efc6909c63..b724c0f672b50 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.test.ts +++ b/x-pack/plugins/security_solution/common/utils/field_formatters.test.ts @@ -5,9 +5,9 @@ * 2.0. */ -import { EventHit, EventSource } from '../../../../../../common/search_strategy'; -import { getDataFromFieldsHits, getDataFromSourceHits, getDataSafety } from './helpers'; -import { eventDetailsFormattedFields, eventHit } from '../mocks'; +import { EventHit, EventSource } from '../search_strategy'; +import { getDataFromFieldsHits, getDataFromSourceHits, getDataSafety } from './field_formatters'; +import { eventDetailsFormattedFields, eventHit } from './mock_event_details'; describe('Events Details Helpers', () => { const fields: EventHit['fields'] = eventHit.fields; diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts b/x-pack/plugins/security_solution/common/utils/field_formatters.ts similarity index 96% rename from x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts rename to x-pack/plugins/security_solution/common/utils/field_formatters.ts index 2fc729729e435..b436f8e616122 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts +++ b/x-pack/plugins/security_solution/common/utils/field_formatters.ts @@ -7,12 +7,8 @@ import { get, isEmpty, isNumber, isObject, isString } from 'lodash/fp'; -import { - EventHit, - EventSource, - TimelineEventsDetailsItem, -} from '../../../../../../common/search_strategy'; -import { toObjectArrayOfStrings, toStringArray } from '../../../../helpers/to_array'; +import { EventHit, EventSource, TimelineEventsDetailsItem } from '../search_strategy'; +import { toObjectArrayOfStrings, toStringArray } from './to_array'; export const baseCategoryFields = ['@timestamp', 'labels', 'message', 'tags']; diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/mocks.ts b/x-pack/plugins/security_solution/common/utils/mock_event_details.ts similarity index 100% rename from x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/mocks.ts rename to x-pack/plugins/security_solution/common/utils/mock_event_details.ts diff --git a/x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts b/x-pack/plugins/security_solution/common/utils/to_array.ts similarity index 100% rename from x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts rename to x-pack/plugins/security_solution/common/utils/to_array.ts diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts index ba0567c40eb92..3edd6e6fda14b 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts @@ -655,4 +655,16 @@ export const mockAlertDetailsData = [ values: ['7.10.0'], originalValue: ['7.10.0'], }, + { + category: 'threat', + field: 'threat.indicator', + values: [`{"first_seen":"2021-03-25T18:17:00.000Z"}`], + originalValue: [`{"first_seen":"2021-03-25T18:17:00.000Z"}`], + }, + { + category: 'threat', + field: 'threat.indicator.matched', + values: `["file", "url"]`, + originalValue: ['file', 'url'], + }, ]; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx similarity index 90% rename from x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx rename to x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx index c19a3952220cf..b8f29996d603b 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { waitFor } from '@testing-library/react'; -import { SummaryViewComponent } from './summary_view'; +import { AlertSummaryView } from './alert_summary_view'; import { mockAlertDetailsData } from './__mocks__'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; import { useRuleAsync } from '../../../detections/containers/detection_engine/rules/use_rule_async'; @@ -30,7 +30,7 @@ const props = { timelineId: 'detections-page', }; -describe('SummaryViewComponent', () => { +describe('AlertSummaryView', () => { const mount = useMountAppended(); beforeEach(() => { @@ -44,7 +44,7 @@ describe('SummaryViewComponent', () => { test('render correct items', () => { const wrapper = mount( - + ); expect(wrapper.find('[data-test-subj="summary-view"]').exists()).toEqual(true); @@ -53,7 +53,7 @@ describe('SummaryViewComponent', () => { test('render investigation guide', async () => { const wrapper = mount( - + ); await waitFor(() => { @@ -69,7 +69,7 @@ describe('SummaryViewComponent', () => { }); const wrapper = mount( - + ); await waitFor(() => { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx new file mode 100644 index 0000000000000..091049b967f02 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBasicTableColumn, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, +} from '@elastic/eui'; +import { get, getOr } from 'lodash/fp'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; +import { BrowserFields } from '../../../../common/search_strategy/index_fields'; +import { + ALERTS_HEADERS_RISK_SCORE, + ALERTS_HEADERS_RULE, + ALERTS_HEADERS_SEVERITY, + ALERTS_HEADERS_THRESHOLD_CARDINALITY, + ALERTS_HEADERS_THRESHOLD_COUNT, + ALERTS_HEADERS_THRESHOLD_TERMS, +} from '../../../detections/components/alerts_table/translations'; +import { + IP_FIELD_TYPE, + SIGNAL_RULE_NAME_FIELD_NAME, +} from '../../../timelines/components/timeline/body/renderers/constants'; +import { DESTINATION_IP_FIELD_NAME, SOURCE_IP_FIELD_NAME } from '../../../network/components/ip'; +import { SummaryView } from './summary_view'; +import { AlertSummaryRow, getSummaryColumns, SummaryRow } from './helpers'; +import { useRuleAsync } from '../../../detections/containers/detection_engine/rules/use_rule_async'; +import * as i18n from './translations'; +import { LineClamp } from '../line_clamp'; + +const StyledEuiDescriptionList = styled(EuiDescriptionList)` + padding: 24px 4px 4px; +`; + +const fields = [ + { id: 'signal.status' }, + { id: '@timestamp' }, + { + id: SIGNAL_RULE_NAME_FIELD_NAME, + linkField: 'signal.rule.id', + label: ALERTS_HEADERS_RULE, + }, + { id: 'signal.rule.severity', label: ALERTS_HEADERS_SEVERITY }, + { id: 'signal.rule.risk_score', label: ALERTS_HEADERS_RISK_SCORE }, + { id: 'host.name' }, + { id: 'user.name' }, + { id: SOURCE_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE }, + { id: DESTINATION_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE }, + { id: 'signal.threshold_result.count', label: ALERTS_HEADERS_THRESHOLD_COUNT }, + { id: 'signal.threshold_result.terms', label: ALERTS_HEADERS_THRESHOLD_TERMS }, + { id: 'signal.threshold_result.cardinality', label: ALERTS_HEADERS_THRESHOLD_CARDINALITY }, +]; + +const getDescription = ({ + contextId, + eventId, + fieldName, + value, + fieldType = '', + linkValue, +}: AlertSummaryRow['description']) => ( + +); + +const getSummaryRows = ({ + data, + browserFields, + timelineId, + eventId, +}: { + data: TimelineEventsDetailsItem[]; + browserFields: BrowserFields; + timelineId: string; + eventId: string; +}) => { + return data != null + ? fields.reduce((acc, item) => { + const field = data.find((d) => d.field === item.id); + if (!field) { + return acc; + } + const linkValueField = + item.linkField != null && data.find((d) => d.field === item.linkField); + const linkValue = getOr(null, 'originalValue.0', linkValueField); + const value = getOr(null, 'originalValue.0', field); + const category = field.category; + const fieldType = get(`${category}.fields.${field.field}.type`, browserFields) as string; + const description = { + contextId: timelineId, + eventId, + fieldName: item.id, + value, + fieldType: item.fieldType ?? fieldType, + linkValue: linkValue ?? undefined, + }; + + if (item.id === 'signal.threshold_result.terms') { + try { + const terms = getOr(null, 'originalValue', field); + const parsedValue = terms.map((term: string) => JSON.parse(term)); + const thresholdTerms = (parsedValue ?? []).map( + (entry: { field: string; value: string }) => { + return { + title: `${entry.field} [threshold]`, + description: { + ...description, + value: entry.value, + }, + }; + } + ); + return [...acc, ...thresholdTerms]; + } catch (err) { + return acc; + } + } + + if (item.id === 'signal.threshold_result.cardinality') { + try { + const parsedValue = JSON.parse(value); + return [ + ...acc, + { + title: ALERTS_HEADERS_THRESHOLD_CARDINALITY, + description: { + ...description, + value: `count(${parsedValue.field}) == ${parsedValue.value}`, + }, + }, + ]; + } catch (err) { + return acc; + } + } + + return [ + ...acc, + { + title: item.label ?? item.id, + description, + }, + ]; + }, []) + : []; +}; + +const summaryColumns: Array> = getSummaryColumns(getDescription); + +const AlertSummaryViewComponent: React.FC<{ + browserFields: BrowserFields; + data: TimelineEventsDetailsItem[]; + eventId: string; + timelineId: string; +}> = ({ browserFields, data, eventId, timelineId }) => { + const summaryRows = useMemo(() => getSummaryRows({ browserFields, data, eventId, timelineId }), [ + browserFields, + data, + eventId, + timelineId, + ]); + + const ruleId = useMemo(() => { + const item = data.find((d) => d.field === 'signal.rule.id'); + return Array.isArray(item?.originalValue) + ? item?.originalValue[0] + : item?.originalValue ?? null; + }, [data]); + const { rule: maybeRule } = useRuleAsync(ruleId); + + return ( + <> + + {maybeRule?.note && ( + + {i18n.INVESTIGATION_GUIDE} + + + + + )} + + ); +}; + +export const AlertSummaryView = React.memo(AlertSummaryViewComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx index 164543a4b84d5..e799df0fdd10d 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx @@ -13,7 +13,7 @@ import '../../mock/match_media'; import '../../mock/react_beautiful_dnd'; import { mockDetailItemData, mockDetailItemDataId, TestProviders } from '../../mock'; -import { EventDetails, EventsViewType } from './event_details'; +import { EventDetails, EventsViewType, EventView, ThreatView } from './event_details'; import { mockBrowserFields } from '../../containers/source/mock'; import { useMountAppended } from '../../utils/use_mount_appended'; import { mockAlertDetailsData } from './__mocks__'; @@ -28,10 +28,12 @@ describe('EventDetails', () => { data: mockDetailItemData, id: mockDetailItemDataId, isAlert: false, - onViewSelected: jest.fn(), + onEventViewSelected: jest.fn(), + onThreatViewSelected: jest.fn(), timelineTabType: TimelineTabs.query, timelineId: 'test', - view: EventsViewType.summaryView, + eventView: EventsViewType.summaryView as EventView, + threatView: EventsViewType.threatSummaryView as ThreatView, }; const alertsProps = { @@ -97,4 +99,27 @@ describe('EventDetails', () => { ).toEqual('Summary'); }); }); + + describe('threat tabs', () => { + ['Threat Summary', 'Threat Details'].forEach((tab) => { + test(`it renders the ${tab} tab`, () => { + expect( + alertsWrapper + .find('[data-test-subj="threatDetails"]') + .find('[role="tablist"]') + .containsMatchingElement({tab}) + ).toBeTruthy(); + }); + }); + + test('the Summary tab is selected by default', () => { + expect( + alertsWrapper + .find('[data-test-subj="threatDetails"]') + .find('.euiTab-isSelected') + .first() + .text() + ).toEqual('Threat Summary'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 4979d70ce2d7b..0e4cf7f4ae2fe 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -14,14 +14,23 @@ import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/ti import { EventFieldsBrowser } from './event_fields_browser'; import { JsonView } from './json_view'; import * as i18n from './translations'; -import { SummaryView } from './summary_view'; +import { AlertSummaryView } from './alert_summary_view'; +import { ThreatSummaryView } from './threat_summary_view'; +import { ThreatDetailsView } from './threat_details_view'; import { TimelineTabs } from '../../../../common/types/timeline'; +import { INDICATOR_DESTINATION_PATH } from '../../../../common/constants'; -export type View = EventsViewType.tableView | EventsViewType.jsonView | EventsViewType.summaryView; +export type EventView = + | EventsViewType.tableView + | EventsViewType.jsonView + | EventsViewType.summaryView; +export type ThreatView = EventsViewType.threatSummaryView | EventsViewType.threatDetailsView; export enum EventsViewType { tableView = 'table-view', jsonView = 'json-view', summaryView = 'summary-view', + threatSummaryView = 'threat-summary-view', + threatDetailsView = 'threat-details-view', } interface Props { @@ -29,8 +38,10 @@ interface Props { data: TimelineEventsDetailsItem[]; id: string; isAlert: boolean; - view: EventsViewType; - onViewSelected: (selected: EventsViewType) => void; + eventView: EventView; + threatView: ThreatView; + onEventViewSelected: (selected: EventView) => void; + onThreatViewSelected: (selected: ThreatView) => void; timelineTabType: TimelineTabs | 'flyout'; timelineId: string; } @@ -45,7 +56,16 @@ const StyledEuiTabbedContent = styled(EuiTabbedContent)` display: flex; flex: 1; flex-direction: column; - overflow: hidden; + overflow: scroll; + ::-webkit-scrollbar { + -webkit-appearance: none; + width: 7px; + } + ::-webkit-scrollbar-thumb { + border-radius: 4px; + background-color: rgba(0, 0, 0, 0.5); + -webkit-box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); + } } `; @@ -57,14 +77,19 @@ const TabContentWrapper = styled.div` const EventDetailsComponent: React.FC = ({ browserFields, data, + eventView, id, - view, - onViewSelected, - timelineTabType, - timelineId, isAlert, + onEventViewSelected, + onThreatViewSelected, + threatView, + timelineId, + timelineTabType, }) => { - const handleTabClick = useCallback((e) => onViewSelected(e.id), [onViewSelected]); + const handleEventTabClick = useCallback((e) => onEventViewSelected(e.id), [onEventViewSelected]); + const handleThreatTabClick = useCallback((e) => onThreatViewSelected(e.id), [ + onThreatViewSelected, + ]); const alerts = useMemo( () => [ @@ -74,11 +99,13 @@ const EventDetailsComponent: React.FC = ({ content: ( <> - ), @@ -122,15 +149,60 @@ const EventDetailsComponent: React.FC = ({ [alerts, browserFields, data, id, isAlert, timelineId, timelineTabType] ); - const selectedTab = useMemo(() => tabs.find((t) => t.id === view) ?? tabs[0], [tabs, view]); + const selectedEventTab = useMemo(() => tabs.find((t) => t.id === eventView) ?? tabs[0], [ + tabs, + eventView, + ]); + + const isThreatPresent: boolean = useMemo( + () => + selectedEventTab.id === tabs[0].id && + isAlert && + data.some((item) => item.field === INDICATOR_DESTINATION_PATH), + [tabs, selectedEventTab, isAlert, data] + ); + + const threatTabs: EuiTabbedContentTab[] = useMemo(() => { + return isAlert && isThreatPresent + ? [ + { + id: EventsViewType.threatSummaryView, + name: i18n.THREAT_SUMMARY, + content: , + }, + { + id: EventsViewType.threatDetailsView, + name: i18n.THREAT_DETAILS, + content: , + }, + ] + : []; + }, [data, id, isAlert, timelineId, isThreatPresent]); + + const selectedThreatTab = useMemo( + () => threatTabs.find((t) => t.id === threatView) ?? threatTabs[0], + [threatTabs, threatView] + ); return ( - + <> + + {isThreatPresent && ( + + )} + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx index 00e2ee276f181..67e67584849cc 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx @@ -7,6 +7,8 @@ import { get, getOr, isEmpty, uniqBy } from 'lodash/fp'; +import React from 'react'; +import { EuiBasicTableColumn, EuiTitle } from '@elastic/eui'; import { elementOrChildrenHasFocus, getFocusedDataColindexCell, @@ -51,6 +53,38 @@ export interface Item { values: ToStringArray; } +export interface AlertSummaryRow { + title: string; + description: { + contextId: string; + eventId: string; + fieldName: string; + value: string; + fieldType: string; + linkValue: string | undefined; + }; +} + +export interface ThreatSummaryRow { + title: string; + description: { + contextId: string; + eventId: string; + fieldName: string; + values: string[]; + }; +} + +export interface ThreatDetailsRow { + title: string; + description: { + fieldName: string; + value: string; + }; +} + +export type SummaryRow = AlertSummaryRow | ThreatSummaryRow | ThreatDetailsRow; + export const getColumnHeaderFromBrowserField = ({ browserField, width = DEFAULT_COLUMN_MIN_WIDTH, @@ -172,3 +206,33 @@ export const onEventDetailsTabKeyPressed = ({ }); } }; + +const getTitle = (title: string) => ( + +
{title}
+
+); +getTitle.displayName = 'getTitle'; + +export const getSummaryColumns = ( + DescriptionComponent: + | React.FC + | React.FC + | React.FC +): Array> => { + return [ + { + field: 'title', + truncateText: false, + render: getTitle, + width: '120px', + name: '', + }, + { + field: 'description', + truncateText: false, + render: DescriptionComponent, + name: '', + }, + ]; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx index 8e07910c1c071..3b2c55e9a6b67 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx @@ -5,69 +5,11 @@ * 2.0. */ -import { get, getOr } from 'lodash/fp'; -import { - EuiTitle, - EuiDescriptionList, - EuiDescriptionListTitle, - EuiDescriptionListDescription, - EuiInMemoryTable, - EuiBasicTableColumn, -} from '@elastic/eui'; -import React, { useMemo } from 'react'; +import { EuiInMemoryTable, EuiBasicTableColumn } from '@elastic/eui'; +import React from 'react'; import styled from 'styled-components'; -import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; -import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field'; -import * as i18n from './translations'; -import { BrowserFields } from '../../../../common/search_strategy/index_fields'; -import { - ALERTS_HEADERS_RISK_SCORE, - ALERTS_HEADERS_RULE, - ALERTS_HEADERS_SEVERITY, - ALERTS_HEADERS_THRESHOLD_COUNT, - ALERTS_HEADERS_THRESHOLD_TERMS, - ALERTS_HEADERS_THRESHOLD_CARDINALITY, -} from '../../../detections/components/alerts_table/translations'; -import { - IP_FIELD_TYPE, - SIGNAL_RULE_NAME_FIELD_NAME, -} from '../../../timelines/components/timeline/body/renderers/constants'; -import { DESTINATION_IP_FIELD_NAME, SOURCE_IP_FIELD_NAME } from '../../../network/components/ip'; -import { LineClamp } from '../line_clamp'; -import { useRuleAsync } from '../../../detections/containers/detection_engine/rules/use_rule_async'; - -interface SummaryRow { - title: string; - description: { - contextId: string; - eventId: string; - fieldName: string; - value: string; - fieldType: string; - linkValue: string | undefined; - }; -} -type Summary = SummaryRow[]; - -const fields = [ - { id: 'signal.status' }, - { id: '@timestamp' }, - { - id: SIGNAL_RULE_NAME_FIELD_NAME, - linkField: 'signal.rule.id', - label: ALERTS_HEADERS_RULE, - }, - { id: 'signal.rule.severity', label: ALERTS_HEADERS_SEVERITY }, - { id: 'signal.rule.risk_score', label: ALERTS_HEADERS_RISK_SCORE }, - { id: 'host.name' }, - { id: 'user.name' }, - { id: SOURCE_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE }, - { id: DESTINATION_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE }, - { id: 'signal.threshold_result.count', label: ALERTS_HEADERS_THRESHOLD_COUNT }, - { id: 'signal.threshold_result.terms', label: ALERTS_HEADERS_THRESHOLD_TERMS }, - { id: 'signal.threshold_result.cardinality', label: ALERTS_HEADERS_THRESHOLD_CARDINALITY }, -]; +import { SummaryRow } from './helpers'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)` @@ -77,173 +19,26 @@ const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)` .euiTableRowCell { border: none; } -`; -const StyledEuiDescriptionList = styled(EuiDescriptionList)` - padding: 24px 4px 4px; + .euiTableCellContent { + display: flex; + flex-direction: column; + align-items: flex-start; + } `; -const getTitle = (title: SummaryRow['title']) => ( - -
{title}
-
-); - -getTitle.displayName = 'getTitle'; - -const getDescription = ({ - contextId, - eventId, - fieldName, - value, - fieldType = '', - linkValue, -}: SummaryRow['description']) => ( - -); - -const getSummary = ({ - data, - browserFields, - timelineId, - eventId, -}: { - data: TimelineEventsDetailsItem[]; - browserFields: BrowserFields; - timelineId: string; - eventId: string; -}) => { - return data != null - ? fields.reduce((acc, item) => { - const field = data.find((d) => d.field === item.id); - if (!field) { - return acc; - } - const linkValueField = - item.linkField != null && data.find((d) => d.field === item.linkField); - const linkValue = getOr(null, 'originalValue.0', linkValueField); - const value = getOr(null, 'originalValue.0', field); - const category = field.category; - const fieldType = get(`${category}.fields.${field.field}.type`, browserFields) as string; - const description = { - contextId: timelineId, - eventId, - fieldName: item.id, - value, - fieldType: item.fieldType ?? fieldType, - linkValue: linkValue ?? undefined, - }; - - if (item.id === 'signal.threshold_result.terms') { - try { - const terms = getOr(null, 'originalValue', field); - const parsedValue = terms.map((term: string) => JSON.parse(term)); - const thresholdTerms = (parsedValue ?? []).map( - (entry: { field: string; value: string }) => { - return { - title: `${entry.field} [threshold]`, - description: { - ...description, - value: entry.value, - }, - }; - } - ); - return [...acc, ...thresholdTerms]; - } catch (err) { - return acc; - } - } - - if (item.id === 'signal.threshold_result.cardinality') { - try { - const parsedValue = JSON.parse(value); - return [ - ...acc, - { - title: ALERTS_HEADERS_THRESHOLD_CARDINALITY, - description: { - ...description, - value: `count(${parsedValue.field}) == ${parsedValue.value}`, - }, - }, - ]; - } catch (err) { - return acc; - } - } - - return [ - ...acc, - { - title: item.label ?? item.id, - description, - }, - ]; - }, []) - : []; -}; - -const summaryColumns: Array> = [ - { - field: 'title', - truncateText: false, - render: getTitle, - width: '120px', - name: '', - }, - { - field: 'description', - truncateText: false, - render: getDescription, - name: '', - }, -]; - export const SummaryViewComponent: React.FC<{ - browserFields: BrowserFields; - data: TimelineEventsDetailsItem[]; - eventId: string; - timelineId: string; -}> = ({ data, eventId, timelineId, browserFields }) => { - const ruleId = useMemo(() => { - const item = data.find((d) => d.field === 'signal.rule.id'); - return Array.isArray(item?.originalValue) - ? item?.originalValue[0] - : item?.originalValue ?? null; - }, [data]); - const { rule: maybeRule } = useRuleAsync(ruleId); - const summaryList = useMemo(() => getSummary({ browserFields, data, eventId, timelineId }), [ - browserFields, - data, - eventId, - timelineId, - ]); - + summaryColumns: Array>; + summaryRows: SummaryRow[]; + dataTestSubj?: string; +}> = ({ summaryColumns, summaryRows, dataTestSubj = 'summary-view' }) => { return ( - <> - - {maybeRule?.note && ( - - {i18n.INVESTIGATION_GUIDE} - - - - - )} - + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/threat_details_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/threat_details_view.test.tsx new file mode 100644 index 0000000000000..81bffe9b66638 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/threat_details_view.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { ThreatDetailsView } from './threat_details_view'; +import { mockAlertDetailsData } from './__mocks__'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; + +import { TestProviders } from '../../mock'; +import { useMountAppended } from '../../utils/use_mount_appended'; + +jest.mock('../../../detections/containers/detection_engine/rules/use_rule_async', () => { + return { + useRuleAsync: jest.fn(), + }; +}); + +const props = { + data: mockAlertDetailsData as TimelineEventsDetailsItem[], + eventId: '5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31', + timelineId: 'detections-page', +}; + +describe('ThreatDetailsView', () => { + const mount = useMountAppended(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('render correct items', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="threat-details-view-0"]').exists()).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/threat_details_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/threat_details_view.tsx new file mode 100644 index 0000000000000..0889986237442 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/threat_details_view.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBasicTableColumn, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiToolTip, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; +import { SummaryView } from './summary_view'; +import { getSummaryColumns, SummaryRow, ThreatDetailsRow } from './helpers'; +import { getDataFromSourceHits } from '../../../../common/utils/field_formatters'; +import { INDICATOR_DESTINATION_PATH } from '../../../../common/constants'; + +const ThreatDetailsDescription: React.FC = ({ + fieldName, + value, +}) => ( + + + {fieldName} + + + } + > + {value} + +); + +const getSummaryRowsArray = ({ + data, +}: { + data: TimelineEventsDetailsItem[]; +}): ThreatDetailsRow[][] => { + if (!data) return [[]]; + const threatInfo = data.find( + ({ field, originalValue }) => field === INDICATOR_DESTINATION_PATH && originalValue + ); + if (!threatInfo) return [[]]; + const { originalValue } = threatInfo; + const values = Array.isArray(originalValue) ? originalValue : [originalValue]; + return values.map((value) => + getDataFromSourceHits(JSON.parse(value)).map((threatInfoItem) => ({ + title: threatInfoItem.field.replace(`${INDICATOR_DESTINATION_PATH}.`, ''), + description: { fieldName: threatInfoItem.field, value: threatInfoItem.originalValue }, + })) + ); +}; + +const summaryColumns: Array> = getSummaryColumns( + ThreatDetailsDescription +); + +const ThreatDetailsViewComponent: React.FC<{ + data: TimelineEventsDetailsItem[]; +}> = ({ data }) => { + const summaryRowsArray = useMemo(() => getSummaryRowsArray({ data }), [data]); + return ( + <> + {summaryRowsArray.map((summaryRows, index, arr) => { + const key = summaryRows.find((threat) => threat.title === 'matched.id')?.description + .value[0]; + return ( +
+ + {index < arr.length - 1 && } +
+ ); + })} + + ); +}; + +export const ThreatDetailsView = React.memo(ThreatDetailsViewComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/threat_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/threat_summary_view.test.tsx new file mode 100644 index 0000000000000..756fc7d32b371 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/threat_summary_view.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { ThreatSummaryView } from './threat_summary_view'; +import { mockAlertDetailsData } from './__mocks__'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; + +import { TestProviders } from '../../mock'; +import { useMountAppended } from '../../utils/use_mount_appended'; + +jest.mock('../../../detections/containers/detection_engine/rules/use_rule_async', () => { + return { + useRuleAsync: jest.fn(), + }; +}); + +const props = { + data: mockAlertDetailsData as TimelineEventsDetailsItem[], + eventId: '5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31', + timelineId: 'detections-page', +}; + +describe('ThreatSummaryView', () => { + const mount = useMountAppended(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('render correct items', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="threat-summary-view"]').exists()).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/threat_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/threat_summary_view.tsx new file mode 100644 index 0000000000000..96ae2071c449b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/threat_summary_view.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBasicTableColumn } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; +import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field'; +import { BrowserFields } from '../../../../common/search_strategy/index_fields'; +import { SummaryView } from './summary_view'; +import { getSummaryColumns, SummaryRow, ThreatSummaryRow } from './helpers'; +import { INDICATOR_DESTINATION_PATH } from '../../../../common/constants'; + +const getDescription = ({ + contextId, + eventId, + fieldName, + values, +}: ThreatSummaryRow['description']): JSX.Element => ( + <> + {values.map((value: string) => ( + + ))} + +); + +const getSummaryRows = ({ + data, + timelineId: contextId, + eventId, +}: { + data: TimelineEventsDetailsItem[]; + browserFields?: BrowserFields; + timelineId: string; + eventId: string; +}) => { + if (!data) return []; + return data.reduce((acc, { field, originalValue }) => { + if (field.startsWith(`${INDICATOR_DESTINATION_PATH}.`) && originalValue) { + return [ + ...acc, + { + title: field.replace(`${INDICATOR_DESTINATION_PATH}.`, ''), + description: { + values: Array.isArray(originalValue) ? originalValue : [originalValue], + contextId, + eventId, + fieldName: field, + }, + }, + ]; + } + return acc; + }, []); +}; + +const summaryColumns: Array> = getSummaryColumns(getDescription); + +const ThreatSummaryViewComponent: React.FC<{ + data: TimelineEventsDetailsItem[]; + eventId: string; + timelineId: string; +}> = ({ data, eventId, timelineId }) => { + const summaryRows = useMemo(() => getSummaryRows({ data, eventId, timelineId }), [ + data, + eventId, + timelineId, + ]); + + return ( + + ); +}; + +export const ThreatSummaryView = React.memo(ThreatSummaryViewComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts index 3a599b174251a..73a2e0d57307c 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts @@ -11,6 +11,14 @@ export const SUMMARY = i18n.translate('xpack.securitySolution.alertDetails.summa defaultMessage: 'Summary', }); +export const THREAT_SUMMARY = i18n.translate('xpack.securitySolution.alertDetails.threatSummary', { + defaultMessage: 'Threat Summary', +}); + +export const THREAT_DETAILS = i18n.translate('xpack.securitySolution.alertDetails.threatDetails', { + defaultMessage: 'Threat Details', +}); + export const INVESTIGATION_GUIDE = i18n.translate( 'xpack.securitySolution.alertDetails.summary.investigationGuide', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index 87392bce3ee63..50970304953ca 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -262,7 +262,7 @@ Array [ -ms-flex: 1; flex: 1; overflow: hidden; - padding: 4px 16px 64px; + padding: 4px 16px 50px; } .c0 { @@ -537,7 +537,7 @@ Array [ -ms-flex: 1; flex: 1; overflow: hidden; - padding: 4px 16px 64px; + padding: 4px 16px 50px; } .c0 { @@ -806,7 +806,7 @@ Array [ -ms-flex: 1; flex: 1; overflow: hidden; - padding: 4px 16px 64px; + padding: 4px 16px 50px; } .c0 { diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx index 435a210b9d260..86175c0e06ad2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx @@ -26,7 +26,8 @@ import { BrowserFields } from '../../../../common/containers/source'; import { EventDetails, EventsViewType, - View, + EventView, + ThreatView, } from '../../../../common/components/event_details/event_details'; import { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline'; import { LineClamp } from '../../../../common/components/line_clamp'; @@ -87,7 +88,8 @@ ExpandableEventTitle.displayName = 'ExpandableEventTitle'; export const ExpandableEvent = React.memo( ({ browserFields, event, timelineId, timelineTabType, isAlert, loading, detailsData }) => { - const [view, setView] = useState(EventsViewType.summaryView); + const [eventView, setEventView] = useState(EventsViewType.summaryView); + const [threatView, setThreatView] = useState(EventsViewType.threatSummaryView); const message = useMemo(() => { if (detailsData) { @@ -131,10 +133,12 @@ export const ExpandableEvent = React.memo( data={detailsData!} id={event.eventId!} isAlert={isAlert} - onViewSelected={setView} - timelineTabType={timelineTabType} + onThreatViewSelected={setThreatView} + onEventViewSelected={setEventView} + threatView={threatView} timelineId={timelineId} - view={view} + timelineTabType={timelineTabType} + eventView={eventView} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index 6f4778f36466b..9a4684193b997 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -25,7 +25,7 @@ const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` .euiFlyoutBody__overflowContent { flex: 1; overflow: hidden; - padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 64px`}; + padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 50px`}; } } `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx index 3032f556251f3..e227c87b99870 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx @@ -44,7 +44,7 @@ const FormattedFieldValueComponent: React.FC<{ isObjectArray?: boolean; fieldFormat?: string; fieldName: string; - fieldType: string; + fieldType?: string; truncate?: boolean; value: string | number | undefined | null; linkValue?: string | null | undefined; diff --git a/x-pack/plugins/security_solution/server/search_strategy/helpers/format_response_object_values.ts b/x-pack/plugins/security_solution/server/search_strategy/helpers/format_response_object_values.ts index 4dab0ebc43149..0b418c0da410c 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/helpers/format_response_object_values.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/helpers/format_response_object_values.ts @@ -7,7 +7,7 @@ import { mapValues, isObject, isArray } from 'lodash/fp'; -import { toArray } from './to_array'; +import { toArray } from '../../../common/utils/to_array'; export const mapObjectValuesToStringArray = (object: object): object => mapValues((o) => { diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.ts index 3f4eb5721164b..bed4a040f92b0 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.ts @@ -14,8 +14,7 @@ import { HostsEdges, HostValue, } from '../../../../../../common/search_strategy/security_solution/hosts'; - -import { toObjectArrayOfStrings } from '../../../../helpers/to_array'; +import { toObjectArrayOfStrings } from '../../../../../../common/utils/to_array'; export const HOSTS_FIELDS: readonly string[] = [ '_id', diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/helpers.ts index aeaefe690cbde..807b78cb9cdd2 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/helpers.ts @@ -8,7 +8,7 @@ import { get, getOr, isEmpty } from 'lodash/fp'; import { set } from '@elastic/safer-lodash-set/fp'; import { mergeFieldsWithHit } from '../../../../../utils/build_query'; -import { toObjectArrayOfStrings } from '../../../../helpers/to_array'; +import { toObjectArrayOfStrings } from '../../../../../../common/utils/to_array'; import { AuthenticationsEdges, AuthenticationHit, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts index d36af61957690..00ed5c0c0dc01 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts @@ -8,6 +8,7 @@ import { set } from '@elastic/safer-lodash-set/fp'; import { get, has, head } from 'lodash/fp'; import { hostFieldsMap } from '../../../../../../common/ecs/ecs_fields'; +import { toObjectArrayOfStrings } from '../../../../../../common/utils/to_array'; import { Direction } from '../../../../../../common/search_strategy/common'; import { AggregationRequest, @@ -16,7 +17,6 @@ import { HostItem, HostValue, } from '../../../../../../common/search_strategy/security_solution/hosts'; -import { toObjectArrayOfStrings } from '../../../../helpers/to_array'; export const HOST_FIELDS = [ '_id', diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/helpers.ts index fe202b48540d7..1c1e2111f3771 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/helpers.ts @@ -14,7 +14,7 @@ import { HostsUncommonProcessesEdges, HostsUncommonProcessHit, } from '../../../../../../common/search_strategy/security_solution/hosts/uncommon_processes'; -import { toObjectArrayOfStrings } from '../../../../helpers/to_array'; +import { toObjectArrayOfStrings } from '../../../../../../common/utils/to_array'; import { HostHits } from '../../../../../../common/search_strategy'; export const uncommonProcessesFields = [ diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/helpers.ts index 8fc7ae0304a35..cc1bfdff8e096 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/helpers.ts @@ -13,7 +13,7 @@ import { NetworkDetailsHostHit, NetworkHit, } from '../../../../../../common/search_strategy/security_solution/network'; -import { toObjectArrayOfStrings } from '../../../../helpers/to_array'; +import { toObjectArrayOfStrings } from '../../../../../../common/utils/to_array'; export const getNetworkDetailsAgg = (type: string, networkHit: NetworkHit | {}) => { const firstSeen = getOr(null, `firstSeen.value_as_string`, networkHit); diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts index 61af6a7664faa..405ddba137dae 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts @@ -8,7 +8,7 @@ import { EventHit } from '../../../../../../common/search_strategy'; import { TIMELINE_EVENTS_FIELDS } from './constants'; import { formatTimelineData } from './helpers'; -import { eventHit } from '../mocks'; +import { eventHit } from '../../../../../../common/utils/mock_event_details'; describe('#formatTimelineData', () => { it('happy path', async () => { diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts index e5bb8cb7e14b7..2c18fb2840865 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts @@ -11,8 +11,11 @@ import { TimelineEdges, TimelineNonEcsData, } from '../../../../../../common/search_strategy'; -import { toStringArray } from '../../../../helpers/to_array'; -import { getDataSafety, getDataFromFieldsHits } from '../details/helpers'; +import { toStringArray } from '../../../../../../common/utils/to_array'; +import { + getDataFromFieldsHits, + getDataSafety, +} from '../../../../../../common/utils/field_formatters'; const getTimestamp = (hit: EventHit): string => { if (hit.fields && hit.fields['@timestamp']) { diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts index 0107ba44baec7..a4d6eebfb71b8 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts @@ -19,7 +19,11 @@ import { import { inspectStringifyObject } from '../../../../../utils/build_query'; import { SecuritySolutionTimelineFactory } from '../../types'; import { buildTimelineDetailsQuery } from './query.events_details.dsl'; -import { getDataFromFieldsHits, getDataFromSourceHits, getDataSafety } from './helpers'; +import { + getDataFromFieldsHits, + getDataFromSourceHits, + getDataSafety, +} from '../../../../../../common/utils/field_formatters'; export const timelineEventsDetails: SecuritySolutionTimelineFactory = { buildDsl: (options: TimelineEventsDetailsRequestOptions) => { From fb24006545b05fb1ba57d10610af1defc7067d4e Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 12 Apr 2021 20:54:03 +0100 Subject: [PATCH 24/28] skip flaky suite (#96788) --- .../integration_tests/migration_7.7.2_xpack_100k.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts index 0e51c886f7f30..7f3ee03f1437d 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts @@ -26,7 +26,8 @@ async function removeLogFile() { await asyncUnlink(logFilePath).catch(() => void 0); } -describe('migration from 7.7.2-xpack with 100k objects', () => { +// FAILING: https://github.com/elastic/kibana/pull/96788 +describe.skip('migration from 7.7.2-xpack with 100k objects', () => { let esServer: kbnTestServer.TestElasticsearchUtils; let root: Root; let coreStart: InternalCoreStart; From 3f131f59662df8c40cbaecc6b6b740c90b8410b8 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 12 Apr 2021 21:19:26 +0100 Subject: [PATCH 25/28] skip flaky suite (#89550) --- test/functional/apps/discover/_discover.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index bf90d90cc828c..0c12f32f6e717 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -182,7 +182,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - describe('query #2, which has an empty time range', () => { + // FLAKY: https://github.com/elastic/kibana/issues/89550 + describe.skip('query #2, which has an empty time range', () => { const fromTime = 'Jun 11, 1999 @ 09:22:11.000'; const toTime = 'Jun 12, 1999 @ 11:21:04.000'; From 5f16bcc15595c4c7b728c2ff2e7d1e7e14d1b581 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Mon, 12 Apr 2021 13:31:17 -0700 Subject: [PATCH 26/28] [docs] minor typo in word (#96684) (#96866) Co-authored-by: Peter Dyson --- docs/user/monitoring/kibana-alerts.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/monitoring/kibana-alerts.asciidoc b/docs/user/monitoring/kibana-alerts.asciidoc index 04f4e986ca289..bbc9c41c6ca5a 100644 --- a/docs/user/monitoring/kibana-alerts.asciidoc +++ b/docs/user/monitoring/kibana-alerts.asciidoc @@ -84,7 +84,7 @@ by running checks on a schedule time of 1 minute with a re-notify interval of 6 This alert is triggered if a large (primary) shard size is found on any of the specified index patterns. The trigger condition is met if an index's shard size is 55gb or higher in the last 5 minutes. The alert is grouped across all indices that match -the default patter of `*` by running checks on a schedule time of 1 minute with a re-notify +the default pattern of `*` by running checks on a schedule time of 1 minute with a re-notify interval of 12 hours. [discrete] From 465734ae99ba72a05faf4fe50b9bfa2c83d8de99 Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Mon, 12 Apr 2021 16:34:41 -0400 Subject: [PATCH 27/28] [Maps] Enable distance filtering on geo_shape (#96832) --- .../tools_control/tools_control.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx index 7ffd2a608c43a..1d2354ba3154a 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx @@ -132,17 +132,11 @@ export class ToolsControl extends Component { name: DRAW_BOUNDS_LABEL, panel: 2, }, - ]; - - const hasGeoPoints = this.props.geoFields.some(({ geoFieldType }) => { - return geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT; - }); - if (hasGeoPoints) { - tools.push({ + { name: DRAW_DISTANCE_LABEL, panel: 3, - }); - } + }, + ]; return [ { @@ -199,9 +193,7 @@ export class ToolsControl extends Component { { - return geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT; - })} + geoFields={this.props.geoFields} getFilterActions={this.props.getFilterActions} getActionContext={this.props.getActionContext} onSubmit={this._initiateDistanceDraw} From e7ecad7c3b158974c0ccfd4dbd740b65e70f53f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ece=20=C3=96zalp?= Date: Mon, 12 Apr 2021 17:10:36 -0400 Subject: [PATCH 28/28] [CTI] Filters alerts table by presence of threat (elastic/security-team#907) (#96096) [CTI] Filters alerts table by presence of threat (elastic/security-team#907) --- .../alerts_utility_bar/index.test.tsx | 271 +++++++++++++++--- .../alerts_table/alerts_utility_bar/index.tsx | 41 ++- .../alerts_utility_bar/translations.ts | 7 + .../alerts_table/default_config.test.tsx | 29 +- .../alerts_table/default_config.tsx | 66 +++-- .../components/alerts_table/index.test.tsx | 2 + .../components/alerts_table/index.tsx | 36 ++- .../detection_engine/detection_engine.tsx | 30 +- .../detection_engine/rules/details/index.tsx | 16 +- 9 files changed, 406 insertions(+), 92 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.test.tsx index 6f83c075f0a9a..4ca2980dc74e5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.test.tsx @@ -17,17 +17,19 @@ describe('AlertsUtilityBar', () => { test('renders correctly', () => { const wrapper = shallow( ); @@ -41,17 +43,19 @@ describe('AlertsUtilityBar', () => { const wrapper = mount( @@ -72,22 +76,61 @@ describe('AlertsUtilityBar', () => { ).toEqual(false); }); + test('does not show the showOnlyThreatIndicatorAlerts checked if the showThreatMatchOnly is false', () => { + const wrapper = mount( + + + + ); + // click the filters button to popup the checkbox to make it visible + wrapper + .find('[data-test-subj="additionalFilters"] button') + .first() + .simulate('click') + .update(); + + // The check box should be false + expect( + wrapper + .find('[data-test-subj="showOnlyThreatIndicatorAlertsCheckbox"] input') + .first() + .prop('checked') + ).toEqual(false); + }); + test('does show the showBuildingBlockAlerts checked if the showBuildingBlockAlerts is true', () => { const onShowBuildingBlockAlertsChanged = jest.fn(); const wrapper = mount( @@ -108,22 +151,61 @@ describe('AlertsUtilityBar', () => { ).toEqual(true); }); + test('does show the showOnlyThreatIndicatorAlerts checked if the showOnlyThreatIndicatorAlerts is true', () => { + const wrapper = mount( + + + + ); + // click the filters button to popup the checkbox to make it visible + wrapper + .find('[data-test-subj="additionalFilters"] button') + .first() + .simulate('click') + .update(); + + // The check box should be true + expect( + wrapper + .find('[data-test-subj="showOnlyThreatIndicatorAlertsCheckbox"] input') + .first() + .prop('checked') + ).toEqual(true); + }); + test('calls the onShowBuildingBlockAlertsChanged when the check box is clicked', () => { const onShowBuildingBlockAlertsChanged = jest.fn(); const wrapper = mount( @@ -145,21 +227,62 @@ describe('AlertsUtilityBar', () => { expect(onShowBuildingBlockAlertsChanged).toHaveBeenCalled(); }); + test('calls the onShowOnlyThreatIndicatorAlertsChanged when the check box is clicked', () => { + const onShowOnlyThreatIndicatorAlertsChanged = jest.fn(); + const wrapper = mount( + + + + ); + // click the filters button to popup the checkbox to make it visible + wrapper + .find('[data-test-subj="additionalFilters"] button') + .first() + .simulate('click') + .update(); + + // check the box + wrapper + .find('[data-test-subj="showOnlyThreatIndicatorAlertsCheckbox"] input') + .first() + .simulate('change', { target: { checked: true } }); + + // Make sure our callback is called + expect(onShowOnlyThreatIndicatorAlertsChanged).toHaveBeenCalled(); + }); + test('can update showBuildingBlockAlerts from false to true', () => { const Proxy = (props: AlertsUtilityBarProps) => ( @@ -167,17 +290,19 @@ describe('AlertsUtilityBar', () => { const wrapper = mount( ); @@ -214,5 +339,79 @@ describe('AlertsUtilityBar', () => { .prop('checked') ).toEqual(true); }); + + test('can update showOnlyThreatIndicatorAlerts from false to true', () => { + const Proxy = (props: AlertsUtilityBarProps) => ( + + + + ); + + const wrapper = mount( + + ); + // click the filters button to popup the checkbox to make it visible + wrapper + .find('[data-test-subj="additionalFilters"] button') + .first() + .simulate('click') + .update(); + + // The check box should false now since we initially set the showBuildingBlockAlerts to false + expect( + wrapper + .find('[data-test-subj="showOnlyThreatIndicatorAlertsCheckbox"] input') + .first() + .prop('checked') + ).toEqual(false); + + wrapper.setProps({ showOnlyThreatIndicatorAlerts: true }); + wrapper.update(); + + // click the filters button to popup the checkbox to make it visible + wrapper + .find('[data-test-subj="additionalFilters"] button') + .first() + .simulate('click') + .update(); + + // The check box should be true now since we changed the showBuildingBlockAlerts from false to true + expect( + wrapper + .find('[data-test-subj="showOnlyThreatIndicatorAlertsCheckbox"] input') + .first() + .prop('checked') + ).toEqual(true); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx index ec2f84ba3e12d..bda8c85ddb315 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx @@ -30,16 +30,18 @@ import { UpdateAlertsStatus } from '../types'; import { FILTER_CLOSED, FILTER_IN_PROGRESS, FILTER_OPEN } from '../alerts_filter_group'; export interface AlertsUtilityBarProps { - hasIndexWrite: boolean; - hasIndexMaintenance: boolean; areEventsLoading: boolean; clearSelection: () => void; currentFilter: Status; + hasIndexMaintenance: boolean; + hasIndexWrite: boolean; + onShowBuildingBlockAlertsChanged: (showBuildingBlockAlerts: boolean) => void; + onShowOnlyThreatIndicatorAlertsChanged: (showOnlyThreatIndicatorAlerts: boolean) => void; selectAll: () => void; selectedEventIds: Readonly>; showBuildingBlockAlerts: boolean; - onShowBuildingBlockAlertsChanged: (showBuildingBlockAlerts: boolean) => void; showClearSelection: boolean; + showOnlyThreatIndicatorAlerts: boolean; totalCount: number; updateAlertsStatus: UpdateAlertsStatus; } @@ -56,21 +58,22 @@ const BuildingBlockContainer = styled(EuiFlexItem)` rgba(245, 167, 0, 0.05) 2px, rgba(245, 167, 0, 0.05) 10px ); - padding: ${({ theme }) => `${theme.eui.paddingSizes.xs}`}; `; const AlertsUtilityBarComponent: React.FC = ({ - hasIndexWrite, - hasIndexMaintenance, areEventsLoading, clearSelection, - totalCount, - selectedEventIds, currentFilter, + hasIndexMaintenance, + hasIndexWrite, + onShowBuildingBlockAlertsChanged, + onShowOnlyThreatIndicatorAlertsChanged, selectAll, + selectedEventIds, showBuildingBlockAlerts, - onShowBuildingBlockAlertsChanged, showClearSelection, + showOnlyThreatIndicatorAlerts, + totalCount, updateAlertsStatus, }) => { const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); @@ -144,7 +147,7 @@ const AlertsUtilityBarComponent: React.FC = ({ ); const UtilityBarAdditionalFiltersContent = (closePopover: () => void) => ( - + = ({ label={i18n.ADDITIONAL_FILTERS_ACTIONS_SHOW_BUILDING_BLOCK} /> + + ) => { + closePopover(); + onShowOnlyThreatIndicatorAlertsChanged(e.target.checked); + }} + checked={showOnlyThreatIndicatorAlerts} + color="text" + data-test-subj="showOnlyThreatIndicatorAlertsCheckbox" + label={i18n.ADDITIONAL_FILTERS_ACTIONS_SHOW_ONLY_THREAT_INDICATOR_ALERTS} + /> + ); @@ -240,5 +257,7 @@ export const AlertsUtilityBar = React.memo( prevProps.selectedEventIds === nextProps.selectedEventIds && prevProps.totalCount === nextProps.totalCount && prevProps.showClearSelection === nextProps.showClearSelection && - prevProps.showBuildingBlockAlerts === nextProps.showBuildingBlockAlerts + prevProps.showBuildingBlockAlerts === nextProps.showBuildingBlockAlerts && + prevProps.onShowOnlyThreatIndicatorAlertsChanged === + nextProps.onShowOnlyThreatIndicatorAlertsChanged ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/translations.ts index 9307e8b1cd5f7..c52e443c50753 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/translations.ts @@ -42,6 +42,13 @@ export const ADDITIONAL_FILTERS_ACTIONS_SHOW_BUILDING_BLOCK = i18n.translate( } ); +export const ADDITIONAL_FILTERS_ACTIONS_SHOW_ONLY_THREAT_INDICATOR_ALERTS = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.utilityBar.additionalFiltersActions.showOnlyThreatIndicatorAlerts', + { + defaultMessage: 'Show only threat indicator alerts', + } +); + export const CLEAR_SELECTION = i18n.translate( 'xpack.securitySolution.detectionEngine.alerts.utilityBar.clearSelectionTitle', { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx index 26bc8f213ca46..79c2a45273c33 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx @@ -6,7 +6,7 @@ */ import { Filter } from '../../../../../../../src/plugins/data/common/es_query'; -import { buildAlertsRuleIdFilter } from './default_config'; +import { buildAlertsRuleIdFilter, buildThreatMatchFilter } from './default_config'; jest.mock('./actions'); @@ -34,7 +34,34 @@ describe('alerts default_config', () => { expect(filters).toHaveLength(1); expect(filters[0]).toEqual(expectedFilter); }); + + describe('buildThreatMatchFilter', () => { + test('given a showOnlyThreatIndicatorAlerts=true this will return an array with a single filter', () => { + const filters: Filter[] = buildThreatMatchFilter(true); + const expectedFilter: Filter = { + meta: { + alias: null, + disabled: false, + negate: false, + key: 'signal.rule.threat_mapping', + type: 'exists', + value: 'exists', + }, + // @ts-expect-error TODO: Rework parent typings to support ExistsFilter[] + exists: { + field: 'signal.rule.threat_mapping', + }, + }; + expect(filters).toHaveLength(1); + expect(filters[0]).toEqual(expectedFilter); + }); + test('given a showOnlyThreatIndicatorAlerts=false this will return an empty filter', () => { + const filters: Filter[] = buildThreatMatchFilter(false); + expect(filters).toHaveLength(0); + }); + }); }); + // TODO: move these tests to ../timelines/components/timeline/body/events/event_column_view.tsx // describe.skip('getAlertActions', () => { // let setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 4fae2e69ac1f6..6a83039bf1ec8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -39,28 +39,31 @@ export const buildAlertStatusFilter = (status: Status): Filter[] => [ }, ]; -export const buildAlertsRuleIdFilter = (ruleId: string): Filter[] => [ - { - meta: { - alias: null, - negate: false, - disabled: false, - type: 'phrase', - key: 'signal.rule.id', - params: { - query: ruleId, - }, - }, - query: { - match_phrase: { - 'signal.rule.id': ruleId, - }, - }, - }, -]; +export const buildAlertsRuleIdFilter = (ruleId: string | null): Filter[] => + ruleId + ? [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'signal.rule.id', + params: { + query: ruleId, + }, + }, + query: { + match_phrase: { + 'signal.rule.id': ruleId, + }, + }, + }, + ] + : []; -export const buildShowBuildingBlockFilter = (showBuildingBlockAlerts: boolean): Filter[] => [ - ...(showBuildingBlockAlerts +export const buildShowBuildingBlockFilter = (showBuildingBlockAlerts: boolean): Filter[] => + showBuildingBlockAlerts ? [] : [ { @@ -75,8 +78,25 @@ export const buildShowBuildingBlockFilter = (showBuildingBlockAlerts: boolean): // @ts-expect-error TODO: Rework parent typings to support ExistsFilter[] exists: { field: 'signal.rule.building_block_type' }, }, - ]), -]; + ]; + +export const buildThreatMatchFilter = (showOnlyThreatIndicatorAlerts: boolean): Filter[] => + showOnlyThreatIndicatorAlerts + ? [ + { + meta: { + alias: null, + disabled: false, + negate: false, + key: 'signal.rule.threat_mapping', + type: 'exists', + value: 'exists', + }, + // @ts-expect-error TODO: Rework parent typings to support ExistsFilter[] + exists: { field: 'signal.rule.threat_mapping' }, + }, + ] + : []; export const alertsHeaders: ColumnHeaderOptions[] = [ { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx index 5c659b7554ec2..be11aecfe47dd 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx @@ -40,6 +40,8 @@ describe('AlertsTableComponent', () => { clearEventsDeleted={jest.fn()} showBuildingBlockAlerts={false} onShowBuildingBlockAlertsChanged={jest.fn()} + showOnlyThreatIndicatorAlerts={false} + onShowOnlyThreatIndicatorAlertsChanged={jest.fn()} /> ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index cf6db52d0cece..2890eb912b84c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -52,22 +52,23 @@ import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; interface OwnProps { - timelineId: TimelineIdLiteral; defaultFilters?: Filter[]; - hasIndexWrite: boolean; - hasIndexMaintenance: boolean; from: string; + hasIndexMaintenance: boolean; + hasIndexWrite: boolean; loading: boolean; onRuleChange?: () => void; - showBuildingBlockAlerts: boolean; onShowBuildingBlockAlertsChanged: (showBuildingBlockAlerts: boolean) => void; + onShowOnlyThreatIndicatorAlertsChanged: (showOnlyThreatIndicatorAlerts: boolean) => void; + showBuildingBlockAlerts: boolean; + showOnlyThreatIndicatorAlerts: boolean; + timelineId: TimelineIdLiteral; to: string; } type AlertsTableComponentProps = OwnProps & PropsFromRedux; export const AlertsTableComponent: React.FC = ({ - timelineId, clearEventsDeleted, clearEventsLoading, clearSelected, @@ -75,17 +76,20 @@ export const AlertsTableComponent: React.FC = ({ from, globalFilters, globalQuery, - hasIndexWrite, hasIndexMaintenance, + hasIndexWrite, isSelectAllChecked, loading, loadingEventIds, onRuleChange, + onShowBuildingBlockAlertsChanged, + onShowOnlyThreatIndicatorAlertsChanged, selectedEventIds, setEventsDeleted, setEventsLoading, showBuildingBlockAlerts, - onShowBuildingBlockAlertsChanged, + showOnlyThreatIndicatorAlerts, + timelineId, to, }) => { const [showClearSelectionAction, setShowClearSelectionAction] = useState(false); @@ -264,30 +268,34 @@ export const AlertsTableComponent: React.FC = ({ 0} clearSelection={clearSelectionCallback} - hasIndexWrite={hasIndexWrite} - hasIndexMaintenance={hasIndexMaintenance} currentFilter={filterGroup} + hasIndexMaintenance={hasIndexMaintenance} + hasIndexWrite={hasIndexWrite} + onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChanged} + onShowOnlyThreatIndicatorAlertsChanged={onShowOnlyThreatIndicatorAlertsChanged} selectAll={selectAllOnAllPagesCallback} selectedEventIds={selectedEventIds} showBuildingBlockAlerts={showBuildingBlockAlerts} - onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChanged} showClearSelection={showClearSelectionAction} + showOnlyThreatIndicatorAlerts={showOnlyThreatIndicatorAlerts} totalCount={totalCount} updateAlertsStatus={updateAlertsStatusCallback.bind(null, refetchQuery)} /> ); }, [ - hasIndexWrite, - hasIndexMaintenance, clearSelectionCallback, filterGroup, - showBuildingBlockAlerts, - onShowBuildingBlockAlertsChanged, + hasIndexMaintenance, + hasIndexWrite, loadingEventIds.length, + onShowBuildingBlockAlertsChanged, + onShowOnlyThreatIndicatorAlertsChanged, selectAllOnAllPagesCallback, selectedEventIds, + showBuildingBlockAlerts, showClearSelectionAction, + showOnlyThreatIndicatorAlerts, updateAlertsStatusCallback, ] ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index 8d2f07e19b36a..02e18d09710d7 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -50,7 +50,10 @@ import { } from '../../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; -import { buildShowBuildingBlockFilter } from '../../components/alerts_table/default_config'; +import { + buildShowBuildingBlockFilter, + buildThreatMatchFilter, +} from '../../components/alerts_table/default_config'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { NeedAdminForUpdateRulesCallOut } from '../../components/callouts/need_admin_for_update_callout'; @@ -100,6 +103,7 @@ const DetectionEnginePageComponent = () => { const [lastAlerts] = useAlertInfo({}); const { formatUrl } = useFormatUrl(SecurityPageName.detections); const [showBuildingBlockAlerts, setShowBuildingBlockAlerts] = useState(false); + const [showOnlyThreatIndicatorAlerts, setShowOnlyThreatIndicatorAlerts] = useState(false); const loading = userInfoLoading || listsConfigLoading; const updateDateRangeCallback = useCallback( @@ -128,14 +132,21 @@ const DetectionEnginePageComponent = () => { ); const alertsHistogramDefaultFilters = useMemo( - () => [...filters, ...buildShowBuildingBlockFilter(showBuildingBlockAlerts)], - [filters, showBuildingBlockAlerts] + () => [ + ...filters, + ...buildShowBuildingBlockFilter(showBuildingBlockAlerts), + ...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts), + ], + [filters, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts] ); // AlertsTable manages global filters itself, so not including `filters` const alertsTableDefaultFilters = useMemo( - () => buildShowBuildingBlockFilter(showBuildingBlockAlerts), - [showBuildingBlockAlerts] + () => [ + ...buildShowBuildingBlockFilter(showBuildingBlockAlerts), + ...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts), + ], + [showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts] ); const onShowBuildingBlockAlertsChangedCallback = useCallback( @@ -145,6 +156,13 @@ const DetectionEnginePageComponent = () => { [setShowBuildingBlockAlerts] ); + const onShowOnlyThreatIndicatorAlertsCallback = useCallback( + (newShowOnlyThreatIndicatorAlerts: boolean) => { + setShowOnlyThreatIndicatorAlerts(newShowOnlyThreatIndicatorAlerts); + }, + [setShowOnlyThreatIndicatorAlerts] + ); + const { indicesExist, indexPattern } = useSourcererScope(SourcererScopeName.detections); const onSkipFocusBeforeEventsTable = useCallback(() => { @@ -250,6 +268,8 @@ const DetectionEnginePageComponent = () => { defaultFilters={alertsTableDefaultFilters} showBuildingBlockAlerts={showBuildingBlockAlerts} onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChangedCallback} + showOnlyThreatIndicatorAlerts={showOnlyThreatIndicatorAlerts} + onShowOnlyThreatIndicatorAlertsChanged={onShowOnlyThreatIndicatorAlertsCallback} to={to} /> diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index dddf8ac1bb839..a8d3742bfd600 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -59,6 +59,7 @@ import { StepScheduleRule } from '../../../../components/rules/step_schedule_rul import { buildAlertsRuleIdFilter, buildShowBuildingBlockFilter, + buildThreatMatchFilter, } from '../../../../components/alerts_table/default_config'; import { ReadOnlyAlertsCallOut } from '../../../../components/callouts/read_only_alerts_callout'; import { ReadOnlyRulesCallOut } from '../../../../components/callouts/read_only_rules_callout'; @@ -208,6 +209,7 @@ const RuleDetailsPageComponent = () => { }; const [lastAlerts] = useAlertInfo({ ruleId }); const [showBuildingBlockAlerts, setShowBuildingBlockAlerts] = useState(false); + const [showOnlyThreatIndicatorAlerts, setShowOnlyThreatIndicatorAlerts] = useState(false); const mlCapabilities = useMlCapabilities(); const history = useHistory(); const { formatUrl } = useFormatUrl(SecurityPageName.detections); @@ -286,10 +288,11 @@ const RuleDetailsPageComponent = () => { const alertDefaultFilters = useMemo( () => [ - ...(ruleId != null ? buildAlertsRuleIdFilter(ruleId) : []), + ...buildAlertsRuleIdFilter(ruleId), ...buildShowBuildingBlockFilter(showBuildingBlockAlerts), + ...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts), ], - [ruleId, showBuildingBlockAlerts] + [ruleId, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts] ); const alertMergedFilters = useMemo(() => [...alertDefaultFilters, ...filters], [ @@ -446,6 +449,13 @@ const RuleDetailsPageComponent = () => { [setShowBuildingBlockAlerts] ); + const onShowOnlyThreatIndicatorAlertsCallback = useCallback( + (newShowOnlyThreatIndicatorAlerts: boolean) => { + setShowOnlyThreatIndicatorAlerts(newShowOnlyThreatIndicatorAlerts); + }, + [setShowOnlyThreatIndicatorAlerts] + ); + const { indicesExist, indexPattern } = useSourcererScope(SourcererScopeName.detections); const exceptionLists = useMemo((): { @@ -670,7 +680,9 @@ const RuleDetailsPageComponent = () => { from={from} loading={loading} showBuildingBlockAlerts={showBuildingBlockAlerts} + showOnlyThreatIndicatorAlerts={showOnlyThreatIndicatorAlerts} onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChangedCallback} + onShowOnlyThreatIndicatorAlertsChanged={onShowOnlyThreatIndicatorAlertsCallback} onRuleChange={refreshRule} to={to} />