diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index 97c0b24e700984..a7a96aa346145d 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -279,3 +279,5 @@ enabled: - x-pack/performance/journeys/promotion_tracking_dashboard.ts - x-pack/performance/journeys/web_logs_dashboard.ts - x-pack/performance/journeys/data_stress_test_lens.ts + - x-pack/performance/journeys/ecommerce_dashboard_saved_search_only.ts + - x-pack/performance/journeys/ecommerce_dashboard_tsvb_gauge_only.ts diff --git a/examples/guided_onboarding_example/public/components/main.tsx b/examples/guided_onboarding_example/public/components/main.tsx index 4c9481d423e4c1..d3636470ec64e8 100644 --- a/examples/guided_onboarding_example/public/components/main.tsx +++ b/examples/guided_onboarding_example/public/components/main.tsx @@ -216,7 +216,8 @@ export const Main = (props: MainProps) => { )} {(guideState?.isActive === true || guideState?.status === 'in_progress' || - guideState?.status === 'ready_to_complete') && ( + guideState?.status === 'ready_to_complete' || + guideState?.status === 'not_started') && ( { }); }); }); + describe('#start()', () => { + it('returns default roles values when wildcard is provided', async () => { + configService = getMockedConfigService({ roles: ['*'] }); + coreContext = mockCoreContext.create({ logger, configService }); + + service = new NodeService(coreContext); + await service.preboot({ loggingSystem: logger }); + const { roles } = service.start(); + + expect(roles.backgroundTasks).toBe(true); + expect(roles.ui).toBe(true); + }); + + it('returns correct roles when node is configured to `background_tasks`', async () => { + configService = getMockedConfigService({ roles: ['background_tasks'] }); + coreContext = mockCoreContext.create({ logger, configService }); + + service = new NodeService(coreContext); + await service.preboot({ loggingSystem: logger }); + const { roles } = service.start(); + + expect(roles.backgroundTasks).toBe(true); + expect(roles.ui).toBe(false); + }); + + it('returns correct roles when node is configured to `ui`', async () => { + configService = getMockedConfigService({ roles: ['ui'] }); + coreContext = mockCoreContext.create({ logger, configService }); + + service = new NodeService(coreContext); + await service.preboot({ loggingSystem: logger }); + const { roles } = service.start(); + + expect(roles.backgroundTasks).toBe(false); + expect(roles.ui).toBe(true); + }); + + it('returns correct roles when node is configured to both `background_tasks` and `ui`', async () => { + configService = getMockedConfigService({ roles: ['background_tasks', 'ui'] }); + coreContext = mockCoreContext.create({ logger, configService }); + + service = new NodeService(coreContext); + await service.preboot({ loggingSystem: logger }); + const { roles } = service.start(); + + expect(roles.backgroundTasks).toBe(true); + expect(roles.ui).toBe(true); + }); + it('throws if preboot has not been run', () => { + configService = getMockedConfigService({ roles: ['background_tasks', 'ui'] }); + coreContext = mockCoreContext.create({ logger, configService }); + + service = new NodeService(coreContext); + expect(() => service.start()).toThrowErrorMatchingInlineSnapshot( + `"NodeService#start() can only be called after NodeService#preboot()"` + ); + }); + }); }); diff --git a/packages/core/node/core-node-server-internal/src/node_service.ts b/packages/core/node/core-node-server-internal/src/node_service.ts index fb4ee57c41cbed..b5c5c0a8b4c17b 100644 --- a/packages/core/node/core-node-server-internal/src/node_service.ts +++ b/packages/core/node/core-node-server-internal/src/node_service.ts @@ -28,7 +28,20 @@ const containsWildcard = (roles: string[]) => roles.includes(NODE_WILDCARD_CHAR) */ export interface InternalNodeServicePreboot { /** - * Retrieve the Kibana instance uuid. + * The Kibana process can take on specialised roles via the `node.roles` config. + * + * The roles can be used by plugins to adjust their behavior based + * on the way the Kibana process has been configured. + */ + roles: NodeRoles; +} + +export interface InternalNodeServiceStart { + /** + * The Kibana process can take on specialised roles via the `node.roles` config. + * + * The roles can be used by plugins to adjust their behavior based + * on the way the Kibana process has been configured. */ roles: NodeRoles; } @@ -41,6 +54,7 @@ export interface PrebootDeps { export class NodeService { private readonly configService: IConfigService; private readonly log: Logger; + private roles?: NodeRoles; constructor(core: CoreContext) { this.configService = core.configService; @@ -52,13 +66,22 @@ export class NodeService { loggingSystem.setGlobalContext({ service: { node: { roles } } }); this.log.info(`Kibana process configured with roles: [${roles.join(', ')}]`); + this.roles = NODE_ACCEPTED_ROLES.reduce((acc, curr) => { + return { ...acc, [camelCase(curr)]: roles.includes(curr) }; + }, {} as NodeRoles); + return { - roles: NODE_ACCEPTED_ROLES.reduce((acc, curr) => { - return { ...acc, [camelCase(curr)]: roles.includes(curr) }; - }, {} as NodeRoles), + roles: this.roles, }; } + public start(): InternalNodeServiceStart { + if (this.roles == null) { + throw new Error('NodeService#start() can only be called after NodeService#preboot()'); + } + return { roles: this.roles }; + } + public stop() { // nothing to do here yet } diff --git a/packages/core/node/core-node-server-mocks/src/node_service.mock.ts b/packages/core/node/core-node-server-mocks/src/node_service.mock.ts index 6ece68ebd1b8f0..ff354b663c2318 100644 --- a/packages/core/node/core-node-server-mocks/src/node_service.mock.ts +++ b/packages/core/node/core-node-server-mocks/src/node_service.mock.ts @@ -7,7 +7,11 @@ */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import type { NodeService, InternalNodeServicePreboot } from '@kbn/core-node-server-internal'; +import type { + NodeService, + InternalNodeServicePreboot, + InternalNodeServiceStart, +} from '@kbn/core-node-server-internal'; const createInternalPrebootContractMock = () => { const prebootContract: jest.Mocked = { @@ -19,17 +23,38 @@ const createInternalPrebootContractMock = () => { return prebootContract; }; +const createInternalStartContractMock = ( + { + ui, + backgroundTasks, + }: { + ui: boolean; + backgroundTasks: boolean; + } = { ui: true, backgroundTasks: true } +) => { + const startContract: jest.Mocked = { + roles: { + backgroundTasks, + ui, + }, + }; + return startContract; +}; + type NodeServiceContract = PublicMethodsOf; const createMock = () => { const mocked: jest.Mocked = { preboot: jest.fn(), + start: jest.fn(), stop: jest.fn(), }; mocked.preboot.mockResolvedValue(createInternalPrebootContractMock()); + mocked.start.mockReturnValue(createInternalStartContractMock()); return mocked; }; export const nodeServiceMock = { create: createMock, createInternalPrebootContract: createInternalPrebootContractMock, + createInternalStartContract: createInternalStartContractMock, }; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/README.md b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/README.md index 12d3b2d4905832..52e79edcf8b7df 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/README.md +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/README.md @@ -167,14 +167,19 @@ the same version could have plugins enabled at any time that would introduce new transforms or mappings. → `OUTDATED_DOCUMENTS_SEARCH` -3. If the `.kibana` alias exists we’re migrating from either a v1 or v2 index +3. If `waitForMigrations` was set we're running on a background-tasks node and +we should not participate in the migration but instead wait for the ui node(s) +to complete the migration. + → `WAIT_FOR_MIGRATION_COMPLETION` + +4. If the `.kibana` alias exists we’re migrating from either a v1 or v2 index and the migration source index is the index the `.kibana` alias points to. → `WAIT_FOR_YELLOW_SOURCE` -4. If `.kibana` is a concrete index, we’re migrating from a legacy index +5. If `.kibana` is a concrete index, we’re migrating from a legacy index → `LEGACY_SET_WRITE_BLOCK` -5. If there are no `.kibana` indices, this is a fresh deployment. Initialize a +6. If there are no `.kibana` indices, this is a fresh deployment. Initialize a new saved objects index → `CREATE_NEW_TARGET` @@ -259,6 +264,15 @@ new `.kibana` alias that points to `.kibana_pre6.5.0_001`. `index_not_found_exception` another instance has already completed this step. → `SET_SOURCE_WRITE_BLOCK` +## WAIT_FOR_MIGRATION_COMPLETION +### Next action +`fetchIndices` +### New control state +1. If the ui node finished the migration + → `DONE` +2. Otherwise wait 2s and check again + → WAIT_FOR_MIGRATION_COMPLETION + ## WAIT_FOR_YELLOW_SOURCE ### Next action `waitForIndexStatus` (status='yellow') @@ -417,6 +431,13 @@ update the mappings and then use an update_by_query to ensure that all fields ar ## UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK ### Next action +`waitForPickupUpdatedMappingsTask` + +### New control state + → `MARK_VERSION_INDEX_READY` + +## MARK_VERSION_INDEX_READY +### Next action `updateAliases` Atomically apply the `versionIndexReadyActions` using the _alias actions API. By performing the following actions we guarantee that if multiple versions of Kibana started the upgrade in parallel, only one version will succeed. diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/migrations_state_action_machine.test.ts.snap b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/migrations_state_action_machine.test.ts.snap index 39ff3c4c5700ae..4e19c9bf626902 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/migrations_state_action_machine.test.ts.snap +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/migrations_state_action_machine.test.ts.snap @@ -178,6 +178,7 @@ Object { "transformedDocBatches": Array [], "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", + "waitForMigrationCompletion": false, }, }, }, @@ -362,6 +363,7 @@ Object { "transformedDocBatches": Array [], "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", + "waitForMigrationCompletion": false, }, }, }, @@ -550,6 +552,7 @@ Object { "transformedDocBatches": Array [], "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", + "waitForMigrationCompletion": false, }, }, }, @@ -742,6 +745,7 @@ Object { "transformedDocBatches": Array [], "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", + "waitForMigrationCompletion": false, }, }, }, @@ -971,6 +975,7 @@ Object { ], "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", + "waitForMigrationCompletion": false, }, }, }, @@ -1166,6 +1171,7 @@ Object { ], "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", + "waitForMigrationCompletion": false, }, }, }, diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.test.ts index d1c19aa7d212ef..35d22e69f724e5 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.test.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.test.ts @@ -40,6 +40,7 @@ describe('createInitialState', () => { expect( createInitialState({ kibanaVersion: '8.1.0', + waitForMigrationCompletion: true, targetMappings: { dynamic: 'strict', properties: { my_type: { properties: { title: { type: 'text' } } } }, @@ -216,10 +217,32 @@ describe('createInitialState', () => { }, "versionAlias": ".kibana_task_manager_8.1.0", "versionIndex": ".kibana_task_manager_8.1.0_001", + "waitForMigrationCompletion": true, } `); }); + it('creates the initial state for the model with waitForMigrationCompletion false,', () => { + expect( + createInitialState({ + kibanaVersion: '8.1.0', + waitForMigrationCompletion: false, + targetMappings: { + dynamic: 'strict', + properties: { my_type: { properties: { title: { type: 'text' } } } }, + }, + migrationVersionPerType: {}, + indexPrefix: '.kibana_task_manager', + migrationsConfig, + typeRegistry, + docLinks, + logger: mockLogger.get(), + }) + ).toMatchObject({ + waitForMigrationCompletion: false, + }); + }); + it('returns state with the correct `knownTypes`', () => { typeRegistry.registerType({ name: 'foo', @@ -236,6 +259,7 @@ describe('createInitialState', () => { const initialState = createInitialState({ kibanaVersion: '8.1.0', + waitForMigrationCompletion: false, targetMappings: { dynamic: 'strict', properties: { my_type: { properties: { title: { type: 'text' } } } }, @@ -263,6 +287,7 @@ describe('createInitialState', () => { const initialState = createInitialState({ kibanaVersion: '8.1.0', + waitForMigrationCompletion: false, targetMappings: { dynamic: 'strict', properties: { my_type: { properties: { title: { type: 'text' } } } }, @@ -282,6 +307,7 @@ describe('createInitialState', () => { const preMigrationScript = "ctx._id = ctx._source.type + ':' + ctx._id"; const initialState = createInitialState({ kibanaVersion: '8.1.0', + waitForMigrationCompletion: false, targetMappings: { dynamic: 'strict', properties: { my_type: { properties: { title: { type: 'text' } } } }, @@ -305,6 +331,7 @@ describe('createInitialState', () => { Option.isNone( createInitialState({ kibanaVersion: '8.1.0', + waitForMigrationCompletion: false, targetMappings: { dynamic: 'strict', properties: { my_type: { properties: { title: { type: 'text' } } } }, @@ -324,6 +351,7 @@ describe('createInitialState', () => { expect( createInitialState({ kibanaVersion: '8.1.0', + waitForMigrationCompletion: false, targetMappings: { dynamic: 'strict', properties: { my_type: { properties: { title: { type: 'text' } } } }, @@ -378,6 +406,7 @@ describe('createInitialState', () => { const logger = mockLogger.get(); const initialState = createInitialState({ kibanaVersion: '8.1.0', + waitForMigrationCompletion: false, targetMappings: { dynamic: 'strict', properties: { my_type: { properties: { title: { type: 'text' } } } }, @@ -398,6 +427,7 @@ describe('createInitialState', () => { const logger = mockLogger.get(); const initialState = createInitialState({ kibanaVersion: '8.1.0', + waitForMigrationCompletion: false, targetMappings: { dynamic: 'strict', properties: { my_type: { properties: { title: { type: 'text' } } } }, @@ -423,6 +453,7 @@ describe('createInitialState', () => { it('initializes the `discardUnknownObjects` flag to true if the value provided in the config matches the current kibana version', () => { const initialState = createInitialState({ kibanaVersion: '8.1.0', + waitForMigrationCompletion: false, targetMappings: { dynamic: 'strict', properties: { my_type: { properties: { title: { type: 'text' } } } }, @@ -445,6 +476,7 @@ describe('createInitialState', () => { const logger = mockLogger.get(); const initialState = createInitialState({ kibanaVersion: '8.1.0', + waitForMigrationCompletion: false, targetMappings: { dynamic: 'strict', properties: { my_type: { properties: { title: { type: 'text' } } } }, @@ -470,6 +502,7 @@ describe('createInitialState', () => { it('initializes the `discardCorruptObjects` flag to true if the value provided in the config matches the current kibana version', () => { const initialState = createInitialState({ kibanaVersion: '8.1.0', + waitForMigrationCompletion: false, targetMappings: { dynamic: 'strict', properties: { my_type: { properties: { title: { type: 'text' } } } }, diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.ts index 1843227934bcd0..72eae8e188094e 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.ts @@ -23,6 +23,7 @@ import { excludeUnusedTypesQuery } from './core'; */ export const createInitialState = ({ kibanaVersion, + waitForMigrationCompletion, targetMappings, preMigrationScript, migrationVersionPerType, @@ -33,6 +34,7 @@ export const createInitialState = ({ logger, }: { kibanaVersion: string; + waitForMigrationCompletion: boolean; targetMappings: IndexMapping; preMigrationScript?: string; migrationVersionPerType: SavedObjectsMigrationVersion; @@ -95,6 +97,7 @@ export const createInitialState = ({ return { controlState: 'INIT', + waitForMigrationCompletion, indexPrefix, legacyIndex: indexPrefix, currentAlias: indexPrefix, diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.test.ts index 5d1cb32eca6d22..dc5addb1624b88 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.test.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.test.ts @@ -253,6 +253,7 @@ const mockOptions = () => { const options: MockedOptions = { logger: loggingSystemMock.create().get(), kibanaVersion: '8.2.3', + waitForMigrationCompletion: false, typeRegistry: createRegistry([ { name: 'testtype', diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.ts index 1d8d766d8ac88a..837c2a47bea58f 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.ts @@ -45,6 +45,7 @@ export interface KibanaMigratorOptions { kibanaVersion: string; logger: Logger; docLinks: DocLinksServiceStart; + waitForMigrationCompletion: boolean; } /** @@ -65,7 +66,7 @@ export class KibanaMigrator implements IKibanaMigrator { private readonly activeMappings: IndexMapping; private readonly soMigrationsConfig: SavedObjectsMigrationConfigType; private readonly docLinks: DocLinksServiceStart; - + private readonly waitForMigrationCompletion: boolean; public readonly kibanaVersion: string; /** @@ -79,6 +80,7 @@ export class KibanaMigrator implements IKibanaMigrator { kibanaVersion, logger, docLinks, + waitForMigrationCompletion, }: KibanaMigratorOptions) { this.client = client; this.kibanaIndex = kibanaIndex; @@ -93,6 +95,7 @@ export class KibanaMigrator implements IKibanaMigrator { typeRegistry, log: this.log, }); + this.waitForMigrationCompletion = waitForMigrationCompletion; // Building the active mappings (and associated md5sums) is an expensive // operation so we cache the result this.activeMappings = buildActiveMappings(this.mappingProperties); @@ -148,6 +151,7 @@ export class KibanaMigrator implements IKibanaMigrator { return runResilientMigrator({ client: this.client, kibanaVersion: this.kibanaVersion, + waitForMigrationCompletion: this.waitForMigrationCompletion, targetMappings: buildActiveMappings(indexMap[index].typeMappings), logger: this.log, preMigrationScript: indexMap[index].script, diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/migrations_state_action_machine.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/migrations_state_action_machine.test.ts index 55b0a53d8f8078..255a26275b6e84 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/migrations_state_action_machine.test.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/migrations_state_action_machine.test.ts @@ -39,6 +39,7 @@ describe('migrationsStateActionMachine', () => { const initialState = createInitialState({ kibanaVersion: '7.11.0', + waitForMigrationCompletion: false, targetMappings: { properties: {} }, migrationVersionPerType: {}, indexPrefix: '.my-so-index', diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/helpers.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/helpers.test.ts index b9756d46237ed7..c364e053c1ff6e 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/helpers.test.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/helpers.test.ts @@ -12,6 +12,7 @@ import { addMustClausesToBoolQuery, addMustNotClausesToBoolQuery, getAliases, + versionMigrationCompleted, } from './helpers'; describe('addExcludedTypesToBoolQuery', () => { @@ -230,3 +231,39 @@ describe('getAliases', () => { `); }); }); + +describe('versionMigrationCompleted', () => { + it('returns true if the current and version alias points to the same index', () => { + expect( + versionMigrationCompleted('.current-alias', '.version-alias', { + '.current-alias': 'myindex', + '.version-alias': 'myindex', + }) + ).toBe(true); + }); + it('returns false if the current and version alias does not point to the same index', () => { + expect( + versionMigrationCompleted('.current-alias', '.version-alias', { + '.current-alias': 'myindex', + '.version-alias': 'anotherindex', + }) + ).toBe(false); + }); + it('returns false if the current alias does not exist', () => { + expect( + versionMigrationCompleted('.current-alias', '.version-alias', { + '.version-alias': 'myindex', + }) + ).toBe(false); + }); + it('returns false if the version alias does not exist', () => { + expect( + versionMigrationCompleted('.current-alias', '.version-alias', { + '.current-alias': 'myindex', + }) + ).toBe(false); + }); + it('returns false if neither the version or current alias exists', () => { + expect(versionMigrationCompleted('.current-alias', '.version-alias', {})).toBe(false); + }); +}); diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/helpers.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/helpers.ts index 5f84dc01af008b..f7377401c16bf1 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/helpers.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/helpers.ts @@ -68,6 +68,19 @@ export function mergeMigrationMappingPropertyHashes( }; } +/** + * If `.kibana` and the version specific aliases both exists and + * are pointing to the same index. This version's migration has already + * been completed. + */ +export function versionMigrationCompleted( + currentAlias: string, + versionAlias: string, + aliases: Record +): boolean { + return aliases[currentAlias] != null && aliases[currentAlias] === aliases[versionAlias]; +} + export function indexBelongsToLaterVersion(indexName: string, kibanaVersion: string): boolean { const version = valid(indexVersion(indexName)); return version != null ? gt(version, kibanaVersion) : false; @@ -157,16 +170,17 @@ export function getAliases( indices: FetchIndexResponse ): Either.Either< { type: 'multiple_indices_per_alias'; alias: string; indices: string[] }, - Record + Record > { - const aliases = {} as Record; + const aliases = {} as Record; for (const index of Object.getOwnPropertyNames(indices)) { for (const alias of Object.getOwnPropertyNames(indices[index].aliases || {})) { - if (aliases[alias] != null) { + const secondIndexThisAliasPointsTo = aliases[alias]; + if (secondIndexThisAliasPointsTo != null) { return Either.left({ type: 'multiple_indices_per_alias', alias, - indices: [aliases[alias], index], + indices: [secondIndexThisAliasPointsTo, index], }); } aliases[alias] = index; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/model.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/model.test.ts index 67b7e40dc5affc..cb446b952e5ec7 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/model.test.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/model.test.ts @@ -102,6 +102,7 @@ describe('migrations v2 model', () => { routingAllocationDisabled: 'routingAllocationDisabled', clusterShardLimitExceeded: 'clusterShardLimitExceeded', }, + waitForMigrationCompletion: false, }; describe('exponential retry delays for retryable_es_client_error', () => { @@ -222,13 +223,14 @@ describe('migrations v2 model', () => { }); describe('INIT', () => { - const initState: State = { + const initBaseState: State = { ...baseState, controlState: 'INIT', currentAlias: '.kibana', versionAlias: '.kibana_7.11.0', versionIndex: '.kibana_7.11.0_001', }; + const mappingsWithUnknownType = { properties: { disabled_saved_object_type: { @@ -244,110 +246,560 @@ describe('migrations v2 model', () => { }, } as const; - test('INIT -> OUTDATED_DOCUMENTS_SEARCH_OPEN_PIT if .kibana is already pointing to the target index', () => { - const res: ResponseType<'INIT'> = Either.right({ - '.kibana_7.11.0_001': { - aliases: { - '.kibana': {}, - '.kibana_7.11.0': {}, + describe('if waitForMigrationCompletion=true', () => { + const initState = Object.assign({}, initBaseState, { + waitForMigrationCompletion: true, + }); + test('INIT -> OUTDATED_DOCUMENTS_SEARCH_OPEN_PIT if .kibana is already pointing to the target index', () => { + const res: ResponseType<'INIT'> = Either.right({ + '.kibana_7.11.0_001': { + aliases: { + '.kibana': {}, + '.kibana_7.11.0': {}, + }, + mappings: mappingsWithUnknownType, + settings: {}, }, - mappings: mappingsWithUnknownType, - settings: {}, - }, + }); + const newState = model(initState, res); + + expect(newState.controlState).toEqual('OUTDATED_DOCUMENTS_SEARCH_OPEN_PIT'); + // This snapshot asserts that we merge the + // migrationMappingPropertyHashes of the existing index, but we leave + // the mappings for the disabled_saved_object_type untouched. There + // might be another Kibana instance that knows about this type and + // needs these mappings in place. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "disabled_saved_object_type": "7997cf5a56cc02bdc9c93361bde732b0", + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); + expect(newState.retryCount).toEqual(0); + expect(newState.retryDelay).toEqual(0); }); - const newState = model(initState, res); + test('INIT -> INIT when cluster routing allocation is incompatible', () => { + const res: ResponseType<'INIT'> = Either.left({ + type: 'incompatible_cluster_routing_allocation', + }); + const newState = model(initState, res) as FatalState; - expect(newState.controlState).toEqual('OUTDATED_DOCUMENTS_SEARCH_OPEN_PIT'); - // This snapshot asserts that we merge the - // migrationMappingPropertyHashes of the existing index, but we leave - // the mappings for the disabled_saved_object_type untouched. There - // might be another Kibana instance that knows about this type and - // needs these mappings in place. - expect(newState.targetIndexMappings).toMatchInlineSnapshot(` - Object { - "_meta": Object { - "migrationMappingPropertyHashes": Object { - "disabled_saved_object_type": "7997cf5a56cc02bdc9c93361bde732b0", - "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + expect(newState.controlState).toEqual('INIT'); + expect(newState.retryCount).toEqual(1); + expect(newState.retryDelay).toEqual(2000); + expect(newState.logs[0]).toMatchInlineSnapshot(` + Object { + "level": "error", + "message": "Action failed with '[incompatible_cluster_routing_allocation] Incompatible Elasticsearch cluster settings detected. Remove the persistent and transient Elasticsearch cluster setting 'cluster.routing.allocation.enable' or set it to a value of 'all' to allow migrations to proceed. Refer to routingAllocationDisabled for more information on how to resolve the issue.'. Retrying attempt 1 in 2 seconds.", + } + `); + }); + test("INIT -> FATAL when .kibana points to newer version's index", () => { + const res: ResponseType<'INIT'> = Either.right({ + '.kibana_7.12.0_001': { + aliases: { + '.kibana': {}, + '.kibana_7.12.0': {}, }, + mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + settings: {}, }, - "properties": Object { - "new_saved_object_type": Object { - "properties": Object { - "value": Object { - "type": "text", + '.kibana_7.11.0_001': { + aliases: { '.kibana_7.11.0': {} }, + mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + settings: {}, + }, + }); + const newState = model(initState, res) as FatalState; + + expect(newState.controlState).toEqual('FATAL'); + expect(newState.reason).toMatchInlineSnapshot( + `"The .kibana alias is pointing to a newer version of Kibana: v7.12.0"` + ); + }); + test('INIT -> FATAL when .kibana points to multiple indices', () => { + const res: ResponseType<'INIT'> = Either.right({ + '.kibana_7.12.0_001': { + aliases: { + '.kibana': {}, + '.kibana_7.12.0': {}, + }, + mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + settings: {}, + }, + '.kibana_7.11.0_001': { + aliases: { '.kibana': {}, '.kibana_7.11.0': {} }, + mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + settings: {}, + }, + }); + const newState = model(initState, res) as FatalState; + + expect(newState.controlState).toEqual('FATAL'); + expect(newState.reason).toMatchInlineSnapshot( + `"The .kibana alias is pointing to multiple indices: .kibana_7.12.0_001,.kibana_7.11.0_001."` + ); + }); + test('INIT -> WAIT_FOR_MIGRATION_COMPLETION 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 + // invalid name when restoring from a snapshot. So we try to be + // lenient and assume it's an older index and perform a migration. + // If the tampered index belonged to a newer version the migration + // will fail when we start transforming documents. + const res: ResponseType<'INIT'> = Either.right({ + '.kibana_7.invalid.0_001': { + aliases: { + '.kibana': {}, + '.kibana_7.12.0': {}, + }, + mappings: mappingsWithUnknownType, + settings: {}, + }, + '.kibana_7.11.0_001': { + aliases: { '.kibana_7.11.0': {} }, + mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + settings: {}, + }, + }); + const newState = model(initState, res) as WaitForYellowSourceState; + + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toBe(2000); + }); + test('INIT -> WAIT_FOR_MIGRATION_COMPLETION 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': {} }, + mappings: mappingsWithUnknownType, + settings: {}, + }, + '.kibana_3': { + aliases: {}, + mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + settings: {}, + }, + }); + const newState = model( + { + ...initState, + ...{ + kibanaVersion: '7.12.0', + versionAlias: '.kibana_7.12.0', + versionIndex: '.kibana_7.12.0_001', + }, + }, + res + ) as WaitForYellowSourceState; + + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toEqual(2000); + }); + test('INIT -> WAIT_FOR_MIGRATION_COMPLETION when migrating from a v1 migrations index (>= 6.5 < 7.11.0)', () => { + const res: ResponseType<'INIT'> = Either.right({ + '.kibana_3': { + aliases: { + '.kibana': {}, + }, + mappings: mappingsWithUnknownType, + settings: {}, + }, + }); + const newState = model(initState, res) as WaitForYellowSourceState; + + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toEqual(2000); + }); + test('INIT -> WAIT_FOR_MIGRATION_COMPLETION when migrating from a legacy index (>= 6.0.0 < 6.5)', () => { + const res: ResponseType<'INIT'> = Either.right({ + '.kibana': { + aliases: {}, + mappings: mappingsWithUnknownType, + settings: {}, + }, + }); + const newState = model(initState, res); + + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toEqual(2000); + }); + test('INIT -> WAIT_FOR_MIGRATION_COMPLETION when migrating from a custom kibana.index name (>= 6.5 < 7.11.0)', () => { + const res: ResponseType<'INIT'> = Either.right({ + 'my-saved-objects_3': { + aliases: { + 'my-saved-objects': {}, + }, + mappings: mappingsWithUnknownType, + settings: {}, + }, + }); + const newState = model( + { + ...initState, + controlState: 'INIT', + currentAlias: 'my-saved-objects', + versionAlias: 'my-saved-objects_7.11.0', + versionIndex: 'my-saved-objects_7.11.0_001', + }, + res + ) as WaitForYellowSourceState; + + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toEqual(2000); + }); + test('INIT -> WAIT_FOR_MIGRATION_COMPLETION 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: { + 'my-saved-objects': {}, + }, + mappings: mappingsWithUnknownType, + settings: {}, + }, + }); + const newState = model( + { + ...initState, + controlState: 'INIT', + kibanaVersion: '7.12.0', + currentAlias: 'my-saved-objects', + versionAlias: 'my-saved-objects_7.12.0', + versionIndex: 'my-saved-objects_7.12.0_001', + }, + res + ) as WaitForYellowSourceState; + + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toEqual(2000); + }); + test('INIT -> WAIT_FOR_MIGRATION_COMPLETION when no indices/aliases exist', () => { + const res: ResponseType<'INIT'> = Either.right({}); + const newState = model(initState, res); + + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toEqual(2000); + }); + }); + describe('if waitForMigrationCompletion=false', () => { + const initState = Object.assign({}, initBaseState, { + waitForMigrationCompletion: false, + }); + test('INIT -> OUTDATED_DOCUMENTS_SEARCH_OPEN_PIT if .kibana is already pointing to the target index', () => { + const res: ResponseType<'INIT'> = Either.right({ + '.kibana_7.11.0_001': { + aliases: { + '.kibana': {}, + '.kibana_7.11.0': {}, + }, + mappings: mappingsWithUnknownType, + settings: {}, + }, + }); + const newState = model(initState, res); + + expect(newState.controlState).toEqual('OUTDATED_DOCUMENTS_SEARCH_OPEN_PIT'); + // This snapshot asserts that we merge the + // migrationMappingPropertyHashes of the existing index, but we leave + // the mappings for the disabled_saved_object_type untouched. There + // might be another Kibana instance that knows about this type and + // needs these mappings in place. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "disabled_saved_object_type": "7997cf5a56cc02bdc9c93361bde732b0", + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "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 -> INIT when cluster routing allocation is incompatible', () => { + const res: ResponseType<'INIT'> = Either.left({ + type: 'incompatible_cluster_routing_allocation', + }); + const newState = model(initState, res) as FatalState; + + expect(newState.controlState).toEqual('INIT'); + expect(newState.retryCount).toEqual(1); + expect(newState.retryDelay).toEqual(2000); + expect(newState.logs[0]).toMatchInlineSnapshot(` + Object { + "level": "error", + "message": "Action failed with '[incompatible_cluster_routing_allocation] Incompatible Elasticsearch cluster settings detected. Remove the persistent and transient Elasticsearch cluster setting 'cluster.routing.allocation.enable' or set it to a value of 'all' to allow migrations to proceed. Refer to routingAllocationDisabled for more information on how to resolve the issue.'. Retrying attempt 1 in 2 seconds.", + } + `); + }); + test("INIT -> FATAL when .kibana points to newer version's index", () => { + const res: ResponseType<'INIT'> = Either.right({ + '.kibana_7.12.0_001': { + aliases: { + '.kibana': {}, + '.kibana_7.12.0': {}, + }, + mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + settings: {}, }, - } - `); - expect(newState.retryCount).toEqual(0); - expect(newState.retryDelay).toEqual(0); - }); - test('INIT -> INIT when cluster routing allocation is incompatible', () => { - const res: ResponseType<'INIT'> = Either.left({ - type: 'incompatible_cluster_routing_allocation', + '.kibana_7.11.0_001': { + aliases: { '.kibana_7.11.0': {} }, + mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + settings: {}, + }, + }); + const newState = model(initState, res) as FatalState; + + expect(newState.controlState).toEqual('FATAL'); + expect(newState.reason).toMatchInlineSnapshot( + `"The .kibana alias is pointing to a newer version of Kibana: v7.12.0"` + ); + }); + test('INIT -> FATAL when .kibana points to multiple indices', () => { + const res: ResponseType<'INIT'> = Either.right({ + '.kibana_7.12.0_001': { + aliases: { + '.kibana': {}, + '.kibana_7.12.0': {}, + }, + mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + settings: {}, + }, + '.kibana_7.11.0_001': { + aliases: { '.kibana': {}, '.kibana_7.11.0': {} }, + mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + settings: {}, + }, + }); + const newState = model(initState, res) as FatalState; + + expect(newState.controlState).toEqual('FATAL'); + expect(newState.reason).toMatchInlineSnapshot( + `"The .kibana alias is pointing to multiple indices: .kibana_7.12.0_001,.kibana_7.11.0_001."` + ); + }); + 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 + // invalid name when restoring from a snapshot. So we try to be + // lenient and assume it's an older index and perform a migration. + // If the tampered index belonged to a newer version the migration + // will fail when we start transforming documents. + const res: ResponseType<'INIT'> = Either.right({ + '.kibana_7.invalid.0_001': { + aliases: { + '.kibana': {}, + '.kibana_7.12.0': {}, + }, + mappings: mappingsWithUnknownType, + settings: {}, + }, + '.kibana_7.11.0_001': { + aliases: { '.kibana_7.11.0': {} }, + mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + settings: {}, + }, + }); + const newState = model(initState, res) as WaitForYellowSourceState; + + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('.kibana_7.invalid.0_001'); }); - const newState = model(initState, res) as FatalState; - expect(newState.controlState).toEqual('INIT'); - expect(newState.retryCount).toEqual(1); - expect(newState.retryDelay).toEqual(2000); - expect(newState.logs[0]).toMatchInlineSnapshot(` - Object { - "level": "error", - "message": "Action failed with '[incompatible_cluster_routing_allocation] Incompatible Elasticsearch cluster settings detected. Remove the persistent and transient Elasticsearch cluster setting 'cluster.routing.allocation.enable' or set it to a value of 'all' to allow migrations to proceed. Refer to routingAllocationDisabled for more information on how to resolve the issue.'. Retrying attempt 1 in 2 seconds.", - } - `); - }); - test("INIT -> FATAL when .kibana points to newer version's index", () => { - const res: ResponseType<'INIT'> = Either.right({ - '.kibana_7.12.0_001': { - aliases: { - '.kibana': {}, - '.kibana_7.12.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': {} }, + mappings: mappingsWithUnknownType, + settings: {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, - settings: {}, - }, - '.kibana_7.11.0_001': { - aliases: { '.kibana_7.11.0': {} }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, - settings: {}, - }, + '.kibana_3': { + aliases: {}, + mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + settings: {}, + }, + }); + const newState = model( + { + ...initState, + ...{ + kibanaVersion: '7.12.0', + versionAlias: '.kibana_7.12.0', + versionIndex: '.kibana_7.12.0_001', + }, + }, + res + ) as WaitForYellowSourceState; + + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('.kibana_7.11.0_001'); + expect(newState.retryCount).toEqual(0); + expect(newState.retryDelay).toEqual(0); }); - const newState = model(initState, res) as FatalState; - expect(newState.controlState).toEqual('FATAL'); - expect(newState.reason).toMatchInlineSnapshot( - `"The .kibana alias is pointing to a newer version of Kibana: v7.12.0"` - ); - }); - test('INIT -> FATAL when .kibana points to multiple indices', () => { - const res: ResponseType<'INIT'> = Either.right({ - '.kibana_7.12.0_001': { - aliases: { - '.kibana': {}, - '.kibana_7.12.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: { + '.kibana': {}, + }, + mappings: mappingsWithUnknownType, + settings: {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, - settings: {}, - }, - '.kibana_7.11.0_001': { - aliases: { '.kibana': {}, '.kibana_7.11.0': {} }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, - settings: {}, - }, + }); + const newState = model(initState, res) as WaitForYellowSourceState; + + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('.kibana_3'); + expect(newState.retryCount).toEqual(0); + expect(newState.retryDelay).toEqual(0); }); - const newState = model(initState, res) as FatalState; + test('INIT -> LEGACY_SET_WRITE_BLOCK when migrating from a legacy index (>= 6.0.0 < 6.5)', () => { + const res: ResponseType<'INIT'> = Either.right({ + '.kibana': { + aliases: {}, + mappings: mappingsWithUnknownType, + settings: {}, + }, + }); + const newState = model(initState, res); - expect(newState.controlState).toEqual('FATAL'); - expect(newState.reason).toMatchInlineSnapshot( - `"The .kibana alias is pointing to multiple indices: .kibana_7.12.0_001,.kibana_7.11.0_001."` - ); + expect(newState).toMatchObject({ + controlState: 'LEGACY_SET_WRITE_BLOCK', + sourceIndex: Option.some('.kibana_pre6.5.0_001'), + 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", + }, + }, + }, + }, + } + `); + expect(newState.retryCount).toEqual(0); + expect(newState.retryDelay).toEqual(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: { + 'my-saved-objects': {}, + }, + mappings: mappingsWithUnknownType, + settings: {}, + }, + }); + const newState = model( + { + ...baseState, + controlState: 'INIT', + currentAlias: 'my-saved-objects', + versionAlias: 'my-saved-objects_7.11.0', + versionIndex: 'my-saved-objects_7.11.0_001', + }, + res + ) as WaitForYellowSourceState; + + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('my-saved-objects_3'); + expect(newState.retryCount).toEqual(0); + expect(newState.retryDelay).toEqual(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: { + 'my-saved-objects': {}, + }, + mappings: mappingsWithUnknownType, + settings: {}, + }, + }); + const newState = model( + { + ...baseState, + controlState: 'INIT', + kibanaVersion: '7.12.0', + currentAlias: 'my-saved-objects', + versionAlias: 'my-saved-objects_7.12.0', + versionIndex: 'my-saved-objects_7.12.0_001', + }, + res + ) as WaitForYellowSourceState; + + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('my-saved-objects_7.11.0'); + + expect(newState.retryCount).toEqual(0); + expect(newState.retryDelay).toEqual(0); + }); + test('INIT -> CREATE_NEW_TARGET when no indices/aliases exist', () => { + const res: ResponseType<'INIT'> = Either.right({}); + const newState = model(initState, res); + + expect(newState).toMatchObject({ + controlState: 'CREATE_NEW_TARGET', + sourceIndex: Option.none, + targetIndex: '.kibana_7.11.0_001', + }); + expect(newState.retryCount).toEqual(0); + expect(newState.retryDelay).toEqual(0); + }); }); - test('INIT -> WAIT_FOR_YELLOW_SOURCE when .kibana points to an index with an invalid version', () => { + }); + + describe('WAIT_FOR_MIGRATION_COMPLETION', () => { + const waitForMState: State = { + ...baseState, + controlState: 'WAIT_FOR_MIGRATION_COMPLETION', + currentAlias: '.kibana', + versionAlias: '.kibana_7.11.0', + versionIndex: '.kibana_7.11.0_001', + }; + + test('WAIT_FOR_MIGRATION_COMPLETION -> WAIT_FOR_MIGRATION_COMPLETION 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 @@ -355,13 +807,13 @@ describe('migrations v2 model', () => { // lenient and assume it's an older index and perform a migration. // If the tampered index belonged to a newer version the migration // will fail when we start transforming documents. - const res: ResponseType<'INIT'> = Either.right({ + const res: ResponseType<'WAIT_FOR_MIGRATION_COMPLETION'> = Either.right({ '.kibana_7.invalid.0_001': { aliases: { '.kibana': {}, '.kibana_7.12.0': {}, }, - mappings: mappingsWithUnknownType, + mappings: { properties: {} }, settings: {}, }, '.kibana_7.11.0_001': { @@ -370,17 +822,16 @@ describe('migrations v2 model', () => { settings: {}, }, }); - const newState = model(initState, res) as WaitForYellowSourceState; + const newState = model(waitForMState, res) as WaitForYellowSourceState; - expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); - expect(newState.sourceIndex.value).toBe('.kibana_7.invalid.0_001'); + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toBe(2000); }); - - test('INIT -> WAIT_FOR_YELLOW_SOURCE when migrating from a v2 migrations index (>= 7.11.0)', () => { - const res: ResponseType<'INIT'> = Either.right({ + test('WAIT_FOR_MIGRATION_COMPLETION -> WAIT_FOR_MIGRATION_COMPLETION when migrating from a v2 migrations index (>= 7.11.0)', () => { + const res: ResponseType<'WAIT_FOR_MIGRATION_COMPLETION'> = Either.right({ '.kibana_7.11.0_001': { aliases: { '.kibana': {}, '.kibana_7.11.0': {} }, - mappings: mappingsWithUnknownType, + mappings: { properties: {} }, settings: {}, }, '.kibana_3': { @@ -391,7 +842,7 @@ describe('migrations v2 model', () => { }); const newState = model( { - ...initState, + ...waitForMState, ...{ kibanaVersion: '7.12.0', versionAlias: '.kibana_7.12.0', @@ -401,86 +852,50 @@ describe('migrations v2 model', () => { res ) as WaitForYellowSourceState; - expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); - expect(newState.sourceIndex.value).toBe('.kibana_7.11.0_001'); - expect(newState.retryCount).toEqual(0); - expect(newState.retryDelay).toEqual(0); + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toEqual(2000); }); - - test('INIT -> WAIT_FOR_YELLOW_SOURCE when migrating from a v1 migrations index (>= 6.5 < 7.11.0)', () => { - const res: ResponseType<'INIT'> = Either.right({ + test('WAIT_FOR_MIGRATION_COMPLETION -> WAIT_FOR_MIGRATION_COMPLETION when migrating from a v1 migrations index (>= 6.5 < 7.11.0)', () => { + const res: ResponseType<'WAIT_FOR_MIGRATION_COMPLETION'> = Either.right({ '.kibana_3': { aliases: { '.kibana': {}, }, - mappings: mappingsWithUnknownType, + mappings: { properties: {} }, settings: {}, }, }); - const newState = model(initState, res) as WaitForYellowSourceState; + const newState = model(waitForMState, res) as WaitForYellowSourceState; - expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); - expect(newState.sourceIndex.value).toBe('.kibana_3'); - expect(newState.retryCount).toEqual(0); - expect(newState.retryDelay).toEqual(0); + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toEqual(2000); }); - test('INIT -> LEGACY_SET_WRITE_BLOCK when migrating from a legacy index (>= 6.0.0 < 6.5)', () => { - const res: ResponseType<'INIT'> = Either.right({ + test('WAIT_FOR_MIGRATION_COMPLETION -> WAIT_FOR_MIGRATION_COMPLETION when migrating from a legacy index (>= 6.0.0 < 6.5)', () => { + const res: ResponseType<'WAIT_FOR_MIGRATION_COMPLETION'> = Either.right({ '.kibana': { aliases: {}, - mappings: mappingsWithUnknownType, + mappings: { properties: {} }, settings: {}, }, }); - const newState = model(initState, res); + const newState = model(waitForMState, res); - expect(newState).toMatchObject({ - controlState: 'LEGACY_SET_WRITE_BLOCK', - sourceIndex: Option.some('.kibana_pre6.5.0_001'), - 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", - }, - }, - }, - }, - } - `); - expect(newState.retryCount).toEqual(0); - expect(newState.retryDelay).toEqual(0); + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toEqual(2000); }); - 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({ + test('WAIT_FOR_MIGRATION_COMPLETION -> WAIT_FOR_MIGRATION_COMPLETION when migrating from a custom kibana.index name (>= 6.5 < 7.11.0)', () => { + const res: ResponseType<'WAIT_FOR_MIGRATION_COMPLETION'> = Either.right({ 'my-saved-objects_3': { aliases: { 'my-saved-objects': {}, }, - mappings: mappingsWithUnknownType, + mappings: { properties: {} }, settings: {}, }, }); const newState = model( { - ...baseState, - controlState: 'INIT', + ...waitForMState, currentAlias: 'my-saved-objects', versionAlias: 'my-saved-objects_7.11.0', versionIndex: 'my-saved-objects_7.11.0_001', @@ -488,25 +903,22 @@ describe('migrations v2 model', () => { res ) as WaitForYellowSourceState; - expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); - expect(newState.sourceIndex.value).toBe('my-saved-objects_3'); - expect(newState.retryCount).toEqual(0); - expect(newState.retryDelay).toEqual(0); + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toEqual(2000); }); - 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({ + test('WAIT_FOR_MIGRATION_COMPLETION -> WAIT_FOR_MIGRATION_COMPLETION when migrating from a custom kibana.index v2 migrations index (>= 7.11.0)', () => { + const res: ResponseType<'WAIT_FOR_MIGRATION_COMPLETION'> = Either.right({ 'my-saved-objects_7.11.0': { aliases: { 'my-saved-objects': {}, }, - mappings: mappingsWithUnknownType, + mappings: { properties: {} }, settings: {}, }, }); const newState = model( { - ...baseState, - controlState: 'INIT', + ...waitForMState, kibanaVersion: '7.12.0', currentAlias: 'my-saved-objects', versionAlias: 'my-saved-objects_7.12.0', @@ -515,23 +927,31 @@ describe('migrations v2 model', () => { res ) as WaitForYellowSourceState; - expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); - expect(newState.sourceIndex.value).toBe('my-saved-objects_7.11.0'); + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toEqual(2000); + }); + test('WAIT_FOR_MIGRATION_COMPLETION -> WAIT_FOR_MIGRATION_COMPLETION when no indices/aliases exist', () => { + const res: ResponseType<'WAIT_FOR_MIGRATION_COMPLETION'> = Either.right({}); + const newState = model(waitForMState, res); - expect(newState.retryCount).toEqual(0); - expect(newState.retryDelay).toEqual(0); + expect(newState.controlState).toBe('WAIT_FOR_MIGRATION_COMPLETION'); + expect(newState.retryDelay).toEqual(2000); }); - test('INIT -> CREATE_NEW_TARGET when no indices/aliases exist', () => { - const res: ResponseType<'INIT'> = Either.right({}); - const newState = model(initState, res); - expect(newState).toMatchObject({ - controlState: 'CREATE_NEW_TARGET', - sourceIndex: Option.none, - targetIndex: '.kibana_7.11.0_001', + it('WAIT_FOR_MIGRATION_COMPLETION -> DONE when another instance finished the migration', () => { + const res: ResponseType<'WAIT_FOR_MIGRATION_COMPLETION'> = Either.right({ + '.kibana_7.11.0_001': { + aliases: { + '.kibana': {}, + '.kibana_7.11.0': {}, + }, + mappings: { properties: {} }, + settings: {}, + }, }); - expect(newState.retryCount).toEqual(0); - expect(newState.retryDelay).toEqual(0); + const newState = model(waitForMState, res); + + expect(newState.controlState).toEqual('DONE'); }); }); diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/model.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/model.ts index 8eca6d12c4f0ad..2ff0a99d6966f8 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/model.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/model.ts @@ -38,6 +38,7 @@ import { mergeMigrationMappingPropertyHashes, throwBadControlState, throwBadResponse, + versionMigrationCompleted, } from './helpers'; import { createBatches } from './create_batches'; import type { MigrationLog } from '../types'; @@ -98,12 +99,8 @@ export const model = (currentState: State, resW: ResponseType): const aliases = aliasesRes.right; if ( - // `.kibana` and the version specific aliases both exists and - // are pointing to the same index. This version's migration has already - // been completed. - aliases[stateP.currentAlias] != null && - aliases[stateP.versionAlias] != null && - aliases[stateP.currentAlias] === aliases[stateP.versionAlias] + // This version's migration has already been completed. + versionMigrationCompleted(stateP.currentAlias, stateP.versionAlias, aliases) ) { return { ...stateP, @@ -117,7 +114,7 @@ export const model = (currentState: State, resW: ResponseType): targetIndex: `${stateP.indexPrefix}_${stateP.kibanaVersion}_001`, targetIndexMappings: mergeMigrationMappingPropertyHashes( stateP.targetIndexMappings, - indices[aliases[stateP.currentAlias]].mappings + indices[aliases[stateP.currentAlias]!].mappings ), versionIndexReadyActions: Option.none, }; @@ -125,7 +122,7 @@ export const model = (currentState: State, resW: ResponseType): // `.kibana` is pointing to an index that belongs to a later // version of Kibana .e.g. a 7.11.0 instance found the `.kibana` alias // pointing to `.kibana_7.12.0_001` - indexBelongsToLaterVersion(aliases[stateP.currentAlias], stateP.kibanaVersion) + indexBelongsToLaterVersion(aliases[stateP.currentAlias]!, stateP.kibanaVersion) ) { return { ...stateP, @@ -136,12 +133,29 @@ export const model = (currentState: State, resW: ResponseType): aliases[stateP.currentAlias] )}`, }; + } else if ( + // Don't actively participate in this migration but wait for another instance to complete it + stateP.waitForMigrationCompletion === true + ) { + return { + ...stateP, + controlState: 'WAIT_FOR_MIGRATION_COMPLETION', + // Wait for 2s before checking again if the migration has completed + retryDelay: 2000, + logs: [ + ...stateP.logs, + { + level: 'info', + message: `Migration required. Waiting until another Kibana instance completes the migration.`, + }, + ], + }; } else if ( // If the `.kibana` alias exists aliases[stateP.currentAlias] != null ) { // The source index is the index the `.kibana` alias points to - const source = aliases[stateP.currentAlias]; + const source = aliases[stateP.currentAlias]!; return { ...stateP, controlState: 'WAIT_FOR_YELLOW_SOURCE', @@ -219,6 +233,47 @@ export const model = (currentState: State, resW: ResponseType): } else { return throwBadResponse(stateP, res); } + } else if (stateP.controlState === 'WAIT_FOR_MIGRATION_COMPLETION') { + const res = resW as ExcludeRetryableEsError>; + const indices = res.right; + const aliasesRes = getAliases(indices); + if ( + // If this version's migration has already been completed we can proceed + Either.isRight(aliasesRes) && + versionMigrationCompleted(stateP.currentAlias, stateP.versionAlias, aliasesRes.right) + ) { + return { + ...stateP, + // Proceed to 'DONE' and start serving traffic. + // Because WAIT_FOR_MIGRATION_COMPLETION can only be used by + // background-task nodes on Cloud, we can be confident that this node + // has exactly the same plugins enabled as the node that finished the + // migration. So we won't need to transform any old documents or update + // the mappings. + controlState: 'DONE', + // Source is a none because we didn't do any migration from a source + // index + sourceIndex: Option.none, + targetIndex: `${stateP.indexPrefix}_${stateP.kibanaVersion}_001`, + versionIndexReadyActions: Option.none, + }; + } else { + // When getAliases returns a left 'multiple_indices_per_alias' error or + // the migration is not yet up to date just continue waiting + return { + ...stateP, + controlState: 'WAIT_FOR_MIGRATION_COMPLETION', + // Wait for 2s before checking again if the migration has completed + retryDelay: 2000, + logs: [ + ...stateP.logs, + { + level: 'info', + message: `Migration required. Waiting until another Kibana instance completes the migration.`, + }, + ], + }; + } } else if (stateP.controlState === 'LEGACY_SET_WRITE_BLOCK') { const res = resW as ExcludeRetryableEsError>; // If the write block is successfully in place @@ -938,16 +993,22 @@ export const model = (currentState: State, resW: ResponseType): throwBadResponse(stateP, res.left); } } - } else if (stateP.controlState === 'UPDATE_TARGET_MAPPINGS') { + } else if (stateP.controlState === 'OUTDATED_DOCUMENTS_SEARCH_CLOSE_PIT') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { + const { pitId, hasTransformedDocs, ...state } = stateP; + if (hasTransformedDocs) { + return { + ...state, + controlState: 'OUTDATED_DOCUMENTS_REFRESH', + }; + } return { - ...stateP, - controlState: 'UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK', - updateTargetMappingsTaskId: res.right.taskId, + ...state, + controlState: 'UPDATE_TARGET_MAPPINGS', }; } else { - throwBadResponse(stateP, res as never); + throwBadResponse(stateP, res); } } else if (stateP.controlState === 'OUTDATED_DOCUMENTS_REFRESH') { const res = resW as ExcludeRetryableEsError>; @@ -959,22 +1020,16 @@ export const model = (currentState: State, resW: ResponseType): } else { throwBadResponse(stateP, res); } - } else if (stateP.controlState === 'OUTDATED_DOCUMENTS_SEARCH_CLOSE_PIT') { + } else if (stateP.controlState === 'UPDATE_TARGET_MAPPINGS') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { - const { pitId, hasTransformedDocs, ...state } = stateP; - if (hasTransformedDocs) { - return { - ...state, - controlState: 'OUTDATED_DOCUMENTS_REFRESH', - }; - } return { - ...state, - controlState: 'UPDATE_TARGET_MAPPINGS', + ...stateP, + controlState: 'UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK', + updateTargetMappingsTaskId: res.right.taskId, }; } else { - throwBadResponse(stateP, res); + throwBadResponse(stateP, res as never); } } else if (stateP.controlState === 'UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK') { const res = resW as ExcludeRetryableEsError>; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/next.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/next.ts index 9ac29a3a849ba7..a82cf881459969 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/next.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/next.ts @@ -40,6 +40,7 @@ import type { OutdatedDocumentsRefresh, CheckUnknownDocumentsState, CalculateExcludeFiltersState, + WaitForMigrationCompletionState, } from './state'; import type { TransformRawDocs } from './types'; import * as Actions from './actions'; @@ -60,6 +61,8 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra return { INIT: (state: InitState) => Actions.initAction({ client, indices: [state.currentAlias, state.versionAlias] }), + WAIT_FOR_MIGRATION_COMPLETION: (state: WaitForMigrationCompletionState) => + Actions.fetchIndices({ client, indices: [state.currentAlias, state.versionAlias] }), WAIT_FOR_YELLOW_SOURCE: (state: WaitForYellowSourceState) => Actions.waitForIndexStatus({ client, index: state.sourceIndex.value, status: 'yellow' }), CHECK_UNKNOWN_DOCUMENTS: (state: CheckUnknownDocumentsState) => diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/run_resilient_migrator.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/run_resilient_migrator.ts index 47fe92ad82c54f..b94a94a715056a 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/run_resilient_migrator.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/run_resilient_migrator.ts @@ -44,6 +44,7 @@ export const MIGRATION_CLIENT_OPTIONS = { maxRetries: 0, requestTimeout: 120_000 export async function runResilientMigrator({ client, kibanaVersion, + waitForMigrationCompletion, targetMappings, logger, preMigrationScript, @@ -56,6 +57,7 @@ export async function runResilientMigrator({ }: { client: ElasticsearchClient; kibanaVersion: string; + waitForMigrationCompletion: boolean; targetMappings: IndexMapping; preMigrationScript?: string; logger: Logger; @@ -68,6 +70,7 @@ export async function runResilientMigrator({ }): Promise { const initialState = createInitialState({ kibanaVersion, + waitForMigrationCompletion, targetMappings, preMigrationScript, migrationVersionPerType, diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/state.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/state.ts index eb8d2e2fbb05b2..33ad01be2335b5 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/state.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/state.ts @@ -149,12 +149,18 @@ export interface BaseState extends ControlState { * DocLinks for savedObjects. to reference online documentation */ readonly migrationDocLinks: DocLinks['kibanaUpgradeSavedObjects']; + readonly waitForMigrationCompletion: boolean; } export interface InitState extends BaseState { readonly controlState: 'INIT'; } +export interface WaitForMigrationCompletionState extends BaseState { + /** Wait until another instance completes the migration */ + readonly controlState: 'WAIT_FOR_MIGRATION_COMPLETION'; +} + export interface PostInitState extends BaseState { /** * The source index is the index from which the migration reads. If the @@ -430,6 +436,7 @@ export interface LegacyDeleteState extends LegacyBaseState { export type State = Readonly< | FatalState | InitState + | WaitForMigrationCompletionState | DoneState | WaitForYellowSourceState | CheckUnknownDocumentsState diff --git a/packages/core/saved-objects/core-saved-objects-server-internal/BUILD.bazel b/packages/core/saved-objects/core-saved-objects-server-internal/BUILD.bazel index f2fcaa1c68991d..7bcee948e25ea8 100644 --- a/packages/core/saved-objects/core-saved-objects-server-internal/BUILD.bazel +++ b/packages/core/saved-objects/core-saved-objects-server-internal/BUILD.bazel @@ -67,6 +67,7 @@ TYPES_DEPS = [ "//packages/core/saved-objects/core-saved-objects-import-export-server-internal:npm_module_types", "//packages/core/usage-data/core-usage-data-base-server-internal:npm_module_types", "//packages/core/deprecations/core-deprecations-server:npm_module_types", + "//packages/core/node/core-node-server:npm_module_types", ] jsts_transpiler( diff --git a/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.test.ts b/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.test.ts index 83854c3aafdab6..61978d3915a1ae 100644 --- a/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.test.ts +++ b/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.test.ts @@ -23,6 +23,7 @@ import { ByteSizeValue } from '@kbn/config-schema'; import { REPO_ROOT } from '@kbn/utils'; import { getEnvOptions } from '@kbn/config-mocks'; import { docLinksServiceMock } from '@kbn/core-doc-links-server-mocks'; +import { nodeServiceMock } from '@kbn/core-node-server-mocks'; import { mockCoreContext } from '@kbn/core-base-server-mocks'; import { httpServiceMock, httpServerMock } from '@kbn/core-http-server-mocks'; import type { SavedObjectsClientFactoryProvider } from '@kbn/core-saved-objects-server'; @@ -84,6 +85,7 @@ describe('SavedObjectsService', () => { pluginsInitialized, elasticsearch: elasticsearchServiceMock.createInternalStart(), docLinks: docLinksServiceMock.createStartContract(), + node: nodeServiceMock.createInternalStartContract(), }; }; @@ -285,6 +287,81 @@ describe('SavedObjectsService', () => { expect(KibanaMigratorMock).toHaveBeenCalledWith(expect.objectContaining({ kibanaVersion })); }); + it('calls KibanaMigrator with waitForMigrationCompletion=false for the default ui+background tasks role', async () => { + const pkg = loadJsonFile.sync(join(REPO_ROOT, 'package.json')) as RawPackageInfo; + const kibanaVersion = pkg.version; + + const coreContext = createCoreContext({ + env: Env.createDefault(REPO_ROOT, getEnvOptions(), { + ...pkg, + version: `${kibanaVersion}-beta1`, // test behavior when release has a version qualifier + }), + }); + + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const startDeps = createStartDeps(); + startDeps.node = nodeServiceMock.createInternalStartContract({ + ui: true, + backgroundTasks: true, + }); + await soService.start(startDeps); + + expect(KibanaMigratorMock).toHaveBeenCalledWith( + expect.objectContaining({ waitForMigrationCompletion: false }) + ); + }); + + it('calls KibanaMigrator with waitForMigrationCompletion=false for the ui only role', async () => { + const pkg = loadJsonFile.sync(join(REPO_ROOT, 'package.json')) as RawPackageInfo; + const kibanaVersion = pkg.version; + + const coreContext = createCoreContext({ + env: Env.createDefault(REPO_ROOT, getEnvOptions(), { + ...pkg, + version: `${kibanaVersion}-beta1`, // test behavior when release has a version qualifier + }), + }); + + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const startDeps = createStartDeps(); + startDeps.node = nodeServiceMock.createInternalStartContract({ + ui: true, + backgroundTasks: false, + }); + await soService.start(startDeps); + + expect(KibanaMigratorMock).toHaveBeenCalledWith( + expect.objectContaining({ waitForMigrationCompletion: false }) + ); + }); + + it('calls KibanaMigrator with waitForMigrationCompletion=true for the background tasks only role', async () => { + const pkg = loadJsonFile.sync(join(REPO_ROOT, 'package.json')) as RawPackageInfo; + const kibanaVersion = pkg.version; + + const coreContext = createCoreContext({ + env: Env.createDefault(REPO_ROOT, getEnvOptions(), { + ...pkg, + version: `${kibanaVersion}-beta1`, // test behavior when release has a version qualifier + }), + }); + + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const startDeps = createStartDeps(); + startDeps.node = nodeServiceMock.createInternalStartContract({ + ui: false, + backgroundTasks: true, + }); + await soService.start(startDeps); + + expect(KibanaMigratorMock).toHaveBeenCalledWith( + expect.objectContaining({ waitForMigrationCompletion: true }) + ); + }); + it('waits for all es nodes to be compatible before running migrations', async () => { expect.assertions(2); const coreContext = createCoreContext({ skipMigration: false }); diff --git a/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.ts b/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.ts index 8036a997d0d516..9f052ff61614c4 100644 --- a/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.ts +++ b/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.ts @@ -48,6 +48,7 @@ import { } from '@kbn/core-saved-objects-import-export-server-internal'; import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal'; import type { DeprecationRegistryProvider } from '@kbn/core-deprecations-server'; +import type { NodeInfo } from '@kbn/core-node-server'; import { registerRoutes } from './routes'; import { calculateStatus$ } from './status'; import { registerCoreObjectTypes } from './object_types'; @@ -85,6 +86,7 @@ export interface SavedObjectsStartDeps { elasticsearch: InternalElasticsearchServiceStart; pluginsInitialized?: boolean; docLinks: DocLinksServiceStart; + node: NodeInfo; } export class SavedObjectsService @@ -185,6 +187,7 @@ export class SavedObjectsService elasticsearch, pluginsInitialized = true, docLinks, + node, }: SavedObjectsStartDeps): Promise { if (!this.setupDeps || !this.config) { throw new Error('#setup() needs to be run first'); @@ -194,10 +197,12 @@ export class SavedObjectsService const client = elasticsearch.client; + const waitForMigrationCompletion = node.roles.backgroundTasks && !node.roles.ui; const migrator = this.createMigrator( this.config.migration, elasticsearch.client.asInternalUser, - docLinks + docLinks, + waitForMigrationCompletion ); this.migrator$.next(migrator); @@ -313,7 +318,8 @@ export class SavedObjectsService private createMigrator( soMigrationsConfig: SavedObjectsMigrationConfigType, client: ElasticsearchClient, - docLinks: DocLinksServiceStart + docLinks: DocLinksServiceStart, + waitForMigrationCompletion: boolean ): IKibanaMigrator { return new KibanaMigrator({ typeRegistry: this.typeRegistry, @@ -323,6 +329,7 @@ export class SavedObjectsService kibanaIndex, client, docLinks, + waitForMigrationCompletion, }); } diff --git a/packages/kbn-guided-onboarding/src/types.ts b/packages/kbn-guided-onboarding/src/types.ts index 6b919835da2e78..251ef7e8e744b3 100644 --- a/packages/kbn-guided-onboarding/src/types.ts +++ b/packages/kbn-guided-onboarding/src/types.ts @@ -10,7 +10,7 @@ export type GuideId = 'observability' | 'security' | 'search' | 'testGuide'; type ObservabilityStepIds = 'add_data' | 'view_dashboard' | 'tour_observability'; type SecurityStepIds = 'add_data' | 'rules' | 'alertsCases'; -type SearchStepIds = 'add_data' | 'browse_docs' | 'search_experience'; +type SearchStepIds = 'add_data' | 'search_experience'; type TestGuideIds = 'step1' | 'step2' | 'step3'; export type GuideStepIds = ObservabilityStepIds | SecurityStepIds | SearchStepIds | TestGuideIds; diff --git a/src/core/server/integration_tests/saved_objects/migrations/wait_for_migration_completion.test.ts b/src/core/server/integration_tests/saved_objects/migrations/wait_for_migration_completion.test.ts new file mode 100644 index 00000000000000..4b5197d7f69e81 --- /dev/null +++ b/src/core/server/integration_tests/saved_objects/migrations/wait_for_migration_completion.test.ts @@ -0,0 +1,154 @@ +/* + * 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 Path from 'path'; +import fs from 'fs/promises'; +import JSON5 from 'json5'; +import { kibanaPackageJson as pkg } from '@kbn/utils'; +import { retryAsync } from '@kbn/core-saved-objects-migration-server-mocks'; +import * as kbnTestServer from '../../../../test_helpers/kbn_server'; +import { Root } from '../../../root'; + +const logFilePath = Path.join(__dirname, 'wait_for_migration_completion.log'); + +async function removeLogFile() { + // ignore errors if it doesn't exist + await fs.unlink(logFilePath).catch(() => void 0); +} + +describe('migration with waitForCompletion=true', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + let root: Root; + + beforeAll(async () => { + await removeLogFile(); + }); + + afterAll(async () => { + if (root) { + await root.shutdown(); + } + if (esServer) { + await esServer.stop(); + } + + await new Promise((resolve) => setTimeout(resolve, 10000)); + }); + + it('waits for another instance to complete the migration', async () => { + const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + es: { + license: 'basic', + }, + }, + }); + + root = createRoot(); + + esServer = await startES(); + await root.preboot(); + await root.setup(); + + root.start(); + const esClient = esServer.es.getClient(); + + await retryAsync( + async () => { + const logFileContent = await fs.readFile(logFilePath, 'utf-8'); + const records = logFileContent + .split('\n') + .filter(Boolean) + .map((str) => JSON5.parse(str)) as any[]; + + expect( + records.find((rec) => + rec.message.startsWith( + `[.kibana] Migration required. Waiting until another Kibana instance completes the migration.` + ) + ) + ).toBeDefined(); + + expect( + records.find((rec) => + rec.message.startsWith(`[.kibana] INIT -> WAIT_FOR_MIGRATION_COMPLETION`) + ) + ).toBeDefined(); + + expect( + records.find((rec) => + rec.message.startsWith( + `[.kibana] WAIT_FOR_MIGRATION_COMPLETION -> WAIT_FOR_MIGRATION_COMPLETION` + ) + ) + ).toBeDefined(); + }, + { retryAttempts: 100, retryDelayMs: 200 } + ); + + const aliases: Record = { '.kibana': {} }; + aliases[`.kibana_${pkg.version}`] = {}; + await esClient.indices.create({ index: `.kibana_${pkg.version}_001`, aliases }); + + await retryAsync( + async () => { + const logFileContent = await fs.readFile(logFilePath, 'utf-8'); + const records = logFileContent + .split('\n') + .filter(Boolean) + .map((str) => JSON5.parse(str)) as any[]; + + expect( + records.find((rec) => + rec.message.startsWith(`[.kibana] WAIT_FOR_MIGRATION_COMPLETION -> DONE`) + ) + ).toBeDefined(); + + expect( + records.find((rec) => rec.message.startsWith(`[.kibana] Migration completed`)) + ).toBeDefined(); + }, + { retryAttempts: 100, retryDelayMs: 200 } + ); + }); +}); + +function createRoot() { + return kbnTestServer.createRootWithCorePlugins( + { + migrations: { + skip: false, + }, + node: { + roles: ['background_tasks'], + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + level: 'info', + appenders: ['file'], + }, + ], + }, + }, + { + oss: true, + } + ); +} diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 1f60c3242215aa..7bddb8da80da1d 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -384,6 +384,7 @@ export class Server { elasticsearch: elasticsearchStart, pluginsInitialized: this.#pluginsInitialized, docLinks: docLinkStart, + node: await this.node.start(), }); await this.resolveSavedObjectsStartPromise!(savedObjectsStart); diff --git a/src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/metric_vis_function.ts b/src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/metric_vis_function.ts index a7d655107fc2d3..77f061e7a0cead 100644 --- a/src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/metric_vis_function.ts +++ b/src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/metric_vis_function.ts @@ -45,6 +45,13 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ defaultMessage: 'Metric visualization', }), args: { + autoScaleMetricAlignment: { + types: ['string'], + help: i18n.translate('expressionLegacyMetricVis.function.autoScaleMetricAlignment.help', { + defaultMessage: 'Metric alignment after scaled', + }), + required: false, + }, percentageMode: { types: ['boolean'], default: false, @@ -177,6 +184,9 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ visType, visConfig: { metric: { + ...(args.autoScaleMetricAlignment + ? { autoScaleMetricAlignment: args.autoScaleMetricAlignment } + : {}), palette: args.palette?.params, percentageMode: args.percentageMode, metricColorMode: args.colorMode, diff --git a/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_functions.ts index f0a63b012dc0a0..746915f2132717 100644 --- a/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_functions.ts @@ -15,10 +15,11 @@ import { } from '@kbn/expressions-plugin/common'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import { ColorMode, CustomPaletteState } from '@kbn/charts-plugin/common'; -import { VisParams, visType, LabelPositionType } from './expression_renderers'; +import { VisParams, visType, LabelPositionType, MetricAlignment } from './expression_renderers'; import { EXPRESSION_METRIC_NAME } from '../constants'; export interface MetricArguments { + autoScaleMetricAlignment?: MetricAlignment; percentageMode: boolean; colorMode: ColorMode; showLabels: boolean; diff --git a/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_renderers.ts index 8c370480a7be99..9341e267575bdd 100644 --- a/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_renderers.ts @@ -29,7 +29,10 @@ export type LabelPositionType = $Values; export type MetricStyle = Style & Pick; export type LabelsConfig = Labels & { style: Style; position: LabelPositionType }; + +export type MetricAlignment = 'left' | 'center' | 'right'; export interface MetricVisParam { + autoScaleMetricAlignment?: MetricAlignment; percentageMode: boolean; percentageFormatPattern?: string; metricColorMode: ColorMode; diff --git a/src/plugins/chart_expressions/expression_legacy_metric/public/components/__snapshots__/with_auto_scale.test.tsx.snap b/src/plugins/chart_expressions/expression_legacy_metric/public/components/__snapshots__/with_auto_scale.test.tsx.snap index 4acbab635ba472..37b8587d6b6afc 100644 --- a/src/plugins/chart_expressions/expression_legacy_metric/public/components/__snapshots__/with_auto_scale.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_legacy_metric/public/components/__snapshots__/with_auto_scale.test.tsx.snap @@ -29,3 +29,4 @@ exports[`AutoScale withAutoScale renders 1`] = ` `; + diff --git a/src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_component.tsx b/src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_component.tsx index 2bebcda46bbe56..5dd9ef4d277aa2 100644 --- a/src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_component.tsx +++ b/src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_component.tsx @@ -143,6 +143,12 @@ class MetricVisComponent extends Component { minHeight: '100%', minWidth: '100%', }, + ...(this.props.visParams.metric?.autoScaleMetricAlignment + ? { + autoScaleMetricAlignment: + this.props.visParams.metric?.autoScaleMetricAlignment, + } + : {}), } : undefined } diff --git a/src/plugins/chart_expressions/expression_legacy_metric/public/components/with_auto_scale.tsx b/src/plugins/chart_expressions/expression_legacy_metric/public/components/with_auto_scale.tsx index 3343385dff40ec..2f67094fdc4edc 100644 --- a/src/plugins/chart_expressions/expression_legacy_metric/public/components/with_auto_scale.tsx +++ b/src/plugins/chart_expressions/expression_legacy_metric/public/components/with_auto_scale.tsx @@ -20,6 +20,7 @@ import { useResizeObserver } from '@elastic/eui'; import { autoScaleWrapperStyle } from './with_auto_scale.styles'; interface AutoScaleParams { + autoScaleMetricAlignment?: 'left' | 'center' | 'right'; minScale?: number; containerStyles: CSSProperties; } @@ -83,7 +84,6 @@ export function withAutoScale(WrappedComponent: ComponentType) { const parentRef = useRef(null); const childrenRef = useRef(null); const parentDimensions = useResizeObserver(parentRef.current); - const scaleFn = useMemo( () => throttle(() => { @@ -120,6 +120,16 @@ export function withAutoScale(WrappedComponent: ComponentType) { ref={childrenRef} style={{ transform: `scale(${scale || 0})`, + ...(parentDimensions.width && + scale && + autoScaleParams?.autoScaleMetricAlignment && + autoScaleParams?.autoScaleMetricAlignment !== 'center' + ? { + position: 'relative', + [autoScaleParams.autoScaleMetricAlignment]: + (1 - scale) * parentDimensions.width * scale * -1, // The difference of width after scaled + } + : {}), }} > diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts index 28a4bb185ea769..10be9bb3e3c66a 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts @@ -16,8 +16,11 @@ import { discoverServiceMock } from '../__mocks__/services'; import { SavedSearchEmbeddable, SearchEmbeddableConfig } from './saved_search_embeddable'; import { render } from 'react-dom'; import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; -import { throwError } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { ReactWrapper } from 'enzyme'; +import { SHOW_FIELD_STATISTICS } from '../../common'; +import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import { VIEW_MODE } from '../components/view_mode_toggle'; import { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component'; let discoverComponent: ReactWrapper; @@ -38,13 +41,17 @@ describe('saved search embeddable', () => { let mountpoint: HTMLDivElement; let filterManagerMock: jest.Mocked; let servicesMock: jest.Mocked; + let executeTriggerActions: jest.Mock; + let showFieldStatisticsMockValue: boolean = false; + let viewModeMockValue: VIEW_MODE = VIEW_MODE.DOCUMENT_LEVEL; const createEmbeddable = (searchMock?: jest.Mock) => { const savedSearchMock = { id: 'mock-id', sort: [['message', 'asc']] as Array<[string, string]>, searchSource: createSearchSourceMock({ index: dataViewMock }, undefined, searchMock), + viewMode: viewModeMockValue, }; const url = getSavedSearchUrl(savedSearchMock.id); @@ -87,28 +94,27 @@ describe('saved search embeddable', () => { beforeEach(() => { mountpoint = document.createElement('div'); filterManagerMock = createFilterManagerMock(); + + showFieldStatisticsMockValue = false; + viewModeMockValue = VIEW_MODE.DOCUMENT_LEVEL; + servicesMock = discoverServiceMock as unknown as jest.Mocked; + + (servicesMock.uiSettings as unknown as jest.Mocked).get.mockImplementation( + (key: string) => { + if (key === SHOW_FIELD_STATISTICS) return showFieldStatisticsMockValue; + } + ); }); afterEach(() => { mountpoint.remove(); - }); - - it('should render saved search embeddable two times initially', async () => { - const { embeddable } = createEmbeddable(); - embeddable.updateOutput = jest.fn(); - - embeddable.render(mountpoint); - expect(render).toHaveBeenCalledTimes(1); - - // wait for data fetching - await waitOneTick(); - expect(render).toHaveBeenCalledTimes(2); + jest.resetAllMocks(); }); it('should update input correctly', async () => { const { embeddable } = createEmbeddable(); - embeddable.updateOutput = jest.fn(); + jest.spyOn(embeddable, 'updateOutput'); embeddable.render(mountpoint); await waitOneTick(); @@ -146,10 +152,101 @@ describe('saved search embeddable', () => { expect(executeTriggerActions).toHaveBeenCalled(); }); + it('should render saved search embeddable when successfully loading data', async () => { + // mock return data + const search = jest.fn().mockReturnValue( + of({ + rawResponse: { hits: { hits: [{ id: 1 }], total: 1 } }, + isPartial: false, + isRunning: false, + }) + ); + const { embeddable } = createEmbeddable(search); + jest.spyOn(embeddable, 'updateOutput'); + + // check that loading state + const loadingOutput = embeddable.getOutput(); + expect(loadingOutput.loading).toBe(true); + expect(loadingOutput.rendered).toBe(false); + expect(loadingOutput.error).toBe(undefined); + + embeddable.render(mountpoint); + expect(render).toHaveBeenCalledTimes(1); + + // wait for data fetching + await waitOneTick(); + expect(render).toHaveBeenCalledTimes(2); + + // check that loading state + const loadedOutput = embeddable.getOutput(); + expect(loadedOutput.loading).toBe(false); + expect(loadedOutput.rendered).toBe(true); + expect(loadedOutput.error).toBe(undefined); + }); + + it('should render saved search embeddable when empty data is returned', async () => { + // mock return data + const search = jest.fn().mockReturnValue( + of({ + rawResponse: { hits: { hits: [], total: 0 } }, + isPartial: false, + isRunning: false, + }) + ); + const { embeddable } = createEmbeddable(search); + jest.spyOn(embeddable, 'updateOutput'); + + // check that loading state + const loadingOutput = embeddable.getOutput(); + expect(loadingOutput.loading).toBe(true); + expect(loadingOutput.rendered).toBe(false); + expect(loadingOutput.error).toBe(undefined); + + embeddable.render(mountpoint); + expect(render).toHaveBeenCalledTimes(1); + + // wait for data fetching + await waitOneTick(); + expect(render).toHaveBeenCalledTimes(2); + + // check that loading state + const loadedOutput = embeddable.getOutput(); + expect(loadedOutput.loading).toBe(false); + expect(loadedOutput.rendered).toBe(true); + expect(loadedOutput.error).toBe(undefined); + }); + + it('should render in AGGREGATED_LEVEL view mode', async () => { + showFieldStatisticsMockValue = true; + viewModeMockValue = VIEW_MODE.AGGREGATED_LEVEL; + + const { embeddable } = createEmbeddable(); + jest.spyOn(embeddable, 'updateOutput'); + + // check that loading state + const loadingOutput = embeddable.getOutput(); + expect(loadingOutput.loading).toBe(true); + expect(loadingOutput.rendered).toBe(false); + expect(loadingOutput.error).toBe(undefined); + + embeddable.render(mountpoint); + expect(render).toHaveBeenCalledTimes(1); + + // wait for data fetching + await waitOneTick(); + expect(render).toHaveBeenCalledTimes(2); + + // check that loading state + const loadedOutput = embeddable.getOutput(); + expect(loadedOutput.loading).toBe(false); + expect(loadedOutput.rendered).toBe(true); + expect(loadedOutput.error).toBe(undefined); + }); + it('should emit error output in case of fetch error', async () => { const search = jest.fn().mockReturnValue(throwError(new Error('Fetch error'))); const { embeddable } = createEmbeddable(search); - embeddable.updateOutput = jest.fn(); + jest.spyOn(embeddable, 'updateOutput'); embeddable.render(mountpoint); // wait for data fetching @@ -158,5 +255,10 @@ describe('saved search embeddable', () => { expect((embeddable.updateOutput as jest.Mock).mock.calls[1][0].error.message).toBe( 'Fetch error' ); + // check that loading state + const loadedOutput = embeddable.getOutput(); + expect(loadedOutput.loading).toBe(false); + expect(loadedOutput.rendered).toBe(true); + expect(loadedOutput.error).not.toBe(undefined); }); }); diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index ffdead82d1daef..440fe6a8084a96 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -479,6 +479,7 @@ export class SavedSearchEmbeddable if (!this.searchProps) { throw new Error('Search props not defined'); } + super.render(domNode as HTMLElement); this.node = domNode; @@ -515,6 +516,10 @@ export class SavedSearchEmbeddable , domNode ); + this.updateOutput({ + ...this.getOutput(), + rendered: true, + }); return; } const useLegacyTable = this.services.uiSettings.get(DOC_TABLE_LEGACY); @@ -534,12 +539,23 @@ export class SavedSearchEmbeddable , domNode ); - } - this.updateOutput({ - ...this.getOutput(), - rendered: true, - }); + const hasError = this.getOutput().error !== undefined; + + if (this.searchProps!.isLoading === false && props.searchProps.rows !== undefined) { + this.renderComplete.dispatchComplete(); + this.updateOutput({ + ...this.getOutput(), + rendered: true, + }); + } else if (hasError) { + this.renderComplete.dispatchError(); + this.updateOutput({ + ...this.getOutput(), + rendered: true, + }); + } + } } private async load(searchProps: SearchProps, forceFetch = false) { diff --git a/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx b/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx index 83a030b385994c..9420e05e9d3fed 100644 --- a/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx @@ -21,50 +21,50 @@ import { registerTestBed, TestBed } from '@kbn/test-jest-helpers'; const applicationMock = applicationServiceMock.createStartContract(); -const mockActiveSearchGuideState: GuideState = { - guideId: 'search', +const mockActiveTestGuideState: GuideState = { + guideId: 'testGuide', isActive: true, status: 'in_progress', steps: [ { - id: 'add_data', + id: 'step1', status: 'active', }, { - id: 'browse_docs', + id: 'step2', status: 'inactive', }, { - id: 'search_experience', + id: 'step3', status: 'inactive', }, ], }; -const mockInProgressSearchGuideState: GuideState = { - ...mockActiveSearchGuideState, +const mockInProgressTestGuideState: GuideState = { + ...mockActiveTestGuideState, steps: [ { - id: mockActiveSearchGuideState.steps[0].id, + ...mockActiveTestGuideState.steps[0], status: 'in_progress', }, - mockActiveSearchGuideState.steps[1], - mockActiveSearchGuideState.steps[2], + mockActiveTestGuideState.steps[1], + mockActiveTestGuideState.steps[2], ], }; -const mockReadyToCompleteSearchGuideState: GuideState = { - ...mockActiveSearchGuideState, +const mockReadyToCompleteTestGuideState: GuideState = { + ...mockActiveTestGuideState, steps: [ { - id: mockActiveSearchGuideState.steps[0].id, + ...mockActiveTestGuideState.steps[0], status: 'complete', }, { - id: mockActiveSearchGuideState.steps[1].id, + ...mockActiveTestGuideState.steps[1], status: 'ready_to_complete', }, - mockActiveSearchGuideState.steps[2], + mockActiveTestGuideState.steps[2], ], }; @@ -120,8 +120,8 @@ describe('Guided setup', () => { test('should be enabled if there is an active guide', async () => { const { exists, component, find } = testBed; - // Enable the "search" guide - await updateComponentWithState(component, mockActiveSearchGuideState, true); + // Enable the "test" guide + await updateComponentWithState(component, mockActiveTestGuideState, true); expect(exists('disabledGuideButton')).toBe(false); expect(exists('guideButton')).toBe(true); @@ -131,7 +131,7 @@ describe('Guided setup', () => { test('should show the step number in the button label if a step is active', async () => { const { component, find } = testBed; - await updateComponentWithState(component, mockInProgressSearchGuideState, true); + await updateComponentWithState(component, mockInProgressTestGuideState, true); expect(find('guideButton').text()).toEqual('Setup guide: step 1'); }); @@ -139,7 +139,7 @@ describe('Guided setup', () => { test('shows the step number in the button label if a step is ready to complete', async () => { const { component, find } = testBed; - await updateComponentWithState(component, mockReadyToCompleteSearchGuideState, true); + await updateComponentWithState(component, mockReadyToCompleteTestGuideState, true); expect(find('guideButton').text()).toEqual('Setup guide: step 2'); }); @@ -147,7 +147,7 @@ describe('Guided setup', () => { test('shows the manual completion popover if a step is ready to complete', async () => { const { component, exists } = testBed; - await updateComponentWithState(component, mockReadyToCompleteSearchGuideState, false); + await updateComponentWithState(component, mockReadyToCompleteTestGuideState, false); expect(exists('manualCompletionPopover')).toBe(true); }); @@ -155,7 +155,7 @@ describe('Guided setup', () => { test('shows no manual completion popover if a step is in progress', async () => { const { component, exists } = testBed; - await updateComponentWithState(component, mockInProgressSearchGuideState, false); + await updateComponentWithState(component, mockInProgressTestGuideState, false); expect(exists('manualCompletionPopoverPanel')).toBe(false); }); @@ -165,29 +165,29 @@ describe('Guided setup', () => { test('should be enabled if a guide is activated', async () => { const { exists, component, find } = testBed; - await updateComponentWithState(component, mockActiveSearchGuideState, true); + await updateComponentWithState(component, mockActiveTestGuideState, true); expect(exists('guidePanel')).toBe(true); expect(exists('guideProgress')).toBe(false); - expect(find('guidePanelStep').length).toEqual(guidesConfig.search.steps.length); + expect(find('guidePanelStep').length).toEqual(guidesConfig.testGuide.steps.length); }); test('should show the progress bar if the first step has been completed', async () => { const { component, exists } = testBed; - const mockCompleteSearchGuideState: GuideState = { - ...mockActiveSearchGuideState, + const mockCompleteTestGuideState: GuideState = { + ...mockActiveTestGuideState, steps: [ { - id: mockActiveSearchGuideState.steps[0].id, + ...mockActiveTestGuideState.steps[0], status: 'complete', }, - mockActiveSearchGuideState.steps[1], - mockActiveSearchGuideState.steps[2], + mockActiveTestGuideState.steps[1], + mockActiveTestGuideState.steps[2], ], }; - await updateComponentWithState(component, mockCompleteSearchGuideState, true); + await updateComponentWithState(component, mockCompleteTestGuideState, true); expect(exists('guidePanel')).toBe(true); expect(exists('guideProgress')).toBe(true); @@ -197,20 +197,20 @@ describe('Guided setup', () => { const { component, exists, find } = testBed; const readyToCompleteGuideState: GuideState = { - guideId: 'search', + guideId: 'testGuide', status: 'ready_to_complete', isActive: true, steps: [ { - id: 'add_data', + id: 'step1', status: 'complete', }, { - id: 'browse_docs', + id: 'step2', status: 'complete', }, { - id: 'search_experience', + id: 'step3', status: 'complete', }, ], @@ -220,7 +220,7 @@ describe('Guided setup', () => { expect(find('guideTitle').text()).toContain('Well done'); expect(find('guideDescription').text()).toContain( - `You've completed the Elastic Enterprise Search guide` + `You've completed the Elastic Testing example guide` ); expect(exists('useElasticButton')).toBe(true); }); @@ -239,7 +239,7 @@ describe('Guided setup', () => { test('can start a step if step has not been started', async () => { const { component, find, exists } = testBed; - await updateComponentWithState(component, mockActiveSearchGuideState, true); + await updateComponentWithState(component, mockActiveTestGuideState, true); expect(find('activeStepButton').text()).toEqual('Start'); @@ -251,7 +251,7 @@ describe('Guided setup', () => { test('can continue a step if step is in progress', async () => { const { component, find, exists } = testBed; - await updateComponentWithState(component, mockInProgressSearchGuideState, true); + await updateComponentWithState(component, mockInProgressTestGuideState, true); expect(find('activeStepButton').text()).toEqual('Continue'); @@ -263,7 +263,7 @@ describe('Guided setup', () => { test('can mark a step "done" if step is ready to complete', async () => { const { component, find, exists } = testBed; - await updateComponentWithState(component, mockReadyToCompleteSearchGuideState, true); + await updateComponentWithState(component, mockReadyToCompleteTestGuideState, true); expect(find('activeStepButton').text()).toEqual('Mark done'); @@ -279,20 +279,20 @@ describe('Guided setup', () => { const { component, find } = testBed; const mockSingleSentenceStepDescriptionGuideState: GuideState = { - guideId: 'observability', + guideId: 'testGuide', isActive: true, status: 'in_progress', steps: [ { - id: 'add_data', + id: 'step1', status: 'complete', }, { - id: 'view_dashboard', + id: 'step2', status: 'complete', }, { - id: 'tour_observability', + id: 'step3', status: 'in_progress', }, ], @@ -307,23 +307,21 @@ describe('Guided setup', () => { expect( find('guidePanelStepDescription') .last() - .containsMatchingElement( -

{guidesConfig.observability.steps[2].descriptionList[0]}

- ) + .containsMatchingElement(

{guidesConfig.testGuide.steps[2].description}

) ).toBe(true); }); test('should render the step description as an unordered list if it is more than one sentence', async () => { const { component, find } = testBed; - await updateComponentWithState(component, mockActiveSearchGuideState, true); + await updateComponentWithState(component, mockActiveTestGuideState, true); expect( find('guidePanelStepDescription') .first() .containsMatchingElement(
    - {guidesConfig.search.steps[0].descriptionList.map((description, i) => ( + {guidesConfig.testGuide.steps[0].descriptionList?.map((description, i) => (
  • {description}
  • ))}
@@ -337,8 +335,8 @@ describe('Guided setup', () => { const { component, find, exists } = testBed; await act(async () => { - // Enable the "search" guide - await apiService.updateGuideState(mockActiveSearchGuideState, true); + // Enable the "test" guide + await apiService.updateGuideState(mockActiveTestGuideState, true); }); component.update(); diff --git a/src/plugins/guided_onboarding/public/components/guide_panel_step.styles.ts b/src/plugins/guided_onboarding/public/components/guide_panel_step.styles.ts index 6994e4235d9d14..24106851dc6e21 100644 --- a/src/plugins/guided_onboarding/public/components/guide_panel_step.styles.ts +++ b/src/plugins/guided_onboarding/public/components/guide_panel_step.styles.ts @@ -29,10 +29,13 @@ export const getGuidePanelStepStyles = (euiTheme: EuiThemeComputed, stepStatus: color: ${euiTheme.colors.title}; } `, - stepDescription: css` - margin-left: 32px; - `, - stepListItems: css` - padding-left: 28px; + description: css` + p { + margin-left: 32px; + margin-block-end: 0; + } + ul { + padding-left: 28px; + } `, }); diff --git a/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx b/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx index 4c617a3307171c..1def8ce0a266af 100644 --- a/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx @@ -93,11 +93,10 @@ export const GuideStep = ({ > <> - - {stepConfig.descriptionList.length === 1 ? ( -

{stepConfig.descriptionList[0]}

// If there is only one description, render it as a paragraph - ) : ( -
    + + {stepConfig.description &&

    {stepConfig.description}

    } + {stepConfig.descriptionList && ( +
      {stepConfig.descriptionList.map((description, index) => { return
    • {description}
    • ; })} diff --git a/src/plugins/guided_onboarding/public/constants/guides_config/observability.ts b/src/plugins/guided_onboarding/public/constants/guides_config/observability.ts index 5862d0d1327359..31263e1b0f54b3 100644 --- a/src/plugins/guided_onboarding/public/constants/guides_config/observability.ts +++ b/src/plugins/guided_onboarding/public/constants/guides_config/observability.ts @@ -31,11 +31,9 @@ export const observabilityConfig: GuideConfig = { defaultMessage: 'Add and verify your data', }), integration: 'kubernetes', - descriptionList: [ - i18n.translate('guidedOnboarding.observabilityGuide.addDataStep.description', { - defaultMessage: 'Start by adding your data by setting up the Kubernetes integration.', - }), - ], + description: i18n.translate('guidedOnboarding.observabilityGuide.addDataStep.description', { + defaultMessage: 'Start by adding your data by setting up the Kubernetes integration.', + }), location: { appID: 'integrations', path: '/detail/kubernetes/overview', @@ -46,11 +44,12 @@ export const observabilityConfig: GuideConfig = { title: i18n.translate('guidedOnboarding.observabilityGuide.viewDashboardStep.title', { defaultMessage: 'Explore Kubernetes metrics', }), - descriptionList: [ - i18n.translate('guidedOnboarding.observabilityGuide.viewDashboardStep.description', { + description: i18n.translate( + 'guidedOnboarding.observabilityGuide.viewDashboardStep.description', + { defaultMessage: 'Stream, visualize, and analyze your Kubernetes infrastructure metrics.', - }), - ], + } + ), location: { appID: 'dashboards', path: '#/view/kubernetes-e0195ce0-bcaf-11ec-b64f-7dd6e8e82013', @@ -76,12 +75,13 @@ export const observabilityConfig: GuideConfig = { title: i18n.translate('guidedOnboarding.observabilityGuide.tourObservabilityStep.title', { defaultMessage: 'Tour Elastic Observability', }), - descriptionList: [ - i18n.translate('guidedOnboarding.observabilityGuide.tourObservabilityStep.description', { + description: i18n.translate( + 'guidedOnboarding.observabilityGuide.tourObservabilityStep.description', + { defaultMessage: 'Get familiar with the rest of Elastic Observability and explore even more integrations.', - }), - ], + } + ), location: { appID: 'observability', path: '/overview', diff --git a/src/plugins/guided_onboarding/public/constants/guides_config/search.ts b/src/plugins/guided_onboarding/public/constants/guides_config/search.ts index f1a8de389ea19b..f074d0924fdea1 100644 --- a/src/plugins/guided_onboarding/public/constants/guides_config/search.ts +++ b/src/plugins/guided_onboarding/public/constants/guides_config/search.ts @@ -6,60 +6,59 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import type { GuideConfig } from '../../types'; export const searchConfig: GuideConfig = { - title: 'Search my data', - description: `We'll help you build world-class search experiences with your data, using Elastic's out-of-the-box web crawler, connectors, and our robust APIs. Gain deep insights from the built-in search analytics and use that data to inform changes to relevance.`, + title: i18n.translate('guidedOnboarding.searchGuide.title', { + defaultMessage: 'Search my data', + }), + description: i18n.translate('guidedOnboarding.searchGuide.description', { + defaultMessage: `Build custom search experiences with your data using Elastic’s out-of-the-box web crawler, connectors, and robust APIs. Gain deep insights from the built-in search analytics to curate results and optimize relevance.`, + }), guideName: 'Enterprise Search', - docs: { - text: 'Enterprise Search 101 Documentation', - url: 'example.com', - }, steps: [ { id: 'add_data', - title: 'Add data', + title: i18n.translate('guidedOnboarding.searchGuide.addDataStep.title', { + defaultMessage: 'Add data', + }), descriptionList: [ - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', - 'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.', + i18n.translate('guidedOnboarding.searchGuide.addDataStep.description1', { + defaultMessage: 'Select an ingestion method.', + }), + i18n.translate('guidedOnboarding.searchGuide.addDataStep.description2', { + defaultMessage: 'Create a new Elasticsearch index.', + }), + i18n.translate('guidedOnboarding.searchGuide.addDataStep.description3', { + defaultMessage: 'Configure your ingestion settings.', + }), ], location: { appID: 'enterpriseSearch', path: '', }, }, - { - id: 'browse_docs', - title: 'Browse your documents', - descriptionList: [ - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', - 'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.', - ], - location: { - appID: 'guidedOnboardingExample', - path: 'stepTwo', - }, - manualCompletion: { - title: 'Manual completion step title', - description: - 'Mark the step complete by opening the panel and clicking the button "Mark done"', - readyToCompleteOnNavigation: true, - }, - }, { id: 'search_experience', - title: 'Build a search experience', + title: i18n.translate('guidedOnboarding.searchGuide.searchExperienceStep.title', { + defaultMessage: 'Build a search experience', + }), descriptionList: [ - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', - 'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.', + i18n.translate('guidedOnboarding.searchGuide.searchExperienceStep.descriptionList.item1', { + defaultMessage: 'Learn more about Elastic’s Search UI framework.', + }), + i18n.translate('guidedOnboarding.searchGuide.searchExperienceStep.descriptionList.item2', { + defaultMessage: 'Try the Search UI tutorial for Elasticsearch.', + }), + i18n.translate('guidedOnboarding.searchGuide.searchExperienceStep.descriptionList.item3', { + defaultMessage: + 'Build a world-class search experience for your customers, employees, or users.', + }), ], location: { - appID: 'guidedOnboardingExample', - path: 'stepThree', + appID: 'enterpriseSearch', + path: '/search_experiences', }, }, ], diff --git a/src/plugins/guided_onboarding/public/constants/guides_config/test_guide.ts b/src/plugins/guided_onboarding/public/constants/guides_config/test_guide.ts index b357ad497c6b4e..fead791d02ed11 100644 --- a/src/plugins/guided_onboarding/public/constants/guides_config/test_guide.ts +++ b/src/plugins/guided_onboarding/public/constants/guides_config/test_guide.ts @@ -51,9 +51,8 @@ export const testGuideConfig: GuideConfig = { { id: 'step3', title: 'Step 3 (manual completion after click)', - descriptionList: [ + description: 'This step is completed by clicking a button on the page and then clicking the popover on the guide button in the header and marking the step done', - ], manualCompletion: { title: 'Manual completion step title', description: diff --git a/src/plugins/guided_onboarding/public/types.ts b/src/plugins/guided_onboarding/public/types.ts index 3ff0507c494dc5..a4d45e8fad4d80 100755 --- a/src/plugins/guided_onboarding/public/types.ts +++ b/src/plugins/guided_onboarding/public/types.ts @@ -54,7 +54,10 @@ export interface GuidedOnboardingApi { export interface StepConfig { id: GuideStepIds; title: string; - descriptionList: string[]; + // description is displayed as a single paragraph, can be combined with description list + description?: string; + // description list is displayed as an unordered list, can be combined with description + descriptionList?: string[]; location?: { appID: string; path: string; diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts index 4b393a7a1c249f..c105b3dd387f00 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -139,6 +139,7 @@ export const applicationUsageSchema = { elasticsearch: commonSchema, appSearch: commonSchema, workplaceSearch: commonSchema, + searchExperiences: commonSchema, graph: commonSchema, logs: commonSchema, metrics: commonSchema, diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 7ad52ecb20d25d..ddf1ba3df941a5 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -2622,6 +2622,137 @@ } } }, + "searchExperiences": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "Always `main`" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 90 days" + } + }, + "views": { + "type": "array", + "items": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "The application view being tracked" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application sub view since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application sub view is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 90 days" + } + } + } + } + } + } + }, "graph": { "properties": { "appId": { diff --git a/src/plugins/unified_field_list/README.md b/src/plugins/unified_field_list/README.md index 9030a32a3bdca0..23edffd5101dce 100755 --- a/src/plugins/unified_field_list/README.md +++ b/src/plugins/unified_field_list/README.md @@ -6,6 +6,8 @@ This Kibana plugin contains components and services for field list UI (as in fie ## Components +* `` - renders a fields list which is split in sections (Selected, Special, Available, Empty, Meta fields). It accepts already grouped fields, please use `useGroupedFields` hook for it. + * `` - loads and renders stats (Top values, Distribution) for a data view field. * `` - renders a button to open this field in Lens. @@ -13,7 +15,7 @@ This Kibana plugin contains components and services for field list UI (as in fie * `` - a popover container component for a field. * `` - this header component included a field name and common actions. -* + * `` - renders Visualize action in the popover footer. These components can be combined and customized as the following: @@ -59,6 +61,47 @@ These components can be combined and customized as the following: * `loadFieldExisting(...)` - returns the loaded existing fields (can also work with Ad-hoc data views) +## Hooks + +* `useExistingFieldsFetcher(...)` - this hook is responsible for fetching fields existence info for specified data views. It can be used higher in components tree than `useExistingFieldsReader` hook. + +* `useExistingFieldsReader(...)` - you can call this hook to read fields existence info which was fetched by `useExistingFieldsFetcher` hook. Using multiple "reader" hooks from different children components is supported. So you would need only one "fetcher" and as many "reader" hooks as necessary. + +* `useGroupedFields(...)` - this hook groups fields list into sections of Selected, Special, Available, Empty, Meta fields. + +An example of using hooks together with ``: + +``` +const { refetchFieldsExistenceInfo, isProcessing } = useExistingFieldsFetcher({ + dataViews, + query, + filters, + fromDate, + toDate, + ... +}); +const fieldsExistenceReader = useExistingFieldsReader() +const { fieldGroups } = useGroupedFields({ + dataViewId: currentDataViewId, + allFields, + fieldsExistenceReader, + ... +}); + +// and now we can render a field list + + +// or check whether a field contains data +const { hasFieldData } = useExistingFieldsReader(); +const hasData = hasFieldData(currentDataViewId, fieldName) // return a boolean +``` + ## Server APIs * `/api/unified_field_list/field_stats` - returns the loaded field stats (except for Ad-hoc data views) diff --git a/x-pack/plugins/lens/public/datasources/form_based/field_list.scss b/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.scss similarity index 78% rename from x-pack/plugins/lens/public/datasources/form_based/field_list.scss rename to src/plugins/unified_field_list/public/components/field_list/field_list_grouped.scss index f28581b835b078..cd4b9ba2f6e225 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/field_list.scss +++ b/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.scss @@ -2,7 +2,7 @@ * 1. Don't cut off the shadow of the field items */ -.lnsIndexPatternFieldList { +.unifiedFieldList__fieldListGrouped { @include euiOverflowShadow; @include euiScrollBar; margin-left: -$euiSize; /* 1 */ @@ -11,7 +11,7 @@ overflow: auto; } -.lnsIndexPatternFieldList__accordionContainer { +.unifiedFieldList__fieldListGrouped__container { padding-top: $euiSizeS; position: absolute; top: 0; diff --git a/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.test.tsx b/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.test.tsx new file mode 100644 index 00000000000000..59cd7e56ff3909 --- /dev/null +++ b/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.test.tsx @@ -0,0 +1,413 @@ +/* + * 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 React from 'react'; +import { act } from 'react-dom/test-utils'; +import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub'; +import { EuiText, EuiLoadingSpinner } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { DataViewField } from '@kbn/data-views-plugin/common'; +import { ReactWrapper } from 'enzyme'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import FieldListGrouped, { type FieldListGroupedProps } from './field_list_grouped'; +import { ExistenceFetchStatus } from '../../types'; +import { FieldsAccordion } from './fields_accordion'; +import { NoFieldsCallout } from './no_fields_callout'; +import { useGroupedFields, type GroupedFieldsParams } from '../../hooks/use_grouped_fields'; + +describe('UnifiedFieldList + useGroupedFields()', () => { + let defaultProps: FieldListGroupedProps; + let mockedServices: GroupedFieldsParams['services']; + const allFields = dataView.fields; + // 5 times more fields. Added fields will be treated as empty as they are not a part of the data view. + const manyFields = [...new Array(5)].flatMap((_, index) => + allFields.map((field) => { + return new DataViewField({ ...field.toSpec(), name: `${field.name}${index || ''}` }); + }) + ); + + beforeEach(() => { + const dataViews = dataViewPluginMocks.createStartContract(); + mockedServices = { + dataViews, + }; + + dataViews.get.mockImplementation(async (id: string) => { + return dataView; + }); + + defaultProps = { + fieldGroups: {}, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + fieldsExistInIndex: true, + screenReaderDescriptionForSearchInputId: 'testId', + renderFieldItem: jest.fn(({ field, itemIndex, groupIndex }) => ( + + {field.name} + + )), + }; + }); + + interface WrapperProps { + listProps: Omit, 'fieldGroups'>; + hookParams: Omit, 'services'>; + } + + async function mountGroupedList({ listProps, hookParams }: WrapperProps): Promise { + const Wrapper: React.FC = (props) => { + const { fieldGroups } = useGroupedFields({ + ...props.hookParams, + services: mockedServices, + }); + + return ; + }; + + let wrapper: ReactWrapper; + await act(async () => { + wrapper = await mountWithIntl(); + // wait for lazy modules if any + await new Promise((resolve) => setTimeout(resolve, 0)); + await wrapper.update(); + }); + + return wrapper!; + } + + it('renders correctly in empty state', () => { + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe(''); + }); + + it('renders correctly in loading state', async () => { + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.unknown, + }, + hookParams: { + dataViewId: dataView.id!, + allFields, + }, + }); + + expect(wrapper.find(FieldListGrouped).prop('fieldsExistenceStatus')).toBe( + ExistenceFetchStatus.unknown + ); + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe(''); + expect(wrapper.find(FieldsAccordion)).toHaveLength(3); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(3); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('hasLoaded')) + ).toStrictEqual([false, false, false]); + expect(wrapper.find(NoFieldsCallout)).toHaveLength(0); + + await act(async () => { + await wrapper.setProps({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + }); + await wrapper.update(); + }); + + expect(wrapper.find(FieldListGrouped).prop('fieldsExistenceStatus')).toBe( + ExistenceFetchStatus.succeeded + ); + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 0 empty fields. 3 meta fields.'); + expect(wrapper.find(FieldsAccordion)).toHaveLength(3); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('hasLoaded')) + ).toStrictEqual([true, true, true]); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length) + ).toStrictEqual([25, 0, 0]); + expect(wrapper.find(NoFieldsCallout)).toHaveLength(1); + }); + + it('renders correctly in failed state', async () => { + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.failed, + }, + hookParams: { + dataViewId: dataView.id!, + allFields, + }, + }); + + expect(wrapper.find(FieldListGrouped).prop('fieldsExistenceStatus')).toBe( + ExistenceFetchStatus.failed + ); + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 0 empty fields. 3 meta fields.'); + expect(wrapper.find(FieldsAccordion)).toHaveLength(3); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('hasLoaded')) + ).toStrictEqual([true, true, true]); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('showExistenceFetchError')) + ).toStrictEqual([true, true, true]); + }); + + it('renders correctly in no fields state', async () => { + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistInIndex: false, + fieldsExistenceStatus: ExistenceFetchStatus.failed, + }, + hookParams: { + dataViewId: dataView.id!, + allFields: [], + }, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('0 available fields. 0 empty fields. 0 meta fields.'); + expect(wrapper.find(FieldsAccordion)).toHaveLength(3); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect( + wrapper.find(NoFieldsCallout).map((callout) => callout.prop('fieldsExistInIndex')) + ).toStrictEqual([false, false, false]); + }); + + it('renders correctly for text-based queries (no data view)', async () => { + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + hookParams: { + dataViewId: null, + allFields, + onSelectedFieldFilter: (field) => field.name === 'bytes', + }, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('1 selected field. 28 available fields.'); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length) + ).toStrictEqual([1, 28]); + }); + + it('renders correctly when Meta gets open', async () => { + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + hookParams: { + dataViewId: dataView.id!, + allFields, + }, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 0 empty fields. 3 meta fields.'); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length) + ).toStrictEqual([25, 0, 0]); + + await act(async () => { + await wrapper + .find('[data-test-subj="fieldListGroupedMetaFields"]') + .find('button') + .first() + .simulate('click'); + await wrapper.update(); + }); + + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length) + ).toStrictEqual([25, 0, 3]); + }); + + it('renders correctly when paginated', async () => { + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + hookParams: { + dataViewId: dataView.id!, + allFields: manyFields, + }, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 112 empty fields. 3 meta fields.'); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length) + ).toStrictEqual([25, 0, 0]); + + await act(async () => { + await wrapper + .find('[data-test-subj="fieldListGroupedEmptyFields"]') + .find('button') + .first() + .simulate('click'); + await wrapper.update(); + }); + + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length) + ).toStrictEqual([25, 50, 0]); + + await act(async () => { + await wrapper + .find('[data-test-subj="fieldListGroupedMetaFields"]') + .find('button') + .first() + .simulate('click'); + await wrapper.update(); + }); + + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length) + ).toStrictEqual([25, 88, 0]); + }); + + it('renders correctly when filtered', async () => { + const hookParams = { + dataViewId: dataView.id!, + allFields: manyFields, + }; + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + hookParams, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 112 empty fields. 3 meta fields.'); + + await act(async () => { + await wrapper.setProps({ + hookParams: { + ...hookParams, + onFilterField: (field: DataViewField) => field.name.startsWith('@'), + }, + }); + await wrapper.update(); + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('2 available fields. 8 empty fields. 0 meta fields.'); + + await act(async () => { + await wrapper.setProps({ + hookParams: { + ...hookParams, + onFilterField: (field: DataViewField) => field.name.startsWith('_'), + }, + }); + await wrapper.update(); + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('0 available fields. 12 empty fields. 3 meta fields.'); + }); + + it('renders correctly when non-supported fields are filtered out', async () => { + const hookParams = { + dataViewId: dataView.id!, + allFields: manyFields, + }; + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + hookParams, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 112 empty fields. 3 meta fields.'); + + await act(async () => { + await wrapper.setProps({ + hookParams: { + ...hookParams, + onSupportedFieldFilter: (field: DataViewField) => field.aggregatable, + }, + }); + await wrapper.update(); + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('23 available fields. 104 empty fields. 3 meta fields.'); + }); + + it('renders correctly when selected fields are present', async () => { + const hookParams = { + dataViewId: dataView.id!, + allFields: manyFields, + }; + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + hookParams, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 112 empty fields. 3 meta fields.'); + + await act(async () => { + await wrapper.setProps({ + hookParams: { + ...hookParams, + onSelectedFieldFilter: (field: DataViewField) => + ['@timestamp', 'bytes'].includes(field.name), + }, + }); + await wrapper.update(); + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('2 selected fields. 25 available fields. 112 empty fields. 3 meta fields.'); + }); +}); diff --git a/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.tsx b/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.tsx new file mode 100644 index 00000000000000..5510ddb2b1d439 --- /dev/null +++ b/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.tsx @@ -0,0 +1,245 @@ +/* + * 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 { partition, throttle } from 'lodash'; +import React, { useState, Fragment, useCallback, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiScreenReaderOnly, EuiSpacer } from '@elastic/eui'; +import { type DataViewField } from '@kbn/data-views-plugin/common'; +import { NoFieldsCallout } from './no_fields_callout'; +import { FieldsAccordion, type FieldsAccordionProps } from './fields_accordion'; +import type { FieldListGroups, FieldListItem } from '../../types'; +import { ExistenceFetchStatus } from '../../types'; +import './field_list_grouped.scss'; + +const PAGINATION_SIZE = 50; + +function getDisplayedFieldsLength( + fieldGroups: FieldListGroups, + accordionState: Partial> +) { + return Object.entries(fieldGroups) + .filter(([key]) => accordionState[key]) + .reduce((allFieldCount, [, { fields }]) => allFieldCount + fields.length, 0); +} + +export interface FieldListGroupedProps { + fieldGroups: FieldListGroups; + fieldsExistenceStatus: ExistenceFetchStatus; + fieldsExistInIndex: boolean; + renderFieldItem: FieldsAccordionProps['renderFieldItem']; + screenReaderDescriptionForSearchInputId?: string; + 'data-test-subj'?: string; +} + +function InnerFieldListGrouped({ + fieldGroups, + fieldsExistenceStatus, + fieldsExistInIndex, + renderFieldItem, + screenReaderDescriptionForSearchInputId, + 'data-test-subj': dataTestSubject = 'fieldListGrouped', +}: FieldListGroupedProps) { + const hasSyncedExistingFields = + fieldsExistenceStatus && fieldsExistenceStatus !== ExistenceFetchStatus.unknown; + + const [fieldGroupsToShow, fieldGroupsToCollapse] = partition( + Object.entries(fieldGroups), + ([, { showInAccordion }]) => showInAccordion + ); + const [pageSize, setPageSize] = useState(PAGINATION_SIZE); + const [scrollContainer, setScrollContainer] = useState(undefined); + const [accordionState, setAccordionState] = useState>>(() => + Object.fromEntries( + fieldGroupsToShow.map(([key, { isInitiallyOpen }]) => [key, isInitiallyOpen]) + ) + ); + + const lazyScroll = useCallback(() => { + if (scrollContainer) { + const nearBottom = + scrollContainer.scrollTop + scrollContainer.clientHeight > + scrollContainer.scrollHeight * 0.9; + if (nearBottom) { + setPageSize( + Math.max( + PAGINATION_SIZE, + Math.min( + pageSize + PAGINATION_SIZE * 0.5, + getDisplayedFieldsLength(fieldGroups, accordionState) + ) + ) + ); + } + } + }, [scrollContainer, pageSize, setPageSize, fieldGroups, accordionState]); + + const paginatedFields = useMemo(() => { + let remainingItems = pageSize; + return Object.fromEntries( + fieldGroupsToShow.map(([key, fieldGroup]) => { + if (!accordionState[key] || remainingItems <= 0) { + return [key, []]; + } + const slicedFieldList = fieldGroup.fields.slice(0, remainingItems); + remainingItems = remainingItems - slicedFieldList.length; + return [key, slicedFieldList]; + }) + ); + }, [pageSize, fieldGroupsToShow, accordionState]); + + return ( +
      { + if (el && !el.dataset.dynamicScroll) { + el.dataset.dynamicScroll = 'true'; + setScrollContainer(el); + } + }} + onScroll={throttle(lazyScroll, 100)} + > +
      + {Boolean(screenReaderDescriptionForSearchInputId) && ( + +
      + {hasSyncedExistingFields + ? [ + fieldGroups.SelectedFields && + (!fieldGroups.SelectedFields?.hideIfEmpty || + fieldGroups.SelectedFields?.fields?.length > 0) && + i18n.translate( + 'unifiedFieldList.fieldListGrouped.fieldSearchForSelectedFieldsLiveRegion', + { + defaultMessage: + '{selectedFields} selected {selectedFields, plural, one {field} other {fields}}.', + values: { + selectedFields: fieldGroups.SelectedFields?.fields?.length || 0, + }, + } + ), + fieldGroups.AvailableFields?.fields && + i18n.translate( + 'unifiedFieldList.fieldListGrouped.fieldSearchForAvailableFieldsLiveRegion', + { + defaultMessage: + '{availableFields} available {availableFields, plural, one {field} other {fields}}.', + values: { + availableFields: fieldGroups.AvailableFields.fields.length, + }, + } + ), + fieldGroups.EmptyFields && + (!fieldGroups.EmptyFields?.hideIfEmpty || + fieldGroups.EmptyFields?.fields?.length > 0) && + i18n.translate( + 'unifiedFieldList.fieldListGrouped.fieldSearchForEmptyFieldsLiveRegion', + { + defaultMessage: + '{emptyFields} empty {emptyFields, plural, one {field} other {fields}}.', + values: { + emptyFields: fieldGroups.EmptyFields?.fields?.length || 0, + }, + } + ), + fieldGroups.MetaFields && + (!fieldGroups.MetaFields?.hideIfEmpty || + fieldGroups.MetaFields?.fields?.length > 0) && + i18n.translate( + 'unifiedFieldList.fieldListGrouped.fieldSearchForMetaFieldsLiveRegion', + { + defaultMessage: + '{metaFields} meta {metaFields, plural, one {field} other {fields}}.', + values: { + metaFields: fieldGroups.MetaFields?.fields?.length || 0, + }, + } + ), + ] + .filter(Boolean) + .join(' ') + : ''} +
      +
      + )} +
        + {fieldGroupsToCollapse.flatMap(([, { fields }]) => + fields.map((field, index) => ( + + {renderFieldItem({ field, itemIndex: index, groupIndex: 0, hideDetails: true })} + + )) + )} +
      + + {fieldGroupsToShow.map(([key, fieldGroup], index) => { + const hidden = Boolean(fieldGroup.hideIfEmpty) && !fieldGroup.fields.length; + if (hidden) { + return null; + } + return ( + + + id={`${dataTestSubject}${key}`} + initialIsOpen={Boolean(accordionState[key])} + label={fieldGroup.title} + helpTooltip={fieldGroup.helpText} + hideDetails={fieldGroup.hideDetails} + hasLoaded={hasSyncedExistingFields} + fieldsCount={fieldGroup.fields.length} + isFiltered={fieldGroup.fieldCount !== fieldGroup.fields.length} + paginatedFields={paginatedFields[key]} + groupIndex={index + 1} + onToggle={(open) => { + setAccordionState((s) => ({ + ...s, + [key]: open, + })); + const displayedFieldLength = getDisplayedFieldsLength(fieldGroups, { + ...accordionState, + [key]: open, + }); + setPageSize( + Math.max( + PAGINATION_SIZE, + Math.min(Math.ceil(pageSize * 1.5), displayedFieldLength) + ) + ); + }} + showExistenceFetchError={fieldsExistenceStatus === ExistenceFetchStatus.failed} + showExistenceFetchTimeout={fieldsExistenceStatus === ExistenceFetchStatus.failed} // TODO: deprecate timeout logic? + renderCallout={() => ( + + )} + renderFieldItem={renderFieldItem} + /> + + + ); + })} +
      +
      + ); +} + +export type GenericFieldListGrouped = typeof InnerFieldListGrouped; +const FieldListGrouped = React.memo(InnerFieldListGrouped) as GenericFieldListGrouped; + +// Necessary for React.lazy +// eslint-disable-next-line import/no-default-export +export default FieldListGrouped; diff --git a/src/plugins/unified_field_list/public/components/field_list/fields_accordion.scss b/src/plugins/unified_field_list/public/components/field_list/fields_accordion.scss new file mode 100644 index 00000000000000..501b27969e768a --- /dev/null +++ b/src/plugins/unified_field_list/public/components/field_list/fields_accordion.scss @@ -0,0 +1,8 @@ +.unifiedFieldList__fieldsAccordion__titleTooltip { + margin-right: $euiSizeXS; +} + +.unifiedFieldList__fieldsAccordion__fieldItems { + // Quick fix for making sure the shadow and focus rings are visible outside the accordion bounds + padding: $euiSizeXS; +} diff --git a/src/plugins/unified_field_list/public/components/field_list/fields_accordion.test.tsx b/src/plugins/unified_field_list/public/components/field_list/fields_accordion.test.tsx new file mode 100644 index 00000000000000..2804c1bbe5ee18 --- /dev/null +++ b/src/plugins/unified_field_list/public/components/field_list/fields_accordion.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 React from 'react'; +import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub'; +import { EuiLoadingSpinner, EuiNotificationBadge, EuiText } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { FieldsAccordion, FieldsAccordionProps } from './fields_accordion'; +import { FieldListItem } from '../../types'; + +describe('UnifiedFieldList ', () => { + let defaultProps: FieldsAccordionProps; + const paginatedFields = dataView.fields; + + beforeEach(() => { + defaultProps = { + initialIsOpen: true, + onToggle: jest.fn(), + groupIndex: 0, + id: 'id', + label: 'label-test', + hasLoaded: true, + fieldsCount: paginatedFields.length, + isFiltered: false, + paginatedFields, + renderCallout: () =>
      Callout
      , + renderFieldItem: ({ field }) => {field.name}, + }; + }); + + it('renders fields correctly', () => { + const wrapper = mountWithIntl(); + expect(wrapper.find(EuiText)).toHaveLength(paginatedFields.length + 1); // + title + expect(wrapper.find(EuiText).first().text()).toBe(defaultProps.label); + expect(wrapper.find(EuiText).at(1).text()).toBe(paginatedFields[0].name); + expect(wrapper.find(EuiText).last().text()).toBe( + paginatedFields[paginatedFields.length - 1].name + ); + }); + + it('renders callout if no fields', () => { + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('#lens-test-callout').length).toEqual(1); + }); + + it('renders accented notificationBadge state if isFiltered', () => { + const wrapper = mountWithIntl(); + expect(wrapper.find(EuiNotificationBadge).prop('color')).toEqual('accent'); + }); + + it('renders spinner if has not loaded', () => { + const wrapper = mountWithIntl(); + expect(wrapper.find(EuiLoadingSpinner).length).toEqual(1); + }); +}); diff --git a/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.tsx b/src/plugins/unified_field_list/public/components/field_list/fields_accordion.tsx similarity index 50% rename from x-pack/plugins/lens/public/datasources/form_based/fields_accordion.tsx rename to src/plugins/unified_field_list/public/components/field_list/fields_accordion.tsx index d6b4c73b510826..5222cf1b0e6783 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.tsx +++ b/src/plugins/unified_field_list/public/components/field_list/fields_accordion.tsx @@ -1,12 +1,12 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 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 './datapanel.scss'; -import React, { memo, useCallback, useMemo } from 'react'; +import React, { useMemo, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiText, @@ -17,26 +17,11 @@ import { EuiIconTip, } from '@elastic/eui'; import classNames from 'classnames'; -import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; -import { Filter } from '@kbn/es-query'; -import type { Query } from '@kbn/es-query'; -import { ChartsPluginSetup } from '@kbn/charts-plugin/public'; -import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; -import { FieldItem } from './field_item'; -import type { DatasourceDataPanelProps, IndexPattern, IndexPatternField } from '../../types'; +import { type DataViewField } from '@kbn/data-views-plugin/common'; +import type { FieldListItem } from '../../types'; +import './fields_accordion.scss'; -export interface FieldItemSharedProps { - core: DatasourceDataPanelProps['core']; - fieldFormats: FieldFormatsStart; - chartsThemeService: ChartsPluginSetup['theme']; - indexPattern: IndexPattern; - highlight?: string; - query: Query; - dateRange: DatasourceDataPanelProps['dateRange']; - filters: Filter[]; -} - -export interface FieldsAccordionProps { +export interface FieldsAccordionProps { initialIsOpen: boolean; onToggle: (open: boolean) => void; id: string; @@ -44,23 +29,22 @@ export interface FieldsAccordionProps { helpTooltip?: string; hasLoaded: boolean; fieldsCount: number; + hideDetails?: boolean; isFiltered: boolean; - paginatedFields: IndexPatternField[]; - fieldProps: FieldItemSharedProps; - renderCallout: JSX.Element; - exists: (field: IndexPatternField) => boolean; + groupIndex: number; + paginatedFields: T[]; + renderFieldItem: (params: { + field: T; + hideDetails?: boolean; + itemIndex: number; + groupIndex: number; + }) => JSX.Element; + renderCallout: () => JSX.Element; showExistenceFetchError?: boolean; showExistenceFetchTimeout?: boolean; - hideDetails?: boolean; - groupIndex: number; - dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; - hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; - editField?: (name: string) => void; - removeField?: (name: string) => void; - uiActions: UiActionsStart; } -export const FieldsAccordion = memo(function InnerFieldsAccordion({ +function InnerFieldsAccordion({ initialIsOpen, onToggle, id, @@ -68,56 +52,21 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ helpTooltip, hasLoaded, fieldsCount, + hideDetails, isFiltered, + groupIndex, paginatedFields, - fieldProps, + renderFieldItem, renderCallout, - exists, - hideDetails, showExistenceFetchError, showExistenceFetchTimeout, - groupIndex, - dropOntoWorkspace, - hasSuggestionForField, - editField, - removeField, - uiActions, -}: FieldsAccordionProps) { - const renderField = useCallback( - (field: IndexPatternField, index) => ( - - ), - [ - fieldProps, - exists, - hideDetails, - dropOntoWorkspace, - hasSuggestionForField, - groupIndex, - editField, - removeField, - uiActions, - ] - ); - +}: FieldsAccordionProps) { const renderButton = useMemo(() => { const titleClassname = classNames({ // eslint-disable-next-line @typescript-eslint/naming-convention - lnsInnerIndexPatternDataPanel__titleTooltip: !!helpTooltip, + unifiedFieldList__fieldsAccordion__titleTooltip: !!helpTooltip, }); + return ( {label} @@ -142,12 +91,12 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ if (showExistenceFetchError) { return ( @@ -156,12 +105,12 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ if (showExistenceFetchTimeout) { return ( @@ -194,12 +143,19 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ {hasLoaded && (!!fieldsCount ? ( -
        - {paginatedFields && paginatedFields.map(renderField)} +
          + {paginatedFields && + paginatedFields.map((field, index) => ( + + {renderFieldItem({ field, itemIndex: index, groupIndex, hideDetails })} + + ))}
        ) : ( - renderCallout + renderCallout() ))} ); -}); +} + +export const FieldsAccordion = React.memo(InnerFieldsAccordion) as typeof InnerFieldsAccordion; diff --git a/src/plugins/unified_field_list/public/components/field_list/index.tsx b/src/plugins/unified_field_list/public/components/field_list/index.tsx new file mode 100755 index 00000000000000..44302a7e1c42b1 --- /dev/null +++ b/src/plugins/unified_field_list/public/components/field_list/index.tsx @@ -0,0 +1,31 @@ +/* + * 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 React, { Fragment } from 'react'; +import { type DataViewField } from '@kbn/data-views-plugin/common'; +import type { FieldListGroupedProps, GenericFieldListGrouped } from './field_list_grouped'; +import { type FieldListItem } from '../../types'; + +const Fallback = () => ; + +const LazyFieldListGrouped = React.lazy( + () => import('./field_list_grouped') +) as GenericFieldListGrouped; + +function WrappedFieldListGrouped( + props: FieldListGroupedProps +) { + return ( + }> + {...props} /> + + ); +} + +export const FieldListGrouped = WrappedFieldListGrouped; +export type { FieldListGroupedProps }; diff --git a/x-pack/plugins/lens/public/datasources/form_based/no_fields_callout.test.tsx b/src/plugins/unified_field_list/public/components/field_list/no_fields_callout.test.tsx similarity index 86% rename from x-pack/plugins/lens/public/datasources/form_based/no_fields_callout.test.tsx rename to src/plugins/unified_field_list/public/components/field_list/no_fields_callout.test.tsx index 635c06691a7339..03936a89877ba4 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/no_fields_callout.test.tsx +++ b/src/plugins/unified_field_list/public/components/field_list/no_fields_callout.test.tsx @@ -1,17 +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; 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 React from 'react'; import { shallow } from 'enzyme'; import { NoFieldsCallout } from './no_fields_callout'; -describe('NoFieldCallout', () => { +describe('UnifiedFieldList ', () => { it('renders correctly for index with no fields', () => { - const component = shallow(); + const component = shallow(); expect(component).toMatchInlineSnapshot(` { `); }); it('renders correctly when empty with no filters/timerange reasons', () => { - const component = shallow(); + const component = shallow(); expect(component).toMatchInlineSnapshot(` { }); it('renders correctly with passed defaultNoFieldsMessage', () => { const component = shallow( - + ); expect(component).toMatchInlineSnapshot(` { it('renders properly when affected by field filter', () => { const component = shallow( - + ); expect(component).toMatchInlineSnapshot(` { it('renders correctly when affected by global filters and timerange', () => { const component = shallow( { it('renders correctly when affected by global filters and field filters', () => { const component = shallow( { it('renders correctly when affected by field filters, global filter and timerange', () => { const component = shallow( { - if (!existFieldsInIndex) { + if (!fieldsExistInIndex) { return ( @@ -44,7 +48,7 @@ export const NoFieldsCallout = ({ color="warning" title={ isAffectedByFieldFilter - ? i18n.translate('xpack.lens.indexPatterns.noFilteredFieldsLabel', { + ? i18n.translate('unifiedFieldList.fieldList.noFieldsCallout.noFilteredFieldsLabel', { defaultMessage: 'No fields match the selected filters.', }) : defaultNoFieldsMessage @@ -53,30 +57,39 @@ export const NoFieldsCallout = ({ {(isAffectedByTimerange || isAffectedByFieldFilter || isAffectedByGlobalFilter) && ( <> - {i18n.translate('xpack.lens.indexPatterns.noFields.tryText', { + {i18n.translate('unifiedFieldList.fieldList.noFieldsCallout.noFields.tryText', { defaultMessage: 'Try:', })}
          {isAffectedByTimerange && (
        • - {i18n.translate('xpack.lens.indexPatterns.noFields.extendTimeBullet', { - defaultMessage: 'Extending the time range', - })} + {i18n.translate( + 'unifiedFieldList.fieldList.noFieldsCallout.noFields.extendTimeBullet', + { + defaultMessage: 'Extending the time range', + } + )}
        • )} {isAffectedByFieldFilter && (
        • - {i18n.translate('xpack.lens.indexPatterns.noFields.fieldTypeFilterBullet', { - defaultMessage: 'Using different field filters', - })} + {i18n.translate( + 'unifiedFieldList.fieldList.noFieldsCallout.noFields.fieldTypeFilterBullet', + { + defaultMessage: 'Using different field filters', + } + )}
        • )} {isAffectedByGlobalFilter && (
        • - {i18n.translate('xpack.lens.indexPatterns.noFields.globalFiltersBullet', { - defaultMessage: 'Changing the global filters', - })} + {i18n.translate( + 'unifiedFieldList.fieldList.noFieldsCallout.noFields.globalFiltersBullet', + { + defaultMessage: 'Changing the global filters', + } + )}
        • )}
        diff --git a/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx b/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx index 07d35b78b58a26..b3600dc9f3971e 100755 --- a/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx +++ b/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx @@ -8,8 +8,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - DataView, - DataViewField, + type DataView, + type DataViewField, ES_FIELD_TYPES, getEsQueryConfig, KBN_FIELD_TYPES, diff --git a/src/plugins/unified_field_list/public/hooks/use_existing_fields.test.tsx b/src/plugins/unified_field_list/public/hooks/use_existing_fields.test.tsx new file mode 100644 index 00000000000000..7a27a1468213dc --- /dev/null +++ b/src/plugins/unified_field_list/public/hooks/use_existing_fields.test.tsx @@ -0,0 +1,536 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub'; +import { createStubDataView, stubFieldSpecMap } from '@kbn/data-plugin/public/stubs'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import { + useExistingFieldsFetcher, + useExistingFieldsReader, + resetExistingFieldsCache, + type ExistingFieldsFetcherParams, + ExistingFieldsReader, +} from './use_existing_fields'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { coreMock } from '@kbn/core/public/mocks'; +import * as ExistingFieldsServiceApi from '../services/field_existing/load_field_existing'; +import { ExistenceFetchStatus } from '../types'; + +const dslQuery = { bool: { must: [], filter: [], should: [], must_not: [] } }; +const rollupAggsMock = { + date_histogram: { + '@timestamp': { + agg: 'date_histogram', + fixed_interval: '20m', + delay: '10m', + time_zone: 'UTC', + }, + }, +}; + +jest.spyOn(ExistingFieldsServiceApi, 'loadFieldExisting').mockImplementation(async () => ({ + indexPatternTitle: 'test', + existingFieldNames: [], +})); + +describe('UnifiedFieldList useExistingFields', () => { + let mockedServices: ExistingFieldsFetcherParams['services']; + const anotherDataView = createStubDataView({ + spec: { + id: 'another-data-view', + title: 'logstash-0', + fields: stubFieldSpecMap, + }, + }); + const dataViewWithRestrictions = createStubDataView({ + spec: { + id: 'another-data-view-with-restrictions', + title: 'logstash-1', + fields: stubFieldSpecMap, + typeMeta: { + aggs: rollupAggsMock, + }, + }, + }); + jest.spyOn(dataViewWithRestrictions, 'getAggregationRestrictions'); + + beforeEach(() => { + const dataViews = dataViewPluginMocks.createStartContract(); + const core = coreMock.createStart(); + mockedServices = { + dataViews, + data: dataPluginMock.createStartContract(), + core, + }; + + core.uiSettings.get.mockImplementation((key: string) => { + if (key === UI_SETTINGS.META_FIELDS) { + return ['_id']; + } + }); + + dataViews.get.mockImplementation(async (id: string) => { + return [dataView, anotherDataView, dataViewWithRestrictions].find((dw) => dw.id === id)!; + }); + + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockClear(); + (dataViewWithRestrictions.getAggregationRestrictions as jest.Mock).mockClear(); + resetExistingFieldsCache(); + }); + + it('should work correctly based on the specified data view', async () => { + const dataViewId = dataView.id!; + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [dataView.fields[0].name], + }; + }); + + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: { + dataViews: [dataView], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + const hookReader = renderHook(useExistingFieldsReader); + + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + + // has existence info for the loaded data view => works more restrictive + expect(hookReader.result.current.isFieldsExistenceInfoUnavailable(dataViewId)).toBe(false); + expect(hookReader.result.current.hasFieldData(dataViewId, dataView.fields[0].name)).toBe(true); + expect(hookReader.result.current.hasFieldData(dataViewId, dataView.fields[1].name)).toBe(false); + expect(hookReader.result.current.getFieldsExistenceStatus(dataViewId)).toBe( + ExistenceFetchStatus.succeeded + ); + + // does not have existence info => works less restrictive + const anotherDataViewId = 'test-id'; + expect(hookReader.result.current.isFieldsExistenceInfoUnavailable(anotherDataViewId)).toBe( + false + ); + expect(hookReader.result.current.hasFieldData(anotherDataViewId, dataView.fields[0].name)).toBe( + true + ); + expect(hookReader.result.current.hasFieldData(anotherDataViewId, dataView.fields[1].name)).toBe( + true + ); + expect(hookReader.result.current.getFieldsExistenceStatus(anotherDataViewId)).toBe( + ExistenceFetchStatus.unknown + ); + }); + + it('should work correctly with multiple readers', async () => { + const dataViewId = dataView.id!; + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [dataView.fields[0].name], + }; + }); + + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: { + dataViews: [dataView], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + const hookReader1 = renderHook(useExistingFieldsReader); + const hookReader2 = renderHook(useExistingFieldsReader); + + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalled(); + + const checkResults = (currentResult: ExistingFieldsReader) => { + expect(currentResult.isFieldsExistenceInfoUnavailable(dataViewId)).toBe(false); + expect(currentResult.hasFieldData(dataViewId, dataView.fields[0].name)).toBe(true); + expect(currentResult.hasFieldData(dataViewId, dataView.fields[1].name)).toBe(false); + expect(currentResult.getFieldsExistenceStatus(dataViewId)).toBe( + ExistenceFetchStatus.succeeded + ); + }; + + // both readers should get the same results + + checkResults(hookReader1.result.current); + checkResults(hookReader2.result.current); + + // info should be persisted even if the fetcher was unmounted + + hookFetcher.unmount(); + + checkResults(hookReader1.result.current); + checkResults(hookReader2.result.current); + }); + + it('should work correctly if load fails', async () => { + const dataViewId = dataView.id!; + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + throw new Error('test'); + }); + + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: { + dataViews: [dataView], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + const hookReader = renderHook(useExistingFieldsReader); + + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalled(); + + const currentResult = hookReader.result.current; + expect(currentResult.isFieldsExistenceInfoUnavailable(dataViewId)).toBe(true); + expect(currentResult.hasFieldData(dataViewId, dataView.fields[0].name)).toBe(true); + expect(currentResult.getFieldsExistenceStatus(dataViewId)).toBe(ExistenceFetchStatus.failed); + }); + + it('should work correctly for multiple data views', async () => { + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation( + async ({ dataView: currentDataView }) => { + return { + existingFieldNames: [currentDataView.fields[0].name], + }; + } + ); + + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: { + dataViews: [dataView, anotherDataView, dataViewWithRestrictions], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + const hookReader = renderHook(useExistingFieldsReader); + await hookFetcher.waitForNextUpdate(); + + const currentResult = hookReader.result.current; + + expect(currentResult.isFieldsExistenceInfoUnavailable(dataView.id!)).toBe(false); + expect(currentResult.isFieldsExistenceInfoUnavailable(anotherDataView.id!)).toBe(false); + expect(currentResult.isFieldsExistenceInfoUnavailable(dataViewWithRestrictions.id!)).toBe(true); + expect(currentResult.isFieldsExistenceInfoUnavailable('test-id')).toBe(false); + + expect(currentResult.hasFieldData(dataView.id!, dataView.fields[0].name)).toBe(true); + expect(currentResult.hasFieldData(dataView.id!, dataView.fields[1].name)).toBe(false); + + expect(currentResult.hasFieldData(anotherDataView.id!, anotherDataView.fields[0].name)).toBe( + true + ); + expect(currentResult.hasFieldData(anotherDataView.id!, anotherDataView.fields[1].name)).toBe( + false + ); + + expect( + currentResult.hasFieldData( + dataViewWithRestrictions.id!, + dataViewWithRestrictions.fields[0].name + ) + ).toBe(true); + expect( + currentResult.hasFieldData( + dataViewWithRestrictions.id!, + dataViewWithRestrictions.fields[1].name + ) + ).toBe(true); + expect(currentResult.hasFieldData('test-id', 'test-field')).toBe(true); + + expect(currentResult.getFieldsExistenceStatus(dataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + expect(currentResult.getFieldsExistenceStatus(anotherDataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + expect(currentResult.getFieldsExistenceStatus(dataViewWithRestrictions.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + expect(currentResult.getFieldsExistenceStatus('test-id')).toBe(ExistenceFetchStatus.unknown); + + expect(dataViewWithRestrictions.getAggregationRestrictions).toHaveBeenCalledTimes(1); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(2); + }); + + it('should work correctly for data views with restrictions', async () => { + const dataViewId = dataViewWithRestrictions.id!; + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + throw new Error('test'); + }); + + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: { + dataViews: [dataViewWithRestrictions], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + const hookReader = renderHook(useExistingFieldsReader); + await hookFetcher.waitForNextUpdate(); + await hookFetcher.waitFor(() => !hookFetcher.result.current.isProcessing); + + expect(dataViewWithRestrictions.getAggregationRestrictions).toHaveBeenCalled(); + expect(ExistingFieldsServiceApi.loadFieldExisting).not.toHaveBeenCalled(); + + const currentResult = hookReader.result.current; + expect(currentResult.isFieldsExistenceInfoUnavailable(dataViewId)).toBe(true); + expect(currentResult.hasFieldData(dataViewId, dataViewWithRestrictions.fields[0].name)).toBe( + true + ); + expect(currentResult.getFieldsExistenceStatus(dataViewId)).toBe(ExistenceFetchStatus.succeeded); + }); + + it('should work correctly for when data views are changed', async () => { + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation( + async ({ dataView: currentDataView }) => { + return { + existingFieldNames: [currentDataView.fields[0].name], + }; + } + ); + + const params: ExistingFieldsFetcherParams = { + dataViews: [dataView], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }; + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: params, + }); + + const hookReader = renderHook(useExistingFieldsReader); + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + + expect(hookReader.result.current.getFieldsExistenceStatus(dataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + expect(hookReader.result.current.getFieldsExistenceStatus(anotherDataView.id!)).toBe( + ExistenceFetchStatus.unknown + ); + + hookFetcher.rerender({ + ...params, + dataViews: [dataView, anotherDataView], + }); + + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView: anotherDataView, + timeFieldName: anotherDataView.timeFieldName, + }) + ); + + expect(hookReader.result.current.getFieldsExistenceStatus(dataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + expect(hookReader.result.current.getFieldsExistenceStatus(anotherDataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + }); + + it('should work correctly for when params are changed', async () => { + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation( + async ({ dataView: currentDataView }) => { + return { + existingFieldNames: [currentDataView.fields[0].name], + }; + } + ); + + const params: ExistingFieldsFetcherParams = { + dataViews: [dataView], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }; + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: params, + }); + + const hookReader = renderHook(useExistingFieldsReader); + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + + expect(hookReader.result.current.getFieldsExistenceStatus(dataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + + hookFetcher.rerender({ + ...params, + fromDate: '2021-01-01', + toDate: '2022-01-01', + query: { query: 'test', language: 'kuery' }, + }); + + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + fromDate: '2021-01-01', + toDate: '2022-01-01', + dslQuery: { + bool: { + filter: [ + { + multi_match: { + lenient: true, + query: 'test', + type: 'best_fields', + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + }, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + }); + + it('should call onNoData callback only once', async () => { + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: ['_id'], + }; + }); + + const params: ExistingFieldsFetcherParams = { + dataViews: [dataView], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + onNoData: jest.fn(), + }; + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: params, + }); + + const hookReader = renderHook(useExistingFieldsReader); + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + + expect(hookReader.result.current.getFieldsExistenceStatus(dataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + + expect(params.onNoData).toHaveBeenCalledWith(dataView.id); + expect(params.onNoData).toHaveBeenCalledTimes(1); + + hookFetcher.rerender({ + ...params, + fromDate: '2021-01-01', + toDate: '2022-01-01', + }); + + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + fromDate: '2021-01-01', + toDate: '2022-01-01', + dslQuery, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + + expect(params.onNoData).toHaveBeenCalledTimes(1); // still 1 time + }); +}); diff --git a/src/plugins/unified_field_list/public/hooks/use_existing_fields.ts b/src/plugins/unified_field_list/public/hooks/use_existing_fields.ts new file mode 100644 index 00000000000000..ebf12d4609500e --- /dev/null +++ b/src/plugins/unified_field_list/public/hooks/use_existing_fields.ts @@ -0,0 +1,347 @@ +/* + * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { htmlIdGenerator } from '@elastic/eui'; +import { BehaviorSubject } from 'rxjs'; +import { CoreStart } from '@kbn/core/public'; +import type { AggregateQuery, EsQueryConfig, Filter, Query } from '@kbn/es-query'; +import { + DataPublicPluginStart, + DataViewsContract, + getEsQueryConfig, + UI_SETTINGS, +} from '@kbn/data-plugin/public'; +import { type DataView } from '@kbn/data-plugin/common'; +import { loadFieldExisting } from '../services/field_existing'; +import { ExistenceFetchStatus } from '../types'; + +const getBuildEsQueryAsync = async () => (await import('@kbn/es-query')).buildEsQuery; +const generateId = htmlIdGenerator(); + +export interface ExistingFieldsInfo { + fetchStatus: ExistenceFetchStatus; + existingFieldsByFieldNameMap: Record; + numberOfFetches: number; + hasDataViewRestrictions?: boolean; +} + +export interface ExistingFieldsFetcherParams { + dataViews: DataView[]; + fromDate: string; + toDate: string; + query: Query | AggregateQuery; + filters: Filter[]; + services: { + core: Pick; + data: DataPublicPluginStart; + dataViews: DataViewsContract; + }; + onNoData?: (dataViewId: string) => unknown; +} + +type ExistingFieldsByDataViewMap = Record; + +export interface ExistingFieldsFetcher { + refetchFieldsExistenceInfo: (dataViewId?: string) => Promise; + isProcessing: boolean; +} + +export interface ExistingFieldsReader { + hasFieldData: (dataViewId: string, fieldName: string) => boolean; + getFieldsExistenceStatus: (dataViewId: string) => ExistenceFetchStatus; + isFieldsExistenceInfoUnavailable: (dataViewId: string) => boolean; +} + +const initialData: ExistingFieldsByDataViewMap = {}; +const unknownInfo: ExistingFieldsInfo = { + fetchStatus: ExistenceFetchStatus.unknown, + existingFieldsByFieldNameMap: {}, + numberOfFetches: 0, +}; + +const globalMap$ = new BehaviorSubject(initialData); // for syncing between hooks +let lastFetchId: string = ''; // persist last fetch id to skip older requests/responses if any + +export const useExistingFieldsFetcher = ( + params: ExistingFieldsFetcherParams +): ExistingFieldsFetcher => { + const mountedRef = useRef(true); + const [activeRequests, setActiveRequests] = useState(0); + const isProcessing = activeRequests > 0; + + const fetchFieldsExistenceInfo = useCallback( + async ({ + dataViewId, + query, + filters, + fromDate, + toDate, + services: { dataViews, data, core }, + onNoData, + fetchId, + }: ExistingFieldsFetcherParams & { + dataViewId: string | undefined; + fetchId: string; + }): Promise => { + if (!dataViewId) { + return; + } + + const currentInfo = globalMap$.getValue()?.[dataViewId]; + + if (!mountedRef.current) { + return; + } + + const numberOfFetches = (currentInfo?.numberOfFetches ?? 0) + 1; + const dataView = await dataViews.get(dataViewId); + + if (!dataView?.title) { + return; + } + + setActiveRequests((value) => value + 1); + + const hasRestrictions = Boolean(dataView.getAggregationRestrictions?.()); + const info: ExistingFieldsInfo = { + ...unknownInfo, + numberOfFetches, + }; + + if (hasRestrictions) { + info.fetchStatus = ExistenceFetchStatus.succeeded; + info.hasDataViewRestrictions = true; + } else { + try { + const result = await loadFieldExisting({ + dslQuery: await buildSafeEsQuery( + dataView, + query, + filters, + getEsQueryConfig(core.uiSettings) + ), + fromDate, + toDate, + timeFieldName: dataView.timeFieldName, + data, + uiSettingsClient: core.uiSettings, + dataViewsService: dataViews, + dataView, + }); + + const existingFieldNames = result?.existingFieldNames || []; + + const metaFields = core.uiSettings.get(UI_SETTINGS.META_FIELDS) || []; + if ( + !existingFieldNames.filter((fieldName) => !metaFields.includes?.(fieldName)).length && + numberOfFetches === 1 && + onNoData + ) { + onNoData(dataViewId); + } + + info.existingFieldsByFieldNameMap = booleanMap(existingFieldNames); + info.fetchStatus = ExistenceFetchStatus.succeeded; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + info.fetchStatus = ExistenceFetchStatus.failed; + } + } + + // skip redundant and older results + if (mountedRef.current && fetchId === lastFetchId) { + globalMap$.next({ + ...globalMap$.getValue(), + [dataViewId]: info, + }); + } + + setActiveRequests((value) => value - 1); + }, + [mountedRef, setActiveRequests] + ); + + const dataViewsHash = getDataViewsHash(params.dataViews); + const refetchFieldsExistenceInfo = useCallback( + async (dataViewId?: string) => { + const fetchId = generateId(); + lastFetchId = fetchId; + // refetch only for the specified data view + if (dataViewId) { + await fetchFieldsExistenceInfo({ + fetchId, + dataViewId, + ...params, + }); + return; + } + // refetch for all mentioned data views + await Promise.all( + params.dataViews.map((dataView) => + fetchFieldsExistenceInfo({ + fetchId, + dataViewId: dataView.id, + ...params, + }) + ) + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + fetchFieldsExistenceInfo, + dataViewsHash, + params.query, + params.filters, + params.fromDate, + params.toDate, + ] + ); + + useEffect(() => { + refetchFieldsExistenceInfo(); + }, [refetchFieldsExistenceInfo]); + + useEffect(() => { + return () => { + mountedRef.current = false; + globalMap$.next({}); // reset the cache (readers will continue using their own data slice until they are unmounted too) + }; + }, [mountedRef]); + + return useMemo( + () => ({ + refetchFieldsExistenceInfo, + isProcessing, + }), + [refetchFieldsExistenceInfo, isProcessing] + ); +}; + +export const useExistingFieldsReader: () => ExistingFieldsReader = () => { + const mountedRef = useRef(true); + const [existingFieldsByDataViewMap, setExistingFieldsByDataViewMap] = + useState(globalMap$.getValue()); + + useEffect(() => { + const subscription = globalMap$.subscribe((data) => { + if (mountedRef.current && Object.keys(data).length > 0) { + setExistingFieldsByDataViewMap((savedData) => ({ + ...savedData, + ...data, + })); + } + }); + + return () => { + subscription.unsubscribe(); + }; + }, [setExistingFieldsByDataViewMap, mountedRef]); + + const hasFieldData = useCallback( + (dataViewId: string, fieldName: string) => { + const info = existingFieldsByDataViewMap[dataViewId]; + + if (info?.fetchStatus === ExistenceFetchStatus.succeeded) { + return ( + info?.hasDataViewRestrictions || Boolean(info?.existingFieldsByFieldNameMap[fieldName]) + ); + } + + return true; + }, + [existingFieldsByDataViewMap] + ); + + const getFieldsExistenceInfo = useCallback( + (dataViewId: string) => { + return dataViewId ? existingFieldsByDataViewMap[dataViewId] : unknownInfo; + }, + [existingFieldsByDataViewMap] + ); + + const getFieldsExistenceStatus = useCallback( + (dataViewId: string): ExistenceFetchStatus => { + return getFieldsExistenceInfo(dataViewId)?.fetchStatus || ExistenceFetchStatus.unknown; + }, + [getFieldsExistenceInfo] + ); + + const isFieldsExistenceInfoUnavailable = useCallback( + (dataViewId: string): boolean => { + const info = getFieldsExistenceInfo(dataViewId); + return Boolean( + info?.fetchStatus === ExistenceFetchStatus.failed || info?.hasDataViewRestrictions + ); + }, + [getFieldsExistenceInfo] + ); + + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, [mountedRef]); + + return useMemo( + () => ({ + hasFieldData, + getFieldsExistenceStatus, + isFieldsExistenceInfoUnavailable, + }), + [hasFieldData, getFieldsExistenceStatus, isFieldsExistenceInfoUnavailable] + ); +}; + +export const resetExistingFieldsCache = () => { + globalMap$.next(initialData); +}; + +function getDataViewsHash(dataViews: DataView[]): string { + return ( + dataViews + // From Lens it's coming as IndexPattern type and not the real DataView type + .map( + (dataView) => + `${dataView.id}:${dataView.title}:${dataView.timeFieldName || 'no-timefield'}:${ + dataView.fields?.length ?? 0 // adding a field will also trigger a refetch of fields existence data + }` + ) + .join(',') + ); +} + +// Wrapper around buildEsQuery, handling errors (e.g. because a query can't be parsed) by +// returning a query dsl object not matching anything +async function buildSafeEsQuery( + dataView: DataView, + query: Query | AggregateQuery, + filters: Filter[], + queryConfig: EsQueryConfig +) { + const buildEsQuery = await getBuildEsQueryAsync(); + try { + return buildEsQuery(dataView, query, filters, queryConfig); + } catch (e) { + return { + bool: { + must_not: { + match_all: {}, + }, + }, + }; + } +} + +function booleanMap(keys: string[]) { + return keys.reduce((acc, key) => { + acc[key] = true; + return acc; + }, {} as Record); +} diff --git a/src/plugins/unified_field_list/public/hooks/use_grouped_fields.test.tsx b/src/plugins/unified_field_list/public/hooks/use_grouped_fields.test.tsx new file mode 100644 index 00000000000000..d4d6d3cdc906f9 --- /dev/null +++ b/src/plugins/unified_field_list/public/hooks/use_grouped_fields.test.tsx @@ -0,0 +1,272 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { + stubDataViewWithoutTimeField, + stubLogstashDataView as dataView, +} from '@kbn/data-views-plugin/common/data_view.stub'; +import { createStubDataView, stubFieldSpecMap } from '@kbn/data-plugin/public/stubs'; +import { DataViewField } from '@kbn/data-views-plugin/common'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { type GroupedFieldsParams, useGroupedFields } from './use_grouped_fields'; +import { ExistenceFetchStatus, FieldListGroups, FieldsGroupNames } from '../types'; + +describe('UnifiedFieldList useGroupedFields()', () => { + let mockedServices: GroupedFieldsParams['services']; + const allFields = dataView.fields; + const anotherDataView = createStubDataView({ + spec: { + id: 'another-data-view', + title: 'logstash-0', + fields: stubFieldSpecMap, + }, + }); + + beforeEach(() => { + const dataViews = dataViewPluginMocks.createStartContract(); + mockedServices = { + dataViews, + }; + + dataViews.get.mockImplementation(async (id: string) => { + return [dataView, stubDataViewWithoutTimeField].find((dw) => dw.id === id)!; + }); + }); + + it('should work correctly for no data', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGroupedFields({ + dataViewId: dataView.id!, + allFields: [], + services: mockedServices, + }) + ); + + await waitForNextUpdate(); + + const fieldGroups = result.current.fieldGroups; + + expect( + Object.keys(fieldGroups!).map( + (key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}` + ) + ).toStrictEqual([ + 'SpecialFields-0', + 'SelectedFields-0', + 'AvailableFields-0', + 'EmptyFields-0', + 'MetaFields-0', + ]); + }); + + it('should work correctly with fields', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGroupedFields({ + dataViewId: dataView.id!, + allFields, + services: mockedServices, + }) + ); + + await waitForNextUpdate(); + + const fieldGroups = result.current.fieldGroups; + + expect( + Object.keys(fieldGroups!).map( + (key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}` + ) + ).toStrictEqual([ + 'SpecialFields-0', + 'SelectedFields-0', + 'AvailableFields-25', + 'EmptyFields-0', + 'MetaFields-3', + ]); + }); + + it('should work correctly when filtered', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGroupedFields({ + dataViewId: dataView.id!, + allFields, + services: mockedServices, + onFilterField: (field: DataViewField) => field.name.startsWith('@'), + }) + ); + + await waitForNextUpdate(); + + const fieldGroups = result.current.fieldGroups; + + expect( + Object.keys(fieldGroups!).map( + (key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}` + ) + ).toStrictEqual([ + 'SpecialFields-0', + 'SelectedFields-0', + 'AvailableFields-2', + 'EmptyFields-0', + 'MetaFields-0', + ]); + }); + + it('should work correctly when custom unsupported fields are skipped', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGroupedFields({ + dataViewId: dataView.id!, + allFields, + services: mockedServices, + onSupportedFieldFilter: (field: DataViewField) => field.aggregatable, + }) + ); + + await waitForNextUpdate(); + + const fieldGroups = result.current.fieldGroups; + + expect( + Object.keys(fieldGroups!).map( + (key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}` + ) + ).toStrictEqual([ + 'SpecialFields-0', + 'SelectedFields-0', + 'AvailableFields-23', + 'EmptyFields-0', + 'MetaFields-3', + ]); + }); + + it('should work correctly when selected fields are present', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGroupedFields({ + dataViewId: dataView.id!, + allFields, + services: mockedServices, + onSelectedFieldFilter: (field: DataViewField) => + ['bytes', 'extension', '_id', '@timestamp'].includes(field.name), + }) + ); + + await waitForNextUpdate(); + + const fieldGroups = result.current.fieldGroups; + + expect( + Object.keys(fieldGroups!).map( + (key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}` + ) + ).toStrictEqual([ + 'SpecialFields-0', + 'SelectedFields-4', + 'AvailableFields-25', + 'EmptyFields-0', + 'MetaFields-3', + ]); + }); + + it('should work correctly for text-based queries (no data view)', async () => { + const { result } = renderHook(() => + useGroupedFields({ + dataViewId: null, + allFields, + services: mockedServices, + }) + ); + + const fieldGroups = result.current.fieldGroups; + + expect( + Object.keys(fieldGroups!).map( + (key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}` + ) + ).toStrictEqual(['SpecialFields-0', 'SelectedFields-0', 'AvailableFields-28', 'MetaFields-0']); + }); + + it('should work correctly when details are overwritten', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGroupedFields({ + dataViewId: dataView.id!, + allFields, + services: mockedServices, + onOverrideFieldGroupDetails: (groupName) => { + if (groupName === FieldsGroupNames.SelectedFields) { + return { + helpText: 'test', + }; + } + }, + }) + ); + + await waitForNextUpdate(); + + const fieldGroups = result.current.fieldGroups; + + expect(fieldGroups[FieldsGroupNames.SelectedFields]?.helpText).toBe('test'); + expect(fieldGroups[FieldsGroupNames.AvailableFields]?.helpText).not.toBe('test'); + }); + + it('should work correctly when changing a data view and existence info is available only for one of them', async () => { + const knownDataViewId = dataView.id!; + let fieldGroups: FieldListGroups; + const props: GroupedFieldsParams = { + dataViewId: dataView.id!, + allFields, + services: mockedServices, + fieldsExistenceReader: { + hasFieldData: (dataViewId, fieldName) => { + return dataViewId === knownDataViewId && ['bytes', 'extension'].includes(fieldName); + }, + getFieldsExistenceStatus: (dataViewId) => + dataViewId === knownDataViewId + ? ExistenceFetchStatus.succeeded + : ExistenceFetchStatus.unknown, + isFieldsExistenceInfoUnavailable: (dataViewId) => dataViewId !== knownDataViewId, + }, + }; + + const { result, waitForNextUpdate, rerender } = renderHook(useGroupedFields, { + initialProps: props, + }); + await waitForNextUpdate(); + + fieldGroups = result.current.fieldGroups; + + expect( + Object.keys(fieldGroups!).map( + (key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}` + ) + ).toStrictEqual([ + 'SpecialFields-0', + 'SelectedFields-0', + 'AvailableFields-2', + 'EmptyFields-23', + 'MetaFields-3', + ]); + + rerender({ + ...props, + dataViewId: anotherDataView.id!, + allFields: anotherDataView.fields, + }); + + await waitForNextUpdate(); + + fieldGroups = result.current.fieldGroups; + + expect( + Object.keys(fieldGroups!).map( + (key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}` + ) + ).toStrictEqual(['SpecialFields-0', 'SelectedFields-0', 'AvailableFields-8', 'MetaFields-0']); + }); +}); diff --git a/src/plugins/unified_field_list/public/hooks/use_grouped_fields.ts b/src/plugins/unified_field_list/public/hooks/use_grouped_fields.ts new file mode 100644 index 00000000000000..cfa5407a238ccb --- /dev/null +++ b/src/plugins/unified_field_list/public/hooks/use_grouped_fields.ts @@ -0,0 +1,267 @@ +/* + * 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 { groupBy } from 'lodash'; +import { useEffect, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { type DataView, type DataViewField } from '@kbn/data-views-plugin/common'; +import { type DataViewsContract } from '@kbn/data-views-plugin/public'; +import { + type FieldListGroups, + type FieldsGroupDetails, + type FieldsGroup, + type FieldListItem, + FieldsGroupNames, +} from '../types'; +import { type ExistingFieldsReader } from './use_existing_fields'; + +export interface GroupedFieldsParams { + dataViewId: string | null; // `null` is for text-based queries + allFields: T[]; + services: { + dataViews: DataViewsContract; + }; + fieldsExistenceReader?: ExistingFieldsReader; + onOverrideFieldGroupDetails?: ( + groupName: FieldsGroupNames + ) => Partial | undefined | null; + onSupportedFieldFilter?: (field: T) => boolean; + onSelectedFieldFilter?: (field: T) => boolean; + onFilterField?: (field: T) => boolean; +} + +export interface GroupedFieldsResult { + fieldGroups: FieldListGroups; +} + +export function useGroupedFields({ + dataViewId, + allFields, + services, + fieldsExistenceReader, + onOverrideFieldGroupDetails, + onSupportedFieldFilter, + onSelectedFieldFilter, + onFilterField, +}: GroupedFieldsParams): GroupedFieldsResult { + const [dataView, setDataView] = useState(null); + const fieldsExistenceInfoUnavailable: boolean = dataViewId + ? fieldsExistenceReader?.isFieldsExistenceInfoUnavailable(dataViewId) ?? false + : true; + const hasFieldDataHandler = + dataViewId && fieldsExistenceReader + ? fieldsExistenceReader.hasFieldData + : hasFieldDataByDefault; + + useEffect(() => { + const getDataView = async () => { + if (dataViewId) { + setDataView(await services.dataViews.get(dataViewId)); + } + }; + getDataView(); + // if field existence information changed, reload the data view too + }, [dataViewId, services.dataViews, setDataView, hasFieldDataHandler]); + + const unfilteredFieldGroups: FieldListGroups = useMemo(() => { + const containsData = (field: T) => { + if (!dataViewId || !dataView) { + return true; + } + const overallField = dataView.getFieldByName?.(field.name); + return Boolean(overallField && hasFieldDataHandler(dataViewId, overallField.name)); + }; + + const fields = allFields || []; + const allSupportedTypesFields = onSupportedFieldFilter + ? fields.filter(onSupportedFieldFilter) + : fields; + const sortedFields = [...allSupportedTypesFields].sort(sortFields); + const groupedFields = { + ...getDefaultFieldGroups(), + ...groupBy(sortedFields, (field) => { + if (field.type === 'document') { + return 'specialFields'; + } else if (dataView?.metaFields?.includes(field.name)) { + return 'metaFields'; + } else if (containsData(field)) { + return 'availableFields'; + } else return 'emptyFields'; + }), + }; + const selectedFields = onSelectedFieldFilter ? sortedFields.filter(onSelectedFieldFilter) : []; + + let fieldGroupDefinitions: FieldListGroups = { + SpecialFields: { + fields: groupedFields.specialFields, + fieldCount: groupedFields.specialFields.length, + isAffectedByGlobalFilter: false, + isAffectedByTimeFilter: false, + isInitiallyOpen: false, + showInAccordion: false, + title: '', + hideDetails: true, + }, + SelectedFields: { + fields: selectedFields, + fieldCount: selectedFields.length, + isInitiallyOpen: true, + showInAccordion: true, + title: i18n.translate('unifiedFieldList.useGroupedFields.selectedFieldsLabel', { + defaultMessage: 'Selected fields', + }), + isAffectedByGlobalFilter: false, + isAffectedByTimeFilter: true, + hideDetails: false, + hideIfEmpty: true, + }, + AvailableFields: { + fields: groupedFields.availableFields, + fieldCount: groupedFields.availableFields.length, + isInitiallyOpen: true, + showInAccordion: true, + title: + dataViewId && fieldsExistenceInfoUnavailable + ? i18n.translate('unifiedFieldList.useGroupedFields.allFieldsLabel', { + defaultMessage: 'All fields', + }) + : i18n.translate('unifiedFieldList.useGroupedFields.availableFieldsLabel', { + defaultMessage: 'Available fields', + }), + isAffectedByGlobalFilter: false, + isAffectedByTimeFilter: true, + // Show details on timeout but not failure + // hideDetails: fieldsExistenceInfoUnavailable && !existenceFetchTimeout, // TODO: is this check still necessary? + hideDetails: fieldsExistenceInfoUnavailable, + defaultNoFieldsMessage: i18n.translate( + 'unifiedFieldList.useGroupedFields.noAvailableDataLabel', + { + defaultMessage: `There are no available fields that contain data.`, + } + ), + }, + EmptyFields: { + fields: groupedFields.emptyFields, + fieldCount: groupedFields.emptyFields.length, + isAffectedByGlobalFilter: false, + isAffectedByTimeFilter: false, + isInitiallyOpen: false, + showInAccordion: true, + hideDetails: false, + hideIfEmpty: !dataViewId, + title: i18n.translate('unifiedFieldList.useGroupedFields.emptyFieldsLabel', { + defaultMessage: 'Empty fields', + }), + defaultNoFieldsMessage: i18n.translate( + 'unifiedFieldList.useGroupedFields.noEmptyDataLabel', + { + defaultMessage: `There are no empty fields.`, + } + ), + helpText: i18n.translate('unifiedFieldList.useGroupedFields.emptyFieldsLabelHelp', { + defaultMessage: 'Empty fields did not contain any values based on your filters.', + }), + }, + MetaFields: { + fields: groupedFields.metaFields, + fieldCount: groupedFields.metaFields.length, + isAffectedByGlobalFilter: false, + isAffectedByTimeFilter: false, + isInitiallyOpen: false, + showInAccordion: true, + hideDetails: false, + hideIfEmpty: !dataViewId, + title: i18n.translate('unifiedFieldList.useGroupedFields.metaFieldsLabel', { + defaultMessage: 'Meta fields', + }), + defaultNoFieldsMessage: i18n.translate( + 'unifiedFieldList.useGroupedFields.noMetaDataLabel', + { + defaultMessage: `There are no meta fields.`, + } + ), + }, + }; + + // do not show empty field accordion if there is no existence information + if (fieldsExistenceInfoUnavailable) { + delete fieldGroupDefinitions.EmptyFields; + } + + if (onOverrideFieldGroupDetails) { + fieldGroupDefinitions = Object.keys(fieldGroupDefinitions).reduce>( + (definitions, name) => { + const groupName = name as FieldsGroupNames; + const group: FieldsGroup | undefined = fieldGroupDefinitions[groupName]; + if (group) { + definitions[groupName] = { + ...group, + ...(onOverrideFieldGroupDetails(groupName) || {}), + }; + } + return definitions; + }, + {} as FieldListGroups + ); + } + + return fieldGroupDefinitions; + }, [ + allFields, + onSupportedFieldFilter, + onSelectedFieldFilter, + onOverrideFieldGroupDetails, + dataView, + dataViewId, + hasFieldDataHandler, + fieldsExistenceInfoUnavailable, + ]); + + const fieldGroups: FieldListGroups = useMemo(() => { + if (!onFilterField) { + return unfilteredFieldGroups; + } + + return Object.fromEntries( + Object.entries(unfilteredFieldGroups).map(([name, group]) => [ + name, + { ...group, fields: group.fields.filter(onFilterField) }, + ]) + ) as FieldListGroups; + }, [unfilteredFieldGroups, onFilterField]); + + return useMemo( + () => ({ + fieldGroups, + }), + [fieldGroups] + ); +} + +function sortFields(fieldA: T, fieldB: T) { + return (fieldA.displayName || fieldA.name).localeCompare( + fieldB.displayName || fieldB.name, + undefined, + { + sensitivity: 'base', + } + ); +} + +function hasFieldDataByDefault(): boolean { + return true; +} + +function getDefaultFieldGroups() { + return { + specialFields: [], + availableFields: [], + emptyFields: [], + metaFields: [], + }; +} diff --git a/src/plugins/unified_field_list/public/index.ts b/src/plugins/unified_field_list/public/index.ts index 2ada1027ee97a7..94abf515664637 100755 --- a/src/plugins/unified_field_list/public/index.ts +++ b/src/plugins/unified_field_list/public/index.ts @@ -14,6 +14,7 @@ export type { NumberStatsResult, TopValuesResult, } from '../common/types'; +export { FieldListGrouped, type FieldListGroupedProps } from './components/field_list'; export type { FieldStatsProps, FieldStatsServices } from './components/field_stats'; export { FieldStats } from './components/field_stats'; export { @@ -44,4 +45,23 @@ export type { UnifiedFieldListPluginSetup, UnifiedFieldListPluginStart, AddFieldFilterHandler, + FieldListGroups, + FieldsGroupDetails, } from './types'; +export { ExistenceFetchStatus, FieldsGroupNames } from './types'; + +export { + useExistingFieldsFetcher, + useExistingFieldsReader, + resetExistingFieldsCache, + type ExistingFieldsInfo, + type ExistingFieldsFetcherParams, + type ExistingFieldsFetcher, + type ExistingFieldsReader, +} from './hooks/use_existing_fields'; + +export { + useGroupedFields, + type GroupedFieldsParams, + type GroupedFieldsResult, +} from './hooks/use_grouped_fields'; diff --git a/src/plugins/unified_field_list/public/services/field_existing/index.ts b/src/plugins/unified_field_list/public/services/field_existing/index.ts index 56be726b7c90fe..6541afb4673bb5 100644 --- a/src/plugins/unified_field_list/public/services/field_existing/index.ts +++ b/src/plugins/unified_field_list/public/services/field_existing/index.ts @@ -6,4 +6,9 @@ * Side Public License, v 1. */ -export { loadFieldExisting } from './load_field_existing'; +import type { LoadFieldExistingHandler } from './load_field_existing'; + +export const loadFieldExisting: LoadFieldExistingHandler = async (params) => { + const { loadFieldExisting: loadFieldExistingHandler } = await import('./load_field_existing'); + return await loadFieldExistingHandler(params); +}; diff --git a/src/plugins/unified_field_list/public/services/field_existing/load_field_existing.ts b/src/plugins/unified_field_list/public/services/field_existing/load_field_existing.ts index 79b2b056c60629..f8e369838c51a1 100644 --- a/src/plugins/unified_field_list/public/services/field_existing/load_field_existing.ts +++ b/src/plugins/unified_field_list/public/services/field_existing/load_field_existing.ts @@ -24,7 +24,12 @@ interface FetchFieldExistenceParams { uiSettingsClient: IUiSettingsClient; } -export async function loadFieldExisting({ +export type LoadFieldExistingHandler = (params: FetchFieldExistenceParams) => Promise<{ + existingFieldNames: string[]; + indexPatternTitle: string; +}>; + +export const loadFieldExisting: LoadFieldExistingHandler = async ({ data, dslQuery, fromDate, @@ -33,7 +38,7 @@ export async function loadFieldExisting({ dataViewsService, uiSettingsClient, dataView, -}: FetchFieldExistenceParams) { +}) => { const includeFrozen = uiSettingsClient.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); const useSampling = uiSettingsClient.get(FIELD_EXISTENCE_SETTING); const metaFields = uiSettingsClient.get(UI_SETTINGS.META_FIELDS); @@ -53,4 +58,4 @@ export async function loadFieldExisting({ return response.rawResponse; }, }); -} +}; diff --git a/src/plugins/unified_field_list/public/types.ts b/src/plugins/unified_field_list/public/types.ts index f7a712534d59d1..de96cf6a44cfbe 100755 --- a/src/plugins/unified_field_list/public/types.ts +++ b/src/plugins/unified_field_list/public/types.ts @@ -19,3 +19,44 @@ export type AddFieldFilterHandler = ( value: unknown, type: '+' | '-' ) => void; + +export enum ExistenceFetchStatus { + failed = 'failed', + succeeded = 'succeeded', + unknown = 'unknown', +} + +export interface FieldListItem { + name: DataViewField['name']; + type?: DataViewField['type']; + displayName?: DataViewField['displayName']; +} + +export enum FieldsGroupNames { + SpecialFields = 'SpecialFields', + SelectedFields = 'SelectedFields', + AvailableFields = 'AvailableFields', + EmptyFields = 'EmptyFields', + MetaFields = 'MetaFields', +} + +export interface FieldsGroupDetails { + showInAccordion: boolean; + isInitiallyOpen: boolean; + title: string; + helpText?: string; + isAffectedByGlobalFilter: boolean; + isAffectedByTimeFilter: boolean; + hideDetails?: boolean; + defaultNoFieldsMessage?: string; + hideIfEmpty?: boolean; +} + +export interface FieldsGroup extends FieldsGroupDetails { + fields: T[]; + fieldCount: number; +} + +export type FieldListGroups = { + [key in FieldsGroupNames]?: FieldsGroup; +}; diff --git a/test/node_roles_functional/background_tasks.config.ts b/test/node_roles_functional/background_tasks.config.ts index a2840cbb230cab..dc7a63b353dccc 100644 --- a/test/node_roles_functional/background_tasks.config.ts +++ b/test/node_roles_functional/background_tasks.config.ts @@ -40,7 +40,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { '--usageCollection.usageCounters.bufferDuration=0', `--plugin-path=${path.resolve(__dirname, 'plugins', 'core_plugin_initializer_context')}`, - '--node.roles=["background_tasks"]', + '--node.roles=["ui","background_tasks"]', ], }, }; diff --git a/test/node_roles_functional/test_suites/background_tasks/initializer_context.ts b/test/node_roles_functional/test_suites/background_tasks/initializer_context.ts index dfe814779ff1ac..e616ec8243c9d0 100644 --- a/test/node_roles_functional/test_suites/background_tasks/initializer_context.ts +++ b/test/node_roles_functional/test_suites/background_tasks/initializer_context.ts @@ -16,7 +16,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide it('passes node roles to server PluginInitializerContext', async () => { await supertest.get('/core_plugin_initializer_context/node/roles').expect(200, { backgroundTasks: true, - ui: false, + ui: true, }); }); }); diff --git a/x-pack/performance/journeys/ecommerce_dashboard.ts b/x-pack/performance/journeys/ecommerce_dashboard.ts index 51b9512f4ba328..b9c107cd12cbd9 100644 --- a/x-pack/performance/journeys/ecommerce_dashboard.ts +++ b/x-pack/performance/journeys/ecommerce_dashboard.ts @@ -21,5 +21,5 @@ export const journey = new Journey({ .step('Go to Ecommerce Dashboard', async ({ page }) => { await page.click(subj('dashboardListingTitleLink-[eCommerce]-Revenue-Dashboard')); - await waitForVisualizations(page, 12); + await waitForVisualizations(page, 13); }); diff --git a/x-pack/performance/journeys/ecommerce_dashboard_saved_search_only.ts b/x-pack/performance/journeys/ecommerce_dashboard_saved_search_only.ts new file mode 100644 index 00000000000000..9c454e890af294 --- /dev/null +++ b/x-pack/performance/journeys/ecommerce_dashboard_saved_search_only.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Journey } from '@kbn/journeys'; +import { subj } from '@kbn/test-subj-selector'; +import { waitForVisualizations } from '../utils'; + +export const journey = new Journey({ + esArchives: ['x-pack/performance/es_archives/sample_data_ecommerce'], + kbnArchives: ['x-pack/performance/kbn_archives/ecommerce_saved_search_only_dashboard'], +}) + + .step('Go to Dashboards Page', async ({ page, kbnUrl }) => { + await page.goto(kbnUrl.get(`/app/dashboards`)); + await page.waitForSelector('#dashboardListingHeading'); + }) + + .step('Go to Ecommerce Dashboard with Saved Search only', async ({ page }) => { + await page.click(subj('dashboardListingTitleLink-[eCommerce]-Saved-Search-Dashboard')); + await waitForVisualizations(page, 1); + }); diff --git a/x-pack/performance/journeys/ecommerce_dashboard_tsvb_gauge_only.ts b/x-pack/performance/journeys/ecommerce_dashboard_tsvb_gauge_only.ts new file mode 100644 index 00000000000000..1988112b9a397f --- /dev/null +++ b/x-pack/performance/journeys/ecommerce_dashboard_tsvb_gauge_only.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Journey } from '@kbn/journeys'; +import { subj } from '@kbn/test-subj-selector'; +import { waitForVisualizations } from '../utils'; + +export const journey = new Journey({ + esArchives: ['x-pack/performance/es_archives/sample_data_ecommerce'], + kbnArchives: ['x-pack/performance/kbn_archives/ecommerce_tsvb_gauge_only_dashboard'], +}) + + .step('Go to Dashboards Page', async ({ page, kbnUrl }) => { + await page.goto(kbnUrl.get(`/app/dashboards`)); + await page.waitForSelector('#dashboardListingHeading'); + }) + + .step('Go to Ecommerce Dashboard with TSVB Gauge only', async ({ page }) => { + await page.click(subj('dashboardListingTitleLink-[eCommerce]-TSVB-Gauge-Only-Dashboard')); + await waitForVisualizations(page, 1); + }); diff --git a/x-pack/performance/journeys/flight_dashboard.ts b/x-pack/performance/journeys/flight_dashboard.ts index a9ac7408be49fd..46030dd47d2fbd 100644 --- a/x-pack/performance/journeys/flight_dashboard.ts +++ b/x-pack/performance/journeys/flight_dashboard.ts @@ -21,5 +21,5 @@ export const journey = new Journey({ .step('Go to Flights Dashboard', async ({ page }) => { await page.click(subj('dashboardListingTitleLink-[Flights]-Global-Flight-Dashboard')); - await waitForVisualizations(page, 13); + await waitForVisualizations(page, 14); }); diff --git a/x-pack/performance/kbn_archives/ecommerce_saved_search_only_dashboard.json b/x-pack/performance/kbn_archives/ecommerce_saved_search_only_dashboard.json new file mode 100644 index 00000000000000..bfec7da206c754 --- /dev/null +++ b/x-pack/performance/kbn_archives/ecommerce_saved_search_only_dashboard.json @@ -0,0 +1,121 @@ +{ + "attributes": { + "fieldAttrs": "{\"products.manufacturer\":{\"count\":1},\"products.price\":{\"count\":1},\"products.product_name\":{\"count\":1},\"total_quantity\":{\"count\":1}}", + "fieldFormatMap": "{\"taxful_total_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.[00]\"}},\"products.price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}},\"taxless_total_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}},\"products.taxless_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}},\"products.taxful_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}},\"products.min_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}},\"products.base_unit_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}},\"products.base_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}}}", + "fields": "[]", + "name": "Kibana Sample Data eCommerce", + "runtimeFieldMap": "{}", + "timeFieldName": "order_date", + "title": "kibana_sample_data_ecommerce", + "typeMeta": "{}" + }, + "coreMigrationVersion": "8.6.0", + "created_at": "2022-10-31T12:06:45.150Z", + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "migrationVersion": { + "index-pattern": "8.0.0" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2022-10-31T12:06:45.150Z", + "version": "WzEzNywxXQ==" +} + +{ + "attributes": { + "columns": [ + "category", + "taxful_total_price", + "products.price", + "products.product_name", + "products.manufacturer", + "sku" + ], + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "sort": [ + [ + "order_date", + "desc" + ] + ], + "title": "[eCommerce] Orders", + "version": 1 + }, + "coreMigrationVersion": "8.6.0", + "created_at": "2022-10-31T12:06:45.150Z", + "id": "3ba638e0-b894-11e8-a6d9-e546fe2bba5f", + "migrationVersion": { + "search": "8.0.0" + }, + "references": [ + { + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "search", + "updated_at": "2022-10-31T12:06:45.150Z", + "version": "WzE0MCwxXQ==" +} + +{ + "attributes": { + "controlGroupInput": { + "chainingSystem": "HIERARCHICAL", + "controlStyle": "oneLine", + "ignoreParentSettingsJSON": "{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}", + "panelsJSON": "{\"1ee1617f-fd8e-45e4-bc6a-d5736710ea20\":{\"order\":0,\"width\":\"small\",\"grow\":true,\"type\":\"optionsListControl\",\"explicitInput\":{\"title\":\"Manufacturer\",\"fieldName\":\"manufacturer.keyword\",\"parentFieldName\":\"manufacturer\",\"id\":\"1ee1617f-fd8e-45e4-bc6a-d5736710ea20\",\"enhancements\":{}}},\"afa9fa0f-a002-41a5-bab9-b738316d2590\":{\"order\":1,\"width\":\"small\",\"grow\":true,\"type\":\"optionsListControl\",\"explicitInput\":{\"title\":\"Category\",\"fieldName\":\"category.keyword\",\"parentFieldName\":\"category\",\"id\":\"afa9fa0f-a002-41a5-bab9-b738316d2590\",\"enhancements\":{}}},\"d3f766cb-5f96-4a12-8d3c-034e08be8855\":{\"order\":2,\"width\":\"small\",\"grow\":true,\"type\":\"rangeSliderControl\",\"explicitInput\":{\"title\":\"Quantity\",\"fieldName\":\"total_quantity\",\"id\":\"d3f766cb-5f96-4a12-8d3c-034e08be8855\",\"enhancements\":{}}}}" + }, + "description": "Analyze mock eCommerce orders and revenue", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}" + }, + "optionsJSON": "{\"hidePanelTitles\":false,\"useMargins\":true}", + "panelsJSON": "[{\"version\":\"8.6.0\",\"type\":\"search\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":18,\"i\":\"10\"},\"panelIndex\":\"10\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_10\"}]", + "refreshInterval": { + "pause": true, + "value": 0 + }, + "timeTo": "2022-10-25T20:00:00.000Z", + "timeFrom": "2022-10-18T20:00:00.000Z", + "timeRestore": true, + "title": "[eCommerce] Saved Search Dashboard", + "version": 1 + }, + "coreMigrationVersion": "8.6.0", + "created_at": "2022-10-31T12:08:58.731Z", + "id": "ccb9a590-5914-11ed-8d12-9d4a72794439", + "migrationVersion": { + "dashboard": "8.6.0" + }, + "references": [ + { + "id": "3ba638e0-b894-11e8-a6d9-e546fe2bba5f", + "name": "10:panel_10", + "type": "search" + }, + { + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "name": "controlGroup_1ee1617f-fd8e-45e4-bc6a-d5736710ea20:optionsListDataView", + "type": "index-pattern" + }, + { + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "name": "controlGroup_afa9fa0f-a002-41a5-bab9-b738316d2590:optionsListDataView", + "type": "index-pattern" + }, + { + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "name": "controlGroup_d3f766cb-5f96-4a12-8d3c-034e08be8855:rangeSliderDataView", + "type": "index-pattern" + } + ], + "type": "dashboard", + "updated_at": "2022-10-31T12:08:58.731Z", + "version": "WzI1NCwxXQ==" +} diff --git a/x-pack/performance/kbn_archives/ecommerce_tsvb_gauge_only_dashboard.json b/x-pack/performance/kbn_archives/ecommerce_tsvb_gauge_only_dashboard.json new file mode 100644 index 00000000000000..1237d5c825f1a3 --- /dev/null +++ b/x-pack/performance/kbn_archives/ecommerce_tsvb_gauge_only_dashboard.json @@ -0,0 +1,108 @@ +{ + "attributes": { + "fieldAttrs": "{\"products.manufacturer\":{\"count\":1},\"products.price\":{\"count\":1},\"products.product_name\":{\"count\":1},\"total_quantity\":{\"count\":1}}", + "fieldFormatMap": "{\"taxful_total_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.[00]\"}},\"products.price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}},\"taxless_total_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}},\"products.taxless_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}},\"products.taxful_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}},\"products.min_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}},\"products.base_unit_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}},\"products.base_price\":{\"id\":\"number\",\"params\":{\"pattern\":\"$0,0.00\"}}}", + "fields": "[]", + "name": "Kibana Sample Data eCommerce", + "runtimeFieldMap": "{}", + "timeFieldName": "order_date", + "title": "kibana_sample_data_ecommerce", + "typeMeta": "{}" + }, + "coreMigrationVersion": "8.6.0", + "created_at": "2022-10-31T12:48:03.382Z", + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "migrationVersion": { + "index-pattern": "8.0.0" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2022-10-31T12:48:03.382Z", + "version": "WzM1NCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "title": "[eCommerce] Sold Products per Day", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"[eCommerce] Sold Products per Day\",\"type\":\"metrics\",\"aggs\":[],\"params\":{\"time_range_mode\":\"entire_time_range\",\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"gauge\",\"series\":[{\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"count\"},{\"id\":\"fd1e1b90-e4e3-11eb-8234-cb7bfd534fce\",\"type\":\"math\",\"variables\":[{\"id\":\"00374270-e4e4-11eb-8234-cb7bfd534fce\",\"name\":\"c\",\"field\":\"61ca57f2-469d-11e7-af02-69e470af7417\"}],\"script\":\"params.c / (params._interval / 1000 / 60 / 60 / 24)\"}],\"separate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"0.0\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"label\":\"Trxns / day\",\"split_color_mode\":\"gradient\",\"value_template\":\"\"}],\"time_field\":\"order_date\",\"interval\":\"1d\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"axis_scale\":\"normal\",\"show_legend\":1,\"show_grid\":1,\"gauge_color_rules\":[{\"value\":150,\"id\":\"6da070c0-b891-11e8-b645-195edeb9de84\",\"gauge\":\"rgba(104,188,0,1)\",\"operator\":\"gte\"},{\"value\":150,\"id\":\"9b0cdbc0-b891-11e8-b645-195edeb9de84\",\"gauge\":\"rgba(244,78,59,1)\",\"operator\":\"lt\"}],\"gauge_width\":\"15\",\"gauge_inner_width\":\"10\",\"gauge_style\":\"half\",\"filter\":\"\",\"gauge_max\":\"300\",\"use_kibana_indexes\":true,\"hide_last_value_indicator\":true,\"tooltip_mode\":\"show_all\",\"drop_last_bucket\":0,\"isModelInvalid\":false,\"index_pattern_ref_name\":\"metrics_0_index_pattern\"}}" + }, + "coreMigrationVersion": "8.6.0", + "created_at": "2022-10-31T12:48:03.382Z", + "id": "b80e6540-b891-11e8-a6d9-e546fe2bba5f", + "migrationVersion": { + "visualization": "8.5.0" + }, + "references": [ + { + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "name": "metrics_0_index_pattern", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2022-10-31T12:48:03.382Z", + "version": "WzM1NiwxXQ==" +} + +{ + "attributes": { + "controlGroupInput": { + "chainingSystem": "HIERARCHICAL", + "controlStyle": "oneLine", + "ignoreParentSettingsJSON": "{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}", + "panelsJSON": "{\"1ee1617f-fd8e-45e4-bc6a-d5736710ea20\":{\"order\":0,\"width\":\"small\",\"grow\":true,\"type\":\"optionsListControl\",\"explicitInput\":{\"title\":\"Manufacturer\",\"fieldName\":\"manufacturer.keyword\",\"parentFieldName\":\"manufacturer\",\"id\":\"1ee1617f-fd8e-45e4-bc6a-d5736710ea20\",\"enhancements\":{}}},\"afa9fa0f-a002-41a5-bab9-b738316d2590\":{\"order\":1,\"width\":\"small\",\"grow\":true,\"type\":\"optionsListControl\",\"explicitInput\":{\"title\":\"Category\",\"fieldName\":\"category.keyword\",\"parentFieldName\":\"category\",\"id\":\"afa9fa0f-a002-41a5-bab9-b738316d2590\",\"enhancements\":{}}},\"d3f766cb-5f96-4a12-8d3c-034e08be8855\":{\"order\":2,\"width\":\"small\",\"grow\":true,\"type\":\"rangeSliderControl\",\"explicitInput\":{\"title\":\"Quantity\",\"fieldName\":\"total_quantity\",\"id\":\"d3f766cb-5f96-4a12-8d3c-034e08be8855\",\"enhancements\":{}}}}" + }, + "description": "Analyze mock eCommerce orders and revenue", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}" + }, + "optionsJSON": "{\"hidePanelTitles\":false,\"useMargins\":true}", + "panelsJSON": "[{\"version\":\"8.6.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":19,\"i\":\"7\"},\"panelIndex\":\"7\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_7\"}]", + "refreshInterval": { + "pause": true, + "value": 0 + }, + "timeTo": "2022-10-25T20:00:00.000Z", + "timeFrom": "2022-10-18T20:00:00.000Z", + "timeRestore": true, + "title": "[eCommerce] TSVB Gauge Only Dashboard", + "version": 1 + }, + "coreMigrationVersion": "8.6.0", + "created_at": "2022-10-31T12:51:08.112Z", + "id": "b05a8ee0-591a-11ed-8d12-9d4a72794439", + "migrationVersion": { + "dashboard": "8.6.0" + }, + "references": [ + { + "id": "b80e6540-b891-11e8-a6d9-e546fe2bba5f", + "name": "7:panel_7", + "type": "visualization" + }, + { + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "name": "controlGroup_1ee1617f-fd8e-45e4-bc6a-d5736710ea20:optionsListDataView", + "type": "index-pattern" + }, + { + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "name": "controlGroup_afa9fa0f-a002-41a5-bab9-b738316d2590:optionsListDataView", + "type": "index-pattern" + }, + { + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "name": "controlGroup_d3f766cb-5f96-4a12-8d3c-034e08be8855:rangeSliderDataView", + "type": "index-pattern" + } + ], + "type": "dashboard", + "updated_at": "2022-10-31T12:51:08.112Z", + "version": "WzQ2MywxXQ==" +} diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index 7fd09f2237eac0..63ab73ccc0efeb 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -304,7 +304,9 @@ test('cleans up action_task_params object', async () => { await taskRunner.run(); - expect(services.savedObjectsClient.delete).toHaveBeenCalledWith('action_task_params', '3'); + expect(services.savedObjectsClient.delete).toHaveBeenCalledWith('action_task_params', '3', { + refresh: false, + }); }); test('task runner should implement CancellableTask cancel method with logging warning message', async () => { @@ -367,7 +369,9 @@ test('runs successfully when cleanup fails and logs the error', async () => { await taskRunner.run(); - expect(services.savedObjectsClient.delete).toHaveBeenCalledWith('action_task_params', '3'); + expect(services.savedObjectsClient.delete).toHaveBeenCalledWith('action_task_params', '3', { + refresh: false, + }); expect(taskRunnerFactoryInitializerParams.logger.error).toHaveBeenCalledWith( 'Failed to cleanup action_task_params object [id="3"]: Fail' ); diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.ts index 51672865d67716..2c23dbc77e3162 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.ts @@ -171,7 +171,8 @@ export class TaskRunnerFactory { // Once support for legacy alert RBAC is dropped, this can be secured await getUnsecuredSavedObjectsClient(request).delete( ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, - actionTaskExecutorParams.actionTaskParamsId + actionTaskExecutorParams.actionTaskParamsId, + { refresh: false } ); } catch (e) { // Log error only, we shouldn't fail the task because of an error here (if ever there's retry logic) diff --git a/x-pack/plugins/apm/common/critical_path/get_aggregated_critical_path_root_nodes.ts b/x-pack/plugins/apm/common/critical_path/get_aggregated_critical_path_root_nodes.ts new file mode 100644 index 00000000000000..5d00db3977b078 --- /dev/null +++ b/x-pack/plugins/apm/common/critical_path/get_aggregated_critical_path_root_nodes.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { sumBy } from 'lodash'; +import type { CriticalPathResponse } from '../../server/routes/traces/get_aggregated_critical_path'; + +export interface CriticalPathTreeNode { + nodeId: string; + children: CriticalPathTreeNode[]; + countInclusive: number; + countExclusive: number; +} + +export function getAggregatedCriticalPathRootNodes(params: { + criticalPath: CriticalPathResponse; +}): { + rootNodes: CriticalPathTreeNode[]; + maxDepth: number; + numNodes: number; +} { + let maxDepth = 20; // min max depth + + const { criticalPath } = params; + + let numNodes = 0; + + function mergeNodesWithSameOperationId( + nodes: CriticalPathTreeNode[] + ): CriticalPathTreeNode[] { + const nodesByOperationId: Record = {}; + const mergedNodes = nodes.reduce( + (prev, node, index, array) => { + const nodeId = node.nodeId; + const operationId = criticalPath.operationIdByNodeId[nodeId]; + if (nodesByOperationId[operationId]) { + const prevNode = nodesByOperationId[operationId]; + prevNode.children.push(...node.children); + prevNode.countExclusive += node.countExclusive; + prevNode.countInclusive += node.countInclusive; + return prev; + } + + nodesByOperationId[operationId] = node; + + prev.push(node); + return prev; + }, + [] + ); + + numNodes += mergedNodes.length; + + mergedNodes.forEach((node) => { + node.children = mergeNodesWithSameOperationId(node.children); + }); + + return mergedNodes; + } + + function getNode(nodeId: string, depth: number): CriticalPathTreeNode { + maxDepth = Math.max(maxDepth, depth); + + const children = criticalPath.nodes[nodeId].map((childNodeId) => + getNode(childNodeId, depth + 1) + ); + + const nodeCountExclusive = criticalPath.timeByNodeId[nodeId] || 0; + const nodeCountInclusive = + sumBy(children, (child) => child.countInclusive) + nodeCountExclusive; + + return { + nodeId, + children, + countInclusive: nodeCountInclusive, + countExclusive: nodeCountExclusive, + }; + } + + const rootNodes = mergeNodesWithSameOperationId( + criticalPath.rootNodes.map((nodeId) => getNode(nodeId, 1)) + ); + + return { + rootNodes, + maxDepth, + numNodes, + }; +} diff --git a/x-pack/plugins/apm/common/critical_path/get_critical_path.ts b/x-pack/plugins/apm/common/critical_path/get_critical_path.ts index c517548bf3d1fc..ad4e166962ccb7 100644 --- a/x-pack/plugins/apm/common/critical_path/get_critical_path.ts +++ b/x-pack/plugins/apm/common/critical_path/get_critical_path.ts @@ -37,7 +37,10 @@ export function getCriticalPath(waterfall: IWaterfall): CriticalPath { const orderedChildren = directChildren.concat().sort((a, b) => { const endTimeA = a.offset + a.skew + a.duration; const endTimeB = b.offset + b.skew + b.duration; - return endTimeB - endTimeA; + if (endTimeA === endTimeB) { + return 0; + } + return endTimeB > endTimeA ? 1 : -1; }); // For each point in time, determine what child is on the critical path. diff --git a/x-pack/plugins/apm/common/index.ts b/x-pack/plugins/apm/common/index.ts new file mode 100644 index 00000000000000..d08ff963b0f1f1 --- /dev/null +++ b/x-pack/plugins/apm/common/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// for API tests +export { getAggregatedCriticalPathRootNodes } from './critical_path/get_aggregated_critical_path_root_nodes'; diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx index 4b41099240f540..21124629a40635 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx @@ -96,7 +96,7 @@ export function DetailView({ errorGroup, urlParams, kuery }: Props) { const method = error.http?.request?.method; const status = error.http?.response?.status_code; - const traceExplorerLink = router.link('/traces/explorer', { + const traceExplorerLink = router.link('/traces/explorer/waterfall', { query: { ...query, showCriticalPath: false, diff --git a/x-pack/plugins/apm/public/components/app/service_map/popover/edge_contents.tsx b/x-pack/plugins/apm/public/components/app/service_map/popover/edge_contents.tsx index add6c48fef8e5c..8f5b7e9acce395 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/popover/edge_contents.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/popover/edge_contents.tsx @@ -49,7 +49,7 @@ export function EdgeContents({ elementData }: ContentsProps) { ` [ span where service.name == "${sourceService}" and span.destination.service.resource == "${edgeData.targetData[SPAN_DESTINATION_SERVICE_RESOURCE]}" ]`; } - const url = apmRouter.link('/traces/explorer', { + const url = apmRouter.link('/traces/explorer/waterfall', { query: { ...query, type: TraceSearchType.eql, diff --git a/x-pack/plugins/apm/public/components/app/top_traces_overview/index.tsx b/x-pack/plugins/apm/public/components/app/top_traces_overview/index.tsx index 37eaf27bfb5622..685074155c3801 100644 --- a/x-pack/plugins/apm/public/components/app/top_traces_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/top_traces_overview/index.tsx @@ -44,18 +44,20 @@ export function TopTracesOverview() { ); return ( - <> - + + + + {fallbackToTransactions && ( - - - - - + + + )} - - + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/trace_explorer/index.tsx b/x-pack/plugins/apm/public/components/app/trace_explorer/index.tsx index 1b6c35adffc048..74b38ea153558b 100644 --- a/x-pack/plugins/apm/public/components/app/trace_explorer/index.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_explorer/index.tsx @@ -4,7 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import React, { useEffect, useMemo, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { @@ -12,39 +13,38 @@ import { TraceSearchType, } from '../../../../common/trace_explorer'; import { useApmParams } from '../../../hooks/use_apm_params'; -import { useFetcher } from '../../../hooks/use_fetcher'; +import { useApmRouter } from '../../../hooks/use_apm_router'; +import { useApmRoutePath } from '../../../hooks/use_apm_route_path'; import { useTimeRange } from '../../../hooks/use_time_range'; +import { TraceExplorerSamplesFetcherContextProvider } from '../../../hooks/use_trace_explorer_samples'; +import { APIClientRequestParamsOf } from '../../../services/rest/create_call_apm_api'; import { ApmDatePicker } from '../../shared/date_picker/apm_date_picker'; -import { fromQuery, toQuery, push } from '../../shared/links/url_helpers'; -import { useWaterfallFetcher } from '../transaction_details/use_waterfall_fetcher'; -import { WaterfallWithSummary } from '../transaction_details/waterfall_with_summary'; +import { push } from '../../shared/links/url_helpers'; +import { TechnicalPreviewBadge } from '../../shared/technical_preview_badge'; +import { TransactionTab } from '../transaction_details/waterfall_with_summary/transaction_tabs'; import { TraceSearchBox } from './trace_search_box'; -export function TraceExplorer() { - const [query, setQuery] = useState({ +export function TraceExplorer({ children }: { children: React.ReactElement }) { + const [searchQuery, setSearchQuery] = useState({ query: '', type: TraceSearchType.kql, }); const { + query, query: { rangeFrom, rangeTo, environment, query: queryFromUrlParams, type: typeFromUrlParams, - traceId, - transactionId, - waterfallItemId, - detailTab, - showCriticalPath, }, } = useApmParams('/traces/explorer'); const history = useHistory(); useEffect(() => { - setQuery({ + setSearchQuery({ query: queryFromUrlParams, type: typeFromUrlParams, }); @@ -55,120 +55,95 @@ export function TraceExplorer() { rangeTo, }); - const { data, status, error } = useFetcher( - (callApmApi) => { - return callApmApi('GET /internal/apm/traces/find', { - params: { - query: { - start, - end, - environment, - query: queryFromUrlParams, - type: typeFromUrlParams, - }, - }, - }); - }, - [start, end, environment, queryFromUrlParams, typeFromUrlParams] - ); + const params = useMemo< + APIClientRequestParamsOf<'GET /internal/apm/traces/find'>['params'] + >(() => { + return { + query: { + start, + end, + environment, + query: queryFromUrlParams, + type: typeFromUrlParams, + }, + }; + }, [start, end, environment, queryFromUrlParams, typeFromUrlParams]); - useEffect(() => { - const nextSample = data?.traceSamples[0]; - const nextWaterfallItemId = ''; - history.replace({ - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - traceId: nextSample?.traceId ?? '', - transactionId: nextSample?.transactionId, - waterfallItemId: nextWaterfallItemId, - }), - }); - }, [data, history]); + const router = useApmRouter(); - const waterfallFetchResult = useWaterfallFetcher({ - traceId, - transactionId, - start, - end, - }); - - const traceSamplesFetchResult = useMemo( - () => ({ - data, - status, - error, - }), - [data, status, error] - ); + const routePath = useApmRoutePath(); return ( - - - - - { - history.push({ - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - query: query.query, - type: query.type, - }), - }); - }} - onQueryChange={(nextQuery) => { - setQuery(nextQuery); - }} - /> - - - - - - - - { - push(history, { - query: { - traceId: sample.traceId, - transactionId: sample.transactionId, - waterfallItemId: '', - }, - }); - }} - onTabClick={(nextDetailTab) => { - push(history, { - query: { - detailTab: nextDetailTab, - }, - }); - }} - detailTab={detailTab} - waterfallItemId={waterfallItemId} - serviceName={ - waterfallFetchResult.waterfall.entryWaterfallTransaction?.doc - .service.name - } - showCriticalPath={showCriticalPath} - onShowCriticalPathChange={(nextShowCriticalPath) => { - push(history, { - query: { - showCriticalPath: nextShowCriticalPath ? 'true' : 'false', - }, - }); - }} - /> - - + + + + + + { + push(history, { + query: { + query: searchQuery.query, + type: searchQuery.type, + }, + }); + }} + onQueryChange={(nextQuery) => { + setSearchQuery(nextQuery); + }} + /> + + + + + + + + + + {i18n.translate('xpack.apm.traceExplorer.waterfallTab', { + defaultMessage: 'Waterfall', + })} + + + + + {i18n.translate('xpack.apm.traceExplorer.criticalPathTab', { + defaultMessage: 'Aggregated critical path', + })} + + + + + + + + + {children} + + ); } diff --git a/x-pack/plugins/apm/public/components/app/trace_explorer/trace_explorer_aggregated_critical_path.tsx b/x-pack/plugins/apm/public/components/app/trace_explorer/trace_explorer_aggregated_critical_path.tsx new file mode 100644 index 00000000000000..6ff12fac2351a0 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/trace_explorer/trace_explorer_aggregated_critical_path.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useMemo } from 'react'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { useTimeRange } from '../../../hooks/use_time_range'; +import { useTraceExplorerSamples } from '../../../hooks/use_trace_explorer_samples'; +import { CriticalPathFlamegraph } from '../../shared/critical_path_flamegraph'; + +export function TraceExplorerAggregatedCriticalPath() { + const { + query: { rangeFrom, rangeTo }, + } = useApmParams('/traces/explorer/critical_path'); + + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const { + data: { traceSamples }, + status: samplesFetchStatus, + } = useTraceExplorerSamples(); + + const traceIds = useMemo(() => { + return traceSamples.map((sample) => sample.traceId); + }, [traceSamples]); + + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/trace_explorer/trace_explorer_waterfall.tsx b/x-pack/plugins/apm/public/components/app/trace_explorer/trace_explorer_waterfall.tsx new file mode 100644 index 00000000000000..5c25421869da41 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/trace_explorer/trace_explorer_waterfall.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { useTimeRange } from '../../../hooks/use_time_range'; +import { useTraceExplorerSamples } from '../../../hooks/use_trace_explorer_samples'; +import { push, replace } from '../../shared/links/url_helpers'; +import { useWaterfallFetcher } from '../transaction_details/use_waterfall_fetcher'; +import { WaterfallWithSummary } from '../transaction_details/waterfall_with_summary'; + +export function TraceExplorerWaterfall() { + const history = useHistory(); + + const traceSamplesFetchResult = useTraceExplorerSamples(); + + const { + query: { + traceId, + transactionId, + waterfallItemId, + rangeFrom, + rangeTo, + environment, + showCriticalPath, + detailTab, + }, + } = useApmParams('/traces/explorer/waterfall'); + + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + useEffect(() => { + const nextSample = traceSamplesFetchResult.data?.traceSamples[0]; + const nextWaterfallItemId = ''; + replace(history, { + query: { + traceId: nextSample?.traceId ?? '', + transactionId: nextSample?.transactionId ?? '', + waterfallItemId: nextWaterfallItemId, + }, + }); + }, [traceSamplesFetchResult.data, history]); + + const waterfallFetchResult = useWaterfallFetcher({ + traceId, + transactionId, + start, + end, + }); + + return ( + { + push(history, { + query: { + traceId: sample.traceId, + transactionId: sample.transactionId, + waterfallItemId: '', + }, + }); + }} + onTabClick={(nextDetailTab) => { + push(history, { + query: { + detailTab: nextDetailTab, + }, + }); + }} + detailTab={detailTab} + waterfallItemId={waterfallItemId} + serviceName={ + waterfallFetchResult.waterfall.entryWaterfallTransaction?.doc.service + .name + } + showCriticalPath={showCriticalPath} + onShowCriticalPathChange={(nextShowCriticalPath) => { + push(history, { + query: { + showCriticalPath: nextShowCriticalPath ? 'true' : 'false', + }, + }); + }} + /> + ); +} 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 4c176527d49f60..7e48b1bb1a60b2 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 @@ -4,16 +4,22 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useApmRouter } from '../../../hooks/use_apm_router'; +import React from 'react'; +import { TraceSearchType } from '../../../../common/trace_explorer'; import { useApmParams } from '../../../hooks/use_apm_params'; +import { useApmRouter } from '../../../hooks/use_apm_router'; import { useApmRoutePath } from '../../../hooks/use_apm_route_path'; -import { TraceSearchType } from '../../../../common/trace_explorer'; -import { TransactionTab } from '../transaction_details/waterfall_with_summary/transaction_tabs'; import { useTraceExplorerEnabledSetting } from '../../../hooks/use_trace_explorer_enabled_setting'; +import { ApmMainTemplate } from '../../routing/templates/apm_main_template'; import { TechnicalPreviewBadge } from '../../shared/technical_preview_badge'; +import { Breadcrumb } from '../breadcrumb'; +import { TransactionTab } from '../transaction_details/waterfall_with_summary/transaction_tabs'; + +type Tab = Required< + Required>['pageHeader'] +>['tabs'][number]; export function TraceOverview({ children }: { children: React.ReactElement }) { const isTraceExplorerEnabled = useTraceExplorerEnabledSetting(); @@ -24,11 +30,7 @@ export function TraceOverview({ children }: { children: React.ReactElement }) { const routePath = useApmRoutePath(); - if (!isTraceExplorerEnabled) { - return children; - } - - const explorerLink = router.link('/traces/explorer', { + const topTracesLink = router.link('/traces', { query: { comparisonEnabled: query.comparisonEnabled, environment: query.environment, @@ -38,17 +40,14 @@ export function TraceOverview({ children }: { children: React.ReactElement }) { offset: query.offset, refreshInterval: query.refreshInterval, refreshPaused: query.refreshPaused, - query: '', - type: TraceSearchType.kql, - waterfallItemId: '', - traceId: '', - transactionId: '', - detailTab: TransactionTab.timeline, - showCriticalPath: false, }, }); - const topTracesLink = router.link('/traces', { + const title = i18n.translate('xpack.apm.views.traceOverview.title', { + defaultMessage: 'Traces', + }); + + const explorerLink = router.link('/traces/explorer/waterfall', { query: { comparisonEnabled: query.comparisonEnabled, environment: query.environment, @@ -58,30 +57,65 @@ export function TraceOverview({ children }: { children: React.ReactElement }) { offset: query.offset, refreshInterval: query.refreshInterval, refreshPaused: query.refreshPaused, + query: '', + type: TraceSearchType.kql, + waterfallItemId: '', + traceId: '', + transactionId: '', + detailTab: TransactionTab.timeline, + showCriticalPath: false, }, }); + const tabs: Tab[] = isTraceExplorerEnabled + ? [ + { + href: topTracesLink, + label: i18n.translate('xpack.apm.traceOverview.topTracesTab', { + defaultMessage: 'Top traces', + }), + isSelected: routePath === '/traces', + }, + { + href: explorerLink, + label: ( + + + {i18n.translate('xpack.apm.traceOverview.traceExplorerTab', { + defaultMessage: 'Explorer', + })} + + + + + + ), + isSelected: routePath.startsWith('/traces/explorer'), + }, + ] + : []; + return ( - - - - - {i18n.translate('xpack.apm.traceOverview.topTracesTab', { - defaultMessage: 'Top traces', - })} - - } - isSelected={routePath === '/traces/explorer'} - > - {i18n.translate('xpack.apm.traceOverview.traceExplorerTab', { - defaultMessage: 'Explorer', - })} - - - - {children} - + + + {children} + + ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/aggregated_critical_path_tab.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/aggregated_critical_path_tab.tsx new file mode 100644 index 00000000000000..3a6f479a612541 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/aggregated_critical_path_tab.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { useTimeRange } from '../../../hooks/use_time_range'; +import { CriticalPathFlamegraph } from '../../shared/critical_path_flamegraph'; +import { TechnicalPreviewBadge } from '../../shared/technical_preview_badge'; +import { TabContentProps } from './transaction_details_tabs'; + +function TransactionDetailAggregatedCriticalPath({ + traceSamplesFetchResult, +}: TabContentProps) { + const { + path: { serviceName }, + query: { rangeFrom, rangeTo, transactionName }, + } = useApmParams('/services/{serviceName}/transactions/view'); + + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const traceIds = useMemo(() => { + return ( + traceSamplesFetchResult.data?.traceSamples.map( + (sample) => sample.traceId + ) ?? [] + ); + }, [traceSamplesFetchResult.data]); + + return ( + + ); +} + +export const aggregatedCriticalPathTab = { + dataTestSubj: 'apmAggregatedCriticalPathTabButton', + key: 'aggregatedCriticalPath', + label: ( + + + {i18n.translate( + 'xpack.apm.transactionDetails.tabs.aggregatedCriticalPathLabel', + { + defaultMessage: 'Aggregated critical path', + } + )} + + + + + + ), + component: TransactionDetailAggregatedCriticalPath, +}; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/transaction_details_tabs.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/transaction_details_tabs.tsx index c99703d4f90f8f..866c94702fb001 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/transaction_details_tabs.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/transaction_details_tabs.tsx @@ -27,6 +27,8 @@ import { latencyCorrelationsTab } from './latency_correlations_tab'; import { traceSamplesTab } from './trace_samples_tab'; import { useSampleChartSelection } from '../../../hooks/use_sample_chart_selection'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { useCriticalPathFeatureEnabledSetting } from '../../../hooks/use_critical_path_feature_enabled_setting'; +import { aggregatedCriticalPathTab } from './aggregated_critical_path_tab'; export interface TabContentProps { clearChartSelection: () => void; @@ -46,12 +48,18 @@ const tabs = [ export function TransactionDetailsTabs() { const { query } = useApmParams('/services/{serviceName}/transactions/view'); + const isCriticalPathFeatureEnabled = useCriticalPathFeatureEnabledSetting(); + + const availableTabs = isCriticalPathFeatureEnabled + ? tabs.concat(aggregatedCriticalPathTab) + : tabs; + const { urlParams } = useLegacyUrlParams(); const history = useHistory(); const [currentTab, setCurrentTab] = useState(traceSamplesTab.key); const { component: TabContent } = - tabs.find((tab) => tab.key === currentTab) ?? traceSamplesTab; + availableTabs.find((tab) => tab.key === currentTab) ?? traceSamplesTab; const { environment, kuery, transactionName } = query; @@ -107,7 +115,7 @@ export function TransactionDetailsTabs() { return ( <> - {tabs.map(({ dataTestSubj, key, label }) => ( + {availableTabs.map(({ dataTestSubj, key, label }) => ( ({ const entryTransaction = entryWaterfallTransaction?.doc; return ( - <> - - - -
        - {i18n.translate('xpack.apm.transactionDetails.traceSampleTitle', { - defaultMessage: 'Trace sample', - })} -
        -
        -
        - - {!!traceSamples?.length && ( - - )} - - - - - - - - + + + + +
        + {i18n.translate( + 'xpack.apm.transactionDetails.traceSampleTitle', + { + defaultMessage: 'Trace sample', + } + )} +
        +
        +
        + + {!!traceSamples?.length && ( + - -
        -
        -
        - - + )} +
        + + + + + + + + + + +
        + {isLoading || !entryTransaction ? ( - <> + - + ) : ( - + + + )} - - - - + + + + ); } diff --git a/x-pack/plugins/apm/public/components/routing/home/index.tsx b/x-pack/plugins/apm/public/components/routing/home/index.tsx index 555d42ddb6f981..b908f87832b95d 100644 --- a/x-pack/plugins/apm/public/components/routing/home/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/home/index.tsx @@ -19,6 +19,8 @@ import { ServiceInventory } from '../../app/service_inventory'; import { ServiceMapHome } from '../../app/service_map'; import { TopTracesOverview } from '../../app/top_traces_overview'; import { TraceExplorer } from '../../app/trace_explorer'; +import { TraceExplorerAggregatedCriticalPath } from '../../app/trace_explorer/trace_explorer_aggregated_critical_path'; +import { TraceExplorerWaterfall } from '../../app/trace_explorer/trace_explorer_waterfall'; import { TraceOverview } from '../../app/trace_overview'; import { TransactionTab } from '../../app/transaction_details/waterfall_with_summary/transaction_tabs'; import { RedirectTo } from '../redirect_to'; @@ -184,11 +186,7 @@ export const home = { element: , serviceGroupContextTab: 'service-map', }), - ...page({ - path: '/traces', - title: i18n.translate('xpack.apm.views.traceOverview.title', { - defaultMessage: 'Traces', - }), + '/traces': { element: ( @@ -196,7 +194,42 @@ export const home = { ), children: { '/traces/explorer': { - element: , + element: ( + + + + ), + children: { + '/traces/explorer/waterfall': { + element: , + params: t.type({ + query: t.type({ + traceId: t.string, + transactionId: t.string, + waterfallItemId: t.string, + detailTab: t.union([ + t.literal(TransactionTab.timeline), + t.literal(TransactionTab.metadata), + t.literal(TransactionTab.logs), + ]), + }), + }), + defaults: { + query: { + waterfallItemId: '', + traceId: '', + transactionId: '', + detailTab: TransactionTab.timeline, + }, + }, + }, + '/traces/explorer/critical_path': { + element: , + }, + '/traces/explorer': { + element: , + }, + }, params: t.type({ query: t.type({ query: t.string, @@ -204,14 +237,6 @@ export const home = { t.literal(TraceSearchType.kql), t.literal(TraceSearchType.eql), ]), - waterfallItemId: t.string, - traceId: t.string, - transactionId: t.string, - detailTab: t.union([ - t.literal(TransactionTab.timeline), - t.literal(TransactionTab.metadata), - t.literal(TransactionTab.logs), - ]), showCriticalPath: toBooleanRt, }), }), @@ -219,10 +244,6 @@ export const home = { query: { query: '', type: TraceSearchType.kql, - waterfallItemId: '', - traceId: '', - transactionId: '', - detailTab: TransactionTab.timeline, showCriticalPath: '', }, }, @@ -231,7 +252,7 @@ export const home = { element: , }, }, - }), + }, ...dependencies, ...legacyBackends, ...storageExplorer, diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx index 60f06ddaef8fcb..c4bcc4e5fc612d 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx @@ -6,17 +6,18 @@ */ import { EuiPageHeaderProps } from '@elastic/eui'; -import React from 'react'; -import { useLocation } from 'react-router-dom'; import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { ObservabilityPageTemplateProps } from '@kbn/observability-plugin/public/components/shared/page_template/page_template'; import type { KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template'; +import React from 'react'; +import { useLocation } from 'react-router-dom'; import { EnvironmentsContextProvider } from '../../../context/environments_context/environments_context'; -import { useFetcher, FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { ApmPluginStartDeps } from '../../../plugin'; -import { ApmEnvironmentFilter } from '../../shared/environment_filter'; -import { getNoDataConfig } from './no_data_config'; import { ServiceGroupSaveButton } from '../../app/service_groups'; import { ServiceGroupsButtonGroup } from '../../app/service_groups/service_groups_button_group'; +import { ApmEnvironmentFilter } from '../../shared/environment_filter'; +import { getNoDataConfig } from './no_data_config'; // Paths that must skip the no data screen const bypassNoDataScreenPaths = ['/settings']; @@ -48,7 +49,8 @@ export function ApmMainTemplate({ showServiceGroupSaveButton?: boolean; showServiceGroupsNav?: boolean; selectedNavButton?: 'serviceGroups' | 'allServices'; -} & KibanaPageTemplateProps) { +} & KibanaPageTemplateProps & + Pick) { const location = useLocation(); const { services } = useKibana(); diff --git a/x-pack/plugins/apm/public/components/shared/critical_path_flamegraph/critical_path_flamegraph_tooltip.tsx b/x-pack/plugins/apm/public/components/shared/critical_path_flamegraph/critical_path_flamegraph_tooltip.tsx new file mode 100644 index 00000000000000..882b974abd6fec --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/critical_path_flamegraph/critical_path_flamegraph_tooltip.tsx @@ -0,0 +1,120 @@ +/* + * 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 { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiPanel, +} from '@elastic/eui'; +import React from 'react'; +import { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { i18n } from '@kbn/i18n'; +import type { CriticalPathResponse } from '../../../../server/routes/traces/get_aggregated_critical_path'; +import { + AGENT_NAME, + SERVICE_NAME, + SPAN_NAME, + SPAN_SUBTYPE, + SPAN_TYPE, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../../common/elasticsearch_fieldnames'; +import { SpanIcon } from '../span_icon'; +import { AgentIcon } from '../agent_icon'; +import { asPercent } from '../../../../common/utils/formatters'; + +export function CriticalPathFlamegraphTooltip({ + metadata, + countInclusive, + countExclusive, + totalCount, +}: { + metadata?: CriticalPathResponse['metadata'][string]; + countInclusive: number; + countExclusive: number; + totalCount: number; +}) { + if (!metadata) { + return <>; + } + + return ( + + + {metadata['processor.event'] === ProcessorEvent.transaction ? ( + + + + {metadata[TRANSACTION_NAME]} + + + {metadata[TRANSACTION_TYPE]} + + + + ) : ( + + + + + + {metadata[SPAN_NAME]} + + + )} + + + + + + + + + {metadata[SERVICE_NAME]} + + + + + + + + + {i18n.translate('xpack.apm.criticalPathFlameGraph.selfTime', { + defaultMessage: 'Self time: {value}', + values: { + value: asPercent(countExclusive / totalCount, 1), + }, + })} + + + {i18n.translate('xpack.apm.criticalPathFlameGraph.totalTime', { + defaultMessage: 'Total time: {value}', + values: { + value: asPercent(countInclusive / totalCount, 1), + }, + })} + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/critical_path_flamegraph/critical_path_to_flamegraph.ts b/x-pack/plugins/apm/public/components/shared/critical_path_flamegraph/critical_path_to_flamegraph.ts new file mode 100644 index 00000000000000..4bc5e222fa8c74 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/critical_path_flamegraph/critical_path_to_flamegraph.ts @@ -0,0 +1,144 @@ +/* + * 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 type { ColumnarViewModel } from '@elastic/charts'; +import { memoize, sumBy } from 'lodash'; +import { lighten, parseToRgb } from 'polished'; +import seedrandom from 'seedrandom'; +import type { CriticalPathResponse } from '../../../../server/routes/traces/get_aggregated_critical_path'; +import { + CriticalPathTreeNode, + getAggregatedCriticalPathRootNodes, +} from '../../../../common/critical_path/get_aggregated_critical_path_root_nodes'; + +const lightenColor = lighten(0.2); + +export function criticalPathToFlamegraph( + params: { + criticalPath: CriticalPathResponse; + colors: string[]; + } & ({ serviceName: string; transactionName: string } | {}) +): { + viewModel: ColumnarViewModel; + operationId: string[]; + countExclusive: Float64Array; + sum: number; +} { + let sum = 0; + + const { criticalPath, colors } = params; + + const { rootNodes, maxDepth, numNodes } = + getAggregatedCriticalPathRootNodes(params); + + // include the root node + const totalSize = numNodes + 1; + + const operationId = new Array(totalSize); + const countInclusive = new Float64Array(totalSize); + const countExclusive = new Float64Array(totalSize); + const label = new Array(totalSize); + const position = new Float32Array(totalSize * 2); + const size = new Float32Array(totalSize); + const color = new Float32Array(totalSize * 4); + + // eslint-disable-next-line guard-for-in + for (const nodeId in criticalPath.timeByNodeId) { + const count = criticalPath.timeByNodeId[nodeId]; + sum += count; + } + + let maxValue = 0; + + let index = 0; + + const availableColors: Array<[number, number, number, number]> = colors.map( + (vizColor) => { + const rgb = parseToRgb(lightenColor(vizColor)); + + return [rgb.red / 255, rgb.green / 255, rgb.blue / 255, 1]; + } + ); + + const pickColor = memoize((identifier: string) => { + const idx = + Math.abs(seedrandom(identifier).int32()) % availableColors.length; + return availableColors[idx]; + }); + + function addNodeToFlamegraph( + node: CriticalPathTreeNode, + x: number, + y: number + ) { + let nodeOperationId: string; + let nodeLabel: string; + let operationMetadata: CriticalPathResponse['metadata'][string] | undefined; + if (node.nodeId === 'root') { + nodeOperationId = ''; + nodeLabel = 'root'; + } else { + nodeOperationId = criticalPath.operationIdByNodeId[node.nodeId]; + operationMetadata = criticalPath.metadata[nodeOperationId]; + nodeLabel = + operationMetadata['processor.event'] === 'transaction' + ? operationMetadata['transaction.name'] + : operationMetadata['span.name']; + } + + operationId[index] = nodeOperationId; + countInclusive[index] = node.countInclusive; + countExclusive[index] = node.countExclusive; + label[index] = nodeLabel; + position[index * 2] = x / maxValue; + position[index * 2 + 1] = 1 - (y + 1) / (maxDepth + 1); + size[index] = node.countInclusive / maxValue; + + const identifier = + operationMetadata?.['processor.event'] === 'transaction' + ? operationMetadata['transaction.type'] + : operationMetadata?.['span.subtype'] || + operationMetadata?.['span.type'] || + ''; + + color.set(pickColor(identifier), index * 4); + + index++; + + let childX = x; + node.children.forEach((child) => { + addNodeToFlamegraph(child, childX, y + 1); + childX += child.countInclusive; + }); + } + + const root: CriticalPathTreeNode = { + children: rootNodes, + nodeId: 'root', + countExclusive: 0, + countInclusive: sumBy(rootNodes, 'countInclusive'), + }; + + maxValue = root.countInclusive; + + addNodeToFlamegraph(root, 0, 0); + + return { + viewModel: { + value: countInclusive, + label, + color, + position0: position, + position1: position, + size0: size, + size1: size, + }, + operationId, + countExclusive, + sum, + }; +} diff --git a/x-pack/plugins/apm/public/components/shared/critical_path_flamegraph/index.tsx b/x-pack/plugins/apm/public/components/shared/critical_path_flamegraph/index.tsx new file mode 100644 index 00000000000000..f99b82a112c788 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/critical_path_flamegraph/index.tsx @@ -0,0 +1,165 @@ +/* + * 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 { Chart, Datum, Flame, Settings } from '@elastic/charts'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + euiPaletteColorBlind, +} from '@elastic/eui'; +import { css } from '@emotion/css'; +import { useChartTheme } from '@kbn/observability-plugin/public'; +import { uniqueId } from 'lodash'; +import React, { useMemo, useRef } from 'react'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; +import { CriticalPathFlamegraphTooltip } from './critical_path_flamegraph_tooltip'; +import { criticalPathToFlamegraph } from './critical_path_to_flamegraph'; + +const chartClassName = css` + flex-grow: 1; +`; + +export function CriticalPathFlamegraph( + props: { + start: string; + end: string; + traceIds: string[]; + traceIdsFetchStatus: FETCH_STATUS; + } & ({ serviceName: string; transactionName: string } | {}) +) { + const { start, end, traceIds, traceIdsFetchStatus } = props; + + const serviceName = 'serviceName' in props ? props.serviceName : null; + const transactionName = + 'transactionName' in props ? props.transactionName : null; + + // Use a reference to time range, to not invalidate the API fetch + // we only care for traceIds, start/end are there to limit the search + // request to a certain time range. It shouldn't affect the actual results + // of the search. + const timerange = useRef({ start, end }); + timerange.current = { start, end }; + + const { + data: { criticalPath } = { criticalPath: null }, + status: criticalPathFetchStatus, + } = useFetcher( + (callApmApi) => { + if (!traceIds.length) { + return Promise.resolve({ criticalPath: null }); + } + + return callApmApi('POST /internal/apm/traces/aggregated_critical_path', { + params: { + body: { + start: timerange.current.start, + end: timerange.current.end, + traceIds, + serviceName, + transactionName, + }, + }, + }); + }, + [timerange, traceIds, serviceName, transactionName] + ); + + const chartTheme = useChartTheme(); + + const isLoading = + traceIdsFetchStatus === FETCH_STATUS.NOT_INITIATED || + traceIdsFetchStatus === FETCH_STATUS.LOADING || + criticalPathFetchStatus === FETCH_STATUS.NOT_INITIATED || + criticalPathFetchStatus === FETCH_STATUS.LOADING; + + const flameGraph = useMemo(() => { + if (!criticalPath) { + return undefined; + } + + const colors = euiPaletteColorBlind({}); + + const flamegraph = criticalPathToFlamegraph({ + criticalPath, + colors, + }); + + return { + ...flamegraph, + // make sure Flame re-renders when data changes, workaround for https://github.com/elastic/elastic-charts/issues/1766 + key: uniqueId(), + }; + }, [criticalPath]); + + const themeOverrides = { + chartMargins: { top: 0, left: 0, bottom: 0, right: 0 }, + chartPaddings: { left: 0, right: 0, top: 0, bottom: 0 }, + }; + + return ( + + {isLoading ? ( + + + + ) : ( + flameGraph && ( + + + { + const valueIndex = tooltipProps.values[0] + .valueAccessor as number; + const operationId = flameGraph.operationId[valueIndex]; + const operationMetadata = + criticalPath?.metadata[operationId]; + const countInclusive = + flameGraph.viewModel.value[valueIndex]; + const countExclusive = + flameGraph.countExclusive[valueIndex]; + + return ( + + ); + }, + }} + onElementClick={(elements) => {}} + /> + d.value as number} + valueFormatter={(value) => `${value}`} + animation={{ duration: 100 }} + controlProviderCallback={{}} + /> + + + ) + )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/technical_preview_badge.tsx b/x-pack/plugins/apm/public/components/shared/technical_preview_badge.tsx index 64002312bdc459..b00d0c256f3f57 100644 --- a/x-pack/plugins/apm/public/components/shared/technical_preview_badge.tsx +++ b/x-pack/plugins/apm/public/components/shared/technical_preview_badge.tsx @@ -9,11 +9,11 @@ import { EuiBetaBadge, IconType } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -interface Props { +type Props = { icon?: IconType; -} +} & Pick, 'size' | 'style'>; -export function TechnicalPreviewBadge({ icon }: Props) { +export function TechnicalPreviewBadge({ icon, size, style }: Props) { return ( ); } diff --git a/x-pack/plugins/apm/public/hooks/create_shared_use_fetcher.tsx b/x-pack/plugins/apm/public/hooks/create_shared_use_fetcher.tsx new file mode 100644 index 00000000000000..ba608fcd451c85 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/create_shared_use_fetcher.tsx @@ -0,0 +1,72 @@ +/* + * 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, { createContext, useContext, useMemo } from 'react'; +import type { APIEndpoint } from '../../server'; +import type { + APIClientRequestParamsOf, + APIReturnType, +} from '../services/rest/create_call_apm_api'; +import { useFetcher, FetcherResult } from './use_fetcher'; + +interface SharedUseFetcher { + useFetcherResult: () => FetcherResult> & { + refetch: () => void; + }; + Provider: React.FunctionComponent< + { + children: React.ReactElement; + params: {}; + } & APIClientRequestParamsOf + >; +} + +export function createSharedUseFetcher( + endpoint: TEndpoint +): SharedUseFetcher { + const Context = createContext< + APIClientRequestParamsOf | undefined + >(undefined); + + const returnValue: SharedUseFetcher = { + useFetcherResult: () => { + const context = useContext(Context); + + if (!context) { + throw new Error('Context was not found'); + } + + const params = context.params; + + const result = useFetcher( + (callApmApi) => { + return callApmApi( + ...([endpoint, { params }] as Parameters) + ); + }, + [params] + ); + + return result as ReturnType< + SharedUseFetcher['useFetcherResult'] + >; + }, + Provider: (props) => { + const { children } = props; + + const params = props.params; + + const memoizedParams = useMemo(() => { + return { params }; + }, [params]); + return ( + {children} + ); + }, + }; + + return returnValue; +} diff --git a/x-pack/plugins/apm/public/hooks/use_trace_explorer_samples.ts b/x-pack/plugins/apm/public/hooks/use_trace_explorer_samples.ts new file mode 100644 index 00000000000000..17b71ac1cc28aa --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_trace_explorer_samples.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { createSharedUseFetcher } from './create_shared_use_fetcher'; + +const sharedUseFetcher = createSharedUseFetcher( + 'GET /internal/apm/traces/find' +); + +const useTraceExplorerSamples = () => { + const result = sharedUseFetcher.useFetcherResult(); + + return useMemo(() => { + return { + ...result, + data: result.data || { + traceSamples: [], + }, + }; + }, [result]); +}; +const TraceExplorerSamplesFetcherContextProvider = sharedUseFetcher.Provider; + +export { useTraceExplorerSamples, TraceExplorerSamplesFetcherContextProvider }; diff --git a/x-pack/plugins/apm/server/routes/traces/get_aggregated_critical_path.ts b/x-pack/plugins/apm/server/routes/traces/get_aggregated_critical_path.ts new file mode 100644 index 00000000000000..8acf458f46e225 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/traces/get_aggregated_critical_path.ts @@ -0,0 +1,406 @@ +/* + * 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 { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { rangeQuery, termsQuery } from '@kbn/observability-plugin/server'; +import { Logger } from '@kbn/logging'; +import { + AGENT_NAME, + PROCESSOR_EVENT, + SERVICE_NAME, + SPAN_NAME, + SPAN_SUBTYPE, + SPAN_TYPE, + TRACE_ID, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; +import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; + +type OperationMetadata = { + [SERVICE_NAME]: string; + [AGENT_NAME]: AgentName; +} & ( + | { + [PROCESSOR_EVENT]: ProcessorEvent.transaction; + [TRANSACTION_TYPE]: string; + [TRANSACTION_NAME]: string; + } + | { + [PROCESSOR_EVENT]: ProcessorEvent.span; + [SPAN_NAME]: string; + [SPAN_TYPE]: string; + [SPAN_SUBTYPE]: string; + } +); + +type OperationId = string; + +type NodeId = string; + +export interface CriticalPathResponse { + metadata: Record; + timeByNodeId: Record; + nodes: Record; + rootNodes: NodeId[]; + operationIdByNodeId: Record; +} + +const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000; + +export async function getAggregatedCriticalPath({ + traceIds, + start, + end, + apmEventClient, + serviceName, + transactionName, + logger, +}: { + traceIds: string[]; + start: number; + end: number; + apmEventClient: APMEventClient; + serviceName: string | null; + transactionName: string | null; + logger: Logger; +}): Promise<{ criticalPath: CriticalPathResponse | null }> { + const now = Date.now(); + + const response = await apmEventClient.search('get_aggregated_critical_path', { + apm: { + events: [ProcessorEvent.span, ProcessorEvent.transaction], + }, + body: { + size: 0, + track_total_hits: false, + query: { + bool: { + filter: [ + ...termsQuery(TRACE_ID, ...traceIds), + // we need a range query to allow ES to skip shards based on the time range, + // but we need enough padding to make sure we get the full trace + ...rangeQuery(start - TWO_DAYS_MS, end + TWO_DAYS_MS), + ], + }, + }, + aggs: { + critical_path: { + scripted_metric: { + params: { + // can't send null parameters to ES. undefined will be removed during JSON serialisation + serviceName: serviceName || undefined, + transactionName: transactionName || undefined, + }, + init_script: { + source: ` + state.eventsById = [:]; + state.metadataByOperationId = [:]; + `, + }, + map_script: { + source: ` + String toHash (def item) { + return item.toString(); + } + + def id; + double duration; + + def operationMetadata = [ + "service.name": doc['service.name'].value, + "processor.event": doc['processor.event'].value, + "agent.name": doc['agent.name'].value + ]; + + def isSpan = !doc['span.id'].empty; + + if (isSpan) { + id = doc['span.id'].value; + operationMetadata.put('span.name', doc['span.name'].value); + if (!doc['span.type'].empty) { + operationMetadata.put('span.type', doc['span.type'].value); + } + if (!doc['span.subtype'].empty) { + operationMetadata.put('span.subtype', doc['span.subtype'].value); + } + duration = doc['span.duration.us'].value; + } else { + id = doc['transaction.id'].value; + operationMetadata.put('transaction.name', doc['transaction.name'].value); + operationMetadata.put('transaction.type', doc['transaction.type'].value); + duration = doc['transaction.duration.us'].value; + } + + String operationId = toHash(operationMetadata); + + def map = [ + "traceId": doc['trace.id'].value, + "id": id, + "parentId": doc['parent.id'].empty ? null : doc['parent.id'].value, + "operationId": operationId, + "timestamp": doc['timestamp.us'].value, + "duration": duration + ]; + + if (state.metadataByOperationId[operationId] == null) { + state.metadataByOperationId.put(operationId, operationMetadata); + } + state.eventsById.put(id, map); + `, + }, + combine_script: { + source: 'return state;', + }, + reduce_script: { + source: ` + String toHash (def item) { + return item.toString(); + } + + def processEvent (def context, def event) { + if (context.processedEvents[event.id] != null) { + return context.processedEvents[event.id]; + } + + def processedEvent = [ + "children": [] + ]; + + if(event.parentId != null) { + def parent = context.events[event.parentId]; + if (parent == null) { + return null; + } + def processedParent = processEvent(context, parent); + if (processedParent == null) { + return null; + } + processedParent.children.add(processedEvent); + } + + context.processedEvents.put(event.id, processedEvent); + + processedEvent.putAll(event); + + if (context.params.serviceName != null && context.params.transactionName != null) { + + def metadata = context.metadata[event.operationId]; + + if (metadata != null + && context.params.serviceName == metadata['service.name'] + && metadata['transaction.name'] != null + && context.params.transactionName == metadata['transaction.name'] + ) { + context.entryTransactions.add(processedEvent); + } + + } else if (event.parentId == null) { + context.entryTransactions.add(processedEvent); + } + + return processedEvent; + } + + double getClockSkew (def context, def item, def parent ) { + if (parent == null) { + return 0; + } + + def processorEvent = context.metadata[item.operationId]['processor.event']; + + def isTransaction = processorEvent == 'transaction'; + + if (!isTransaction) { + return parent.skew; + } + + double parentStart = parent.timestamp + parent.skew; + double offsetStart = parentStart - item.timestamp; + if (offsetStart > 0) { + double latency = Math.round(Math.max(parent.duration - item.duration, 0) / 2); + return offsetStart + latency; + } + + return 0; + } + + void setOffsetAndSkew ( def context, def event, def parent, def startOfTrace ) { + event.skew = getClockSkew(context, event, parent); + event.offset = event.timestamp - startOfTrace; + for(child in event.children) { + setOffsetAndSkew(context, child, event, startOfTrace); + } + event.end = event.offset + event.skew + event.duration; + } + + void count ( def context, def nodeId, def duration ) { + context.timeByNodeId[nodeId] = (context.timeByNodeId[nodeId] ?: 0) + duration; + } + + void scan ( def context, def item, def start, def end, def path ) { + + def nodeId = toHash(path); + + def childNodes = context.nodes[nodeId] != null ? context.nodes[nodeId] : []; + + context.nodes[nodeId] = childNodes; + + context.operationIdByNodeId[nodeId] = item.operationId; + + if (item.children.size() == 0) { + count(context, nodeId, end - start); + return; + } + + item.children.sort((a, b) -> { + if (b.end === a.end) { + return 0; + } + if (b.end > a.end) { + return 1; + } + return -1; + }); + + def scanTime = end; + + for(child in item.children) { + double normalizedChildStart = Math.max(child.offset + child.skew, start); + double childEnd = child.offset + child.skew + child.duration; + + double normalizedChildEnd = Math.min(childEnd, scanTime); + + def isOnCriticalPath = !( + normalizedChildStart >= scanTime || + normalizedChildEnd < start || + childEnd > scanTime + ); + + if (!isOnCriticalPath) { + continue; + } + + def childPath = path.clone(); + + childPath.add(child.operationId); + + def childId = toHash(childPath); + + if(!childNodes.contains(childId)) { + childNodes.add(childId); + } + + if (normalizedChildEnd < (scanTime - 1000)) { + count(context, nodeId, scanTime - normalizedChildEnd); + } + + scan(context, child, normalizedChildStart, childEnd, childPath); + + scanTime = normalizedChildStart; + } + + if (scanTime > start) { + count(context, nodeId, scanTime - start); + } + + } + + def events = [:]; + def metadata = [:]; + def processedEvents = [:]; + def entryTransactions = []; + def timeByNodeId = [:]; + def nodes = [:]; + def rootNodes = []; + def operationIdByNodeId = [:]; + + + def context = [ + "events": events, + "metadata": metadata, + "processedEvents": processedEvents, + "entryTransactions": entryTransactions, + "timeByNodeId": timeByNodeId, + "nodes": nodes, + "operationIdByNodeId": operationIdByNodeId, + "params": params + ]; + + for(state in states) { + if (state.eventsById != null) { + events.putAll(state.eventsById); + } + if (state.metadataByOperationId != null) { + metadata.putAll(state.metadataByOperationId); + } + } + + + for(def event: events.values()) { + processEvent(context, event); + } + + for(transaction in context.entryTransactions) { + transaction.skew = 0; + transaction.offset = 0; + setOffsetAndSkew(context, transaction, null, transaction.timestamp); + + def path = []; + def parent = transaction; + while (parent != null) { + path.add(parent.operationId); + if (parent.parentId == null) { + break; + } + parent = context.processedEvents[parent.parentId]; + } + + Collections.reverse(path); + + def nodeId = toHash(path); + + scan(context, transaction, 0, transaction.duration, path); + + if (!rootNodes.contains(nodeId)) { + rootNodes.add(nodeId); + } + + } + + return [ + "timeByNodeId": timeByNodeId, + "metadata": metadata, + "nodes": nodes, + "rootNodes": rootNodes, + "operationIdByNodeId": operationIdByNodeId + ];`, + }, + }, + }, + }, + }, + }); + + logger.debug( + `Retrieved critical path in ${Date.now() - now}ms, took: ${response.took}ms` + ); + + if (!response.aggregations) { + return { + criticalPath: null, + }; + } + + const criticalPath = response.aggregations?.critical_path + .value as CriticalPathResponse; + + return { + criticalPath, + }; +} diff --git a/x-pack/plugins/apm/server/routes/traces/route.ts b/x-pack/plugins/apm/server/routes/traces/route.ts index dc1408f9bddef4..5c72a91c4b0139 100644 --- a/x-pack/plugins/apm/server/routes/traces/route.ts +++ b/x-pack/plugins/apm/server/routes/traces/route.ts @@ -6,6 +6,7 @@ */ import * as t from 'io-ts'; +import { nonEmptyStringRt } from '@kbn/io-ts-utils'; import { TraceSearchType } from '../../../common/trace_explorer'; import { setupRequest } from '../../lib/helpers/setup_request'; import { getSearchTransactionsEvents } from '../../lib/helpers/transactions'; @@ -23,6 +24,10 @@ import { getTraceItems } from './get_trace_items'; import { getTraceSamplesByQuery } from './get_trace_samples_by_query'; import { getRandomSampler } from '../../lib/helpers/get_random_sampler'; import { getApmEventClient } from '../../lib/helpers/get_apm_event_client'; +import { + CriticalPathResponse, + getAggregatedCriticalPath, +} from './get_aggregated_critical_path'; const tracesRoute = createApmServerRoute({ endpoint: 'GET /internal/apm/traces', @@ -194,10 +199,49 @@ const findTracesRoute = createApmServerRoute({ }, }); +const aggregatedCriticalPathRoute = createApmServerRoute({ + endpoint: 'POST /internal/apm/traces/aggregated_critical_path', + params: t.type({ + body: t.intersection([ + t.type({ + traceIds: t.array(t.string), + serviceName: t.union([nonEmptyStringRt, t.null]), + transactionName: t.union([nonEmptyStringRt, t.null]), + }), + rangeRt, + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async ( + resources + ): Promise<{ criticalPath: CriticalPathResponse | null }> => { + const { + params: { + body: { traceIds, start, end, serviceName, transactionName }, + }, + } = resources; + + const apmEventClient = await getApmEventClient(resources); + + return getAggregatedCriticalPath({ + traceIds, + start, + end, + apmEventClient, + serviceName, + transactionName, + logger: resources.logger, + }); + }, +}); + export const traceRouteRepository = { ...tracesByIdRoute, ...tracesRoute, ...rootTransactionByTraceIdRoute, ...transactionByIdRoute, ...findTracesRoute, + ...aggregatedCriticalPathRoute, }; diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index a6b81dc42af740..ecda555f49553c 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -14,27 +14,21 @@ import { CasesStatusResponseRt, CaseStatusRt } from './status'; import { CaseConnectorRt } from '../connectors'; import { CaseAssigneesRt } from './assignee'; -const BucketsAggs = rt.array( - rt.type({ - key: rt.string, - }) -); +export const AttachmentTotalsRt = rt.type({ + alerts: rt.number, + userComments: rt.number, +}); -export const GetCaseIdsByAlertIdAggsRt = rt.type({ - references: rt.type({ - doc_count: rt.number, - caseIds: rt.type({ - buckets: BucketsAggs, - }), - }), +export const RelatedCaseInfoRt = rt.type({ + id: rt.string, + title: rt.string, + description: rt.string, + status: CaseStatusRt, + createdAt: rt.string, + totals: AttachmentTotalsRt, }); -export const CasesByAlertIdRt = rt.array( - rt.type({ - id: rt.string, - title: rt.string, - }) -); +export const CasesByAlertIdRt = rt.array(RelatedCaseInfoRt); export const SettingsRt = rt.type({ syncAlerts: rt.boolean, @@ -350,5 +344,6 @@ export type CaseExternalServiceBasic = rt.TypeOf; export type AllReportersFindRequest = AllTagsFindRequest; -export type GetCaseIdsByAlertIdAggs = rt.TypeOf; +export type AttachmentTotals = rt.TypeOf; +export type RelatedCaseInfo = rt.TypeOf; export type CasesByAlertId = rt.TypeOf; diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index 27d098702f4c87..4f219db2c28f7f 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -19,6 +19,7 @@ import type { CasesByAlertIDRequest, CasesByAlertId, CaseAttributes, + AttachmentTotals, } from '../../../common/api'; import { CaseResponseRt, @@ -62,9 +63,10 @@ export const getCasesByAlertID = async ( clientArgs: CasesClientArgs ): Promise => { const { - services: { caseService }, + services: { caseService, attachmentService }, logger, authorization, + unsecuredSavedObjectsClient, } = clientArgs; try { @@ -104,6 +106,11 @@ export const getCasesByAlertID = async ( return []; } + const commentStats = await attachmentService.getCaseCommentStats({ + unsecuredSavedObjectsClient, + caseIds, + }); + const casesInfo = await caseService.getCases({ caseIds, }); @@ -125,6 +132,10 @@ export const getCasesByAlertID = async ( validCasesInfo.map((caseInfo) => ({ id: caseInfo.id, title: caseInfo.attributes.title, + description: caseInfo.attributes.description, + status: caseInfo.attributes.status, + createdAt: caseInfo.attributes.created_at, + totals: getAttachmentTotalsForCaseId(caseInfo.id, commentStats), })) ); } catch (error) { @@ -138,6 +149,9 @@ export const getCasesByAlertID = async ( } }; +const getAttachmentTotalsForCaseId = (id: string, stats: Map) => + stats.get(id) ?? { alerts: 0, userComments: 0 }; + /** * The parameters for retrieving a case */ diff --git a/x-pack/plugins/cases/server/services/attachments/index.ts b/x-pack/plugins/cases/server/services/attachments/index.ts index e12ff71a20cf97..325038b016c45f 100644 --- a/x-pack/plugins/cases/server/services/attachments/index.ts +++ b/x-pack/plugins/cases/server/services/attachments/index.ts @@ -20,6 +20,7 @@ import type { import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { KueryNode } from '@kbn/es-query'; import type { + AttachmentTotals, AttributesTypeAlerts, CommentAttributes as AttachmentAttributes, CommentAttributesWithoutRefs as AttachmentAttributesWithoutRefs, @@ -93,11 +94,6 @@ interface BulkUpdateAttachmentArgs extends ClientArgs, IndexRefresh { comments: UpdateArgs[]; } -interface CommentStats { - nonAlerts: number; - alerts: number; -} - export class AttachmentService { constructor( private readonly log: Logger, @@ -463,7 +459,7 @@ export class AttachmentService { }: { unsecuredSavedObjectsClient: SavedObjectsClientContract; caseIds: string[]; - }): Promise> { + }): Promise> { if (caseIds.length <= 0) { return new Map(); } @@ -498,11 +494,11 @@ export class AttachmentService { return ( res.aggregations?.references.caseIds.buckets.reduce((acc, idBucket) => { acc.set(idBucket.key, { - nonAlerts: idBucket.reverse.comments.doc_count, + userComments: idBucket.reverse.comments.doc_count, alerts: idBucket.reverse.alerts.value, }); return acc; - }, new Map()) ?? new Map() + }, new Map()) ?? new Map() ); } diff --git a/x-pack/plugins/cases/server/services/cases/index.ts b/x-pack/plugins/cases/server/services/cases/index.ts index 8b0a5e70f925e1..8359675a0ce0bc 100644 --- a/x-pack/plugins/cases/server/services/cases/index.ts +++ b/x-pack/plugins/cases/server/services/cases/index.ts @@ -28,7 +28,6 @@ import { MAX_DOCS_PER_PAGE, } from '../../../common/constants'; import type { - GetCaseIdsByAlertIdAggs, CaseResponse, CasesFindRequest, CommentAttributes, @@ -122,6 +121,15 @@ interface GetReportersArgs { filter?: KueryNode; } +interface GetCaseIdsByAlertIdAggs { + references: { + doc_count: number; + caseIds: { + buckets: Array<{ key: string }>; + }; + }; +} + export class CasesService { private readonly log: Logger; private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; @@ -222,13 +230,13 @@ export class CasesService { const casesWithComments = new Map(); for (const [id, caseInfo] of casesMap.entries()) { - const { alerts, nonAlerts } = commentTotals.get(id) ?? { alerts: 0, nonAlerts: 0 }; + const { alerts, userComments } = commentTotals.get(id) ?? { alerts: 0, userComments: 0 }; casesWithComments.set( id, flattenCaseSavedObject({ savedObject: caseInfo, - totalComment: nonAlerts, + totalComment: userComments, totalAlerts: alerts, }) ); diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index 3fe7d29d3801ac..b0801ee654d6f1 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -97,6 +97,26 @@ export const WORKPLACE_SEARCH_PLUGIN = { SUPPORT_URL: 'https://discuss.elastic.co/c/enterprise-search/workplace-search/', }; +export const SEARCH_EXPERIENCES_PLUGIN = { + ID: 'searchExperiences', + NAME: i18n.translate('xpack.enterpriseSearch.searchExperiences.productName', { + defaultMessage: 'Enterprise Search', + }), + NAV_TITLE: i18n.translate('xpack.enterpriseSearch.searchExperiences.navTitle', { + defaultMessage: 'Search experiences', + }), + DESCRIPTION: i18n.translate('xpack.enterpriseSearch.searchExperiences.productDescription', { + defaultMessage: 'Build an intuitive, engaging search experience without reinventing the wheel.', + }), + URL: '/app/enterprise_search/search_experiences', + SUPPORT_URL: 'https://discuss.elastic.co/c/enterprise-search/', + GITHUB_URL: 'https://github.com/elastic/search-ui/', + DOCUMENTATION_URL: 'https://docs.elastic.co/search-ui/', + ELASTICSEARCH_TUTORIAL_URL: 'https://docs.elastic.co/search-ui/tutorials/elasticsearch', + APP_SEARCH_TUTORIAL_URL: 'https://docs.elastic.co/search-ui/tutorials/app-search', + WORKPLACE_SEARCH_TUTORIAL_URL: 'https://docs.elastic.co/search-ui/tutorials/workplace-search', +}; + export const LICENSED_SUPPORT_URL = 'https://support.elastic.co'; export const JSON_HEADER = { diff --git a/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/layout/index.ts b/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/layout/index.ts new file mode 100644 index 00000000000000..51fa23df81b012 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/layout/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { EnterpriseSearchSearchExperiencesPageTemplate } from './page_template'; diff --git a/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/layout/page_template.test.tsx b/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/layout/page_template.test.tsx new file mode 100644 index 00000000000000..f1c889c020d7a0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/layout/page_template.test.tsx @@ -0,0 +1,77 @@ +/* + * 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. + */ + +jest.mock('../../../shared/layout/nav', () => ({ + useEnterpriseSearchNav: () => [], +})); + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { SetSearchExperiencesChrome } from '../../../shared/kibana_chrome'; +import { EnterpriseSearchPageTemplateWrapper } from '../../../shared/layout'; +import { SendEnterpriseSearchTelemetry } from '../../../shared/telemetry'; + +import { EnterpriseSearchSearchExperiencesPageTemplate } from './page_template'; + +describe('EnterpriseSearchSearchExperiencesPageTemplate', () => { + it('renders', () => { + const wrapper = shallow( + +
        world
        +
        + ); + + expect(wrapper.type()).toEqual(EnterpriseSearchPageTemplateWrapper); + expect(wrapper.prop('solutionNav')).toEqual({ name: 'Enterprise Search', items: [] }); + expect(wrapper.find('.hello').text()).toEqual('world'); + }); + + describe('page chrome', () => { + it('takes a breadcrumb array & renders a product-specific page chrome', () => { + const wrapper = shallow( + + ); + const setPageChrome = wrapper + .find(EnterpriseSearchPageTemplateWrapper) + .prop('setPageChrome') as any; + + expect(setPageChrome.type).toEqual(SetSearchExperiencesChrome); + expect(setPageChrome.props.trail).toEqual(['Some page']); + }); + }); + + describe('page telemetry', () => { + it('takes a metric & renders product-specific telemetry viewed event', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(SendEnterpriseSearchTelemetry).prop('action')).toEqual('viewed'); + expect(wrapper.find(SendEnterpriseSearchTelemetry).prop('metric')).toEqual('some_page'); + }); + }); + + describe('props', () => { + it('passes down any ...pageTemplateProps that EnterpriseSearchPageTemplateWrapper accepts', () => { + const wrapper = shallow( + } + /> + ); + + expect( + wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('pageHeader')!.pageTitle + ).toEqual('hello world'); + expect(wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('isLoading')).toEqual(false); + expect(wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('emptyState')).toEqual(
        ); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/layout/page_template.tsx new file mode 100644 index 00000000000000..5c4d1958d0c38d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/layout/page_template.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { SEARCH_EXPERIENCES_PLUGIN } from '../../../../../common/constants'; +import { SetSearchExperiencesChrome } from '../../../shared/kibana_chrome'; +import { EnterpriseSearchPageTemplateWrapper, PageTemplateProps } from '../../../shared/layout'; +import { useEnterpriseSearchNav } from '../../../shared/layout'; +import { SendEnterpriseSearchTelemetry } from '../../../shared/telemetry'; + +export const EnterpriseSearchSearchExperiencesPageTemplate: React.FC = ({ + children, + pageChrome, + pageViewTelemetry, + ...pageTemplateProps +}) => { + return ( + } + > + {pageViewTelemetry && ( + + )} + {children} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/search_experiences_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/search_experiences_guide/index.ts new file mode 100644 index 00000000000000..fd811cfd6a92b9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/search_experiences_guide/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SearchExperiencesGuide } from './search_experiences_guide'; diff --git a/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/search_experiences_guide/search_experiences_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/search_experiences_guide/search_experiences_guide.tsx new file mode 100644 index 00000000000000..3d32130a50e51f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/search_experiences/components/search_experiences_guide/search_experiences_guide.tsx @@ -0,0 +1,214 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiTitle, + EuiSpacer, + EuiText, + EuiButton, + EuiButtonEmpty, + EuiImage, + EuiHorizontalRule, + EuiCard, + EuiIcon, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +import { SEARCH_EXPERIENCES_PLUGIN } from '../../../../../common/constants'; +import searchExperiencesIllustration from '../../../../assets/images/search_experiences.svg'; + +import { SetSearchExperiencesChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { EnterpriseSearchSearchExperiencesPageTemplate } from '../layout'; + +export const SearchExperiencesGuide: React.FC = () => { + return ( + + + + + + + + +

        About Search UI

        +
        + + +

        + +

        +
        +
        + + + + + + + + + + + + + + + + +

        + +

        +
        + + +
          +
        • + +
        • +
        • + +
        • +
        • + +
        • +
        • + +
        • +
        • + +
        • +
        +
        +
        +
        +
        + + + +
        + + +

        + +

        +
        + + + + } + title="Elasticsearch" + description={i18n.translate( + 'xpack.enterpriseSearch.searchExperiences.guide.tutorials.elasticsearch.description', + { + defaultMessage: 'Build a search experience with Elasticsearch and Search UI.', + } + )} + href={SEARCH_EXPERIENCES_PLUGIN.ELASTICSEARCH_TUTORIAL_URL} + target="_blank" + /> + + + } + title="App Search" + description={i18n.translate( + 'xpack.enterpriseSearch.searchExperiences.guide.tutorials.appSearch.description', + { + defaultMessage: 'Build a search experience with App Search and Search UI.', + } + )} + href={SEARCH_EXPERIENCES_PLUGIN.APP_SEARCH_TUTORIAL_URL} + target="_blank" + /> + + + } + title="Workplace Search" + description={i18n.translate( + 'xpack.enterpriseSearch.searchExperiences.guide.tutorials.workplaceSearch.description', + { + defaultMessage: 'Build a search experience with Workplace Search and Search UI.', + } + )} + href={SEARCH_EXPERIENCES_PLUGIN.WORKPLACE_SEARCH_TUTORIAL_URL} + target="_blank" + /> + + +
        +
        + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/search_experiences/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/search_experiences/index.test.tsx new file mode 100644 index 00000000000000..ceaf7358022265 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/search_experiences/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues } from '../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { SearchExperiencesGuide } from './components/search_experiences_guide'; + +import { SearchExperiences } from '.'; + +describe('SearchExperiences', () => { + it('renders the Search Experiences guide', () => { + setMockValues({ + errorConnectingMessage: '', + config: { host: 'localhost' }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SearchExperiencesGuide)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/search_experiences/index.tsx b/x-pack/plugins/enterprise_search/public/applications/search_experiences/index.tsx new file mode 100644 index 00000000000000..4416c8309211b2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/search_experiences/index.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { isVersionMismatch } from '../../../common/is_version_mismatch'; +import { InitialAppData } from '../../../common/types'; +import { VersionMismatchPage } from '../shared/version_mismatch'; + +import { SearchExperiencesGuide } from './components/search_experiences_guide'; + +import { ROOT_PATH } from './routes'; + +export const SearchExperiences: React.FC = (props) => { + const { enterpriseSearchVersion, kibanaVersion } = props; + const incompatibleVersions = isVersionMismatch(enterpriseSearchVersion, kibanaVersion); + + const showView = () => { + if (incompatibleVersions) { + return ( + + ); + } + + return ; + }; + + return ( + + + {showView()} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/search_experiences/routes.ts b/x-pack/plugins/enterprise_search/public/applications/search_experiences/routes.ts new file mode 100644 index 00000000000000..d6b0b0a669281c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/search_experiences/routes.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ROOT_PATH = '/'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts index 70fcedb9da2609..140ec10df23da3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts @@ -15,6 +15,7 @@ import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN, ENTERPRISE_SEARCH_CONTENT_PLUGIN, + SEARCH_EXPERIENCES_PLUGIN, } from '../../../../common/constants'; import { stripLeadingSlash } from '../../../../common/strip_slashes'; @@ -129,3 +130,9 @@ export const useEnterpriseSearchContentBreadcrumbs = (breadcrumbs: Breadcrumbs = { text: ENTERPRISE_SEARCH_CONTENT_PLUGIN.NAV_TITLE, path: '/' }, ...breadcrumbs, ]); + +export const useSearchExperiencesBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => + useEnterpriseSearchBreadcrumbs([ + { text: SEARCH_EXPERIENCES_PLUGIN.NAV_TITLE, path: '/' }, + ...breadcrumbs, + ]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts index 29b52953a578f5..49279881ef00e0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts @@ -10,6 +10,7 @@ import { ANALYTICS_PLUGIN, APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN, + SEARCH_EXPERIENCES_PLUGIN, } from '../../../../common/constants'; /** @@ -43,3 +44,6 @@ export const appSearchTitle = (page: Title = []) => export const workplaceSearchTitle = (page: Title = []) => generateTitle([...page, WORKPLACE_SEARCH_PLUGIN.NAME]); + +export const searchExperiencesTitle = (page: Title = []) => + generateTitle([...page, SEARCH_EXPERIENCES_PLUGIN.NAME]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/index.ts index 28e5833a0facae..1a013bf11df358 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/index.ts @@ -12,4 +12,5 @@ export { SetElasticsearchChrome, SetAppSearchChrome, SetWorkplaceSearchChrome, + SetSearchExperiencesChrome, } from './set_chrome'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx index d0327e528376dd..72b68c9a5cc7f7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx @@ -20,6 +20,7 @@ import { useAppSearchBreadcrumbs, useWorkplaceSearchBreadcrumbs, BreadcrumbTrail, + useSearchExperiencesBreadcrumbs, } from './generate_breadcrumbs'; import { enterpriseSearchTitle, @@ -27,6 +28,7 @@ import { elasticsearchTitle, appSearchTitle, workplaceSearchTitle, + searchExperiencesTitle, } from './generate_title'; /** @@ -150,5 +152,22 @@ export const SetEnterpriseSearchContentChrome: React.FC = ({ tra return null; }; +export const SetSearchExperiencesChrome: React.FC = ({ trail = [] }) => { + const { setBreadcrumbs, setDocTitle } = useValues(KibanaLogic); + + const title = reverseArray(trail); + const docTitle = searchExperiencesTitle(title); + + const crumbs = useGenerateBreadcrumbs(trail); + const breadcrumbs = useSearchExperiencesBreadcrumbs(crumbs); + + useEffect(() => { + setBreadcrumbs(breadcrumbs); + setDocTitle(docTitle); + }, [trail]); + + return null; +}; + // Small util - performantly reverses an array without mutating the original array const reverseArray = (array: string[]) => array.slice().reverse(); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx index 67fb93ae643f62..bd367e7de8e7d0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx @@ -72,6 +72,11 @@ describe('useEnterpriseSearchContentNav', () => { id: 'elasticsearch', name: 'Elasticsearch', }, + { + href: '/app/enterprise_search/search_experiences', + id: 'searchExperiences', + name: 'Search Experiences', + }, { href: '/app/enterprise_search/app_search', id: 'app_search', @@ -108,6 +113,11 @@ describe('useEnterpriseSearchContentNav', () => { id: 'elasticsearch', name: 'Elasticsearch', }, + { + href: '/app/enterprise_search/search_experiences', + id: 'searchExperiences', + name: 'Search Experiences', + }, ], name: 'Search', }); @@ -129,6 +139,11 @@ describe('useEnterpriseSearchContentNav', () => { id: 'elasticsearch', name: 'Elasticsearch', }, + { + href: '/app/enterprise_search/search_experiences', + id: 'searchExperiences', + name: 'Search Experiences', + }, { href: '/app/enterprise_search/workplace_search', id: 'workplace_search', @@ -155,6 +170,11 @@ describe('useEnterpriseSearchContentNav', () => { id: 'elasticsearch', name: 'Elasticsearch', }, + { + href: '/app/enterprise_search/search_experiences', + id: 'searchExperiences', + name: 'Search Experiences', + }, { href: '/app/enterprise_search/app_search', id: 'app_search', diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx index 967095d9674baa..471c40668b4a3b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx @@ -16,6 +16,7 @@ import { ELASTICSEARCH_PLUGIN, ENTERPRISE_SEARCH_CONTENT_PLUGIN, ENTERPRISE_SEARCH_OVERVIEW_PLUGIN, + SEARCH_EXPERIENCES_PLUGIN, WORKPLACE_SEARCH_PLUGIN, } from '../../../../common/constants'; import { enableBehavioralAnalyticsSection } from '../../../../common/ui_settings_keys'; @@ -106,6 +107,16 @@ export const useEnterpriseSearchNav = () => { to: ELASTICSEARCH_PLUGIN.URL, }), }, + { + id: 'searchExperiences', + name: i18n.translate('xpack.enterpriseSearch.nav.searchExperiencesTitle', { + defaultMessage: 'Search Experiences', + }), + ...generateNavLink({ + shouldNotCreateHref: true, + to: SEARCH_EXPERIENCES_PLUGIN.URL, + }), + }, ...(productAccess.hasAppSearchAccess ? [ { @@ -135,7 +146,7 @@ export const useEnterpriseSearchNav = () => { ] : []), ], - name: i18n.translate('xpack.enterpriseSearch.nav.searchExperiencesTitle', { + name: i18n.translate('xpack.enterpriseSearch.nav.searchTitle', { defaultMessage: 'Search', }), }, diff --git a/x-pack/plugins/enterprise_search/public/assets/images/search_experiences.svg b/x-pack/plugins/enterprise_search/public/assets/images/search_experiences.svg new file mode 100644 index 00000000000000..4baea2fca1c7d8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/assets/images/search_experiences.svg @@ -0,0 +1,1393 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index e9fc2d81563e65..6e35890bdcaa50 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -30,6 +30,7 @@ import { ENTERPRISE_SEARCH_CONTENT_PLUGIN, ENTERPRISE_SEARCH_OVERVIEW_PLUGIN, WORKPLACE_SEARCH_PLUGIN, + SEARCH_EXPERIENCES_PLUGIN, } from '../common/constants'; import { InitialAppData } from '../common/types'; @@ -215,6 +216,27 @@ export class EnterpriseSearchPlugin implements Plugin { }, }); + core.application.register({ + id: SEARCH_EXPERIENCES_PLUGIN.ID, + title: SEARCH_EXPERIENCES_PLUGIN.NAME, + euiIconType: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.LOGO, + appRoute: SEARCH_EXPERIENCES_PLUGIN.URL, + category: DEFAULT_APP_CATEGORIES.enterpriseSearch, + mount: async (params: AppMountParameters) => { + const kibanaDeps = await this.getKibanaDeps(core, params, cloud); + const { chrome, http } = kibanaDeps.core; + chrome.docTitle.change(SEARCH_EXPERIENCES_PLUGIN.NAME); + + await this.getInitialData(http); + const pluginData = this.getPluginData(); + + const { renderApp } = await import('./applications'); + const { SearchExperiences } = await import('./applications/search_experiences'); + + return renderApp(SearchExperiences, kibanaDeps, pluginData); + }, + }); + if (plugins.home) { plugins.home.featureCatalogue.registerSolution({ id: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.ID, @@ -266,6 +288,16 @@ export class EnterpriseSearchPlugin implements Plugin { category: 'data', showOnHomePage: false, }); + + plugins.home.featureCatalogue.register({ + id: SEARCH_EXPERIENCES_PLUGIN.ID, + title: SEARCH_EXPERIENCES_PLUGIN.NAME, + icon: 'logoEnterpriseSearch', + description: SEARCH_EXPERIENCES_PLUGIN.DESCRIPTION, + path: SEARCH_EXPERIENCES_PLUGIN.URL, + category: 'data', + showOnHomePage: false, + }); } } diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 90620af30f6b8c..436e4129581782 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -30,6 +30,7 @@ import { ANALYTICS_PLUGIN, APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN, + SEARCH_EXPERIENCES_PLUGIN, ENTERPRISE_SEARCH_RELEVANCE_LOGS_SOURCE_ID, ENTERPRISE_SEARCH_AUDIT_LOGS_SOURCE_ID, ENTERPRISE_SEARCH_ANALYTICS_LOGS_SOURCE_ID, @@ -110,6 +111,7 @@ export class EnterpriseSearchPlugin implements Plugin { ANALYTICS_PLUGIN.ID, APP_SEARCH_PLUGIN.ID, WORKPLACE_SEARCH_PLUGIN.ID, + SEARCH_EXPERIENCES_PLUGIN.ID, ]; if (customIntegrations) { @@ -158,6 +160,7 @@ export class EnterpriseSearchPlugin implements Plugin { elasticsearch: showEnterpriseSearch, appSearch: hasAppSearchAccess, workplaceSearch: hasWorkplaceSearchAccess, + searchExperiences: showEnterpriseSearch, }, catalogue: { enterpriseSearch: showEnterpriseSearch, @@ -166,6 +169,7 @@ export class EnterpriseSearchPlugin implements Plugin { elasticsearch: showEnterpriseSearch, appSearch: hasAppSearchAccess, workplaceSearch: hasWorkplaceSearchAccess, + searchExperiences: showEnterpriseSearch, }, }; }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx index 11ddcef4c8aee9..0eccf5a8cec33d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx @@ -188,15 +188,19 @@ export const AgentDetailsOverviewSection: React.FunctionComponent<{ description: (agent.tags ?? []).length > 0 ? : '-', }, ].map(({ title, description }) => { + const tooltip = + typeof description === 'string' && description.length > 20 ? description : ''; return ( {title} - - {description} - + + + {description} + + ); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout.tsx index dfa6623f4a90a6..c3b5605ecd4845 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout.tsx @@ -254,6 +254,11 @@ const actionNames: { cancelledText: 'update tags', }, CANCEL: { inProgressText: 'Cancelling', completedText: 'cancelled', cancelledText: '' }, + SETTINGS: { + inProgressText: 'Updating settings of', + completedText: 'updated settings', + cancelledText: 'update settings', + }, ACTION: { inProgressText: 'Actioning', completedText: 'actioned', cancelledText: 'action' }, }; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 527b93a66d1308..14a773cfd94bf9 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -228,9 +228,17 @@ export function buildComponentTemplates(params: { templateName: string; registryElasticsearch: RegistryElasticsearch | undefined; packageName: string; + pipelineName?: string; defaultSettings: IndexTemplate['template']['settings']; }) { - const { templateName, registryElasticsearch, packageName, defaultSettings, mappings } = params; + const { + templateName, + registryElasticsearch, + packageName, + defaultSettings, + mappings, + pipelineName, + } = params; const packageTemplateName = `${templateName}${PACKAGE_TEMPLATE_SUFFIX}`; const userSettingsTemplateName = `${templateName}${USER_SETTINGS_TEMPLATE_SUFFIX}`; @@ -256,6 +264,7 @@ export function buildComponentTemplates(params: { ...templateSettings, index: { ...templateSettings.index, + ...(pipelineName ? { default_pipeline: pipelineName } : {}), mapping: { ...templateSettings?.mapping, total_fields: { @@ -392,12 +401,12 @@ export function prepareTemplate({ mappings, packageName, templateName, + pipelineName, registryElasticsearch: dataStream.elasticsearch, }); const template = getTemplate({ templateIndexPattern, - pipelineName, packageName, composedOfTemplates: Object.keys(componentTemplates), templatePriority, diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 5605125bd4ff39..c0187e1446258a 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -58,14 +58,12 @@ const META_PROP_KEYS = ['metric_type', 'unit']; */ export function getTemplate({ templateIndexPattern, - pipelineName, packageName, composedOfTemplates, templatePriority, hidden, }: { templateIndexPattern: string; - pipelineName?: string | undefined; packageName: string; composedOfTemplates: string[]; templatePriority: number; @@ -78,9 +76,6 @@ export function getTemplate({ templatePriority, hidden ); - if (pipelineName) { - template.template.settings.index.default_pipeline = pipelineName; - } if (template.template.settings.index.final_pipeline) { throw new Error(`Error template for ${templateIndexPattern} contains a final_pipeline`); } diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index 9056e58eef1eb6..3bf19d4d78b5c5 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -20,11 +20,6 @@ export type { OriginalColumn } from './expressions/map_to_columns'; export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; -export interface ExistingFields { - indexPatternTitle: string; - existingFieldNames: string[]; -} - export interface DateRange { fromDate: string; toDate: string; @@ -85,6 +80,7 @@ export interface PieVisualizationState { palette?: PaletteOutput; } export interface LegacyMetricState { + autoScaleMetricAlignment?: 'left' | 'right' | 'center'; layerId: string; accessor?: string; layerType: LayerType; diff --git a/x-pack/plugins/lens/public/data_views_service/loader.test.ts b/x-pack/plugins/lens/public/data_views_service/loader.test.ts index 97ded75233cdae..e7e2bab166a700 100644 --- a/x-pack/plugins/lens/public/data_views_service/loader.test.ts +++ b/x-pack/plugins/lens/public/data_views_service/loader.test.ts @@ -5,22 +5,10 @@ * 2.0. */ -import { DataViewsContract, DataViewSpec, FieldSpec } from '@kbn/data-views-plugin/public'; -import { IndexPattern, IndexPatternField } from '../types'; -import { - ensureIndexPattern, - loadIndexPatternRefs, - loadIndexPatterns, - syncExistingFields, -} from './loader'; +import { DataViewsContract } from '@kbn/data-views-plugin/public'; +import { ensureIndexPattern, loadIndexPatternRefs, loadIndexPatterns } from './loader'; import { sampleIndexPatterns, mockDataViewsService } from './mocks'; import { documentField } from '../datasources/form_based/document_field'; -import { coreMock } from '@kbn/core/public/mocks'; -import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; -import type { DataView } from '@kbn/data-views-plugin/public'; -import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; -import { UI_SETTINGS } from '@kbn/data-plugin/public'; -import { createHttpFetchError } from '@kbn/core-http-browser-mocks'; describe('loader', () => { describe('loadIndexPatternRefs', () => { @@ -266,218 +254,4 @@ describe('loader', () => { expect(onError).not.toHaveBeenCalled(); }); }); - - describe('syncExistingFields', () => { - const core = coreMock.createStart(); - const dataViews = dataViewPluginMocks.createStartContract(); - const data = dataPluginMock.createStartContract(); - - const dslQuery = { - bool: { - must: [], - filter: [{ match_all: {} }], - should: [], - must_not: [], - }, - }; - - function getIndexPatternList() { - return [ - { - id: '1', - title: '1', - fields: [{ name: 'ip1_field_1' }, { name: 'ip1_field_2' }], - hasRestrictions: false, - }, - { - id: '2', - title: '2', - fields: [{ name: 'ip2_field_1' }, { name: 'ip2_field_2' }], - hasRestrictions: false, - }, - { - id: '3', - title: '3', - fields: [{ name: 'ip3_field_1' }, { name: 'ip3_field_2' }], - hasRestrictions: false, - }, - ] as unknown as IndexPattern[]; - } - - beforeEach(() => { - core.uiSettings.get.mockImplementation((key: string) => { - if (key === UI_SETTINGS.META_FIELDS) { - return []; - } - }); - dataViews.get.mockImplementation((id: string) => - Promise.resolve( - getIndexPatternList().find( - (indexPattern) => indexPattern.id === id - ) as unknown as DataView - ) - ); - }); - - it('should call once for each index pattern', async () => { - const updateIndexPatterns = jest.fn(); - dataViews.getFieldsForIndexPattern.mockImplementation( - (dataView: DataViewSpec | DataView) => - Promise.resolve(dataView.fields) as Promise - ); - - await syncExistingFields({ - dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, - indexPatternList: getIndexPatternList(), - updateIndexPatterns, - dslQuery, - onNoData: jest.fn(), - currentIndexPatternTitle: 'abc', - isFirstExistenceFetch: false, - existingFields: {}, - core, - data, - dataViews, - }); - - expect(dataViews.get).toHaveBeenCalledTimes(3); - expect(dataViews.getFieldsForIndexPattern).toHaveBeenCalledTimes(3); - expect(updateIndexPatterns).toHaveBeenCalledTimes(1); - - const [newState, options] = updateIndexPatterns.mock.calls[0]; - expect(options).toEqual({ applyImmediately: true }); - - expect(newState).toEqual({ - isFirstExistenceFetch: false, - existingFields: { - '1': { ip1_field_1: true, ip1_field_2: true }, - '2': { ip2_field_1: true, ip2_field_2: true }, - '3': { ip3_field_1: true, ip3_field_2: true }, - }, - }); - }); - - it('should call onNoData callback if current index pattern returns no fields', async () => { - const updateIndexPatterns = jest.fn(); - const onNoData = jest.fn(); - dataViews.getFieldsForIndexPattern.mockImplementation( - async (dataView: DataViewSpec | DataView) => { - return (dataView.title === '1' - ? [{ name: `${dataView.title}_field_1` }, { name: `${dataView.title}_field_2` }] - : []) as unknown as Promise; - } - ); - - const args = { - dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, - indexPatternList: getIndexPatternList(), - updateIndexPatterns, - dslQuery, - onNoData, - currentIndexPatternTitle: 'abc', - isFirstExistenceFetch: false, - existingFields: {}, - core, - data, - dataViews, - }; - - await syncExistingFields(args); - - expect(onNoData).not.toHaveBeenCalled(); - - await syncExistingFields({ ...args, isFirstExistenceFetch: true }); - expect(onNoData).not.toHaveBeenCalled(); - }); - - it('should set all fields to available and existence error flag if the request fails', async () => { - const updateIndexPatterns = jest.fn(); - dataViews.getFieldsForIndexPattern.mockImplementation(() => { - return new Promise((_, reject) => { - reject(new Error()); - }); - }); - - const args = { - dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, - indexPatternList: [ - { - id: '1', - title: '1', - hasRestrictions: false, - fields: [{ name: 'field1' }, { name: 'field2' }] as IndexPatternField[], - }, - ] as IndexPattern[], - updateIndexPatterns, - dslQuery, - onNoData: jest.fn(), - currentIndexPatternTitle: 'abc', - isFirstExistenceFetch: false, - existingFields: {}, - core, - data, - dataViews, - }; - - await syncExistingFields(args); - - const [newState, options] = updateIndexPatterns.mock.calls[0]; - expect(options).toEqual({ applyImmediately: true }); - - expect(newState.existenceFetchFailed).toEqual(true); - expect(newState.existenceFetchTimeout).toEqual(false); - expect(newState.existingFields['1']).toEqual({ - field1: true, - field2: true, - }); - }); - - it('should set all fields to available and existence error flag if the request times out', async () => { - const updateIndexPatterns = jest.fn(); - dataViews.getFieldsForIndexPattern.mockImplementation(() => { - return new Promise((_, reject) => { - const error = createHttpFetchError( - 'timeout', - 'error', - {} as Request, - { status: 408 } as Response - ); - reject(error); - }); - }); - - const args = { - dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, - indexPatternList: [ - { - id: '1', - title: '1', - hasRestrictions: false, - fields: [{ name: 'field1' }, { name: 'field2' }] as IndexPatternField[], - }, - ] as IndexPattern[], - updateIndexPatterns, - dslQuery, - onNoData: jest.fn(), - currentIndexPatternTitle: 'abc', - isFirstExistenceFetch: false, - existingFields: {}, - core, - data, - dataViews, - }; - - await syncExistingFields(args); - - const [newState, options] = updateIndexPatterns.mock.calls[0]; - expect(options).toEqual({ applyImmediately: true }); - - expect(newState.existenceFetchFailed).toEqual(false); - expect(newState.existenceFetchTimeout).toEqual(true); - expect(newState.existingFields['1']).toEqual({ - field1: true, - field2: true, - }); - }); - }); }); diff --git a/x-pack/plugins/lens/public/data_views_service/loader.ts b/x-pack/plugins/lens/public/data_views_service/loader.ts index f0184d0a11d0b2..f33ba8f3d37a91 100644 --- a/x-pack/plugins/lens/public/data_views_service/loader.ts +++ b/x-pack/plugins/lens/public/data_views_service/loader.ts @@ -8,13 +8,8 @@ import { isNestedField } from '@kbn/data-views-plugin/common'; import type { DataViewsContract, DataView, DataViewSpec } from '@kbn/data-views-plugin/public'; import { keyBy } from 'lodash'; -import { CoreStart } from '@kbn/core/public'; -import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { loadFieldExisting } from '@kbn/unified-field-list-plugin/public'; import { IndexPattern, IndexPatternField, IndexPatternMap, IndexPatternRef } from '../types'; import { documentField } from '../datasources/form_based/document_field'; -import { DateRange } from '../../common'; -import { DataViewsState } from '../state_management'; type ErrorHandler = (err: Error) => void; type MinimalDataViewsContract = Pick; @@ -247,120 +242,3 @@ export async function ensureIndexPattern({ }; return newIndexPatterns; } - -async function refreshExistingFields({ - dateRange, - indexPatternList, - dslQuery, - core, - data, - dataViews, -}: { - dateRange: DateRange; - indexPatternList: IndexPattern[]; - dslQuery: object; - core: Pick; - data: DataPublicPluginStart; - dataViews: DataViewsContract; -}) { - try { - const emptinessInfo = await Promise.all( - indexPatternList.map(async (pattern) => { - if (pattern.hasRestrictions) { - return { - indexPatternTitle: pattern.title, - existingFieldNames: pattern.fields.map((field) => field.name), - }; - } - - const dataView = await dataViews.get(pattern.id); - return await loadFieldExisting({ - dslQuery, - fromDate: dateRange.fromDate, - toDate: dateRange.toDate, - timeFieldName: pattern.timeFieldName, - data, - uiSettingsClient: core.uiSettings, - dataViewsService: dataViews, - dataView, - }); - }) - ); - return { result: emptinessInfo, status: 200 }; - } catch (e) { - return { result: undefined, status: e.res?.status as number }; - } -} - -type FieldsPropsFromDataViewsState = Pick< - DataViewsState, - 'existingFields' | 'isFirstExistenceFetch' | 'existenceFetchTimeout' | 'existenceFetchFailed' ->; -export async function syncExistingFields({ - updateIndexPatterns, - isFirstExistenceFetch, - currentIndexPatternTitle, - onNoData, - existingFields, - ...requestOptions -}: { - dateRange: DateRange; - indexPatternList: IndexPattern[]; - existingFields: Record>; - updateIndexPatterns: ( - newFieldState: FieldsPropsFromDataViewsState, - options: { applyImmediately: boolean } - ) => void; - isFirstExistenceFetch: boolean; - currentIndexPatternTitle: string; - dslQuery: object; - onNoData?: () => void; - core: Pick; - data: DataPublicPluginStart; - dataViews: DataViewsContract; -}) { - const { indexPatternList } = requestOptions; - const newExistingFields = { ...existingFields }; - - const { result, status } = await refreshExistingFields(requestOptions); - - if (result) { - if (isFirstExistenceFetch) { - const fieldsCurrentIndexPattern = result.find( - (info) => info.indexPatternTitle === currentIndexPatternTitle - ); - if (fieldsCurrentIndexPattern && fieldsCurrentIndexPattern.existingFieldNames.length === 0) { - onNoData?.(); - } - } - - for (const { indexPatternTitle, existingFieldNames } of result) { - newExistingFields[indexPatternTitle] = booleanMap(existingFieldNames); - } - } else { - for (const { title, fields } of indexPatternList) { - newExistingFields[title] = booleanMap(fields.map((field) => field.name)); - } - } - - updateIndexPatterns( - { - existingFields: newExistingFields, - ...(result - ? { isFirstExistenceFetch: status !== 200 } - : { - isFirstExistenceFetch, - existenceFetchFailed: status !== 408, - existenceFetchTimeout: status === 408, - }), - }, - { applyImmediately: true } - ); -} - -function booleanMap(keys: string[]) { - return keys.reduce((acc, key) => { - acc[key] = true; - return acc; - }, {} as Record); -} diff --git a/x-pack/plugins/lens/public/data_views_service/mocks.ts b/x-pack/plugins/lens/public/data_views_service/mocks.ts index ed8d6e86e58a4e..b4acacbe98b739 100644 --- a/x-pack/plugins/lens/public/data_views_service/mocks.ts +++ b/x-pack/plugins/lens/public/data_views_service/mocks.ts @@ -12,7 +12,7 @@ import { createMockedRestrictedIndexPattern, } from '../datasources/form_based/mocks'; import { DataViewsState } from '../state_management'; -import { ExistingFieldsMap, IndexPattern } from '../types'; +import { IndexPattern } from '../types'; import { getFieldByNameFactory } from './loader'; /** @@ -22,25 +22,13 @@ import { getFieldByNameFactory } from './loader'; export const createMockDataViewsState = ({ indexPatterns, indexPatternRefs, - isFirstExistenceFetch, - existingFields, }: Partial = {}): DataViewsState => { const refs = indexPatternRefs ?? Object.values(indexPatterns ?? {}).map(({ id, title, name }) => ({ id, title, name })); - const allFields = - existingFields ?? - refs.reduce((acc, { id, title }) => { - if (indexPatterns && id in indexPatterns) { - acc[title] = Object.fromEntries(indexPatterns[id].fields.map((f) => [f.displayName, true])); - } - return acc; - }, {} as ExistingFieldsMap); return { indexPatterns: indexPatterns ?? {}, indexPatternRefs: refs, - isFirstExistenceFetch: Boolean(isFirstExistenceFetch), - existingFields: allFields, }; }; diff --git a/x-pack/plugins/lens/public/data_views_service/service.ts b/x-pack/plugins/lens/public/data_views_service/service.ts index 28a0d827999923..5192de1d2385e4 100644 --- a/x-pack/plugins/lens/public/data_views_service/service.ts +++ b/x-pack/plugins/lens/public/data_views_service/service.ts @@ -14,14 +14,8 @@ import { UPDATE_FILTER_REFERENCES_ACTION, UPDATE_FILTER_REFERENCES_TRIGGER, } from '@kbn/unified-search-plugin/public'; -import type { DateRange } from '../../common'; import type { IndexPattern, IndexPatternMap, IndexPatternRef } from '../types'; -import { - ensureIndexPattern, - loadIndexPatternRefs, - loadIndexPatterns, - syncExistingFields, -} from './loader'; +import { ensureIndexPattern, loadIndexPatternRefs, loadIndexPatterns } from './loader'; import type { DataViewsState } from '../state_management'; import { generateId } from '../id_generator'; @@ -71,18 +65,6 @@ export interface IndexPatternServiceAPI { id: string; cache: IndexPatternMap; }) => Promise; - /** - * Loads the existingFields map given the current context - */ - refreshExistingFields: (args: { - dateRange: DateRange; - currentIndexPatternTitle: string; - dslQuery: object; - onNoData?: () => void; - existingFields: Record>; - indexPatternList: IndexPattern[]; - isFirstExistenceFetch: boolean; - }) => Promise; replaceDataViewId: (newDataView: DataView) => Promise; /** @@ -150,14 +132,6 @@ export function createIndexPatternService({ }, ensureIndexPattern: (args) => ensureIndexPattern({ onError: onChangeError, dataViews, ...args }), - refreshExistingFields: (args) => - syncExistingFields({ - updateIndexPatterns, - ...args, - data, - dataViews, - core, - }), loadIndexPatternRefs: async ({ isFullEditor }) => isFullEditor ? loadIndexPatternRefs(dataViews) : [], getDefaultIndex: () => core.uiSettings.get('defaultIndex'), diff --git a/x-pack/plugins/lens/public/datasources/form_based/__mocks__/loader.ts b/x-pack/plugins/lens/public/datasources/form_based/__mocks__/loader.ts index 47af8d816b73f6..7ad4172ce38292 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/__mocks__/loader.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/__mocks__/loader.ts @@ -28,11 +28,9 @@ export function loadInitialDataViews() { const restricted = createMockedRestrictedIndexPattern(); return { indexPatternRefs: [], - existingFields: {}, indexPatterns: { [indexPattern.id]: indexPattern, [restricted.id]: restricted, }, - isFirstExistenceFetch: false, }; } diff --git a/x-pack/plugins/lens/public/datasources/form_based/datapanel.scss b/x-pack/plugins/lens/public/datasources/form_based/datapanel.scss index ef68c784100e4a..32887d3f9350d0 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/datapanel.scss +++ b/x-pack/plugins/lens/public/datasources/form_based/datapanel.scss @@ -14,15 +14,6 @@ margin-bottom: $euiSizeS; } -.lnsInnerIndexPatternDataPanel__titleTooltip { - margin-right: $euiSizeXS; -} - -.lnsInnerIndexPatternDataPanel__fieldItems { - // Quick fix for making sure the shadow and focus rings are visible outside the accordion bounds - padding: $euiSizeXS; -} - .lnsInnerIndexPatternDataPanel__textField { @include euiFormControlLayoutPadding(1, 'right'); @include euiFormControlLayoutPadding(1, 'left'); @@ -60,4 +51,4 @@ .lnsFilterButton .euiFilterButton__textShift { min-width: 0; -} \ No newline at end of file +} diff --git a/x-pack/plugins/lens/public/datasources/form_based/datapanel.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/datapanel.test.tsx index e7b0cd6d457a96..6639484ca6be42 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/datapanel.test.tsx @@ -13,15 +13,16 @@ import { dataViewPluginMocks, Start as DataViewPublicStart, } from '@kbn/data-views-plugin/public/mocks'; -import { InnerFormBasedDataPanel, FormBasedDataPanel, Props } from './datapanel'; -import { FieldList } from './field_list'; +import { InnerFormBasedDataPanel, FormBasedDataPanel } from './datapanel'; +import { FieldListGrouped } from '@kbn/unified-field-list-plugin/public'; +import * as UseExistingFieldsApi from '@kbn/unified-field-list-plugin/public/hooks/use_existing_fields'; +import * as ExistingFieldsServiceApi from '@kbn/unified-field-list-plugin/public/services/field_existing/load_field_existing'; import { FieldItem } from './field_item'; -import { NoFieldsCallout } from './no_fields_callout'; import { act } from 'react-dom/test-utils'; import { coreMock } from '@kbn/core/public/mocks'; import { FormBasedPrivateState } from './types'; -import { mountWithIntl, shallowWithIntl } from '@kbn/test-jest-helpers'; -import { EuiProgress, EuiLoadingSpinner } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { EuiCallOut, EuiLoadingSpinner, EuiProgress } from '@elastic/eui'; import { documentField } from './document_field'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; @@ -33,10 +34,9 @@ import { DOCUMENT_FIELD_NAME } from '../../../common'; import { createIndexPatternServiceMock } from '../../mocks/data_views_service_mock'; import { createMockFramePublicAPI } from '../../mocks'; import { DataViewsState } from '../../state_management'; -import { ExistingFieldsMap, FramePublicAPI, IndexPattern } from '../../types'; -import { IndexPatternServiceProps } from '../../data_views_service/service'; -import { FieldSpec, DataView } from '@kbn/data-views-plugin/public'; +import { DataView } from '@kbn/data-views-plugin/public'; import { UI_SETTINGS } from '@kbn/data-plugin/public'; +import { ReactWrapper } from 'enzyme'; const fieldsOne = [ { @@ -162,17 +162,12 @@ const fieldsThree = [ documentField, ]; -function getExistingFields(indexPatterns: Record) { - const existingFields: ExistingFieldsMap = {}; - for (const { title, fields } of Object.values(indexPatterns)) { - const fieldsMap: Record = {}; - for (const { displayName, name } of fields) { - fieldsMap[displayName ?? name] = true; - } - existingFields[title] = fieldsMap; - } - return existingFields; -} +jest.spyOn(UseExistingFieldsApi, 'useExistingFieldsFetcher'); +jest.spyOn(UseExistingFieldsApi, 'useExistingFieldsReader'); +jest.spyOn(ExistingFieldsServiceApi, 'loadFieldExisting').mockImplementation(async () => ({ + indexPatternTitle: 'test', + existingFieldNames: [], +})); const initialState: FormBasedPrivateState = { currentIndexPatternId: '1', @@ -234,8 +229,63 @@ const initialState: FormBasedPrivateState = { }, }; -function getFrameAPIMock({ indexPatterns, existingFields, ...rest }: Partial = {}) { +function getFrameAPIMock({ + indexPatterns, + ...rest +}: Partial & { indexPatterns: DataViewsState['indexPatterns'] }) { const frameAPI = createMockFramePublicAPI(); + + return { + ...frameAPI, + dataViews: { + ...frameAPI.dataViews, + indexPatterns, + ...rest, + }, + }; +} + +const dslQuery = { bool: { must: [], filter: [], should: [], must_not: [] } }; + +// @ts-expect-error Portal mocks are notoriously difficult to type +ReactDOM.createPortal = jest.fn((element) => element); + +async function mountAndWaitForLazyModules(component: React.ReactElement): Promise { + let inst: ReactWrapper; + await act(async () => { + inst = await mountWithIntl(component); + // wait for lazy modules + await new Promise((resolve) => setTimeout(resolve, 0)); + await inst.update(); + }); + + return inst!; +} + +describe('FormBased Data Panel', () => { + const indexPatterns = { + a: { + id: 'a', + title: 'aaa', + timeFieldName: 'atime', + fields: fieldsOne, + getFieldByName: getFieldByNameFactory(fieldsOne), + hasRestrictions: false, + isPersisted: true, + spec: {}, + }, + b: { + id: 'b', + title: 'bbb', + timeFieldName: 'btime', + fields: fieldsTwo, + getFieldByName: getFieldByNameFactory(fieldsTwo), + hasRestrictions: false, + isPersisted: true, + spec: {}, + }, + }; + const defaultIndexPatterns = { '1': { id: '1', @@ -268,42 +318,7 @@ function getFrameAPIMock({ indexPatterns, existingFields, ...rest }: Partial element); -describe('FormBased Data Panel', () => { - const indexPatterns = { - a: { - id: 'a', - title: 'aaa', - timeFieldName: 'atime', - fields: [{ name: 'aaa_field_1' }, { name: 'aaa_field_2' }], - getFieldByName: getFieldByNameFactory([]), - hasRestrictions: false, - }, - b: { - id: 'b', - title: 'bbb', - timeFieldName: 'btime', - fields: [{ name: 'bbb_field_1' }, { name: 'bbb_field_2' }], - getFieldByName: getFieldByNameFactory([]), - hasRestrictions: false, - }, - }; let defaultProps: Parameters[0] & { showNoDataPopover: () => void; }; @@ -313,9 +328,10 @@ describe('FormBased Data Panel', () => { beforeEach(() => { core = coreMock.createStart(); dataViews = dataViewPluginMocks.createStartContract(); + const frame = getFrameAPIMock({ indexPatterns: defaultIndexPatterns }); defaultProps = { data: dataPluginMock.createStartContract(), - dataViews: dataViewPluginMocks.createStartContract(), + dataViews, fieldFormats: fieldFormatsServiceMock.createStartContract(), indexPatternFieldEditor: indexPatternFieldEditorPluginMock.createStartContract(), onIndexPatternRefresh: jest.fn(), @@ -334,12 +350,34 @@ describe('FormBased Data Panel', () => { hasSuggestionForField: jest.fn(() => false), uiActions: uiActionsPluginMock.createStartContract(), indexPatternService: createIndexPatternServiceMock({ core, dataViews }), - frame: getFrameAPIMock(), + frame, + activeIndexPatterns: [frame.dataViews.indexPatterns['1']], }; + + core.uiSettings.get.mockImplementation((key: string) => { + if (key === UI_SETTINGS.META_FIELDS) { + return []; + } + }); + dataViews.get.mockImplementation(async (id: string) => { + const dataView = [ + indexPatterns.a, + indexPatterns.b, + defaultIndexPatterns['1'], + defaultIndexPatterns['2'], + defaultIndexPatterns['3'], + ].find((indexPattern) => indexPattern.id === id) as unknown as DataView; + dataView.metaFields = ['_id']; + return dataView; + }); + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockClear(); + (UseExistingFieldsApi.useExistingFieldsReader as jest.Mock).mockClear(); + (UseExistingFieldsApi.useExistingFieldsFetcher as jest.Mock).mockClear(); + UseExistingFieldsApi.resetExistingFieldsCache(); }); - it('should render a warning if there are no index patterns', () => { - const wrapper = shallowWithIntl( + it('should render a warning if there are no index patterns', async () => { + const wrapper = await mountAndWaitForLazyModules( { frame={createMockFramePublicAPI()} /> ); - expect(wrapper.find('[data-test-subj="indexPattern-no-indexpatterns"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="indexPattern-no-indexpatterns"]').exists()).toBeTruthy(); }); describe('loading existence data', () => { - function testProps(updateIndexPatterns: IndexPatternServiceProps['updateIndexPatterns']) { - core.uiSettings.get.mockImplementation((key: string) => { - if (key === UI_SETTINGS.META_FIELDS) { - return []; - } - }); - dataViews.getFieldsForIndexPattern.mockImplementation((dataView) => { - return Promise.resolve([ - { name: `${dataView.title}_field_1` }, - { name: `${dataView.title}_field_2` }, - ]) as Promise; - }); - dataViews.get.mockImplementation(async (id: string) => { - return [indexPatterns.a, indexPatterns.b].find( - (indexPattern) => indexPattern.id === id - ) as unknown as DataView; - }); + function testProps({ + currentIndexPatternId, + otherProps, + }: { + currentIndexPatternId: keyof typeof indexPatterns; + otherProps?: object; + }) { return { ...defaultProps, indexPatternService: createIndexPatternServiceMock({ - updateIndexPatterns, + updateIndexPatterns: jest.fn(), core, dataViews, }), @@ -388,290 +416,329 @@ describe('FormBased Data Panel', () => { dragging: { id: '1', humanData: { label: 'Label' } }, }, dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' }, - frame: { - dataViews: { - indexPatternRefs: [], - existingFields: {}, - isFirstExistenceFetch: false, - indexPatterns, - }, - } as unknown as FramePublicAPI, + frame: getFrameAPIMock({ + indexPatterns: indexPatterns as unknown as DataViewsState['indexPatterns'], + }), state: { - currentIndexPatternId: 'a', + currentIndexPatternId, layers: { 1: { - indexPatternId: 'a', + indexPatternId: currentIndexPatternId, columnOrder: [], columns: {}, }, }, } as FormBasedPrivateState, + ...(otherProps || {}), }; } - async function testExistenceLoading( - props: Props, - stateChanges?: Partial, - propChanges?: Partial - ) { - const inst = mountWithIntl(); + it('loads existence data', async () => { + const props = testProps({ + currentIndexPatternId: 'a', + }); - await act(async () => { - inst.update(); + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [indexPatterns.a.fields[0].name, indexPatterns.a.fields[1].name], + }; }); - if (stateChanges || propChanges) { - await act(async () => { - inst.setProps({ - ...props, - ...(propChanges || {}), - state: { - ...props.state, - ...(stateChanges || {}), - }, - }); - inst.update(); - }); - } - } + const inst = await mountAndWaitForLazyModules(); - it('loads existence data', async () => { - const updateIndexPatterns = jest.fn(); - await testExistenceLoading(testProps(updateIndexPatterns)); - - expect(updateIndexPatterns).toHaveBeenCalledWith( - { - existingFields: { - aaa: { - aaa_field_1: true, - aaa_field_2: true, - }, - }, - isFirstExistenceFetch: false, - }, - { applyImmediately: true } + expect(UseExistingFieldsApi.useExistingFieldsFetcher).toHaveBeenCalledWith( + expect.objectContaining({ + dataViews: [indexPatterns.a], + query: props.query, + filters: props.filters, + fromDate: props.dateRange.fromDate, + toDate: props.dateRange.toDate, + }) + ); + expect(UseExistingFieldsApi.useExistingFieldsReader).toHaveBeenCalled(); + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '2 available fields. 3 empty fields. 0 meta fields.' ); }); it('loads existence data for current index pattern id', async () => { - const updateIndexPatterns = jest.fn(); - await testExistenceLoading(testProps(updateIndexPatterns), { + const props = testProps({ currentIndexPatternId: 'b', }); - expect(updateIndexPatterns).toHaveBeenCalledWith( - { - existingFields: { - aaa: { - aaa_field_1: true, - aaa_field_2: true, - }, - bbb: { - bbb_field_1: true, - bbb_field_2: true, - }, - }, - isFirstExistenceFetch: false, - }, - { applyImmediately: true } + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [indexPatterns.b.fields[0].name], + }; + }); + + const inst = await mountAndWaitForLazyModules(); + + expect(UseExistingFieldsApi.useExistingFieldsFetcher).toHaveBeenCalledWith( + expect.objectContaining({ + dataViews: [indexPatterns.b], + query: props.query, + filters: props.filters, + fromDate: props.dateRange.fromDate, + toDate: props.dateRange.toDate, + }) + ); + expect(UseExistingFieldsApi.useExistingFieldsReader).toHaveBeenCalled(); + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '1 available field. 2 empty fields. 0 meta fields.' ); }); it('does not load existence data if date and index pattern ids are unchanged', async () => { - const updateIndexPatterns = jest.fn(); - await testExistenceLoading( - testProps(updateIndexPatterns), - { - currentIndexPatternId: 'a', + const props = testProps({ + currentIndexPatternId: 'a', + otherProps: { + dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' }, }, - { dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' } } + }); + + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [indexPatterns.a.fields[0].name, indexPatterns.a.fields[1].name], + }; + }); + + const inst = await mountAndWaitForLazyModules(); + + expect(UseExistingFieldsApi.useExistingFieldsFetcher).toHaveBeenCalledWith( + expect.objectContaining({ + dataViews: [indexPatterns.a], + fromDate: props.dateRange.fromDate, + toDate: props.dateRange.toDate, + }) ); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(1); - expect(updateIndexPatterns).toHaveBeenCalledTimes(1); + await act(async () => { + await inst.setProps({ dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' } }); + await inst.update(); + }); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(1); }); it('loads existence data if date range changes', async () => { - const updateIndexPatterns = jest.fn(); - await testExistenceLoading(testProps(updateIndexPatterns), undefined, { - dateRange: { fromDate: '2019-01-01', toDate: '2020-01-02' }, + const props = testProps({ + currentIndexPatternId: 'a', + otherProps: { + dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' }, + }, }); - expect(updateIndexPatterns).toHaveBeenCalledTimes(2); - expect(dataViews.getFieldsForIndexPattern).toHaveBeenCalledTimes(2); - expect(dataViews.get).toHaveBeenCalledTimes(2); - - const firstCall = dataViews.getFieldsForIndexPattern.mock.calls[0]; - expect(firstCall[0]).toEqual(indexPatterns.a); - expect(firstCall[1]?.filter?.bool?.filter).toContainEqual(dslQuery); - expect(firstCall[1]?.filter?.bool?.filter).toContainEqual({ - range: { - atime: { - format: 'strict_date_optional_time', - gte: '2019-01-01', - lte: '2020-01-01', - }, - }, + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [indexPatterns.a.fields[0].name, indexPatterns.a.fields[1].name], + }; }); - const secondCall = dataViews.getFieldsForIndexPattern.mock.calls[1]; - expect(secondCall[0]).toEqual(indexPatterns.a); - expect(secondCall[1]?.filter?.bool?.filter).toContainEqual(dslQuery); - expect(secondCall[1]?.filter?.bool?.filter).toContainEqual({ - range: { - atime: { - format: 'strict_date_optional_time', - gte: '2019-01-01', - lte: '2020-01-02', - }, - }, + const inst = await mountAndWaitForLazyModules(); + + expect(UseExistingFieldsApi.useExistingFieldsFetcher).toHaveBeenCalledWith( + expect.objectContaining({ + dataViews: [indexPatterns.a], + fromDate: props.dateRange.fromDate, + toDate: props.dateRange.toDate, + }) + ); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(1); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView: indexPatterns.a, + timeFieldName: indexPatterns.a.timeFieldName, + }) + ); + + await act(async () => { + await inst.setProps({ dateRange: { fromDate: '2019-01-01', toDate: '2020-01-02' } }); + await inst.update(); }); - expect(updateIndexPatterns).toHaveBeenCalledWith( - { - existingFields: { - aaa: { - aaa_field_1: true, - aaa_field_2: true, - }, - }, - isFirstExistenceFetch: false, - }, - { applyImmediately: true } + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(2); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-02', + dslQuery, + dataView: indexPatterns.a, + timeFieldName: indexPatterns.a.timeFieldName, + }) ); }); it('loads existence data if layer index pattern changes', async () => { - const updateIndexPatterns = jest.fn(); - await testExistenceLoading(testProps(updateIndexPatterns), { - layers: { - 1: { - indexPatternId: 'b', - columnOrder: [], - columns: {}, - }, + const props = testProps({ + currentIndexPatternId: 'a', + otherProps: { + dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' }, }, }); - expect(updateIndexPatterns).toHaveBeenCalledTimes(2); - - const secondCall = dataViews.getFieldsForIndexPattern.mock.calls[1]; - expect(secondCall[0]).toEqual(indexPatterns.a); - expect(secondCall[1]?.filter?.bool?.filter).toContainEqual(dslQuery); - expect(secondCall[1]?.filter?.bool?.filter).toContainEqual({ - range: { - atime: { - format: 'strict_date_optional_time', - gte: '2019-01-01', - lte: '2020-01-01', - }, - }, - }); + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation( + async ({ dataView }) => { + return { + existingFieldNames: + dataView === indexPatterns.a + ? [indexPatterns.a.fields[0].name, indexPatterns.a.fields[1].name] + : [indexPatterns.b.fields[0].name], + }; + } + ); - const thirdCall = dataViews.getFieldsForIndexPattern.mock.calls[2]; - expect(thirdCall[0]).toEqual(indexPatterns.b); - expect(thirdCall[1]?.filter?.bool?.filter).toContainEqual(dslQuery); - expect(thirdCall[1]?.filter?.bool?.filter).toContainEqual({ - range: { - btime: { - format: 'strict_date_optional_time', - gte: '2019-01-01', - lte: '2020-01-01', - }, - }, - }); + const inst = await mountAndWaitForLazyModules(); - expect(updateIndexPatterns).toHaveBeenCalledWith( - { - existingFields: { - aaa: { - aaa_field_1: true, - aaa_field_2: true, - }, - bbb: { - bbb_field_1: true, - bbb_field_2: true, - }, - }, - isFirstExistenceFetch: false, - }, - { applyImmediately: true } + expect(UseExistingFieldsApi.useExistingFieldsFetcher).toHaveBeenCalledWith( + expect.objectContaining({ + dataViews: [indexPatterns.a], + fromDate: props.dateRange.fromDate, + toDate: props.dateRange.toDate, + }) + ); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(1); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView: indexPatterns.a, + timeFieldName: indexPatterns.a.timeFieldName, + }) ); - }); - it('shows a loading indicator when loading', async () => { - const updateIndexPatterns = jest.fn(); - const load = async () => {}; - const inst = mountWithIntl(); - expect(inst.find(EuiProgress).length).toEqual(1); - await act(load); - inst.update(); - expect(inst.find(EuiProgress).length).toEqual(0); - }); + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '2 available fields. 3 empty fields. 0 meta fields.' + ); - it('does not perform multiple queries at once', async () => { - const updateIndexPatterns = jest.fn(); - let queryCount = 0; - let overlapCount = 0; - const props = testProps(updateIndexPatterns); + await act(async () => { + await inst.setProps({ + currentIndexPatternId: 'b', + state: { + currentIndexPatternId: 'b', + layers: { + 1: { + indexPatternId: 'b', + columnOrder: [], + columns: {}, + }, + }, + } as FormBasedPrivateState, + }); + await inst.update(); + }); - dataViews.getFieldsForIndexPattern.mockImplementation((dataView) => { - if (queryCount) { - ++overlapCount; - } - ++queryCount; - const result = Promise.resolve([ - { name: `${dataView.title}_field_1` }, - { name: `${dataView.title}_field_2` }, - ]) as Promise; + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(2); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView: indexPatterns.b, + timeFieldName: indexPatterns.b.timeFieldName, + }) + ); - result.then(() => --queryCount); + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '1 available field. 2 empty fields. 0 meta fields.' + ); + }); - return result; + it('shows a loading indicator when loading', async () => { + const props = testProps({ + currentIndexPatternId: 'b', }); - const inst = mountWithIntl(); + let resolveFunction: (arg: unknown) => void; + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockReset(); + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(() => { + return new Promise((resolve) => { + resolveFunction = resolve; + }); + }); + const inst = await mountAndWaitForLazyModules(); - inst.update(); + expect(inst.find(EuiProgress).length).toEqual(1); + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '' + ); - act(() => { - (inst.setProps as unknown as (props: unknown) => {})({ - ...props, - dateRange: { fromDate: '2019-01-01', toDate: '2020-01-02' }, + await act(async () => { + resolveFunction!({ + existingFieldNames: [indexPatterns.b.fields[0].name], }); - inst.update(); + await inst.update(); }); await act(async () => { - (inst.setProps as unknown as (props: unknown) => {})({ - ...props, - dateRange: { fromDate: '2019-01-01', toDate: '2020-01-03' }, - }); - inst.update(); + await inst.update(); }); - expect(dataViews.getFieldsForIndexPattern).toHaveBeenCalledTimes(2); - expect(overlapCount).toEqual(0); + expect(inst.find(EuiProgress).length).toEqual(0); + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '1 available field. 2 empty fields. 0 meta fields.' + ); }); - it("should default to empty dsl if query can't be parsed", async () => { - const updateIndexPatterns = jest.fn(); - const props = { - ...testProps(updateIndexPatterns), - query: { - language: 'kuery', - query: '@timestamp : NOT *', - }, - }; - await testExistenceLoading(props, undefined, undefined); + it("should trigger showNoDataPopover if fields don't have data", async () => { + const props = testProps({ + currentIndexPatternId: 'a', + }); + + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [], + }; + }); + + const inst = await mountAndWaitForLazyModules(); - const firstCall = dataViews.getFieldsForIndexPattern.mock.calls[0]; - expect(firstCall[1]?.filter?.bool?.filter).toContainEqual({ - bool: { - must_not: { - match_all: {}, + expect(defaultProps.showNoDataPopover).toHaveBeenCalled(); + + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '0 available fields. 5 empty fields. 0 meta fields.' + ); + }); + + it("should default to empty dsl if query can't be parsed", async () => { + const props = testProps({ + currentIndexPatternId: 'a', + otherProps: { + query: { + language: 'kuery', + query: '@timestamp : NOT *', }, }, }); + + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [indexPatterns.a.fields[0].name, indexPatterns.a.fields[1].name], + }; + }); + + const inst = await mountAndWaitForLazyModules(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + dslQuery: { + bool: { + must_not: { + match_all: {}, + }, + }, + }, + }) + ); + + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '2 available fields. 3 empty fields. 0 meta fields.' + ); }); }); @@ -680,15 +747,13 @@ describe('FormBased Data Panel', () => { beforeEach(() => { props = { ...defaultProps, - frame: getFrameAPIMock({ - existingFields: { - idx1: { - bytes: true, - memory: true, - }, - }, - }), }; + + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: ['bytes', 'memory'], + }; + }); }); it('should list all selected fields if exist', async () => { @@ -696,7 +761,9 @@ describe('FormBased Data Panel', () => { ...props, layerFields: ['bytes'], }; - const wrapper = mountWithIntl(); + + const wrapper = await mountAndWaitForLazyModules(); + expect( wrapper .find('[data-test-subj="lnsIndexPatternSelectedFields"]') @@ -706,9 +773,10 @@ describe('FormBased Data Panel', () => { }); it('should not list the selected fields accordion if no fields given', async () => { - const wrapper = mountWithIntl(); + const wrapper = await mountAndWaitForLazyModules(); + expect( - wrapper + wrapper! .find('[data-test-subj="lnsIndexPatternSelectedFields"]') .find(FieldItem) .map((fieldItem) => fieldItem.prop('field').name) @@ -716,14 +784,14 @@ describe('FormBased Data Panel', () => { }); it('should list all supported fields in the pattern sorted alphabetically in groups', async () => { - const wrapper = mountWithIntl(); + const wrapper = await mountAndWaitForLazyModules(); + expect(wrapper.find(FieldItem).first().prop('field').displayName).toEqual('Records'); + const availableAccordion = wrapper.find('[data-test-subj="lnsIndexPatternAvailableFields"]'); expect( - wrapper - .find('[data-test-subj="lnsIndexPatternAvailableFields"]') - .find(FieldItem) - .map((fieldItem) => fieldItem.prop('field').name) + availableAccordion.find(FieldItem).map((fieldItem) => fieldItem.prop('field').name) ).toEqual(['memory', 'bytes']); + expect(availableAccordion.find(FieldItem).at(0).prop('exists')).toEqual(true); wrapper .find('[data-test-subj="lnsIndexPatternEmptyFields"]') .find('button') @@ -736,10 +804,11 @@ describe('FormBased Data Panel', () => { expect( emptyAccordion.find(FieldItem).map((fieldItem) => fieldItem.prop('field').displayName) ).toEqual(['client', 'source', 'timestampLabel']); + expect(emptyAccordion.find(FieldItem).at(1).prop('exists')).toEqual(false); }); it('should show meta fields accordion', async () => { - const wrapper = mountWithIntl( + const wrapper = await mountAndWaitForLazyModules( { })} /> ); + wrapper .find('[data-test-subj="lnsIndexPatternMetaFields"]') .find('button') @@ -777,13 +847,15 @@ describe('FormBased Data Panel', () => { }); it('should display NoFieldsCallout when all fields are empty', async () => { - const wrapper = mountWithIntl( - - ); - expect(wrapper.find(NoFieldsCallout).length).toEqual(2); + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [], + }; + }); + + const wrapper = await mountAndWaitForLazyModules(); + + expect(wrapper.find(EuiCallOut).length).toEqual(2); expect( wrapper .find('[data-test-subj="lnsIndexPatternAvailableFields"]') @@ -804,52 +876,55 @@ describe('FormBased Data Panel', () => { }); it('should display spinner for available fields accordion if existing fields are not loaded yet', async () => { - const wrapper = mountWithIntl( - - ); + let resolveFunction: (arg: unknown) => void; + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockReset(); + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(() => { + return new Promise((resolve) => { + resolveFunction = resolve; + }); + }); + const wrapper = await mountAndWaitForLazyModules(); + expect( wrapper.find('[data-test-subj="lnsIndexPatternAvailableFields"]').find(EuiLoadingSpinner) .length ).toEqual(1); - wrapper.setProps({ frame: getFrameAPIMock({ existingFields: { idx1: {} } }) }); - expect(wrapper.find(NoFieldsCallout).length).toEqual(2); - }); + expect(wrapper.find(EuiCallOut).length).toEqual(0); - it('should not allow field details when error', () => { - const wrapper = mountWithIntl( - - ); + await act(async () => { + resolveFunction!({ + existingFieldNames: [], + }); + }); - expect(wrapper.find(FieldList).prop('fieldGroups')).toEqual( - expect.objectContaining({ - AvailableFields: expect.objectContaining({ hideDetails: true }), - }) - ); + await act(async () => { + await wrapper.update(); + }); + + expect( + wrapper.find('[data-test-subj="lnsIndexPatternAvailableFields"]').find(EuiLoadingSpinner) + .length + ).toEqual(0); + expect(wrapper.find(EuiCallOut).length).toEqual(2); }); - it('should allow field details when timeout', () => { - const wrapper = mountWithIntl( - - ); + it('should not allow field details when error', async () => { + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + throw new Error('test'); + }); - expect(wrapper.find(FieldList).prop('fieldGroups')).toEqual( + const wrapper = await mountAndWaitForLazyModules(); + + expect(wrapper.find(FieldListGrouped).prop('fieldGroups')).toEqual( expect.objectContaining({ - AvailableFields: expect.objectContaining({ hideDetails: false }), + AvailableFields: expect.objectContaining({ hideDetails: true }), }) ); }); - it('should filter down by name', () => { - const wrapper = mountWithIntl(); + it('should filter down by name', async () => { + const wrapper = await mountAndWaitForLazyModules(); + act(() => { wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').simulate('change', { target: { value: 'me' }, @@ -867,8 +942,9 @@ describe('FormBased Data Panel', () => { ]); }); - it('should announce filter in live region', () => { - const wrapper = mountWithIntl(); + it('should announce filter in live region', async () => { + const wrapper = await mountAndWaitForLazyModules(); + act(() => { wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').simulate('change', { target: { value: 'me' }, @@ -886,8 +962,8 @@ describe('FormBased Data Panel', () => { ); }); - it('should filter down by type', () => { - const wrapper = mountWithIntl(); + it('should filter down by type', async () => { + const wrapper = await mountAndWaitForLazyModules(); wrapper.find('[data-test-subj="lnsIndexPatternFiltersToggle"]').first().simulate('click'); @@ -898,8 +974,8 @@ describe('FormBased Data Panel', () => { ).toEqual(['amemory', 'bytes']); }); - it('should display no fields in groups when filtered by type Record', () => { - const wrapper = mountWithIntl(); + it('should display no fields in groups when filtered by type Record', async () => { + const wrapper = await mountAndWaitForLazyModules(); wrapper.find('[data-test-subj="lnsIndexPatternFiltersToggle"]').first().simulate('click'); @@ -908,11 +984,12 @@ describe('FormBased Data Panel', () => { expect(wrapper.find(FieldItem).map((fieldItem) => fieldItem.prop('field').name)).toEqual([ DOCUMENT_FIELD_NAME, ]); - expect(wrapper.find(NoFieldsCallout).length).toEqual(3); + expect(wrapper.find(EuiCallOut).length).toEqual(3); }); - it('should toggle type if clicked again', () => { - const wrapper = mountWithIntl(); + it('should toggle type if clicked again', async () => { + const wrapper = await mountAndWaitForLazyModules(); + wrapper.find('[data-test-subj="lnsIndexPatternFiltersToggle"]').first().simulate('click'); wrapper.find('[data-test-subj="typeFilter-number"]').first().simulate('click'); @@ -927,8 +1004,9 @@ describe('FormBased Data Panel', () => { ).toEqual(['Records', 'amemory', 'bytes', 'client', 'source', 'timestampLabel']); }); - it('should filter down by type and by name', () => { - const wrapper = mountWithIntl(); + it('should filter down by type and by name', async () => { + const wrapper = await mountAndWaitForLazyModules(); + act(() => { wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').simulate('change', { target: { value: 'me' }, diff --git a/x-pack/plugins/lens/public/datasources/form_based/datapanel.tsx b/x-pack/plugins/lens/public/datasources/form_based/datapanel.tsx index 8a7916a01a09a0..7da9c57d0123b1 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/datapanel.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/datapanel.tsx @@ -6,32 +6,38 @@ */ import './datapanel.scss'; -import { uniq, groupBy } from 'lodash'; -import React, { useState, memo, useCallback, useMemo, useRef, useEffect } from 'react'; +import { uniq } from 'lodash'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + EuiCallOut, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFilterButton, EuiFlexGroup, EuiFlexItem, - EuiContextMenuPanel, - EuiContextMenuItem, - EuiPopover, - EuiCallOut, EuiFormControlLayout, - EuiFilterButton, - EuiScreenReaderOnly, EuiIcon, + EuiPopover, + EuiProgress, + htmlIdGenerator, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import type { EsQueryConfig, Query, Filter } from '@kbn/es-query'; import { FormattedMessage } from '@kbn/i18n-react'; import type { CoreStart } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { type DataView } from '@kbn/data-plugin/common'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; -import { htmlIdGenerator } from '@elastic/eui'; -import { buildEsQuery } from '@kbn/es-query'; -import { getEsQueryConfig } from '@kbn/data-plugin/public'; import { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public'; import { VISUALIZE_GEO_FIELD_TRIGGER } from '@kbn/ui-actions-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { + FieldsGroupNames, + FieldListGrouped, + type FieldListGroupedProps, + useExistingFieldsFetcher, + useGroupedFields, + useExistingFieldsReader, +} from '@kbn/unified-field-list-plugin/public'; import { ChartsPluginSetup } from '@kbn/charts-plugin/public'; import type { DatasourceDataPanelProps, @@ -42,12 +48,11 @@ import type { } from '../../types'; import { ChildDragDropProvider, DragContextState } from '../../drag_drop'; import type { FormBasedPrivateState } from './types'; -import { Loader } from '../../loader'; import { LensFieldIcon } from '../../shared_components/field_picker/lens_field_icon'; import { getFieldType } from './pure_utils'; -import { FieldGroups, FieldList } from './field_list'; -import { fieldContainsData, fieldExists } from '../../shared_components'; +import { fieldContainsData } from '../../shared_components'; import { IndexPatternServiceAPI } from '../../data_views_service/service'; +import { FieldItem } from './field_item'; export type Props = Omit< DatasourceDataPanelProps, @@ -65,10 +70,6 @@ export type Props = Omit< layerFields?: string[]; }; -function sortFields(fieldA: IndexPatternField, fieldB: IndexPatternField) { - return fieldA.displayName.localeCompare(fieldB.displayName, undefined, { sensitivity: 'base' }); -} - const supportedFieldTypes = new Set([ 'string', 'number', @@ -104,25 +105,8 @@ const fieldTypeNames: Record = { murmur3: i18n.translate('xpack.lens.datatypes.murmur3', { defaultMessage: 'murmur3' }), }; -// Wrapper around buildEsQuery, handling errors (e.g. because a query can't be parsed) by -// returning a query dsl object not matching anything -function buildSafeEsQuery( - indexPattern: IndexPattern, - query: Query, - filters: Filter[], - queryConfig: EsQueryConfig -) { - try { - return buildEsQuery(indexPattern, query, filters, queryConfig); - } catch (e) { - return { - bool: { - must_not: { - match_all: {}, - }, - }, - }; - } +function onSupportedFieldFilter(field: IndexPatternField): boolean { + return supportedFieldTypes.has(field.type); } export function FormBasedDataPanel({ @@ -147,51 +131,22 @@ export function FormBasedDataPanel({ usedIndexPatterns, layerFields, }: Props) { - const { indexPatterns, indexPatternRefs, existingFields, isFirstExistenceFetch } = - frame.dataViews; + const { indexPatterns, indexPatternRefs } = frame.dataViews; const { currentIndexPatternId } = state; - const indexPatternList = uniq( - ( - usedIndexPatterns ?? Object.values(state.layers).map(({ indexPatternId }) => indexPatternId) - ).concat(currentIndexPatternId) - ) - .filter((id) => !!indexPatterns[id]) - .sort() - .map((id) => indexPatterns[id]); - - const dslQuery = buildSafeEsQuery( - indexPatterns[currentIndexPatternId], - query, - filters, - getEsQueryConfig(core.uiSettings) - ); + const activeIndexPatterns = useMemo(() => { + return uniq( + ( + usedIndexPatterns ?? Object.values(state.layers).map(({ indexPatternId }) => indexPatternId) + ).concat(currentIndexPatternId) + ) + .filter((id) => !!indexPatterns[id]) + .sort() + .map((id) => indexPatterns[id]); + }, [usedIndexPatterns, indexPatterns, state.layers, currentIndexPatternId]); return ( <> - - indexPatternService.refreshExistingFields({ - dateRange, - currentIndexPatternTitle: indexPatterns[currentIndexPatternId]?.title || '', - onNoData: showNoDataPopover, - dslQuery, - indexPatternList, - isFirstExistenceFetch, - existingFields, - }) - } - loadDeps={[ - query, - filters, - dateRange.fromDate, - dateRange.toDate, - indexPatternList.map((x) => `${x.title}:${x.timeFieldName}`).join(','), - // important here to rerun the fields existence on indexPattern change (i.e. add new fields in place) - frame.dataViews.indexPatterns, - ]} - /> - {Object.keys(indexPatterns).length === 0 && indexPatternRefs.length === 0 ? ( )} @@ -252,18 +209,6 @@ interface DataPanelState { isMetaAccordionOpen: boolean; } -const defaultFieldGroups: { - specialFields: IndexPatternField[]; - availableFields: IndexPatternField[]; - emptyFields: IndexPatternField[]; - metaFields: IndexPatternField[]; -} = { - specialFields: [], - availableFields: [], - emptyFields: [], - metaFields: [], -}; - const htmlId = htmlIdGenerator('datapanel'); const fieldSearchDescriptionId = htmlId(); @@ -286,9 +231,11 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ frame, onIndexPatternRefresh, layerFields, + showNoDataPopover, + activeIndexPatterns, }: Omit< DatasourceDataPanelProps, - 'state' | 'setState' | 'showNoDataPopover' | 'core' | 'onChangeIndexPattern' | 'usedIndexPatterns' + 'state' | 'setState' | 'core' | 'onChangeIndexPattern' | 'usedIndexPatterns' > & { data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; @@ -301,6 +248,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ indexPatternFieldEditor: IndexPatternFieldEditorStart; onIndexPatternRefresh: () => void; layerFields?: string[]; + activeIndexPatterns: IndexPattern[]; }) { const [localState, setLocalState] = useState({ nameFilter: '', @@ -310,10 +258,30 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ isEmptyAccordionOpen: false, isMetaAccordionOpen: false, }); - const { existenceFetchFailed, existenceFetchTimeout, indexPatterns, existingFields } = - frame.dataViews; + const { indexPatterns } = frame.dataViews; const currentIndexPattern = indexPatterns[currentIndexPatternId]; - const existingFieldsForIndexPattern = existingFields[currentIndexPattern?.title]; + + const { refetchFieldsExistenceInfo, isProcessing } = useExistingFieldsFetcher({ + dataViews: activeIndexPatterns as unknown as DataView[], + query, + filters, + fromDate: dateRange.fromDate, + toDate: dateRange.toDate, + services: { + data, + dataViews, + core, + }, + onNoData: (dataViewId) => { + if (dataViewId === currentIndexPatternId) { + showNoDataPopover(); + } + }, + }); + const fieldsExistenceReader = useExistingFieldsReader(); + const fieldsExistenceStatus = + fieldsExistenceReader.getFieldsExistenceStatus(currentIndexPatternId); + const visualizeGeoFieldTrigger = uiActions.getTrigger(VISUALIZE_GEO_FIELD_TRIGGER); const allFields = useMemo(() => { if (!currentIndexPattern) return []; @@ -331,187 +299,74 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ ...localState.typeFilter, ]); - const fieldInfoUnavailable = - existenceFetchFailed || existenceFetchTimeout || currentIndexPattern?.hasRestrictions; - const editPermission = indexPatternFieldEditor.userPermissions.editIndexPattern() || !currentIndexPattern.isPersisted; - const unfilteredFieldGroups: FieldGroups = useMemo(() => { - const containsData = (field: IndexPatternField) => { - const overallField = currentIndexPattern?.getFieldByName(field.name); - return ( - overallField && - existingFieldsForIndexPattern && - fieldExists(existingFieldsForIndexPattern, overallField.name) - ); - }; - - const allSupportedTypesFields = allFields.filter((field) => - supportedFieldTypes.has(field.type) - ); - const usedByLayersFields = allFields.filter((field) => layerFields?.includes(field.name)); - const sorted = allSupportedTypesFields.sort(sortFields); - const groupedFields = { - ...defaultFieldGroups, - ...groupBy(sorted, (field) => { - if (field.type === 'document') { - return 'specialFields'; - } else if (field.meta) { - return 'metaFields'; - } else if (containsData(field)) { - return 'availableFields'; - } else return 'emptyFields'; - }), - }; - - const isUsingSampling = core.uiSettings.get('lens:useFieldExistenceSampling'); - - const fieldGroupDefinitions: FieldGroups = { - SpecialFields: { - fields: groupedFields.specialFields, - fieldCount: 1, - isAffectedByGlobalFilter: false, - isAffectedByTimeFilter: false, - isInitiallyOpen: false, - showInAccordion: false, - title: '', - hideDetails: true, - }, - SelectedFields: { - fields: usedByLayersFields, - fieldCount: usedByLayersFields.length, - isInitiallyOpen: true, - showInAccordion: true, - title: i18n.translate('xpack.lens.indexPattern.selectedFieldsLabel', { - defaultMessage: 'Selected fields', - }), - isAffectedByGlobalFilter: !!filters.length, - isAffectedByTimeFilter: true, - hideDetails: false, - hideIfEmpty: true, - }, - AvailableFields: { - fields: groupedFields.availableFields, - fieldCount: groupedFields.availableFields.length, - isInitiallyOpen: true, - showInAccordion: true, - title: fieldInfoUnavailable - ? i18n.translate('xpack.lens.indexPattern.allFieldsLabel', { - defaultMessage: 'All fields', - }) - : i18n.translate('xpack.lens.indexPattern.availableFieldsLabel', { - defaultMessage: 'Available fields', - }), - helpText: isUsingSampling - ? i18n.translate('xpack.lens.indexPattern.allFieldsSamplingLabelHelp', { - defaultMessage: - 'Available fields contain the data in the first 500 documents that match your filters. To view all fields, expand Empty fields. You are unable to create visualizations with full text, geographic, flattened, and object fields.', - }) - : i18n.translate('xpack.lens.indexPattern.allFieldsLabelHelp', { - defaultMessage: - 'Drag and drop available fields to the workspace and create visualizations. To change the available fields, select a different data view, edit your queries, or use a different time range. Some field types cannot be visualized in Lens, including full text and geographic fields.', - }), - isAffectedByGlobalFilter: !!filters.length, - isAffectedByTimeFilter: true, - // Show details on timeout but not failure - hideDetails: fieldInfoUnavailable && !existenceFetchTimeout, - defaultNoFieldsMessage: i18n.translate('xpack.lens.indexPatterns.noAvailableDataLabel', { - defaultMessage: `There are no available fields that contain data.`, - }), - }, - EmptyFields: { - fields: groupedFields.emptyFields, - fieldCount: groupedFields.emptyFields.length, - isAffectedByGlobalFilter: false, - isAffectedByTimeFilter: false, - isInitiallyOpen: false, - showInAccordion: true, - hideDetails: false, - title: i18n.translate('xpack.lens.indexPattern.emptyFieldsLabel', { - defaultMessage: 'Empty fields', - }), - defaultNoFieldsMessage: i18n.translate('xpack.lens.indexPatterns.noEmptyDataLabel', { - defaultMessage: `There are no empty fields.`, - }), - helpText: i18n.translate('xpack.lens.indexPattern.emptyFieldsLabelHelp', { - defaultMessage: - 'Empty fields did not contain any values in the first 500 documents based on your filters.', - }), - }, - MetaFields: { - fields: groupedFields.metaFields, - fieldCount: groupedFields.metaFields.length, - isAffectedByGlobalFilter: false, - isAffectedByTimeFilter: false, - isInitiallyOpen: false, - showInAccordion: true, - hideDetails: false, - title: i18n.translate('xpack.lens.indexPattern.metaFieldsLabel', { - defaultMessage: 'Meta fields', - }), - defaultNoFieldsMessage: i18n.translate('xpack.lens.indexPatterns.noMetaDataLabel', { - defaultMessage: `There are no meta fields.`, - }), - }, - }; - - // do not show empty field accordion if there is no existence information - if (fieldInfoUnavailable) { - delete fieldGroupDefinitions.EmptyFields; - } - - return fieldGroupDefinitions; - }, [ - allFields, - core.uiSettings, - fieldInfoUnavailable, - filters.length, - existenceFetchTimeout, - currentIndexPattern, - existingFieldsForIndexPattern, - layerFields, - ]); - - const fieldGroups: FieldGroups = useMemo(() => { - const filterFieldGroup = (fieldGroup: IndexPatternField[]) => - fieldGroup.filter((field) => { - if ( - localState.nameFilter.length && - !field.name.toLowerCase().includes(localState.nameFilter.toLowerCase()) && - !field.displayName.toLowerCase().includes(localState.nameFilter.toLowerCase()) - ) { - return false; - } - if (localState.typeFilter.length > 0) { - return localState.typeFilter.includes(getFieldType(field) as DataType); - } - return true; - }); - return Object.fromEntries( - Object.entries(unfilteredFieldGroups).map(([name, group]) => [ - name, - { ...group, fields: filterFieldGroup(group.fields) }, - ]) - ); - }, [unfilteredFieldGroups, localState.nameFilter, localState.typeFilter]); - - const checkFieldExists = useCallback( - (field: IndexPatternField) => - fieldContainsData(field.name, currentIndexPattern, existingFieldsForIndexPattern), - [currentIndexPattern, existingFieldsForIndexPattern] + const onSelectedFieldFilter = useCallback( + (field: IndexPatternField): boolean => { + return Boolean(layerFields?.includes(field.name)); + }, + [layerFields] ); - const { nameFilter, typeFilter } = localState; + const onFilterField = useCallback( + (field: IndexPatternField) => { + if ( + localState.nameFilter.length && + !field.name.toLowerCase().includes(localState.nameFilter.toLowerCase()) && + !field.displayName.toLowerCase().includes(localState.nameFilter.toLowerCase()) + ) { + return false; + } + if (localState.typeFilter.length > 0) { + return localState.typeFilter.includes(getFieldType(field) as DataType); + } + return true; + }, + [localState] + ); - const filter = useMemo( - () => ({ - nameFilter, - typeFilter, - }), - [nameFilter, typeFilter] + const hasFilters = Boolean(filters.length); + const onOverrideFieldGroupDetails = useCallback( + (groupName) => { + if (groupName === FieldsGroupNames.AvailableFields) { + const isUsingSampling = core.uiSettings.get('lens:useFieldExistenceSampling'); + + return { + helpText: isUsingSampling + ? i18n.translate('xpack.lens.indexPattern.allFieldsSamplingLabelHelp', { + defaultMessage: + 'Available fields contain the data in the first 500 documents that match your filters. To view all fields, expand Empty fields. You are unable to create visualizations with full text, geographic, flattened, and object fields.', + }) + : i18n.translate('xpack.lens.indexPattern.allFieldsLabelHelp', { + defaultMessage: + 'Drag and drop available fields to the workspace and create visualizations. To change the available fields, select a different data view, edit your queries, or use a different time range. Some field types cannot be visualized in Lens, including full text and geographic fields.', + }), + isAffectedByGlobalFilter: hasFilters, + }; + } + if (groupName === FieldsGroupNames.SelectedFields) { + return { + isAffectedByGlobalFilter: hasFilters, + }; + } + }, + [core.uiSettings, hasFilters] ); + const { fieldGroups } = useGroupedFields({ + dataViewId: currentIndexPatternId, + allFields, + services: { + dataViews, + }, + fieldsExistenceReader, + onFilterField, + onSupportedFieldFilter, + onSelectedFieldFilter, + onOverrideFieldGroupDetails, + }); + const closeFieldEditor = useRef<() => void | undefined>(); useEffect(() => { @@ -560,6 +415,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ onSave: () => { if (indexPatternInstance.isPersisted()) { refreshFieldList(); + refetchFieldsExistenceInfo(indexPatternInstance.id); } else { indexPatternService.replaceDataViewId(indexPatternInstance); } @@ -574,6 +430,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ indexPatternFieldEditor, refreshFieldList, indexPatternService, + refetchFieldsExistenceInfo, ] ); @@ -590,6 +447,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ onDelete: () => { if (indexPatternInstance.isPersisted()) { refreshFieldList(); + refetchFieldsExistenceInfo(indexPatternInstance.id); } else { indexPatternService.replaceDataViewId(indexPatternInstance); } @@ -604,24 +462,39 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ indexPatternFieldEditor, indexPatternService, refreshFieldList, + refetchFieldsExistenceInfo, ] ); - const fieldProps = useMemo( - () => ({ - core, - data, - fieldFormats, - indexPattern: currentIndexPattern, - highlight: localState.nameFilter.toLowerCase(), - dateRange, - query, - filters, - chartsThemeService: charts.theme, - }), + const renderFieldItem: FieldListGroupedProps['renderFieldItem'] = useCallback( + ({ field, itemIndex, groupIndex, hideDetails }) => ( + + ), [ core, - data, fieldFormats, currentIndexPattern, dateRange, @@ -629,6 +502,12 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ filters, localState.nameFilter, charts.theme, + fieldsExistenceReader.hasFieldData, + dropOntoWorkspace, + hasSuggestionForField, + editField, + removeField, + uiActions, ] ); @@ -640,6 +519,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ direction="column" responsive={false} > + {isProcessing && } - -
        - {i18n.translate('xpack.lens.indexPatterns.fieldSearchLiveRegion', { - defaultMessage: - '{availableFields} available {availableFields, plural, one {field} other {fields}}. {emptyFields} empty {emptyFields, plural, one {field} other {fields}}. {metaFields} meta {metaFields, plural, one {field} other {fields}}.', - values: { - availableFields: fieldGroups.AvailableFields.fields.length, - // empty fields can be undefined if there is no existence information to be fetched - emptyFields: fieldGroups.EmptyFields?.fields.length || 0, - metaFields: fieldGroups.MetaFields.fields.length, - }, - })} -
        -
        - fieldGroups={fieldGroups} - hasSyncedExistingFields={!!existingFieldsForIndexPattern} - filter={filter} - currentIndexPatternId={currentIndexPatternId} - existenceFetchFailed={existenceFetchFailed} - existenceFetchTimeout={existenceFetchTimeout} - existFieldsInIndex={!!allFields.length} - dropOntoWorkspace={dropOntoWorkspace} - hasSuggestionForField={hasSuggestionForField} - editField={editField} - removeField={removeField} - uiActions={uiActions} + fieldsExistenceStatus={fieldsExistenceStatus} + fieldsExistInIndex={!!allFields.length} + renderFieldItem={renderFieldItem} + screenReaderDescriptionForSearchInputId={fieldSearchDescriptionId} + data-test-subj="lnsIndexPattern" />
        diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx index 330d3285b29511..97dabaca05c037 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx @@ -606,7 +606,6 @@ export function DimensionEditor(props: DimensionEditorProps) { setIsCloseable, paramEditorCustomProps, ReferenceEditor, - existingFields: props.existingFields, ...services, }; @@ -789,7 +788,6 @@ export function DimensionEditor(props: DimensionEditorProps) { }} validation={validation} currentIndexPattern={currentIndexPattern} - existingFields={props.existingFields} selectionStyle={selectedOperationDefinition.selectionStyle} dateRange={dateRange} labelAppend={selectedOperationDefinition?.getHelpMessage?.({ @@ -815,7 +813,6 @@ export function DimensionEditor(props: DimensionEditorProps) { selectedColumn={selectedColumn as FieldBasedIndexPatternColumn} columnId={columnId} indexPattern={currentIndexPattern} - existingFields={props.existingFields} operationSupportMatrix={operationSupportMatrix} updateLayer={(newLayer) => { if (temporaryQuickFunction) { @@ -845,7 +842,6 @@ export function DimensionEditor(props: DimensionEditorProps) { const customParamEditor = ParamEditor ? ( <> { }; }); +jest.mock('@kbn/unified-field-list-plugin/public/hooks/use_existing_fields', () => ({ + useExistingFieldsReader: jest.fn(() => { + return { + hasFieldData: (dataViewId: string, fieldName: string) => { + return ['timestamp', 'bytes', 'memory', 'source'].includes(fieldName); + }, + }; + }), +})); + const fields = [ { name: 'timestamp', @@ -197,14 +208,6 @@ describe('FormBasedDimensionEditor', () => { defaultProps = { indexPatterns: expectedIndexPatterns, - existingFields: { - 'my-fake-index-pattern': { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, state, setState, dateRange: { fromDate: 'now-1d', toDate: 'now' }, @@ -339,16 +342,15 @@ describe('FormBasedDimensionEditor', () => { }); it('should hide fields that have no data', () => { - const props = { - ...defaultProps, - existingFields: { - 'my-fake-index-pattern': { - timestamp: true, - source: true, + (useExistingFieldsReader as jest.Mock).mockImplementationOnce(() => { + return { + hasFieldData: (dataViewId: string, fieldName: string) => { + return ['timestamp', 'source'].includes(fieldName); }, - }, - }; - wrapper = mount(); + }; + }); + + wrapper = mount(); const options = wrapper .find(EuiComboBox) diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.test.tsx index 877dc18156cdf3..a135b08082c9ed 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.test.tsx @@ -112,11 +112,7 @@ function getLayer(col1: GenericIndexPatternColumn = getStringBasedOperationColum }, }; } -function getDefaultOperationSupportMatrix( - layer: FormBasedLayer, - columnId: string, - existingFields: Record> -) { +function getDefaultOperationSupportMatrix(layer: FormBasedLayer, columnId: string) { return getOperationSupportMatrix({ state: { layers: { layer1: layer }, @@ -130,29 +126,36 @@ function getDefaultOperationSupportMatrix( }); } -function getExistingFields() { - const fields: Record = {}; - for (const field of defaultProps.indexPattern.fields) { - fields[field.name] = true; - } - return { - [defaultProps.indexPattern.title]: fields, - }; -} +const mockedReader = { + hasFieldData: (dataViewId: string, fieldName: string) => { + if (defaultProps.indexPattern.id !== dataViewId) { + return false; + } + + const map: Record = {}; + for (const field of defaultProps.indexPattern.fields) { + map[field.name] = true; + } + + return map[fieldName]; + }, +}; + +jest.mock('@kbn/unified-field-list-plugin/public/hooks/use_existing_fields', () => ({ + useExistingFieldsReader: jest.fn(() => mockedReader), +})); describe('FieldInput', () => { it('should render a field select box', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( ); @@ -163,15 +166,13 @@ describe('FieldInput', () => { it('should render an error message when incomplete operation is on', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( { (_, col: ReferenceBasedIndexPatternColumn) => { const updateLayerSpy = jest.fn(); const layer = getLayer(col); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix( - layer, - 'col1', - existingFields - ); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( @@ -234,19 +229,13 @@ describe('FieldInput', () => { (_, col: ReferenceBasedIndexPatternColumn) => { const updateLayerSpy = jest.fn(); const layer = getLayer(col); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix( - layer, - 'col1', - existingFields - ); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( { it('should render an error message for invalid fields', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( @@ -295,15 +282,13 @@ describe('FieldInput', () => { it('should render a help message when passed and no errors are found', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( @@ -320,15 +305,13 @@ describe('FieldInput', () => { it('should prioritize errors over help messages', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( { it('should update the layer on field selection', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( @@ -372,15 +353,13 @@ describe('FieldInput', () => { it('should not trigger when the same selected field is selected again', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( @@ -398,15 +377,13 @@ describe('FieldInput', () => { it('should prioritize incomplete fields over selected column field to display', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( { const updateLayerSpy = jest.fn(); const onDeleteColumn = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.tsx index ec471b70de614e..462cd0b546f220 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.tsx @@ -22,7 +22,6 @@ export function FieldInput({ selectedColumn, columnId, indexPattern, - existingFields, operationSupportMatrix, updateLayer, onDeleteColumn, @@ -62,7 +61,6 @@ export function FieldInput({ void; onDeleteColumn?: () => void; - existingFields: ExistingFieldsMap[string]; fieldIsInvalid: boolean; markAllFieldsCompatible?: boolean; 'data-test-subj'?: string; @@ -47,12 +47,12 @@ export function FieldSelect({ operationByField, onChoose, onDeleteColumn, - existingFields, fieldIsInvalid, markAllFieldsCompatible, ['data-test-subj']: dataTestSub, ...rest }: FieldSelectProps) { + const { hasFieldData } = useExistingFieldsReader(); const memoizedFieldOptions = useMemo(() => { const fields = Object.keys(operationByField).sort(); @@ -67,8 +67,8 @@ export function FieldSelect({ (field) => currentIndexPattern.getFieldByName(field)?.type === 'document' ); - function containsData(field: string) { - return fieldContainsData(field, currentIndexPattern, existingFields); + function containsData(fieldName: string) { + return fieldContainsData(fieldName, currentIndexPattern, hasFieldData); } function fieldNamesToOptions(items: string[]) { @@ -145,7 +145,7 @@ export function FieldSelect({ selectedOperationType, currentIndexPattern, operationByField, - existingFields, + hasFieldData, markAllFieldsCompatible, ]); diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.test.tsx index d46dabf6c12f35..cb50049e3fbec6 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.test.tsx @@ -28,6 +28,16 @@ import { import { FieldSelect } from './field_select'; import { FormBasedLayer } from '../types'; +jest.mock('@kbn/unified-field-list-plugin/public/hooks/use_existing_fields', () => ({ + useExistingFieldsReader: jest.fn(() => { + return { + hasFieldData: (dataViewId: string, fieldName: string) => { + return ['timestamp', 'bytes', 'memory', 'source'].includes(fieldName); + }, + }; + }), +})); + jest.mock('../operations'); describe('reference editor', () => { @@ -59,14 +69,6 @@ describe('reference editor', () => { paramEditorUpdater, selectionStyle: 'full' as const, currentIndexPattern: createMockedIndexPattern(), - existingFields: { - 'my-fake-index-pattern': { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, dateRange: { fromDate: 'now-1d', toDate: 'now' }, storage: {} as IStorageWrapper, uiSettings: {} as IUiSettingsClient, diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.tsx index cefee79349087f..6b8ecbbfe52460 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.tsx @@ -29,12 +29,7 @@ import { import { FieldChoiceWithOperationType, FieldSelect } from './field_select'; import { hasField } from '../pure_utils'; import type { FormBasedLayer } from '../types'; -import type { - ExistingFieldsMap, - IndexPattern, - IndexPatternField, - ParamEditorCustomProps, -} from '../../../types'; +import type { IndexPattern, IndexPatternField, ParamEditorCustomProps } from '../../../types'; import type { FormBasedDimensionEditorProps } from './dimension_panel'; import { FormRow } from '../operations/definitions/shared_components'; @@ -83,7 +78,6 @@ export interface ReferenceEditorProps { fieldLabel?: string; operationDefinitionMap: Record; isInline?: boolean; - existingFields: ExistingFieldsMap; dateRange: DateRange; labelAppend?: EuiFormRowProps['labelAppend']; isFullscreen: boolean; @@ -114,7 +108,6 @@ export interface ReferenceEditorProps { export const ReferenceEditor = (props: ReferenceEditorProps) => { const { currentIndexPattern, - existingFields, validation, selectionStyle, labelAppend, @@ -307,7 +300,6 @@ export const ReferenceEditor = (props: ReferenceEditorProps) => { ; - -function getDisplayedFieldsLength( - fieldGroups: FieldGroups, - accordionState: Partial> -) { - return Object.entries(fieldGroups) - .filter(([key]) => accordionState[key]) - .reduce((allFieldCount, [, { fields }]) => allFieldCount + fields.length, 0); -} - -export const FieldList = React.memo(function FieldList({ - exists, - fieldGroups, - existenceFetchFailed, - existenceFetchTimeout, - fieldProps, - hasSyncedExistingFields, - filter, - currentIndexPatternId, - existFieldsInIndex, - dropOntoWorkspace, - hasSuggestionForField, - editField, - removeField, - uiActions, -}: { - exists: (field: IndexPatternField) => boolean; - fieldGroups: FieldGroups; - fieldProps: FieldItemSharedProps; - hasSyncedExistingFields: boolean; - existenceFetchFailed?: boolean; - existenceFetchTimeout?: boolean; - filter: { - nameFilter: string; - typeFilter: string[]; - }; - currentIndexPatternId: string; - existFieldsInIndex: boolean; - dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; - hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; - editField?: (name: string) => void; - removeField?: (name: string) => void; - uiActions: UiActionsStart; -}) { - const [fieldGroupsToShow, fieldFroupsToCollapse] = partition( - Object.entries(fieldGroups), - ([, { showInAccordion }]) => showInAccordion - ); - const [pageSize, setPageSize] = useState(PAGINATION_SIZE); - const [scrollContainer, setScrollContainer] = useState(undefined); - const [accordionState, setAccordionState] = useState>>(() => - Object.fromEntries( - fieldGroupsToShow.map(([key, { isInitiallyOpen }]) => [key, isInitiallyOpen]) - ) - ); - - useEffect(() => { - // Reset the scroll if we have made material changes to the field list - if (scrollContainer) { - scrollContainer.scrollTop = 0; - setPageSize(PAGINATION_SIZE); - } - }, [filter.nameFilter, filter.typeFilter, currentIndexPatternId, scrollContainer]); - - const lazyScroll = useCallback(() => { - if (scrollContainer) { - const nearBottom = - scrollContainer.scrollTop + scrollContainer.clientHeight > - scrollContainer.scrollHeight * 0.9; - if (nearBottom) { - setPageSize( - Math.max( - PAGINATION_SIZE, - Math.min( - pageSize + PAGINATION_SIZE * 0.5, - getDisplayedFieldsLength(fieldGroups, accordionState) - ) - ) - ); - } - } - }, [scrollContainer, pageSize, setPageSize, fieldGroups, accordionState]); - - const paginatedFields = useMemo(() => { - let remainingItems = pageSize; - return Object.fromEntries( - fieldGroupsToShow.map(([key, fieldGroup]) => { - if (!accordionState[key] || remainingItems <= 0) { - return [key, []]; - } - const slicedFieldList = fieldGroup.fields.slice(0, remainingItems); - remainingItems = remainingItems - slicedFieldList.length; - return [key, slicedFieldList]; - }) - ); - }, [pageSize, fieldGroupsToShow, accordionState]); - - return ( -
        { - if (el && !el.dataset.dynamicScroll) { - el.dataset.dynamicScroll = 'true'; - setScrollContainer(el); - } - }} - onScroll={throttle(lazyScroll, 100)} - > -
        -
          - {fieldFroupsToCollapse.flatMap(([, { fields }]) => - fields.map((field, index) => ( - - )) - )} -
        - - {fieldGroupsToShow.map(([key, fieldGroup], index) => { - if (Boolean(fieldGroup.hideIfEmpty) && !fieldGroup.fields.length) return null; - return ( - - { - setAccordionState((s) => ({ - ...s, - [key]: open, - })); - const displayedFieldLength = getDisplayedFieldsLength(fieldGroups, { - ...accordionState, - [key]: open, - }); - setPageSize( - Math.max(PAGINATION_SIZE, Math.min(pageSize * 1.5, displayedFieldLength)) - ); - }} - showExistenceFetchError={existenceFetchFailed} - showExistenceFetchTimeout={existenceFetchTimeout} - renderCallout={ - - } - uiActions={uiActions} - /> - - - ); - })} -
        -
        - ); -}); diff --git a/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.test.tsx deleted file mode 100644 index a471f8e0fa309b..00000000000000 --- a/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiLoadingSpinner, EuiNotificationBadge } from '@elastic/eui'; -import { coreMock } from '@kbn/core/public/mocks'; -import { mountWithIntl, shallowWithIntl } from '@kbn/test-jest-helpers'; -import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; -import { IndexPattern } from '../../types'; -import { FieldItem } from './field_item'; -import { FieldsAccordion, FieldsAccordionProps, FieldItemSharedProps } from './fields_accordion'; -import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; -import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; - -describe('Fields Accordion', () => { - let defaultProps: FieldsAccordionProps; - let indexPattern: IndexPattern; - let core: ReturnType; - let fieldProps: FieldItemSharedProps; - - beforeEach(() => { - indexPattern = { - id: '1', - title: 'my-fake-index-pattern', - timeFieldName: 'timestamp', - fields: [ - { - name: 'timestamp', - type: 'date', - aggregatable: true, - searchable: true, - }, - { - name: 'bytes', - type: 'number', - aggregatable: true, - searchable: true, - }, - ], - } as IndexPattern; - core = coreMock.createStart(); - core.http.post.mockClear(); - - fieldProps = { - indexPattern, - fieldFormats: fieldFormatsServiceMock.createStartContract(), - core, - highlight: '', - dateRange: { - fromDate: 'now-7d', - toDate: 'now', - }, - query: { query: '', language: 'lucene' }, - filters: [], - chartsThemeService: chartPluginMock.createSetupContract().theme, - }; - - defaultProps = { - initialIsOpen: true, - onToggle: jest.fn(), - id: 'id', - label: 'label', - hasLoaded: true, - fieldsCount: 2, - isFiltered: false, - paginatedFields: indexPattern.fields, - fieldProps, - renderCallout:
        Callout
        , - exists: () => true, - groupIndex: 0, - dropOntoWorkspace: () => {}, - hasSuggestionForField: () => false, - uiActions: uiActionsPluginMock.createStartContract(), - }; - }); - - it('renders correct number of Field Items', () => { - const wrapper = mountWithIntl( - field.name === 'timestamp'} /> - ); - expect(wrapper.find(FieldItem).at(0).prop('exists')).toEqual(true); - expect(wrapper.find(FieldItem).at(1).prop('exists')).toEqual(false); - }); - - it('passed correct exists flag to each field', () => { - const wrapper = mountWithIntl(); - expect(wrapper.find(FieldItem).length).toEqual(2); - }); - - it('renders callout if no fields', () => { - const wrapper = shallowWithIntl( - - ); - expect(wrapper.find('#lens-test-callout').length).toEqual(1); - }); - - it('renders accented notificationBadge state if isFiltered', () => { - const wrapper = mountWithIntl(); - expect(wrapper.find(EuiNotificationBadge).prop('color')).toEqual('accent'); - }); - - it('renders spinner if has not loaded', () => { - const wrapper = mountWithIntl(); - expect(wrapper.find(EuiLoadingSpinner).length).toEqual(1); - }); -}); diff --git a/x-pack/plugins/lens/public/datasources/form_based/layerpanel.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/layerpanel.test.tsx index defc505f1d9e13..86fd5490f383b8 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/layerpanel.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/layerpanel.test.tsx @@ -181,8 +181,6 @@ describe('Layer Data Panel', () => { { id: '2', title: 'my-fake-restricted-pattern' }, { id: '3', title: 'my-compatible-pattern' }, ], - existingFields: {}, - isFirstExistenceFetch: false, indexPatterns: { '1': { id: '1', diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/date_histogram.test.tsx index c08f8703c723fa..d9510256eb92da 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/date_histogram.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/date_histogram.test.tsx @@ -113,14 +113,6 @@ const defaultOptions = { isFullscreen: false, toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; describe('date_histogram', () => { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/filters/filters.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/filters/filters.test.tsx index 2264fa8f185fbb..e5199a5295ec6f 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/filters/filters.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/filters/filters.test.tsx @@ -38,14 +38,6 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; // mocking random id generator function diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts index 5253267b286cd7..1ed621b19b8bd6 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts @@ -198,7 +198,6 @@ export interface ParamEditorProps< activeData?: FormBasedDimensionEditorProps['activeData']; operationDefinitionMap: Record; paramEditorCustomProps?: ParamEditorCustomProps; - existingFields: Record>; isReferenced?: boolean; } @@ -215,10 +214,6 @@ export interface FieldInputProps { incompleteParams: Omit; dimensionGroups: FormBasedDimensionEditorProps['dimensionGroups']; groupId: FormBasedDimensionEditorProps['groupId']; - /** - * indexPatternId -> fieldName -> boolean - */ - existingFields: Record>; operationSupportMatrix: OperationSupportMatrix; helpMessage?: React.ReactNode; operationDefinitionMap: Record; diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.test.tsx index 16c6f2727ea509..cf5babde1feb6c 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.test.tsx @@ -44,14 +44,6 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; describe('last_value', () => { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile.test.tsx index e78ac9e9360da9..59d8602d7c2756 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile.test.tsx @@ -60,14 +60,6 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; describe('percentile', () => { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile_ranks.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile_ranks.test.tsx index adb0d8e491fd7c..c29e5ca2c14990 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile_ranks.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile_ranks.test.tsx @@ -53,14 +53,6 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; describe('percentile ranks', () => { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/ranges/ranges.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/ranges/ranges.test.tsx index d238fd16b89324..e5a870985d2c0c 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/ranges/ranges.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/ranges/ranges.test.tsx @@ -86,14 +86,6 @@ const defaultOptions = { storage: {} as IStorageWrapper, uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, dateRange: { fromDate: 'now-1y', toDate: 'now', diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/static_value.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/static_value.test.tsx index 0ed6a60677f732..6d79d19f44a535 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/static_value.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/static_value.test.tsx @@ -52,14 +52,6 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; describe('static_value', () => { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/field_inputs.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/field_inputs.tsx index 17b0e5e475ffd8..b7f24e7d3d9c1b 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/field_inputs.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/field_inputs.tsx @@ -8,7 +8,7 @@ import React, { useCallback, useMemo } from 'react'; import { htmlIdGenerator } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { ExistingFieldsMap, IndexPattern } from '../../../../../types'; +import { IndexPattern } from '../../../../../types'; import { DragDropBuckets, FieldsBucketContainer, @@ -27,7 +27,6 @@ export const MAX_MULTI_FIELDS_SIZE = 3; export interface FieldInputsProps { column: TermsIndexPatternColumn; indexPattern: IndexPattern; - existingFields: ExistingFieldsMap; invalidFields?: string[]; operationSupportMatrix: Pick; onChange: (newValues: string[]) => void; @@ -49,7 +48,6 @@ export function FieldInputs({ column, onChange, indexPattern, - existingFields, operationSupportMatrix, invalidFields, }: FieldInputsProps) { @@ -153,7 +151,6 @@ export function FieldInputs({ { throw new Error('Should not be called'); }} diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/terms.test.tsx index 58f2f479f401a6..d7a87701110809 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/terms.test.tsx @@ -50,6 +50,16 @@ jest.mock('@kbn/unified-field-list-plugin/public/services/field_stats', () => ({ }), })); +jest.mock('@kbn/unified-field-list-plugin/public/hooks/use_existing_fields', () => ({ + useExistingFieldsReader: jest.fn(() => { + return { + hasFieldData: (dataViewId: string, fieldName: string) => { + return ['timestamp', 'bytes', 'memory', 'source'].includes(fieldName); + }, + }; + }), +})); + // mocking random id generator function jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -93,14 +103,6 @@ const defaultProps = { setIsCloseable: jest.fn(), layerId: '1', ReferenceEditor, - existingFields: { - 'my-fake-index-pattern': { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; describe('terms', () => { @@ -1170,20 +1172,7 @@ describe('terms', () => { >, }; - function getExistingFields() { - const fields: Record = {}; - for (const field of defaultProps.indexPattern.fields) { - fields[field.name] = true; - } - return { - [defaultProps.indexPattern.title]: fields, - }; - } - - function getDefaultOperationSupportMatrix( - columnId: string, - existingFields: Record> - ) { + function getDefaultOperationSupportMatrix(columnId: string) { return getOperationSupportMatrix({ state: { layers: { layer1: layer }, @@ -1199,15 +1188,13 @@ describe('terms', () => { it('should render the default field input for no field (incomplete operation)', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); const instance = mount( @@ -1226,8 +1213,7 @@ describe('terms', () => { it('should show an error message when first field is invalid', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); layer.columns.col1 = { label: 'Top value of unsupported', @@ -1247,7 +1233,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} currentFieldIsInvalid /> @@ -1259,8 +1244,7 @@ describe('terms', () => { it('should show an error message when first field is not supported', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); layer.columns.col1 = { label: 'Top value of timestamp', @@ -1280,7 +1264,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} incompleteOperation="terms" @@ -1293,8 +1276,7 @@ describe('terms', () => { it('should show an error message when any field but the first is invalid', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); layer.columns.col1 = { label: 'Top value of geo.src + 1 other', @@ -1315,7 +1297,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1327,8 +1308,7 @@ describe('terms', () => { it('should show an error message when any field but the first is not supported', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); layer.columns.col1 = { label: 'Top value of geo.src + 1 other', @@ -1349,7 +1329,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1361,15 +1340,13 @@ describe('terms', () => { it('should render the an add button for single layer and disabled the remove button', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); const instance = mount( @@ -1392,15 +1369,13 @@ describe('terms', () => { it('should switch to the first supported operation when in single term mode and the picked field is not supported', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); const instance = mount( @@ -1426,8 +1401,7 @@ describe('terms', () => { it('should render the multi terms specific UI', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['bytes']; const instance = mount( @@ -1436,7 +1410,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1457,8 +1430,7 @@ describe('terms', () => { it('should return to single value UI when removing second item of two', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; const instance = mount( @@ -1467,7 +1439,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1489,8 +1460,7 @@ describe('terms', () => { it('should disable remove button and reorder drag when single value and one temporary new field', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); let instance = mount( { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1532,8 +1501,7 @@ describe('terms', () => { it('should accept scripted fields for single value', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).sourceField = 'scripted'; const instance = mount( @@ -1542,7 +1510,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1558,8 +1525,7 @@ describe('terms', () => { it('should mark scripted fields for multiple values', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).sourceField = 'scripted'; (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; @@ -1569,7 +1535,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1588,8 +1553,7 @@ describe('terms', () => { it('should not filter scripted fields when in single value', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); const instance = mount( { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1618,8 +1581,7 @@ describe('terms', () => { it('should filter scripted fields when in multi terms mode', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; const instance = mount( @@ -1628,7 +1590,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1650,8 +1611,7 @@ describe('terms', () => { it('should filter already used fields when displaying fields list', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory', 'bytes']; let instance = mount( @@ -1660,7 +1620,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1690,8 +1649,7 @@ describe('terms', () => { it('should filter fields with unsupported types when in multi terms mode', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; const instance = mount( @@ -1700,7 +1658,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1722,8 +1679,7 @@ describe('terms', () => { it('should limit the number of multiple fields', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = [ 'memory', @@ -1736,7 +1692,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1757,8 +1712,7 @@ describe('terms', () => { it('should let the user add new empty field up to the limit', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); let instance = mount( { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1793,8 +1746,7 @@ describe('terms', () => { it('should update the parentFormatter on transition between single to multi terms', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); let instance = mount( { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1834,8 +1785,7 @@ describe('terms', () => { it('should preserve custom label when set by the user', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); layer.columns.col1 = { label: 'MyCustomLabel', @@ -1857,7 +1807,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> diff --git a/x-pack/plugins/lens/public/datasources/text_based/datapanel.test.tsx b/x-pack/plugins/lens/public/datasources/text_based/datapanel.test.tsx index b5f158ecd453ff..cac4fc380cc99b 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/datapanel.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { act } from 'react-dom/test-utils'; +import { ReactWrapper } from 'enzyme'; import type { Query } from '@kbn/es-query'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; @@ -30,7 +31,6 @@ import { createIndexPatternServiceMock } from '../../mocks/data_views_service_mo import { createMockFramePublicAPI } from '../../mocks'; import { createMockedDragDropContext } from './mocks'; import { DataViewsState } from '../../state_management'; -import { ExistingFieldsMap, IndexPattern } from '../../types'; const fieldsFromQuery = [ { @@ -101,18 +101,6 @@ const fieldsOne = [ }, ]; -function getExistingFields(indexPatterns: Record) { - const existingFields: ExistingFieldsMap = {}; - for (const { title, fields } of Object.values(indexPatterns)) { - const fieldsMap: Record = {}; - for (const { displayName, name } of fields) { - fieldsMap[displayName ?? name] = true; - } - existingFields[title] = fieldsMap; - } - return existingFields; -} - const initialState: TextBasedPrivateState = { layers: { first: { @@ -130,27 +118,16 @@ const initialState: TextBasedPrivateState = { fieldList: fieldsFromQuery, }; -function getFrameAPIMock({ indexPatterns, existingFields, ...rest }: Partial = {}) { +function getFrameAPIMock({ + indexPatterns, + ...rest +}: Partial & { indexPatterns: DataViewsState['indexPatterns'] }) { const frameAPI = createMockFramePublicAPI(); - const defaultIndexPatterns = { - '1': { - id: '1', - title: 'idx1', - timeFieldName: 'timestamp', - hasRestrictions: false, - fields: fieldsOne, - getFieldByName: jest.fn(), - isPersisted: true, - spec: {}, - }, - }; return { ...frameAPI, dataViews: { ...frameAPI.dataViews, - indexPatterns: indexPatterns ?? defaultIndexPatterns, - existingFields: existingFields ?? getExistingFields(indexPatterns ?? defaultIndexPatterns), - isFirstExistenceFetch: false, + indexPatterns, ...rest, }, }; @@ -159,12 +136,39 @@ function getFrameAPIMock({ indexPatterns, existingFields, ...rest }: Partial element); +async function mountAndWaitForLazyModules(component: React.ReactElement): Promise { + let inst: ReactWrapper; + await act(async () => { + inst = await mountWithIntl(component); + // wait for lazy modules + await new Promise((resolve) => setTimeout(resolve, 0)); + inst.update(); + }); + + await inst!.update(); + + return inst!; +} + describe('TextBased Query Languages Data Panel', () => { let core: ReturnType; let dataViews: DataViewPublicStart; + const defaultIndexPatterns = { + '1': { + id: '1', + title: 'idx1', + timeFieldName: 'timestamp', + hasRestrictions: false, + fields: fieldsOne, + getFieldByName: jest.fn(), + isPersisted: true, + spec: {}, + }, + }; let defaultProps: TextBasedDataPanelProps; const dataViewsMock = dataViewPluginMocks.createStartContract(); + beforeEach(() => { core = coreMock.createStart(); dataViews = dataViewPluginMocks.createStartContract(); @@ -194,7 +198,7 @@ describe('TextBased Query Languages Data Panel', () => { hasSuggestionForField: jest.fn(() => false), uiActions: uiActionsPluginMock.createStartContract(), indexPatternService: createIndexPatternServiceMock({ core, dataViews }), - frame: getFrameAPIMock(), + frame: getFrameAPIMock({ indexPatterns: defaultIndexPatterns }), state: initialState, setState: jest.fn(), onChangeIndexPattern: jest.fn(), @@ -202,23 +206,33 @@ describe('TextBased Query Languages Data Panel', () => { }); it('should render a search box', async () => { - const wrapper = mountWithIntl(); - expect(wrapper.find('[data-test-subj="lnsTextBasedLangugesFieldSearch"]').length).toEqual(1); + const wrapper = await mountAndWaitForLazyModules(); + + expect(wrapper.find('[data-test-subj="lnsTextBasedLanguagesFieldSearch"]').length).toEqual(1); }); it('should list all supported fields in the pattern', async () => { - const wrapper = mountWithIntl(); + const wrapper = await mountAndWaitForLazyModules(); + expect( wrapper - .find('[data-test-subj="lnsTextBasedLanguagesPanelFields"]') + .find('[data-test-subj="lnsTextBasedLanguagesAvailableFields"]') .find(FieldButton) .map((fieldItem) => fieldItem.prop('fieldName')) - ).toEqual(['timestamp', 'bytes', 'memory']); + ).toEqual(['bytes', 'memory', 'timestamp']); + + expect(wrapper.find('[data-test-subj="lnsTextBasedLanguagesEmptyFields"]').exists()).toBe( + false + ); + expect(wrapper.find('[data-test-subj="lnsTextBasedLanguagesMetaFields"]').exists()).toBe(false); }); it('should not display the selected fields accordion if there are no fields displayed', async () => { - const wrapper = mountWithIntl(); - expect(wrapper.find('[data-test-subj="lnsSelectedFieldsTextBased"]').length).toEqual(0); + const wrapper = await mountAndWaitForLazyModules(); + + expect(wrapper.find('[data-test-subj="lnsTextBasedLanguagesSelectedFields"]').length).toEqual( + 0 + ); }); it('should display the selected fields accordion if there are fields displayed', async () => { @@ -226,13 +240,17 @@ describe('TextBased Query Languages Data Panel', () => { ...defaultProps, layerFields: ['memory'], }; - const wrapper = mountWithIntl(); - expect(wrapper.find('[data-test-subj="lnsSelectedFieldsTextBased"]').length).not.toEqual(0); + const wrapper = await mountAndWaitForLazyModules(); + + expect( + wrapper.find('[data-test-subj="lnsTextBasedLanguagesSelectedFields"]').length + ).not.toEqual(0); }); it('should list all supported fields in the pattern that match the search input', async () => { - const wrapper = mountWithIntl(); - const searchBox = wrapper.find('[data-test-subj="lnsTextBasedLangugesFieldSearch"]'); + const wrapper = await mountAndWaitForLazyModules(); + + const searchBox = wrapper.find('[data-test-subj="lnsTextBasedLanguagesFieldSearch"]'); act(() => { searchBox.prop('onChange')!({ @@ -240,10 +258,10 @@ describe('TextBased Query Languages Data Panel', () => { } as React.ChangeEvent); }); - wrapper.update(); + await wrapper.update(); expect( wrapper - .find('[data-test-subj="lnsTextBasedLanguagesPanelFields"]') + .find('[data-test-subj="lnsTextBasedLanguagesAvailableFields"]') .find(FieldButton) .map((fieldItem) => fieldItem.prop('fieldName')) ).toEqual(['memory']); diff --git a/x-pack/plugins/lens/public/datasources/text_based/datapanel.tsx b/x-pack/plugins/lens/public/datasources/text_based/datapanel.tsx index 1b0699b2eb9309..0416d163670fb8 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/datapanel.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/datapanel.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiFormControlLayout, htmlIdGenerator } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import usePrevious from 'react-use/lib/usePrevious'; @@ -14,13 +14,22 @@ import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { isOfAggregateQueryType } from '@kbn/es-query'; -import { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import { DatatableColumn, ExpressionsStart } from '@kbn/expressions-plugin/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { + ExistenceFetchStatus, + FieldListGrouped, + FieldListGroupedProps, + FieldsGroupNames, + useGroupedFields, +} from '@kbn/unified-field-list-plugin/public'; +import { FieldButton } from '@kbn/react-field'; import type { DatasourceDataPanelProps } from '../../types'; import type { TextBasedPrivateState } from './types'; import { getStateFromAggregateQuery } from './utils'; -import { ChildDragDropProvider } from '../../drag_drop'; -import { FieldsAccordion } from './fields_accordion'; +import { ChildDragDropProvider, DragDrop } from '../../drag_drop'; +import { DataType } from '../../types'; +import { LensFieldIcon } from '../../shared_components'; export type TextBasedDataPanelProps = DatasourceDataPanelProps & { data: DataPublicPluginStart; @@ -67,8 +76,16 @@ export function TextBasedDataPanel({ }, [data, dataViews, expressions, prevQuery, query, setState, state]); const { fieldList } = state; - const filteredFields = useMemo(() => { - return fieldList.filter((field) => { + + const onSelectedFieldFilter = useCallback( + (field: DatatableColumn): boolean => { + return Boolean(layerFields?.includes(field.name)); + }, + [layerFields] + ); + + const onFilterField = useCallback( + (field: DatatableColumn) => { if ( localState.nameFilter && !field.name.toLowerCase().includes(localState.nameFilter.toLowerCase()) @@ -76,9 +93,57 @@ export function TextBasedDataPanel({ return false; } return true; - }); - }, [fieldList, localState.nameFilter]); - const usedByLayersFields = fieldList.filter((field) => layerFields?.includes(field.name)); + }, + [localState] + ); + + const onOverrideFieldGroupDetails = useCallback((groupName) => { + if (groupName === FieldsGroupNames.AvailableFields) { + return { + helpText: i18n.translate('xpack.lens.indexPattern.allFieldsForTextBasedLabelHelp', { + defaultMessage: + 'Drag and drop available fields to the workspace and create visualizations. To change the available fields, edit your query.', + }), + }; + } + }, []); + + const { fieldGroups } = useGroupedFields({ + dataViewId: null, + allFields: fieldList, + services: { + dataViews, + }, + onFilterField, + onSelectedFieldFilter, + onOverrideFieldGroupDetails, + }); + + const renderFieldItem: FieldListGroupedProps['renderFieldItem'] = useCallback( + ({ field, itemIndex, groupIndex, hideDetails }) => { + return ( + + {}} + fieldIcon={} + fieldName={field?.name} + /> + + ); + }, + [] + ); return ( -
        -
        - {usedByLayersFields.length > 0 && ( - - )} - -
        -
        + + fieldGroups={fieldGroups} + fieldsExistenceStatus={ + dataHasLoaded ? ExistenceFetchStatus.succeeded : ExistenceFetchStatus.unknown + } + fieldsExistInIndex={Boolean(fieldList.length)} + renderFieldItem={renderFieldItem} + screenReaderDescriptionForSearchInputId={fieldSearchDescriptionId} + data-test-subj="lnsTextBasedLanguages" + />
        diff --git a/x-pack/plugins/lens/public/datasources/text_based/fields_accordion.tsx b/x-pack/plugins/lens/public/datasources/text_based/fields_accordion.tsx deleted file mode 100644 index d02fd98bc9c87f..00000000000000 --- a/x-pack/plugins/lens/public/datasources/text_based/fields_accordion.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo } from 'react'; -import type { DatatableColumn } from '@kbn/expressions-plugin/public'; - -import { - EuiText, - EuiNotificationBadge, - EuiAccordion, - EuiLoadingSpinner, - EuiSpacer, -} from '@elastic/eui'; -import { FieldButton } from '@kbn/react-field'; -import { DragDrop } from '../../drag_drop'; -import { LensFieldIcon } from '../../shared_components'; -import type { DataType } from '../../types'; - -export interface FieldsAccordionProps { - initialIsOpen: boolean; - hasLoaded: boolean; - isFiltered: boolean; - // forceState: 'open' | 'closed'; - id: string; - label: string; - fields: DatatableColumn[]; -} - -export const FieldsAccordion = memo(function InnerFieldsAccordion({ - initialIsOpen, - hasLoaded, - isFiltered, - id, - label, - fields, -}: FieldsAccordionProps) { - const renderButton = useMemo(() => { - return ( - - {label} - - ); - }, [label]); - - const extraAction = useMemo(() => { - if (hasLoaded) { - return ( - - {fields.length} - - ); - } - - return ; - }, [fields.length, hasLoaded, id, isFiltered]); - - return ( - <> - -
          - {fields.length > 0 && - fields.map((field, index) => ( -
        • - - {}} - fieldIcon={} - fieldName={field?.name} - /> - -
        • - ))} -
        -
        - - - ); -}); diff --git a/x-pack/plugins/lens/public/datasources/text_based/layerpanel.test.tsx b/x-pack/plugins/lens/public/datasources/text_based/layerpanel.test.tsx index f0a9d147ddfd63..bc2d64e8ac55d1 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/layerpanel.test.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/layerpanel.test.tsx @@ -68,8 +68,6 @@ describe('Layer Data Panel', () => { { id: '2', title: 'my-fake-restricted-pattern', name: 'my-fake-restricted-pattern' }, { id: '3', title: 'my-compatible-pattern', name: 'my-compatible-pattern' }, ], - existingFields: {}, - isFirstExistenceFetch: false, indexPatterns: {}, } as DataViewsState, }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index acbeb79bbe74d8..13939fa276b319 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -568,7 +568,6 @@ export function LayerPanel( invalid: group.invalid, invalidMessage: group.invalidMessage, indexPatterns: dataViews.indexPatterns, - existingFields: dataViews.existingFields, }} /> ) : ( @@ -728,7 +727,6 @@ export function LayerPanel( formatSelectorOptions: activeGroup.formatSelectorOptions, layerType: activeVisualization.getLayerType(layerId, visualizationState), indexPatterns: dataViews.indexPatterns, - existingFields: dataViews.existingFields, activeData: layerVisualizationConfigProps.activeData, }} /> diff --git a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx index 56939b54299ce1..e05f82160b658b 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx @@ -62,6 +62,7 @@ export type TypedLensByValueInput = Omit & { export type EmbeddableComponentProps = (TypedLensByValueInput | LensByReferenceInput) & { withDefaultActions?: boolean; extraActions?: Action[]; + showInspector?: boolean; }; interface PluginsStartDependencies { @@ -89,6 +90,7 @@ export function getEmbeddableComponent(core: CoreStart, plugins: PluginsStartDep input={input} theme={theme} extraActions={input.extraActions} + showInspector={input.showInspector} withDefaultActions={input.withDefaultActions} /> ); @@ -119,6 +121,7 @@ interface EmbeddablePanelWrapperProps { input: EmbeddableComponentProps; theme: ThemeServiceStart; extraActions?: Action[]; + showInspector?: boolean; withDefaultActions?: boolean; } @@ -130,6 +133,7 @@ const EmbeddablePanelWrapper: FC = ({ input, theme, extraActions, + showInspector = true, withDefaultActions, }) => { const [embeddable, loading] = useEmbeddableFactory({ factory, input }); @@ -154,7 +158,7 @@ const EmbeddablePanelWrapper: FC = ({ return [...(extraActions ?? []), ...actions]; }} - inspector={inspector} + inspector={showInspector ? inspector : undefined} actionPredicate={actionPredicate} showShadow={false} showBadges={false} diff --git a/x-pack/plugins/lens/public/mocks/store_mocks.tsx b/x-pack/plugins/lens/public/mocks/store_mocks.tsx index 8320f429e9d5a9..d4ba2d042b1cad 100644 --- a/x-pack/plugins/lens/public/mocks/store_mocks.tsx +++ b/x-pack/plugins/lens/public/mocks/store_mocks.tsx @@ -59,8 +59,6 @@ export const defaultState = { dataViews: { indexPatterns: {}, indexPatternRefs: [], - existingFields: {}, - isFirstExistenceFetch: false, }, }; diff --git a/x-pack/plugins/lens/public/shared_components/dataview_picker/helpers.ts b/x-pack/plugins/lens/public/shared_components/dataview_picker/helpers.ts index 4f53930fa49732..febf8b1d7c5008 100644 --- a/x-pack/plugins/lens/public/shared_components/dataview_picker/helpers.ts +++ b/x-pack/plugins/lens/public/shared_components/dataview_picker/helpers.ts @@ -5,25 +5,17 @@ * 2.0. */ +import { type ExistingFieldsReader } from '@kbn/unified-field-list-plugin/public'; import { IndexPattern } from '../../types'; /** * Checks if the provided field contains data (works for meta field) */ export function fieldContainsData( - field: string, + fieldName: string, indexPattern: IndexPattern, - existingFields: Record + hasFieldData: ExistingFieldsReader['hasFieldData'] ) { - return ( - indexPattern.getFieldByName(field)?.type === 'document' || fieldExists(existingFields, field) - ); -} - -/** - * Performs an existence check on the existingFields data structure for the provided field. - * Does not work for meta fields. - */ -export function fieldExists(existingFields: Record, fieldName: string) { - return existingFields[fieldName]; + const field = indexPattern.getFieldByName(fieldName); + return field?.type === 'document' || hasFieldData(indexPattern.id, fieldName); } diff --git a/x-pack/plugins/lens/public/shared_components/dataview_picker/index.ts b/x-pack/plugins/lens/public/shared_components/dataview_picker/index.ts index 4de03b2f8b92c3..6bf23c9d414db7 100644 --- a/x-pack/plugins/lens/public/shared_components/dataview_picker/index.ts +++ b/x-pack/plugins/lens/public/shared_components/dataview_picker/index.ts @@ -6,4 +6,4 @@ */ export { ChangeIndexPattern } from './dataview_picker'; -export { fieldExists, fieldContainsData } from './helpers'; +export { fieldContainsData } from './helpers'; diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index a2fcc9c54882d7..e57a18b3ee2eee 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -11,7 +11,7 @@ export { LegendSettingsPopover } from './legend_settings_popover'; export { PalettePicker } from './palette_picker'; export { FieldPicker, LensFieldIcon, TruncatedLabel } from './field_picker'; export type { FieldOption, FieldOptionValue } from './field_picker'; -export { ChangeIndexPattern, fieldExists, fieldContainsData } from './dataview_picker'; +export { ChangeIndexPattern, fieldContainsData } from './dataview_picker'; export { QueryInput, isQueryValid, validateQuery } from './query_input'; export { NewBucketButton, diff --git a/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap b/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap index a6759521f562e7..d30a68e5e52b00 100644 --- a/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap +++ b/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap @@ -5,10 +5,8 @@ Object { "lens": Object { "activeDatasourceId": "testDatasource", "dataViews": Object { - "existingFields": Object {}, "indexPatternRefs": Array [], "indexPatterns": Object {}, - "isFirstExistenceFetch": true, }, "datasourceStates": Object { "testDatasource": Object { diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index f21d1e6c4aa1a6..e8874fbcda822b 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -50,8 +50,6 @@ export const initialState: LensAppState = { dataViews: { indexPatternRefs: [], indexPatterns: {}, - existingFields: {}, - isFirstExistenceFetch: true, }, }; diff --git a/x-pack/plugins/lens/public/state_management/types.ts b/x-pack/plugins/lens/public/state_management/types.ts index 9399506f5fca1a..4f7500ec20a5ed 100644 --- a/x-pack/plugins/lens/public/state_management/types.ts +++ b/x-pack/plugins/lens/public/state_management/types.ts @@ -30,10 +30,6 @@ export interface VisualizationState { export interface DataViewsState { indexPatternRefs: IndexPatternRef[]; indexPatterns: Record; - existingFields: Record>; - isFirstExistenceFetch: boolean; - existenceFetchFailed?: boolean; - existenceFetchTimeout?: boolean; } export type DatasourceStates = Record; diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index a8389c7841712b..628afa8d612761 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -108,7 +108,6 @@ export interface EditorFrameProps { export type VisualizationMap = Record; export type DatasourceMap = Record; export type IndexPatternMap = Record; -export type ExistingFieldsMap = Record>; export interface EditorFrameInstance { EditorFrameContainer: (props: EditorFrameProps) => React.ReactElement; @@ -589,7 +588,6 @@ export type DatasourceDimensionProps = SharedDimensionProps & { state: T; activeData?: Record; indexPatterns: IndexPatternMap; - existingFields: Record>; hideTooltip?: boolean; invalid?: boolean; invalidMessage?: string; diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index efa0d3c226468e..619c9f7d71f307 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -80,8 +80,6 @@ export function getInitialDataViewsObject( return { indexPatterns, indexPatternRefs, - existingFields: {}, - isFirstExistenceFetch: true, }; } @@ -107,9 +105,6 @@ export async function refreshIndexPatternsList({ onIndexPatternRefresh: () => onRefreshCallbacks.forEach((fn) => fn()), }); const indexPattern = newlyMappedIndexPattern[indexPatternId]; - // But what about existingFields here? - // When the indexPatterns cache object gets updated, the data panel will - // notice it and refetch the fields list existence map indexPatternService.updateDataViewsState({ indexPatterns: { ...indexPatternsCache, diff --git a/x-pack/plugins/lens/public/visualizations/heatmap/visualization.test.ts b/x-pack/plugins/lens/public/visualizations/heatmap/visualization.test.ts index bbebf37ff4b5cb..fd73a652b53e09 100644 --- a/x-pack/plugins/lens/public/visualizations/heatmap/visualization.test.ts +++ b/x-pack/plugins/lens/public/visualizations/heatmap/visualization.test.ts @@ -560,10 +560,10 @@ describe('heatmap', () => { isCellLabelVisible: [false], // Y-axis isYAxisLabelVisible: [false], - isYAxisTitleVisible: [true], + isYAxisTitleVisible: [false], // X-axis isXAxisLabelVisible: [false], - isXAxisTitleVisible: [true], + isXAxisTitleVisible: [false], xTitle: [''], yTitle: [''], }, diff --git a/x-pack/plugins/lens/public/visualizations/heatmap/visualization.tsx b/x-pack/plugins/lens/public/visualizations/heatmap/visualization.tsx index dfa37ba75f38ca..9750cc89c55e7a 100644 --- a/x-pack/plugins/lens/public/visualizations/heatmap/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/heatmap/visualization.tsx @@ -404,11 +404,11 @@ export const getHeatmapVisualization = ({ isCellLabelVisible: false, // Y-axis isYAxisLabelVisible: false, - isYAxisTitleVisible: state.gridConfig.isYAxisTitleVisible, + isYAxisTitleVisible: false, yTitle: state.gridConfig.yTitle ?? '', // X-axis isXAxisLabelVisible: false, - isXAxisTitleVisible: state.gridConfig.isXAxisTitleVisible, + isXAxisTitleVisible: false, xTitle: state.gridConfig.xTitle ?? '', } ); diff --git a/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.tsx b/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.tsx index 37304e09523a7a..c4546fc8e71412 100644 --- a/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.tsx @@ -105,6 +105,9 @@ const toExpression = ( type: 'function', function: 'legacyMetricVis', arguments: { + ...(state?.autoScaleMetricAlignment + ? { autoScaleMetricAlignment: [state?.autoScaleMetricAlignment] } + : {}), labelPosition: [state?.titlePosition || DEFAULT_TITLE_POSITION], font: [ { diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/annotations_panel.tsx b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/annotations_panel.tsx index 480c0773f4520c..748217469ce63d 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/annotations_panel.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/annotations_panel.tsx @@ -23,6 +23,7 @@ import { QueryPointEventAnnotationConfig, } from '@kbn/event-annotation-plugin/common'; import moment from 'moment'; +import { useExistingFieldsReader } from '@kbn/unified-field-list-plugin/public'; import { FieldOption, FieldOptionValue, @@ -31,7 +32,6 @@ import { import { FormatFactory } from '../../../../../common'; import { DimensionEditorSection, - fieldExists, NameInput, useDebouncedValue, } from '../../../../shared_components'; @@ -58,6 +58,7 @@ export const AnnotationsPanel = ( ) => { const { state, setState, layerId, accessor, frame } = props; const isHorizontal = isHorizontalChart(state.layers); + const { hasFieldData } = useExistingFieldsReader(); const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue({ value: state, @@ -248,10 +249,7 @@ export const AnnotationsPanel = ( field: field.name, dataType: field.type, }, - exists: fieldExists( - frame.dataViews.existingFields[currentIndexPattern.title], - field.name - ), + exists: hasFieldData(currentIndexPattern.id, field.name), compatible: true, 'data-test-subj': `lnsXY-annotation-fieldOption-${field.name}`, } as FieldOption) @@ -379,7 +377,6 @@ export const AnnotationsPanel = ( currentConfig={currentAnnotation} setConfig={setAnnotations} indexPattern={frame.dataViews.indexPatterns[localLayer.indexPatternId]} - existingFields={frame.dataViews.existingFields} /> diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/query_annotation_panel.tsx b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/query_annotation_panel.tsx index 0ee0d1f06d1c8b..00f0013c92822a 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/query_annotation_panel.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/query_annotation_panel.tsx @@ -10,8 +10,8 @@ import type { Query } from '@kbn/data-plugin/common'; import type { QueryPointEventAnnotationConfig } from '@kbn/event-annotation-plugin/common'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { useExistingFieldsReader } from '@kbn/unified-field-list-plugin/public'; import { - fieldExists, FieldOption, FieldOptionValue, FieldPicker, @@ -41,7 +41,7 @@ export const ConfigPanelQueryAnnotation = ({ queryInputShouldOpen?: boolean; }) => { const currentIndexPattern = frame.dataViews.indexPatterns[layer.indexPatternId]; - const currentExistingFields = frame.dataViews.existingFields[currentIndexPattern.title]; + const { hasFieldData } = useExistingFieldsReader(); // list only date fields const options = currentIndexPattern.fields .filter((field) => field.type === 'date' && field.displayName) @@ -53,7 +53,7 @@ export const ConfigPanelQueryAnnotation = ({ field: field.name, dataType: field.type, }, - exists: fieldExists(currentExistingFields, field.name), + exists: hasFieldData(currentIndexPattern.id, field.name), compatible: true, 'data-test-subj': `lns-fieldOption-${field.name}`, } as FieldOption; diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/tooltip_annotation_panel.tsx b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/tooltip_annotation_panel.tsx index 20a99e8458fc0c..d3f68686c3bac6 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/tooltip_annotation_panel.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/tooltip_annotation_panel.tsx @@ -9,9 +9,9 @@ import { htmlIdGenerator, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo } from 'react'; import { QueryPointEventAnnotationConfig } from '@kbn/event-annotation-plugin/common'; -import type { ExistingFieldsMap, IndexPattern } from '../../../../types'; +import { useExistingFieldsReader } from '@kbn/unified-field-list-plugin/public'; +import type { IndexPattern } from '../../../../types'; import { - fieldExists, FieldOption, FieldOptionValue, FieldPicker, @@ -31,7 +31,6 @@ export interface FieldInputsProps { currentConfig: QueryPointEventAnnotationConfig; setConfig: (config: QueryPointEventAnnotationConfig) => void; indexPattern: IndexPattern; - existingFields: ExistingFieldsMap; invalidFields?: string[]; } @@ -51,9 +50,9 @@ export function TooltipSection({ currentConfig, setConfig, indexPattern, - existingFields, invalidFields, }: FieldInputsProps) { + const { hasFieldData } = useExistingFieldsReader(); const onChangeWrapped = useCallback( (values: WrappedValue[]) => { setConfig({ @@ -124,7 +123,6 @@ export function TooltipSection({ ); } - const currentExistingField = existingFields[indexPattern.title]; const options = indexPattern.fields .filter( @@ -140,7 +138,7 @@ export function TooltipSection({ field: field.name, dataType: field.type, }, - exists: fieldExists(currentExistingField, field.name), + exists: hasFieldData(indexPattern.id, field.name), compatible: true, 'data-test-subj': `lnsXY-annotation-tooltip-fieldOption-${field.name}`, } as FieldOption) diff --git a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx index 42ff021679ce0e..6b11566b6e5a7b 100644 --- a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx +++ b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx @@ -7,6 +7,7 @@ import { PartialTheme } from '@elastic/charts'; import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; +import { useMemo } from 'react'; import { useTheme } from './use_theme'; export function useChartTheme(): PartialTheme[] { @@ -15,24 +16,27 @@ export function useChartTheme(): PartialTheme[] { ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme; - return [ - { - chartMargins: { - left: 10, - right: 10, - top: 10, - bottom: 10, + return useMemo( + () => [ + { + chartMargins: { + left: 10, + right: 10, + top: 10, + bottom: 10, + }, + background: { + color: 'transparent', + }, + lineSeriesStyle: { + point: { visible: false }, + }, + areaSeriesStyle: { + point: { visible: false }, + }, }, - background: { - color: 'transparent', - }, - lineSeriesStyle: { - point: { visible: false }, - }, - areaSeriesStyle: { - point: { visible: false }, - }, - }, - baseChartTheme, - ]; + baseChartTheme, + ], + [baseChartTheme] + ); } diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.mock.ts index 548d8b571660e2..fd210ad99680f5 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.mock.ts @@ -5,18 +5,18 @@ * 2.0. */ -import { BulkAction, BulkActionEditType } from './request_schema'; +import { BulkActionType, BulkActionEditType } from './request_schema'; import type { PerformBulkActionRequestBody } from './request_schema'; export const getPerformBulkActionSchemaMock = (): PerformBulkActionRequestBody => ({ query: '', ids: undefined, - action: BulkAction.disable, + action: BulkActionType.disable, }); export const getPerformBulkActionEditSchemaMock = (): PerformBulkActionRequestBody => ({ query: '', ids: undefined, - action: BulkAction.edit, - [BulkAction.edit]: [{ type: BulkActionEditType.add_tags, value: ['tag1'] }], + action: BulkActionType.edit, + [BulkActionType.edit]: [{ type: BulkActionEditType.add_tags, value: ['tag1'] }], }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.test.ts index 63de1d45ec3cb7..99f5413e6688b9 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.test.ts @@ -7,7 +7,7 @@ import { left } from 'fp-ts/lib/Either'; import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; -import { PerformBulkActionRequestBody, BulkAction, BulkActionEditType } from './request_schema'; +import { PerformBulkActionRequestBody, BulkActionType, BulkActionEditType } from './request_schema'; const retrieveValidationMessage = (payload: unknown) => { const decoded = PerformBulkActionRequestBody.decode(payload); @@ -21,7 +21,7 @@ describe('Perform bulk action request schema', () => { test('valid request: missing query', () => { const payload: PerformBulkActionRequestBody = { query: undefined, - action: BulkAction.enable, + action: BulkActionType.enable, }; const message = retrieveValidationMessage(payload); @@ -59,7 +59,7 @@ describe('Perform bulk action request schema', () => { test('invalid request: unknown property', () => { const payload = { query: 'name: test', - action: BulkAction.enable, + action: BulkActionType.enable, mock: ['id'], }; const message = retrieveValidationMessage(payload); @@ -71,7 +71,7 @@ describe('Perform bulk action request schema', () => { test('invalid request: wrong type for ids', () => { const payload = { ids: 'mock', - action: BulkAction.enable, + action: BulkActionType.enable, }; const message = retrieveValidationMessage(payload); @@ -84,7 +84,7 @@ describe('Perform bulk action request schema', () => { test('valid request', () => { const payload: PerformBulkActionRequestBody = { query: 'name: test', - action: BulkAction.enable, + action: BulkActionType.enable, }; const message = retrieveValidationMessage(payload); expect(getPaths(left(message.errors))).toEqual([]); @@ -96,7 +96,7 @@ describe('Perform bulk action request schema', () => { test('valid request', () => { const payload: PerformBulkActionRequestBody = { query: 'name: test', - action: BulkAction.disable, + action: BulkActionType.disable, }; const message = retrieveValidationMessage(payload); expect(getPaths(left(message.errors))).toEqual([]); @@ -108,7 +108,7 @@ describe('Perform bulk action request schema', () => { test('valid request', () => { const payload: PerformBulkActionRequestBody = { query: 'name: test', - action: BulkAction.export, + action: BulkActionType.export, }; const message = retrieveValidationMessage(payload); expect(getPaths(left(message.errors))).toEqual([]); @@ -120,7 +120,7 @@ describe('Perform bulk action request schema', () => { test('valid request', () => { const payload: PerformBulkActionRequestBody = { query: 'name: test', - action: BulkAction.delete, + action: BulkActionType.delete, }; const message = retrieveValidationMessage(payload); expect(getPaths(left(message.errors))).toEqual([]); @@ -132,7 +132,7 @@ describe('Perform bulk action request schema', () => { test('valid request', () => { const payload: PerformBulkActionRequestBody = { query: 'name: test', - action: BulkAction.duplicate, + action: BulkActionType.duplicate, }; const message = retrieveValidationMessage(payload); expect(getPaths(left(message.errors))).toEqual([]); @@ -145,7 +145,7 @@ describe('Perform bulk action request schema', () => { test('invalid request: missing edit payload', () => { const payload = { query: 'name: test', - action: BulkAction.edit, + action: BulkActionType.edit, }; const message = retrieveValidationMessage(payload); @@ -160,8 +160,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: specified edit payload for another action', () => { const payload = { query: 'name: test', - action: BulkAction.enable, - [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: ['test-tag'] }], + action: BulkActionType.enable, + [BulkActionType.edit]: [{ type: BulkActionEditType.set_tags, value: ['test-tag'] }], }; const message = retrieveValidationMessage(payload); @@ -175,8 +175,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: wrong type for edit payload', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: { type: BulkActionEditType.set_tags, value: ['test-tag'] }, + action: BulkActionType.edit, + [BulkActionType.edit]: { type: BulkActionEditType.set_tags, value: ['test-tag'] }, }; const message = retrieveValidationMessage(payload); @@ -193,8 +193,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: wrong tags type', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: 'test-tag' }], + action: BulkActionType.edit, + [BulkActionType.edit]: [{ type: BulkActionEditType.set_tags, value: 'test-tag' }], }; const message = retrieveValidationMessage(payload); @@ -210,8 +210,8 @@ describe('Perform bulk action request schema', () => { test('valid request: add_tags edit action', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [{ type: BulkActionEditType.add_tags, value: ['test-tag'] }], + action: BulkActionType.edit, + [BulkActionType.edit]: [{ type: BulkActionEditType.add_tags, value: ['test-tag'] }], }; const message = retrieveValidationMessage(payload); @@ -223,8 +223,8 @@ describe('Perform bulk action request schema', () => { test('valid request: set_tags edit action', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: ['test-tag'] }], + action: BulkActionType.edit, + [BulkActionType.edit]: [{ type: BulkActionEditType.set_tags, value: ['test-tag'] }], }; const message = retrieveValidationMessage(payload); @@ -236,8 +236,8 @@ describe('Perform bulk action request schema', () => { test('valid request: delete_tags edit action', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [{ type: BulkActionEditType.delete_tags, value: ['test-tag'] }], + action: BulkActionType.edit, + [BulkActionType.edit]: [{ type: BulkActionEditType.delete_tags, value: ['test-tag'] }], }; const message = retrieveValidationMessage(payload); @@ -251,8 +251,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: wrong index_patterns type', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: 'logs-*' }], + action: BulkActionType.edit, + [BulkActionType.edit]: [{ type: BulkActionEditType.set_tags, value: 'logs-*' }], }; const message = retrieveValidationMessage(payload); @@ -268,8 +268,10 @@ describe('Perform bulk action request schema', () => { test('valid request: set_index_patterns edit action', () => { const payload: PerformBulkActionRequestBody = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [{ type: BulkActionEditType.set_index_patterns, value: ['logs-*'] }], + action: BulkActionType.edit, + [BulkActionType.edit]: [ + { type: BulkActionEditType.set_index_patterns, value: ['logs-*'] }, + ], }; const message = retrieveValidationMessage(payload); @@ -281,8 +283,10 @@ describe('Perform bulk action request schema', () => { test('valid request: add_index_patterns edit action', () => { const payload: PerformBulkActionRequestBody = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [{ type: BulkActionEditType.add_index_patterns, value: ['logs-*'] }], + action: BulkActionType.edit, + [BulkActionType.edit]: [ + { type: BulkActionEditType.add_index_patterns, value: ['logs-*'] }, + ], }; const message = retrieveValidationMessage(payload); @@ -294,8 +298,8 @@ describe('Perform bulk action request schema', () => { test('valid request: delete_index_patterns edit action', () => { const payload: PerformBulkActionRequestBody = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.delete_index_patterns, value: ['logs-*'] }, ], }; @@ -311,8 +315,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: wrong timeline payload type', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [{ type: BulkActionEditType.set_timeline, value: [] }], + action: BulkActionType.edit, + [BulkActionType.edit]: [{ type: BulkActionEditType.set_timeline, value: [] }], }; const message = retrieveValidationMessage(payload); @@ -328,8 +332,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: missing timeline_id', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_timeline, value: { @@ -353,8 +357,8 @@ describe('Perform bulk action request schema', () => { test('valid request: set_timeline edit action', () => { const payload: PerformBulkActionRequestBody = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_timeline, value: { @@ -376,8 +380,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: wrong schedules payload type', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [{ type: BulkActionEditType.set_schedule, value: [] }], + action: BulkActionType.edit, + [BulkActionType.edit]: [{ type: BulkActionEditType.set_schedule, value: [] }], }; const message = retrieveValidationMessage(payload); @@ -393,8 +397,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: wrong type of payload data', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_schedule, value: { @@ -420,8 +424,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: missing interval', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_schedule, value: { @@ -446,8 +450,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: missing lookback', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_schedule, value: { @@ -472,8 +476,8 @@ describe('Perform bulk action request schema', () => { test('valid request: set_schedule edit action', () => { const payload: PerformBulkActionRequestBody = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_schedule, value: { @@ -495,8 +499,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: invalid rule actions payload', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [{ type: BulkActionEditType.add_rule_actions, value: [] }], + action: BulkActionType.edit, + [BulkActionType.edit]: [{ type: BulkActionEditType.add_rule_actions, value: [] }], }; const message = retrieveValidationMessage(payload); @@ -510,8 +514,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: missing throttle in payload', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_rule_actions, value: { @@ -532,8 +536,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: missing actions in payload', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_rule_actions, value: { @@ -554,8 +558,8 @@ describe('Perform bulk action request schema', () => { test('invalid request: invalid action_type_id property in actions array', () => { const payload = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_rule_actions, value: { @@ -587,8 +591,8 @@ describe('Perform bulk action request schema', () => { test('valid request: add_rule_actions edit action', () => { const payload: PerformBulkActionRequestBody = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_rule_actions, value: { @@ -618,8 +622,8 @@ describe('Perform bulk action request schema', () => { test('valid request: set_rule_actions edit action', () => { const payload: PerformBulkActionRequestBody = { query: 'name: test', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_rule_actions, value: { diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.ts index 6e5248075b7dcd..c09a2c27ea576d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.ts @@ -22,7 +22,7 @@ import { TimelineTemplateTitle, } from '../../../../rule_schema'; -export enum BulkAction { +export enum BulkActionType { 'enable' = 'enable', 'disable' = 'disable', 'export' = 'export', @@ -162,18 +162,18 @@ export const PerformBulkActionRequestBody = t.intersection([ t.exact( t.type({ action: t.union([ - t.literal(BulkAction.delete), - t.literal(BulkAction.disable), - t.literal(BulkAction.duplicate), - t.literal(BulkAction.enable), - t.literal(BulkAction.export), + t.literal(BulkActionType.delete), + t.literal(BulkActionType.disable), + t.literal(BulkActionType.duplicate), + t.literal(BulkActionType.enable), + t.literal(BulkActionType.export), ]), }) ), t.exact( t.type({ - action: t.literal(BulkAction.edit), - [BulkAction.edit]: NonEmptyArray(BulkActionEditPayload), + action: t.literal(BulkActionType.edit), + [BulkActionType.edit]: NonEmptyArray(BulkActionEditPayload), }) ), ]), diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts index f8af9d80058938..538efd053611a6 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts @@ -193,6 +193,21 @@ export class EndpointActionGenerator extends BaseDataGenerator { return details as unknown as ActionDetails; } + randomGetFileFailureCode(): string { + return this.randomChoice([ + 'ra_get-file_error_not-found', + 'ra_get-file_error_is-directory', + 'ra_get-file_error_invalid-input', + 'ra_get-file_error_not-permitted', + 'ra_get-file_error_too-big', + 'ra_get-file_error_disk-quota', + 'ra_get-file_error_processing', + 'ra_get-file_error_upload-api-unreachable', + 'ra_get-file_error_upload-timeout', + 'ra_get-file_error_queue-timeout', + ]); + } + generateActivityLogAction( overrides: DeepPartial ): EndpointActivityLogAction { diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index 3d4f25d248e6a9..339dd8f9e46c19 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -40,7 +40,7 @@ import { addsFields, closeFieldsBrowser, filterFieldsBrowser } from './fields_br export const enablesRule = () => { // Rules get enabled via _bulk_action endpoint - cy.intercept('POST', '/api/detection_engine/rules/_bulk_action').as('bulk_action'); + cy.intercept('POST', '/api/detection_engine/rules/_bulk_action?dry_run=false').as('bulk_action'); cy.get(RULE_SWITCH).should('be.visible'); cy.get(RULE_SWITCH).click(); cy.wait('@bulk_action').then(({ response }) => { diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_dns_queries.test.ts.snap b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_dns_queries.test.ts.snap index 34bc6f13f8004a..11d7e4f53ede4f 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_dns_queries.test.ts.snap +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_dns_queries.test.ts.snap @@ -158,6 +158,7 @@ Object { }, "visualization": Object { "accessor": "0374e520-eae0-4ac1-bcfe-37565e7fc9e3", + "autoScaleMetricAlignment": "left", "colorMode": "None", "layerId": "cea37c70-8f91-43bf-b9fe-72d8c049f6a3", "layerType": "data", diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_network_events.test.ts.snap b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_network_events.test.ts.snap index b29761bbf8a742..f91197d1525842 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_network_events.test.ts.snap +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_network_events.test.ts.snap @@ -163,6 +163,7 @@ Object { }, "visualization": Object { "accessor": "370ebd07-5ce0-4f46-a847-0e363c50d037", + "autoScaleMetricAlignment": "left", "layerId": "eaadfec7-deaa-4aeb-a403-3b4e516416d2", "layerType": "data", }, diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_tls_handshakes.test.ts.snap b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_tls_handshakes.test.ts.snap index b8ed38aa918c45..1ee417328f1949 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_tls_handshakes.test.ts.snap +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_tls_handshakes.test.ts.snap @@ -182,6 +182,7 @@ Object { }, "visualization": Object { "accessor": "21052b6b-5504-4084-a2e2-c17f772345cf", + "autoScaleMetricAlignment": "left", "layerId": "1f48a633-8eee-45ae-9471-861227e9ca03", "layerType": "data", }, diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_unique_flow_ids.test.ts.snap b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_unique_flow_ids.test.ts.snap index 06daf745e03454..d971cfa0cd7ce1 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_unique_flow_ids.test.ts.snap +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/__snapshots__/kpi_unique_flow_ids.test.ts.snap @@ -146,6 +146,7 @@ Object { }, "visualization": Object { "accessor": "a27f3503-9c73-4fc1-86bb-12461dae4b70", + "autoScaleMetricAlignment": "left", "layerId": "5d46d48f-6ce8-46be-a797-17ad50642564", "layerType": "data", }, diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_dns_queries.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_dns_queries.ts index c4691a4797b5b6..4f759160aebb8c 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_dns_queries.ts +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_dns_queries.ts @@ -17,6 +17,7 @@ export const kpiDnsQueriesLensAttributes: LensAttributes = { accessor: '0374e520-eae0-4ac1-bcfe-37565e7fc9e3', layerType: 'data', colorMode: 'None', + autoScaleMetricAlignment: 'left', }, query: { query: '', diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_network_events.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_network_events.ts index bb88ceb732c663..bdaa099a95dac7 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_network_events.ts +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_network_events.ts @@ -16,6 +16,7 @@ export const kpiNetworkEventsLensAttributes: LensAttributes = { layerId: 'eaadfec7-deaa-4aeb-a403-3b4e516416d2', accessor: '370ebd07-5ce0-4f46-a847-0e363c50d037', layerType: 'data', + autoScaleMetricAlignment: 'left', }, query: { query: '', diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_tls_handshakes.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_tls_handshakes.ts index b7b651bf56362f..8b4e8c6b5e43e8 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_tls_handshakes.ts +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_tls_handshakes.ts @@ -15,6 +15,7 @@ export const kpiTlsHandshakesLensAttributes: LensAttributes = { layerId: '1f48a633-8eee-45ae-9471-861227e9ca03', accessor: '21052b6b-5504-4084-a2e2-c17f772345cf', layerType: 'data', + autoScaleMetricAlignment: 'left', }, query: { query: diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_flow_ids.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_flow_ids.ts index 3660f2ff6ad06b..01d59b68ad8003 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_flow_ids.ts +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_flow_ids.ts @@ -16,6 +16,7 @@ export const kpiUniqueFlowIdsLensAttributes: LensAttributes = { layerId: '5d46d48f-6ce8-46be-a797-17ad50642564', accessor: 'a27f3503-9c73-4fc1-86bb-12461dae4b70', layerType: 'data', + autoScaleMetricAlignment: 'left', }, query: { query: 'source.ip: * or destination.ip: * ', diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts index ca84045e06c658..265cf9d42e9d24 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts @@ -15,7 +15,10 @@ import { getUpdateRulesSchemaMock, getRulesSchemaMock, } from '../../../../common/detection_engine/rule_schema/mocks'; - +import { + BulkActionType, + BulkActionEditType, +} from '../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import { rulesMock } from '../logic/mock'; import type { FindRulesReferencedByExceptionsListProp } from '../logic/types'; @@ -32,6 +35,7 @@ import { getPrePackagedRulesStatus, previewRule, findRuleExceptionReferences, + performBulkAction, } from './api'; const abortCtrl = new AbortController(); @@ -701,4 +705,96 @@ describe('Detections Rules API', () => { }); }); }); + + describe('performBulkAction', () => { + const fetchMockResult = {}; + + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(fetchMockResult); + }); + + test('passes a query', async () => { + await performBulkAction({ bulkAction: { type: BulkActionType.enable, query: 'some query' } }); + + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_action', { + method: 'POST', + body: JSON.stringify({ + action: 'enable', + query: 'some query', + }), + query: { + dry_run: false, + }, + }); + }); + + test('passes ids', async () => { + await performBulkAction({ + bulkAction: { type: BulkActionType.disable, ids: ['ruleId1', 'ruleId2'] }, + }); + + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_action', { + method: 'POST', + body: JSON.stringify({ + action: 'disable', + ids: ['ruleId1', 'ruleId2'], + }), + query: { + dry_run: false, + }, + }); + }); + + test('passes edit payload', async () => { + await performBulkAction({ + bulkAction: { + type: BulkActionType.edit, + ids: ['ruleId1'], + editPayload: [ + { type: BulkActionEditType.add_index_patterns, value: ['some-index-pattern'] }, + ], + }, + }); + + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_action', { + method: 'POST', + body: JSON.stringify({ + action: 'edit', + ids: ['ruleId1'], + edit: [{ type: 'add_index_patterns', value: ['some-index-pattern'] }], + }), + query: { + dry_run: false, + }, + }); + }); + + test('executes dry run', async () => { + await performBulkAction({ + bulkAction: { type: BulkActionType.disable, query: 'some query' }, + dryRun: true, + }); + + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_action', { + method: 'POST', + body: JSON.stringify({ + action: 'disable', + query: 'some query', + }), + query: { dry_run: true }, + }); + }); + + test('returns result', async () => { + const result = await performBulkAction({ + bulkAction: { + type: BulkActionType.disable, + query: 'some query', + }, + }); + + expect(result).toBe(fetchMockResult); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index 3fd5fd65bb6392..40a00178c31b88 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -29,7 +29,7 @@ import type { RulesReferencedByExceptionListsSchema } from '../../../../common/d import { DETECTION_ENGINE_RULES_EXCEPTIONS_REFERENCE_URL } from '../../../../common/detection_engine/rule_exceptions'; import type { BulkActionEditPayload } from '../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; -import { BulkAction } from '../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { BulkActionType } from '../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import type { RuleResponse, @@ -213,48 +213,45 @@ export interface BulkActionResponse { }; } -export interface BulkActionProps { - action: Exclude; - query?: string; - ids?: string[]; - edit?: BulkActionEditPayload[]; - isDryRun?: boolean; +export type QueryOrIds = { query: string; ids?: undefined } | { query?: undefined; ids: string[] }; +type PlainBulkAction = { + type: Exclude; +} & QueryOrIds; +type EditBulkAction = { + type: BulkActionType.edit; + editPayload: BulkActionEditPayload[]; +} & QueryOrIds; +export type BulkAction = PlainBulkAction | EditBulkAction; + +export interface PerformBulkActionProps { + bulkAction: BulkAction; + dryRun?: boolean; } /** * Perform bulk action with rules selected by a filter query * - * @param query filter query to select rules to perform bulk action with - * @param ids string[] rule ids to select rules to perform bulk action with - * @param edit BulkEditActionPayload edit action payload - * @param action bulk action to perform - * @param isDryRun enables dry run mode for bulk actions + * @param bulkAction bulk action which contains type, query or ids and edit fields + * @param dryRun enables dry run mode for bulk actions * * @throws An error if response is not OK */ -export const performBulkAction = async ({ - action, - query, - edit, - ids, - isDryRun, -}: BulkActionProps): Promise => - KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_BULK_ACTION, { +export async function performBulkAction({ + bulkAction, + dryRun = false, +}: PerformBulkActionProps): Promise { + const params = { + action: bulkAction.type, + query: bulkAction.query, + ids: bulkAction.ids, + edit: bulkAction.type === BulkActionType.edit ? bulkAction.editPayload : undefined, + }; + + return KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_BULK_ACTION, { method: 'POST', - body: JSON.stringify({ - action, - ...(edit ? { edit } : {}), - ...(ids ? { ids } : {}), - ...(query !== undefined ? { query } : {}), - }), - query: { - ...(isDryRun ? { dry_run: isDryRun } : {}), - }, + body: JSON.stringify(params), + query: { dry_run: dryRun }, }); - -export interface BulkExportProps { - query?: string; - ids?: string[]; } export type BulkExportResponse = Blob; @@ -262,23 +259,22 @@ export type BulkExportResponse = Blob; /** * Bulk export rules selected by a filter query * - * @param query filter query to select rules to perform bulk action with - * @param ids string[] rule ids to select rules to perform bulk action with + * @param queryOrIds filter query to select rules to perform bulk action with or rule ids to select rules to perform bulk action with * * @throws An error if response is not OK */ -export const bulkExportRules = async ({ - query, - ids, -}: BulkExportProps): Promise => - KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_BULK_ACTION, { +export async function bulkExportRules(queryOrIds: QueryOrIds): Promise { + const params = { + action: BulkActionType.export, + query: queryOrIds.query, + ids: queryOrIds.ids, + }; + + return KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_BULK_ACTION, { method: 'POST', - body: JSON.stringify({ - action: BulkAction.export, - ...(ids ? { ids } : {}), - ...(query !== undefined ? { query } : {}), - }), + body: JSON.stringify(params), }); +} export interface CreatePrepackagedRulesResponse { rules_installed: number; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts index 647230982c8347..866ce74e3c323b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts @@ -6,8 +6,8 @@ */ import type { UseMutationOptions } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query'; -import { BulkAction } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; -import type { BulkActionProps, BulkActionResponse } from '../api'; +import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import type { BulkActionResponse, PerformBulkActionProps } from '../api'; import { performBulkAction } from '../api'; import { useInvalidateFetchPrebuiltRulesStatusQuery } from './use_fetch_prebuilt_rules_status_query'; import { useInvalidateFindRulesQuery, useUpdateRulesCache } from './use_find_rules_query'; @@ -18,7 +18,7 @@ import { DETECTION_ENGINE_RULES_BULK_ACTION } from '../../../../../common/consta export const BULK_ACTION_MUTATION_KEY = ['POST', DETECTION_ENGINE_RULES_BULK_ACTION]; export const useBulkActionMutation = ( - options?: UseMutationOptions + options?: UseMutationOptions ) => { const invalidateFindRulesQuery = useInvalidateFindRulesQuery(); const invalidateFetchRuleByIdQuery = useInvalidateFetchRuleByIdQuery(); @@ -26,32 +26,37 @@ export const useBulkActionMutation = ( const invalidateFetchPrebuiltRulesStatusQuery = useInvalidateFetchPrebuiltRulesStatusQuery(); const updateRulesCache = useUpdateRulesCache(); - return useMutation( - (action: BulkActionProps) => performBulkAction(action), + return useMutation( + (bulkActionProps: PerformBulkActionProps) => performBulkAction(bulkActionProps), { ...options, mutationKey: BULK_ACTION_MUTATION_KEY, onSuccess: (...args) => { - const [res, { action }] = args; - switch (action) { - case BulkAction.enable: - case BulkAction.disable: { + const [ + res, + { + bulkAction: { type: actionType }, + }, + ] = args; + switch (actionType) { + case BulkActionType.enable: + case BulkActionType.disable: { invalidateFetchRuleByIdQuery(); // This action doesn't affect rule content, no need for invalidation updateRulesCache(res?.attributes?.results?.updated ?? []); break; } - case BulkAction.delete: + case BulkActionType.delete: invalidateFindRulesQuery(); invalidateFetchRuleByIdQuery(); invalidateFetchTagsQuery(); invalidateFetchPrebuiltRulesStatusQuery(); break; - case BulkAction.duplicate: + case BulkActionType.duplicate: invalidateFindRulesQuery(); invalidateFetchPrebuiltRulesStatusQuery(); break; - case BulkAction.edit: + case BulkActionType.edit: updateRulesCache(res?.attributes?.results?.updated ?? []); invalidateFetchRuleByIdQuery(); invalidateFetchTagsQuery(); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_export_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_export_mutation.ts index 623db44af60981..dbd3a0b9fac3c2 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_export_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_export_mutation.ts @@ -7,16 +7,16 @@ import type { UseMutationOptions } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query'; import { DETECTION_ENGINE_RULES_BULK_ACTION } from '../../../../../common/constants'; -import type { BulkExportProps, BulkExportResponse } from '../api'; +import type { BulkExportResponse, QueryOrIds } from '../api'; import { bulkExportRules } from '../api'; export const BULK_ACTION_MUTATION_KEY = ['POST', DETECTION_ENGINE_RULES_BULK_ACTION]; export const useBulkExportMutation = ( - options?: UseMutationOptions + options?: UseMutationOptions ) => { - return useMutation( - (action: BulkExportProps) => bulkExportRules(action), + return useMutation( + (action: QueryOrIds) => bulkExportRules(action), { ...options, mutationKey: BULK_ACTION_MUTATION_KEY, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/show_bulk_error_toast.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/show_bulk_error_toast.ts new file mode 100644 index 00000000000000..1c88db4b28ab1e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/show_bulk_error_toast.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { HTTPError } from '../../../../../common/detection_engine/types'; +import type { UseAppToasts } from '../../../../common/hooks/use_app_toasts'; +import type { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { explainBulkError, summarizeBulkError } from './translations'; + +export function showBulkErrorToast( + toasts: UseAppToasts, + action: BulkActionType, + error: HTTPError +): void { + toasts.addError(populateErrorStack(error), { + title: summarizeBulkError(action), + toastMessage: explainBulkError(action, error), + }); +} + +function populateErrorStack(error: HTTPError): HTTPError { + error.stack = JSON.stringify(error.body, null, 2); + + return error; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/show_bulk_success_toast.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/show_bulk_success_toast.ts new file mode 100644 index 00000000000000..7992747ba2f06f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/show_bulk_success_toast.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { BulkActionSummary } from '..'; +import type { UseAppToasts } from '../../../../common/hooks/use_app_toasts'; +import type { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { explainBulkSuccess, summarizeBulkSuccess } from './translations'; + +export function showBulkSuccessToast( + toasts: UseAppToasts, + action: BulkActionType, + summary: BulkActionSummary +): void { + toasts.addSuccess({ + title: summarizeBulkSuccess(action), + text: explainBulkSuccess(action, summary), + }); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/translations.ts index 314811d0b142d1..76e0b702c5e94c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/translations.ts @@ -5,92 +5,104 @@ * 2.0. */ -import type { ErrorToastOptions } from '@kbn/core-notifications-browser'; -import { BulkAction } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import type { HTTPError } from '../../../../../common/detection_engine/types'; +import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import * as i18n from '../../../../detections/pages/detection_engine/rules/translations'; -import type { BulkActionSummary } from '../../api/api'; +import type { BulkActionResponse, BulkActionSummary } from '../../api/api'; -export function getErrorToastContent( - action: BulkAction, - summary: BulkActionSummary -): ErrorToastOptions { - let title: string; - let toastMessage: string | undefined; +export function summarizeBulkSuccess(action: BulkActionType): string { + switch (action) { + case BulkActionType.export: + return i18n.RULES_BULK_EXPORT_SUCCESS; + + case BulkActionType.duplicate: + return i18n.RULES_BULK_DUPLICATE_SUCCESS; + + case BulkActionType.delete: + return i18n.RULES_BULK_DELETE_SUCCESS; + + case BulkActionType.enable: + return i18n.RULES_BULK_ENABLE_SUCCESS; + + case BulkActionType.disable: + return i18n.RULES_BULK_DISABLE_SUCCESS; + + case BulkActionType.edit: + return i18n.RULES_BULK_EDIT_SUCCESS; + } +} +export function explainBulkSuccess(action: BulkActionType, summary: BulkActionSummary): string { switch (action) { - case BulkAction.export: - title = i18n.RULES_BULK_EXPORT_FAILURE; - if (summary) { - toastMessage = i18n.RULES_BULK_EXPORT_FAILURE_DESCRIPTION(summary.failed); - } - break; - case BulkAction.duplicate: - title = i18n.RULES_BULK_DUPLICATE_FAILURE; - if (summary) { - toastMessage = i18n.RULES_BULK_DUPLICATE_FAILURE_DESCRIPTION(summary.failed); - } - break; - case BulkAction.delete: - title = i18n.RULES_BULK_DELETE_FAILURE; - if (summary) { - toastMessage = i18n.RULES_BULK_DELETE_FAILURE_DESCRIPTION(summary.failed); - } - break; - case BulkAction.enable: - title = i18n.RULES_BULK_ENABLE_FAILURE; - if (summary) { - toastMessage = i18n.RULES_BULK_ENABLE_FAILURE_DESCRIPTION(summary.failed); - } - break; - case BulkAction.disable: - title = i18n.RULES_BULK_DISABLE_FAILURE; - if (summary) { - toastMessage = i18n.RULES_BULK_DISABLE_FAILURE_DESCRIPTION(summary.failed); - } - break; - case BulkAction.edit: - title = i18n.RULES_BULK_EDIT_FAILURE; - if (summary) { - toastMessage = i18n.RULES_BULK_EDIT_FAILURE_DESCRIPTION(summary.failed); - } - break; + case BulkActionType.export: + return getExportSuccessToastMessage(summary.succeeded, summary.total); + + case BulkActionType.duplicate: + return i18n.RULES_BULK_DUPLICATE_SUCCESS_DESCRIPTION(summary.succeeded); + + case BulkActionType.delete: + return i18n.RULES_BULK_DELETE_SUCCESS_DESCRIPTION(summary.succeeded); + + case BulkActionType.enable: + return i18n.RULES_BULK_ENABLE_SUCCESS_DESCRIPTION(summary.succeeded); + + case BulkActionType.disable: + return i18n.RULES_BULK_DISABLE_SUCCESS_DESCRIPTION(summary.succeeded); + + case BulkActionType.edit: + return i18n.RULES_BULK_EDIT_SUCCESS_DESCRIPTION(summary.succeeded); } +} - return { title, toastMessage }; +export function summarizeBulkError(action: BulkActionType): string { + switch (action) { + case BulkActionType.export: + return i18n.RULES_BULK_EXPORT_FAILURE; + + case BulkActionType.duplicate: + return i18n.RULES_BULK_DUPLICATE_FAILURE; + + case BulkActionType.delete: + return i18n.RULES_BULK_DELETE_FAILURE; + + case BulkActionType.enable: + return i18n.RULES_BULK_ENABLE_FAILURE; + + case BulkActionType.disable: + return i18n.RULES_BULK_DISABLE_FAILURE; + + case BulkActionType.edit: + return i18n.RULES_BULK_EDIT_FAILURE; + } } -export function getSuccessToastContent(action: BulkAction, summary: BulkActionSummary) { - let title: string; - let text: string | undefined; +export function explainBulkError(action: BulkActionType, error: HTTPError): string { + // if response doesn't have number of failed rules, it means the whole bulk action failed + const summary = (error.body as BulkActionResponse)?.attributes?.summary; - switch (action) { - case BulkAction.export: - title = i18n.RULES_BULK_EXPORT_SUCCESS; - text = getExportSuccessToastMessage(summary.succeeded, summary.total); - break; - case BulkAction.duplicate: - title = i18n.RULES_BULK_DUPLICATE_SUCCESS; - text = i18n.RULES_BULK_DUPLICATE_SUCCESS_DESCRIPTION(summary.succeeded); - break; - case BulkAction.delete: - title = i18n.RULES_BULK_DELETE_SUCCESS; - text = i18n.RULES_BULK_DELETE_SUCCESS_DESCRIPTION(summary.succeeded); - break; - case BulkAction.enable: - title = i18n.RULES_BULK_ENABLE_SUCCESS; - text = i18n.RULES_BULK_ENABLE_SUCCESS_DESCRIPTION(summary.succeeded); - break; - case BulkAction.disable: - title = i18n.RULES_BULK_DISABLE_SUCCESS; - text = i18n.RULES_BULK_DISABLE_SUCCESS_DESCRIPTION(summary.succeeded); - break; - case BulkAction.edit: - title = i18n.RULES_BULK_EDIT_SUCCESS; - text = i18n.RULES_BULK_EDIT_SUCCESS_DESCRIPTION(summary.succeeded); - break; + if (!summary) { + return ''; } - return { title, text }; + switch (action) { + case BulkActionType.export: + return i18n.RULES_BULK_EXPORT_FAILURE_DESCRIPTION(summary.failed); + + case BulkActionType.duplicate: + return i18n.RULES_BULK_DUPLICATE_FAILURE_DESCRIPTION(summary.failed); + + case BulkActionType.delete: + return i18n.RULES_BULK_DELETE_FAILURE_DESCRIPTION(summary.failed); + + case BulkActionType.enable: + return i18n.RULES_BULK_ENABLE_FAILURE_DESCRIPTION(summary.failed); + + case BulkActionType.disable: + return i18n.RULES_BULK_DISABLE_FAILURE_DESCRIPTION(summary.failed); + + case BulkActionType.edit: + return i18n.RULES_BULK_EDIT_FAILURE_DESCRIPTION(summary.failed); + } } const getExportSuccessToastMessage = (succeeded: number, total: number) => { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_bulk_export.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_bulk_export.test.ts new file mode 100644 index 00000000000000..001a2815fa02e9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_bulk_export.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useRulesTableContextOptional } from '../../../rule_management_ui/components/rules_table/rules_table/rules_table_context'; +import { useBulkExportMutation } from '../../api/hooks/use_bulk_export_mutation'; +import type { QueryOrIds } from '../../api/api'; +import { useBulkExport } from './use_bulk_export'; + +jest.mock('../../../../common/hooks/use_app_toasts'); +jest.mock('../../api/hooks/use_bulk_export_mutation'); +jest.mock('../../../rule_management_ui/components/rules_table/rules_table/rules_table_context'); + +async function bulkExport(queryOrIds: QueryOrIds): Promise { + const { + result: { + current: { bulkExport: bulkExportFn }, + }, + } = renderHook(() => useBulkExport()); + + await bulkExportFn(queryOrIds); +} + +describe('useBulkExport', () => { + let mutateAsync: jest.Mock; + let toasts: Record; + + beforeEach(() => { + jest.clearAllMocks(); + + mutateAsync = jest.fn().mockResolvedValue({ + attributes: { + results: { + updated: [{ immutable: true }, { immutable: false }], + }, + summary: { + total: 2, + succeeded: 2, + }, + }, + }); + (useBulkExportMutation as jest.Mock).mockReturnValue({ mutateAsync }); + + toasts = { + addSuccess: jest.fn(), + addError: jest.fn(), + }; + (useAppToasts as jest.Mock).mockReturnValue(toasts); + }); + + it('executes bulk export action', async () => { + await bulkExport({ query: 'some query' }); + + expect(mutateAsync).toHaveBeenCalledWith({ query: 'some query' }); + }); + + describe('state handlers', () => { + it('shows error toast upon failure', async () => { + (useBulkExportMutation as jest.Mock).mockReturnValue({ + mutateAsync: jest.fn().mockRejectedValue(new Error()), + }); + + await bulkExport({ ids: ['ruleId1'] }); + + expect(toasts.addError).toHaveBeenCalled(); + }); + }); + + describe('when rules table context is available', () => { + let setLoadingRules: jest.Mock; + + beforeEach(() => { + setLoadingRules = jest.fn(); + (useRulesTableContextOptional as jest.Mock).mockReturnValue({ + actions: { + setLoadingRules, + }, + state: { + isAllSelected: false, + }, + }); + }); + + it('sets the loading state before execution', async () => { + await bulkExport({ ids: ['ruleId1', 'ruleId2'] }); + + expect(setLoadingRules).toHaveBeenCalledWith({ + ids: ['ruleId1', 'ruleId2'], + action: BulkActionType.export, + }); + }); + + it('sets the empty loading state before execution when query is set', async () => { + await bulkExport({ query: 'some query' }); + + expect(setLoadingRules).toHaveBeenCalledWith({ + ids: [], + action: BulkActionType.export, + }); + }); + + it('clears loading state for the processing rules after execution', async () => { + await bulkExport({ ids: ['ruleId1', 'ruleId2'] }); + + expect(setLoadingRules).toHaveBeenCalledWith({ ids: [], action: null }); + }); + + it('clears loading state for the processing rules after execution failure', async () => { + (useBulkExportMutation as jest.Mock).mockReturnValue({ + mutateAsync: jest.fn().mockRejectedValue(new Error()), + }); + + await bulkExport({ ids: ['ruleId1', 'ruleId2'] }); + + expect(setLoadingRules).toHaveBeenCalledWith({ ids: [], action: null }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_bulk_export.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_bulk_export.ts index 793580e5d3be01..59c38208d8b105 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_bulk_export.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_bulk_export.ts @@ -6,44 +6,45 @@ */ import { useCallback } from 'react'; -import type { BulkActionResponse, BulkActionSummary } from '..'; -import { BulkAction } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; -import type { HTTPError } from '../../../../../common/detection_engine/types'; +import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import type { UseAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { downloadBlob } from '../../../../common/utils/download_blob'; import * as i18n from '../../../../detections/pages/detection_engine/rules/translations'; +import { useRulesTableContextOptional } from '../../../rule_management_ui/components/rules_table/rules_table/rules_table_context'; import { getExportedRulesCounts } from '../../../rule_management_ui/components/rules_table/helpers'; -import type { RulesTableActions } from '../../../rule_management_ui/components/rules_table/rules_table/rules_table_context'; import { useBulkExportMutation } from '../../api/hooks/use_bulk_export_mutation'; -import { getErrorToastContent, getSuccessToastContent } from './translations'; +import { showBulkErrorToast } from './show_bulk_error_toast'; +import { showBulkSuccessToast } from './show_bulk_success_toast'; +import type { QueryOrIds } from '../../api/api'; +import { useGuessRuleIdsForBulkAction } from './use_guess_rule_ids_for_bulk_action'; -interface RulesBulkActionArgs { - visibleRuleIds?: string[]; - search: { query: string } | { ids: string[] }; - setLoadingRules?: RulesTableActions['setLoadingRules']; -} - -export const useBulkExport = () => { +export function useBulkExport() { const toasts = useAppToasts(); const { mutateAsync } = useBulkExportMutation(); + const guessRuleIdsForBulkAction = useGuessRuleIdsForBulkAction(); + const rulesTableContext = useRulesTableContextOptional(); + const setLoadingRules = rulesTableContext?.actions.setLoadingRules; const bulkExport = useCallback( - async ({ visibleRuleIds = [], setLoadingRules, search }: RulesBulkActionArgs) => { + async (queryOrIds: QueryOrIds) => { try { - setLoadingRules?.({ ids: visibleRuleIds, action: BulkAction.export }); - return await mutateAsync(search); + setLoadingRules?.({ + ids: queryOrIds.ids ?? guessRuleIdsForBulkAction(BulkActionType.export), + action: BulkActionType.export, + }); + return await mutateAsync(queryOrIds); } catch (error) { - defaultErrorHandler(toasts, error); + showBulkErrorToast(toasts, BulkActionType.export, error); } finally { setLoadingRules?.({ ids: [], action: null }); } }, - [mutateAsync, toasts] + [guessRuleIdsForBulkAction, setLoadingRules, mutateAsync, toasts] ); return { bulkExport }; -}; +} /** * downloads exported rules, received from export action @@ -61,19 +62,8 @@ export async function downloadExportedRules({ }) { try { downloadBlob(response, `${i18n.EXPORT_FILENAME}.ndjson`); - defaultSuccessHandler(toasts, await getExportedRulesCounts(response)); + showBulkSuccessToast(toasts, BulkActionType.export, await getExportedRulesCounts(response)); } catch (error) { - defaultErrorHandler(toasts, error); + showBulkErrorToast(toasts, BulkActionType.export, error); } } - -function defaultErrorHandler(toasts: UseAppToasts, error: HTTPError): void { - const summary = (error?.body as BulkActionResponse)?.attributes?.summary; - error.stack = JSON.stringify(error.body, null, 2); - - toasts.addError(error, getErrorToastContent(BulkAction.export, summary)); -} - -function defaultSuccessHandler(toasts: UseAppToasts, summary: BulkActionSummary): void { - toasts.addSuccess(getSuccessToastContent(BulkAction.export, summary)); -} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_execute_bulk_action.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_execute_bulk_action.test.ts new file mode 100644 index 00000000000000..aa2648bf633e17 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_execute_bulk_action.test.ts @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../../common/lib/telemetry'; +import { useBulkActionMutation } from '../../api/hooks/use_bulk_action_mutation'; +import { useRulesTableContextOptional } from '../../../rule_management_ui/components/rules_table/rules_table/rules_table_context'; +import { useExecuteBulkAction } from './use_execute_bulk_action'; +import type { BulkAction } from '../../api/api'; + +jest.mock('../../../../common/hooks/use_app_toasts'); +jest.mock('../../../../common/lib/telemetry'); +jest.mock('../../api/hooks/use_bulk_action_mutation'); +jest.mock('../../../rule_management_ui/components/rules_table/rules_table/rules_table_context'); + +async function executeBulkAction( + bulkAction: BulkAction, + options?: Parameters[0] +): Promise { + const { + result: { + current: { executeBulkAction: executeBulkActionFn }, + }, + } = renderHook(() => useExecuteBulkAction(options)); + + await executeBulkActionFn(bulkAction); +} + +describe('useExecuteBulkAction', () => { + let mutateAsync: jest.Mock; + let toasts: Record; + + beforeEach(() => { + jest.clearAllMocks(); + + mutateAsync = jest.fn().mockResolvedValue({ + attributes: { + results: { + updated: [{ immutable: true }, { immutable: false }], + }, + summary: { + total: 2, + succeeded: 2, + }, + }, + }); + (useBulkActionMutation as jest.Mock).mockReturnValue({ mutateAsync }); + + toasts = { + addSuccess: jest.fn(), + addError: jest.fn(), + }; + (useAppToasts as jest.Mock).mockReturnValue(toasts); + }); + + it('executes bulk action', async () => { + const bulkAction = { + type: BulkActionType.enable, + query: 'some query', + } as const; + + await executeBulkAction(bulkAction); + + expect(mutateAsync).toHaveBeenCalledWith({ bulkAction }); + }); + + describe('state handlers', () => { + it('shows success toast upon completion', async () => { + await executeBulkAction({ + type: BulkActionType.enable, + ids: ['ruleId1'], + }); + + expect(toasts.addSuccess).toHaveBeenCalled(); + expect(toasts.addError).not.toHaveBeenCalled(); + }); + + it('does not shows success toast upon completion if suppressed', async () => { + await executeBulkAction( + { + type: BulkActionType.enable, + ids: ['ruleId1'], + }, + { suppressSuccessToast: true } + ); + + expect(toasts.addSuccess).not.toHaveBeenCalled(); + expect(toasts.addError).not.toHaveBeenCalled(); + }); + + it('shows error toast upon failure', async () => { + (useBulkActionMutation as jest.Mock).mockReturnValue({ + mutateAsync: jest.fn().mockRejectedValue(new Error()), + }); + + await executeBulkAction({ + type: BulkActionType.enable, + ids: ['ruleId1'], + }); + + expect(toasts.addError).toHaveBeenCalled(); + expect(toasts.addSuccess).not.toHaveBeenCalled(); + }); + }); + + describe('when rules table context is available', () => { + let setLoadingRules: jest.Mock; + + beforeEach(() => { + setLoadingRules = jest.fn(); + (useRulesTableContextOptional as jest.Mock).mockReturnValue({ + actions: { + setLoadingRules, + }, + state: { + isAllSelected: false, + }, + }); + }); + + it('sets the loading state before execution', async () => { + await executeBulkAction({ + type: BulkActionType.enable, + ids: ['ruleId1', 'ruleId2'], + }); + + expect(setLoadingRules).toHaveBeenCalledWith({ + ids: ['ruleId1', 'ruleId2'], + action: BulkActionType.enable, + }); + }); + + it('sets the empty loading state before execution when query is set', async () => { + await executeBulkAction({ + type: BulkActionType.enable, + query: 'some query', + }); + + expect(setLoadingRules).toHaveBeenCalledWith({ + ids: [], + action: BulkActionType.enable, + }); + }); + + it('clears loading state for the processing rules after execution', async () => { + await executeBulkAction({ + type: BulkActionType.enable, + ids: ['ruleId1', 'ruleId2'], + }); + + expect(setLoadingRules).toHaveBeenCalledWith({ ids: [], action: null }); + }); + + it('clears loading state for the processing rules after execution failure', async () => { + (useBulkActionMutation as jest.Mock).mockReturnValue({ + mutateAsync: jest.fn().mockRejectedValue(new Error()), + }); + + await executeBulkAction({ + type: BulkActionType.enable, + ids: ['ruleId1', 'ruleId2'], + }); + + expect(setLoadingRules).toHaveBeenCalledWith({ ids: [], action: null }); + }); + }); + + describe('telemetry', () => { + it('sends for enable action', async () => { + await executeBulkAction({ + type: BulkActionType.enable, + query: 'some query', + }); + + expect(track).toHaveBeenCalledWith(METRIC_TYPE.COUNT, TELEMETRY_EVENT.SIEM_RULE_ENABLED); + expect(track).toHaveBeenCalledWith(METRIC_TYPE.COUNT, TELEMETRY_EVENT.CUSTOM_RULE_ENABLED); + }); + + it('sends for disable action', async () => { + await executeBulkAction({ + type: BulkActionType.disable, + query: 'some query', + }); + + expect(track).toHaveBeenCalledWith(METRIC_TYPE.COUNT, TELEMETRY_EVENT.SIEM_RULE_DISABLED); + expect(track).toHaveBeenCalledWith(METRIC_TYPE.COUNT, TELEMETRY_EVENT.CUSTOM_RULE_DISABLED); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_execute_bulk_action.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_execute_bulk_action.ts index 3ba9e353f3fcde..80bf92c8e3583a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_execute_bulk_action.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_execute_bulk_action.ts @@ -7,19 +7,19 @@ import type { NavigateToAppOptions } from '@kbn/core/public'; import { useCallback } from 'react'; -import type { BulkActionResponse, BulkActionSummary } from '..'; +import type { BulkActionResponse } from '..'; import { APP_UI_ID } from '../../../../../common/constants'; -import type { BulkActionEditPayload } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; -import { BulkAction } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; -import type { HTTPError } from '../../../../../common/detection_engine/types'; +import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import { SecurityPageName } from '../../../../app/types'; import { getEditRuleUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; -import type { UseAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../../common/lib/telemetry'; -import type { RulesTableActions } from '../../../rule_management_ui/components/rules_table/rules_table/rules_table_context'; +import { useRulesTableContextOptional } from '../../../rule_management_ui/components/rules_table/rules_table/rules_table_context'; +import type { BulkAction } from '../../api/api'; import { useBulkActionMutation } from '../../api/hooks/use_bulk_action_mutation'; -import { getErrorToastContent, getSuccessToastContent } from './translations'; +import { showBulkErrorToast } from './show_bulk_error_toast'; +import { showBulkSuccessToast } from './show_bulk_success_toast'; +import { useGuessRuleIdsForBulkAction } from './use_guess_rule_ids_for_bulk_action'; export const goToRuleEditPage = ( ruleId: string, @@ -31,92 +31,65 @@ export const goToRuleEditPage = ( }); }; -type OnActionSuccessCallback = ( - toasts: UseAppToasts, - action: BulkAction, - summary: BulkActionSummary -) => void; - -type OnActionErrorCallback = (toasts: UseAppToasts, action: BulkAction, error: HTTPError) => void; - -interface RulesBulkActionArgs { - action: Exclude; - visibleRuleIds?: string[]; - search: { query: string } | { ids: string[] }; - payload?: { edit?: BulkActionEditPayload[] }; - onError?: OnActionErrorCallback; - onFinish?: () => void; - onSuccess?: OnActionSuccessCallback; - setLoadingRules?: RulesTableActions['setLoadingRules']; +interface UseExecuteBulkActionOptions { + suppressSuccessToast?: boolean; } -export const useExecuteBulkAction = () => { +export const useExecuteBulkAction = (options?: UseExecuteBulkActionOptions) => { const toasts = useAppToasts(); const { mutateAsync } = useBulkActionMutation(); + const guessRuleIdsForBulkAction = useGuessRuleIdsForBulkAction(); + const rulesTableContext = useRulesTableContextOptional(); + const setLoadingRules = rulesTableContext?.actions.setLoadingRules; const executeBulkAction = useCallback( - async ({ - visibleRuleIds = [], - action, - setLoadingRules, - search, - payload, - onSuccess = defaultSuccessHandler, - onError = defaultErrorHandler, - onFinish, - }: RulesBulkActionArgs) => { + async (bulkAction: BulkAction) => { try { - setLoadingRules?.({ ids: visibleRuleIds, action }); - const response = await mutateAsync({ ...search, action, edit: payload?.edit }); - sendTelemetry(action, response); - onSuccess(toasts, action, response.attributes.summary); + setLoadingRules?.({ + ids: bulkAction.ids ?? guessRuleIdsForBulkAction(bulkAction.type), + action: bulkAction.type, + }); + + const response = await mutateAsync({ bulkAction }); + sendTelemetry(bulkAction.type, response); + + if (!options?.suppressSuccessToast) { + showBulkSuccessToast(toasts, bulkAction.type, response.attributes.summary); + } return response; } catch (error) { - onError(toasts, action, error); + showBulkErrorToast(toasts, bulkAction.type, error); } finally { setLoadingRules?.({ ids: [], action: null }); - onFinish?.(); } }, - [mutateAsync, toasts] + [options?.suppressSuccessToast, guessRuleIdsForBulkAction, setLoadingRules, mutateAsync, toasts] ); return { executeBulkAction }; }; -function defaultErrorHandler(toasts: UseAppToasts, action: BulkAction, error: HTTPError) { - const summary = (error?.body as BulkActionResponse)?.attributes?.summary; - error.stack = JSON.stringify(error.body, null, 2); - - toasts.addError(error, getErrorToastContent(action, summary)); -} +function sendTelemetry(action: BulkActionType, response: BulkActionResponse): void { + if (action !== BulkActionType.disable && action !== BulkActionType.enable) { + return; + } -async function defaultSuccessHandler( - toasts: UseAppToasts, - action: BulkAction, - summary: BulkActionSummary -) { - toasts.addSuccess(getSuccessToastContent(action, summary)); -} + if (response.attributes.results.updated.some((rule) => rule.immutable)) { + track( + METRIC_TYPE.COUNT, + action === BulkActionType.enable + ? TELEMETRY_EVENT.SIEM_RULE_ENABLED + : TELEMETRY_EVENT.SIEM_RULE_DISABLED + ); + } -function sendTelemetry(action: BulkAction, response: BulkActionResponse) { - if (action === BulkAction.disable || action === BulkAction.enable) { - if (response.attributes.results.updated.some((rule) => rule.immutable)) { - track( - METRIC_TYPE.COUNT, - action === BulkAction.enable - ? TELEMETRY_EVENT.SIEM_RULE_ENABLED - : TELEMETRY_EVENT.SIEM_RULE_DISABLED - ); - } - if (response.attributes.results.updated.some((rule) => !rule.immutable)) { - track( - METRIC_TYPE.COUNT, - action === BulkAction.disable - ? TELEMETRY_EVENT.CUSTOM_RULE_ENABLED - : TELEMETRY_EVENT.CUSTOM_RULE_DISABLED - ); - } + if (response.attributes.results.updated.some((rule) => !rule.immutable)) { + track( + METRIC_TYPE.COUNT, + action === BulkActionType.disable + ? TELEMETRY_EVENT.CUSTOM_RULE_DISABLED + : TELEMETRY_EVENT.CUSTOM_RULE_ENABLED + ); } } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_guess_rule_ids_for_bulk_action.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_guess_rule_ids_for_bulk_action.ts new file mode 100644 index 00000000000000..af2e1515d6fe17 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/bulk_actions/use_guess_rule_ids_for_bulk_action.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { useRulesTableContextOptional } from '../../../rule_management_ui/components/rules_table/rules_table/rules_table_context'; + +export function useGuessRuleIdsForBulkAction(): (bulkActionType: BulkActionType) => string[] { + const rulesTableContext = useRulesTableContextOptional(); + + return useCallback( + (bulkActionType: BulkActionType) => { + const allRules = rulesTableContext?.state.isAllSelected ? rulesTableContext.state.rules : []; + const processingRules = + bulkActionType === BulkActionType.enable + ? allRules.filter((x) => !x.enabled) + : bulkActionType === BulkActionType.disable + ? allRules.filter((x) => x.enabled) + : allRules; + + return processingRules.map((r) => r.id); + }, + [rulesTableContext] + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_action_dry_run_confirmation.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_action_dry_run_confirmation.tsx index 941339be4fa31d..26ec4db023ba43 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_action_dry_run_confirmation.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_action_dry_run_confirmation.tsx @@ -10,7 +10,7 @@ import { EuiConfirmModal } from '@elastic/eui'; import * as i18n from '../../../../../detections/pages/detection_engine/rules/translations'; import { BulkActionRuleErrorsList } from './bulk_action_rule_errors_list'; -import { BulkAction } from '../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { BulkActionType } from '../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import { assertUnreachable } from '../../../../../../common/utility_types'; import type { BulkActionForConfirmation, DryRunResult } from './types'; @@ -20,9 +20,9 @@ const getActionRejectedTitle = ( failedRulesCount: number ) => { switch (bulkAction) { - case BulkAction.edit: + case BulkActionType.edit: return i18n.BULK_EDIT_CONFIRMATION_REJECTED_TITLE(failedRulesCount); - case BulkAction.export: + case BulkActionType.export: return i18n.BULK_EXPORT_CONFIRMATION_REJECTED_TITLE(failedRulesCount); default: assertUnreachable(bulkAction); @@ -34,9 +34,9 @@ const getActionConfirmLabel = ( succeededRulesCount: number ) => { switch (bulkAction) { - case BulkAction.edit: + case BulkActionType.edit: return i18n.BULK_EDIT_CONFIRMATION_CONFIRM(succeededRulesCount); - case BulkAction.export: + case BulkActionType.export: return i18n.BULK_EXPORT_CONFIRMATION_CONFIRM(succeededRulesCount); default: assertUnreachable(bulkAction); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_action_rule_errors_list.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_action_rule_errors_list.test.tsx index 758ccf0893e36c..fd19e01bcde653 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_action_rule_errors_list.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_action_rule_errors_list.test.tsx @@ -13,7 +13,7 @@ import { render, screen } from '@testing-library/react'; import { BulkActionRuleErrorsList } from './bulk_action_rule_errors_list'; import { BulkActionsDryRunErrCode } from '../../../../../../common/constants'; import type { DryRunResult } from './types'; -import { BulkAction } from '../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { BulkActionType } from '../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; const Wrapper: FC = ({ children }) => { return ( @@ -26,7 +26,7 @@ const Wrapper: FC = ({ children }) => { describe('Component BulkEditRuleErrorsList', () => { test('should not render component if no errors present', () => { const { container } = render( - , + , { wrapper: Wrapper, } @@ -46,7 +46,7 @@ describe('Component BulkEditRuleErrorsList', () => { ruleIds: ['rule:1'], }, ]; - render(, { + render(, { wrapper: Wrapper, }); @@ -76,7 +76,7 @@ describe('Component BulkEditRuleErrorsList', () => { ruleIds: ['rule:1', 'rule:2'], }, ]; - render(, { + render(, { wrapper: Wrapper, }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_action_rule_errors_list.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_action_rule_errors_list.tsx index 9789377f13ef3e..8b908e207daee1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_action_rule_errors_list.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_action_rule_errors_list.tsx @@ -10,7 +10,7 @@ import { EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { BulkActionsDryRunErrCode } from '../../../../../../common/constants'; -import { BulkAction } from '../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { BulkActionType } from '../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import type { DryRunResult, BulkActionForConfirmation } from './types'; @@ -122,7 +122,7 @@ const BulkActionRuleErrorsListComponent = ({ {ruleErrors.map(({ message, errorCode, ruleIds }) => { const rulesCount = ruleIds.length; switch (bulkAction) { - case BulkAction.edit: + case BulkActionType.edit: return ( ); - case BulkAction.export: + case BulkActionType.export: return ( id); await executeBulkAction({ - visibleRuleIds: ruleIds, - action: BulkAction.enable, - setLoadingRules, - search: isAllSelected ? { query: filterQuery } : { ids: ruleIds }, + type: BulkActionType.enable, + ...(isAllSelected ? { query: filterQuery } : { ids: ruleIds }), }); }; @@ -118,10 +117,8 @@ export const useBulkActions = ({ const enabledIds = selectedRules.filter(({ enabled }) => enabled).map(({ id }) => id); await executeBulkAction({ - visibleRuleIds: enabledIds, - action: BulkAction.disable, - setLoadingRules, - search: isAllSelected ? { query: filterQuery } : { ids: enabledIds }, + type: BulkActionType.disable, + ...(isAllSelected ? { query: filterQuery } : { ids: enabledIds }), }); }; @@ -130,10 +127,8 @@ export const useBulkActions = ({ closePopover(); await executeBulkAction({ - visibleRuleIds: selectedRuleIds, - action: BulkAction.duplicate, - setLoadingRules, - search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }, + type: BulkActionType.duplicate, + ...(isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }), }); clearRulesSelection(); }; @@ -151,10 +146,8 @@ export const useBulkActions = ({ startTransaction({ name: BULK_RULE_ACTIONS.DELETE }); await executeBulkAction({ - visibleRuleIds: selectedRuleIds, - action: BulkAction.delete, - setLoadingRules, - search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }, + type: BulkActionType.delete, + ...(isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }), }); }; @@ -162,11 +155,9 @@ export const useBulkActions = ({ closePopover(); startTransaction({ name: BULK_RULE_ACTIONS.EXPORT }); - const response = await bulkExport({ - visibleRuleIds: selectedRuleIds, - setLoadingRules, - search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }, - }); + const response = await bulkExport( + isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds } + ); // if response null, likely network error happened and export rules haven't been received if (!response) { @@ -179,7 +170,7 @@ export const useBulkActions = ({ // they can either cancel action or proceed with export of succeeded rules const hasActionBeenConfirmed = await showBulkActionConfirmation( transformExportDetailsToDryRunResult(details), - BulkAction.export + BulkActionType.export ); if (hasActionBeenConfirmed === false) { return; @@ -195,17 +186,17 @@ export const useBulkActions = ({ closePopover(); const dryRunResult = await executeBulkActionsDryRun({ - action: BulkAction.edit, - editAction: bulkEditActionType, - searchParams: isAllSelected + type: BulkActionType.edit, + ...(isAllSelected ? { query: convertRulesFilterToKQL(filterOptions) } - : { ids: selectedRuleIds }, + : { ids: selectedRuleIds }), + editPayload: computeDryRunEditPayload(bulkEditActionType), }); // User has cancelled edit action or there are no custom rules to proceed const hasActionBeenConfirmed = await showBulkActionConfirmation( dryRunResult, - BulkAction.edit + BulkActionType.edit ); if (hasActionBeenConfirmed === false) { return; @@ -256,17 +247,16 @@ export const useBulkActions = ({ }, 5 * 1000); await executeBulkAction({ - visibleRuleIds: selectedRuleIds, - action: BulkAction.edit, - setLoadingRules, - payload: { edit: [editPayload] }, - onFinish: () => hideWarningToast(), - search: prepareSearchParams({ + type: BulkActionType.edit, + ...prepareSearchParams({ ...(isAllSelected ? { filterOptions } : { selectedRuleIds }), dryRunResult, }), + editPayload: [editPayload], }); + hideWarningToast(); + isBulkEditFinished = true; }; @@ -462,7 +452,6 @@ export const useBulkActions = ({ startTransaction, hasMlPermissions, executeBulkAction, - setLoadingRules, filterQuery, toasts, clearRulesSelection, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions_dry_run.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions_dry_run.ts index d11a2d6c167b83..00e6ef3ad78147 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions_dry_run.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions_dry_run.ts @@ -8,13 +8,8 @@ import type { UseMutateAsyncFunction } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query'; -import type { - BulkAction, - BulkActionEditType, -} from '../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; -import type { BulkActionResponse } from '../../../../rule_management/logic'; +import type { BulkAction, BulkActionResponse } from '../../../../rule_management/logic'; import { performBulkAction } from '../../../../rule_management/logic'; -import { computeDryRunPayload } from './utils/compute_dry_run_payload'; import { processDryRunResult } from './utils/dry_run_result'; import type { DryRunResult } from './types'; @@ -24,7 +19,7 @@ const BULK_ACTIONS_DRY_RUN_QUERY_KEY = 'bulkActionsDryRun'; export type ExecuteBulkActionsDryRun = UseMutateAsyncFunction< DryRunResult | undefined, unknown, - BulkActionsDryRunVariables + BulkAction >; export type UseBulkActionsDryRun = () => { @@ -33,30 +28,16 @@ export type UseBulkActionsDryRun = () => { executeBulkActionsDryRun: ExecuteBulkActionsDryRun; }; -interface BulkActionsDryRunVariables { - action?: Exclude; - editAction?: BulkActionEditType; - searchParams: { query?: string } | { ids?: string[] }; -} - export const useBulkActionsDryRun: UseBulkActionsDryRun = () => { const { data, mutateAsync, isLoading } = useMutation< DryRunResult | undefined, unknown, - BulkActionsDryRunVariables - >([BULK_ACTIONS_DRY_RUN_QUERY_KEY], async ({ searchParams, action, editAction }) => { - if (!action) { - return undefined; - } - + BulkAction + >([BULK_ACTIONS_DRY_RUN_QUERY_KEY], async (bulkAction) => { let result: BulkActionResponse; + try { - result = await performBulkAction({ - ...searchParams, - action, - edit: computeDryRunPayload(action, editAction), - isDryRun: true, - }); + result = await performBulkAction({ bulkAction, dryRun: true }); } catch (err) { // if body doesn't have summary data, action failed altogether and no data available for dry run if ((err.body as BulkActionResponse)?.attributes?.summary?.total === undefined) { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/compute_dry_run_payload.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/compute_dry_run_edit_payload.test.ts similarity index 52% rename from x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/compute_dry_run_payload.test.ts rename to x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/compute_dry_run_edit_payload.test.ts index b3fe47dd214a0d..a1344dcc53ad5e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/compute_dry_run_payload.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/compute_dry_run_edit_payload.test.ts @@ -5,28 +5,11 @@ * 2.0. */ -import { - BulkAction, - BulkActionEditType, -} from '../../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { BulkActionEditType } from '../../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; -import { computeDryRunPayload } from './compute_dry_run_payload'; - -describe('computeDryRunPayload', () => { - test.each([ - [BulkAction.export], - [BulkAction.duplicate], - [BulkAction.delete], - [BulkAction.enable], - [BulkAction.disable], - ])('should return payload undefined if action is %s', (action) => { - expect(computeDryRunPayload(action)).toBeUndefined(); - }); - - test('should return payload undefined if bulkEdit action is not defined', () => { - expect(computeDryRunPayload(BulkAction.edit)).toBeUndefined(); - }); +import { computeDryRunEditPayload } from './compute_dry_run_edit_payload'; +describe('computeDryRunEditPayload', () => { test.each([ [BulkActionEditType.set_index_patterns, []], [BulkActionEditType.delete_index_patterns, []], @@ -36,7 +19,7 @@ describe('computeDryRunPayload', () => { [BulkActionEditType.set_tags, []], [BulkActionEditType.set_timeline, { timeline_id: '', timeline_title: '' }], ])('should return correct payload for bulk edit action %s', (editAction, value) => { - const payload = computeDryRunPayload(BulkAction.edit, editAction); + const payload = computeDryRunEditPayload(editAction); expect(payload).toHaveLength(1); expect(payload?.[0].type).toEqual(editAction); expect(payload?.[0].value).toEqual(value); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/compute_dry_run_payload.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/compute_dry_run_edit_payload.ts similarity index 82% rename from x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/compute_dry_run_payload.ts rename to x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/compute_dry_run_edit_payload.ts index 36ebb0d5a644d3..c8f49ebe4a6c69 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/compute_dry_run_payload.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/compute_dry_run_edit_payload.ts @@ -6,10 +6,7 @@ */ import type { BulkActionEditPayload } from '../../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; -import { - BulkAction, - BulkActionEditType, -} from '../../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { BulkActionEditType } from '../../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import { assertUnreachable } from '../../../../../../../common/utility_types'; /** @@ -18,14 +15,7 @@ import { assertUnreachable } from '../../../../../../../common/utility_types'; * @param {BulkActionEditType | undefined} editAction * @returns {BulkActionEditPayload[] | undefined} */ -export const computeDryRunPayload = ( - action: BulkAction, - editAction?: BulkActionEditType -): BulkActionEditPayload[] | undefined => { - if (action !== BulkAction.edit || !editAction) { - return undefined; - } - +export function computeDryRunEditPayload(editAction: BulkActionEditType): BulkActionEditPayload[] { switch (editAction) { case BulkActionEditType.add_index_patterns: case BulkActionEditType.delete_index_patterns: @@ -74,4 +64,11 @@ export const computeDryRunPayload = ( default: assertUnreachable(editAction); } -}; + + return [ + { + type: editAction, + value: [], + }, + ]; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts index 3c5dc13ffab669..4a88d19e53970c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts @@ -39,7 +39,7 @@ describe('prepareSearchParams', () => { dryRunResult, }); - expect(result.ids).toEqual(['rule:1']); + expect(result).toEqual({ ids: ['rule:1'] }); }); test.each([ @@ -105,7 +105,7 @@ describe('prepareSearchParams', () => { }); expect(mockConvertRulesFilterToKQL).toHaveBeenCalledWith(value); - expect(result.query).toEqual(expect.any(String)); + expect(result).toEqual({ query: expect.any(String) }); } ); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts index c761e56f4bfefd..9b0986d1d68d3c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { QueryOrIds } from '../../../../../rule_management/logic'; import type { DryRunResult } from '../types'; import type { FilterOptions } from '../../../../../rule_management/logic/types'; @@ -23,7 +24,10 @@ type PrepareSearchFilterProps = * @param filterOptions {@link FilterOptions} find filter * @returns either list of ids or KQL search query */ -export const prepareSearchParams = ({ dryRunResult, ...props }: PrepareSearchFilterProps) => { +export const prepareSearchParams = ({ + dryRunResult, + ...props +}: PrepareSearchFilterProps): QueryOrIds => { // if selectedRuleIds present, filter out rules that failed during dry run if ('selectedRuleIds' in props) { const failedRuleIdsSet = new Set(dryRunResult?.ruleErrors.flatMap(({ ruleIds }) => ruleIds)); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx index 6e0e2bb7610074..6ee7acd438c6e8 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx @@ -8,7 +8,7 @@ import type { DefaultItemAction } from '@elastic/eui'; import { EuiToolTip } from '@elastic/eui'; import React from 'react'; -import { BulkAction } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { SINGLE_RULE_ACTIONS } from '../../../../common/lib/apm/user_actions'; import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; @@ -24,14 +24,12 @@ import { goToRuleEditPage, useExecuteBulkAction, } from '../../../rule_management/logic/bulk_actions/use_execute_bulk_action'; -import { useRulesTableContext } from './rules_table/rules_table_context'; import { useHasActionsPrivileges } from './use_has_actions_privileges'; export const useRulesTableActions = (): Array> => { const { navigateToApp } = useKibana().services.application; const hasActionsPrivileges = useHasActionsPrivileges(); const toasts = useAppToasts(); - const { setLoadingRules } = useRulesTableContext().actions; const { startTransaction } = useStartTransaction(); const { executeBulkAction } = useExecuteBulkAction(); const { bulkExport } = useBulkExport(); @@ -69,10 +67,8 @@ export const useRulesTableActions = (): Array> => { onClick: async (rule: Rule) => { startTransaction({ name: SINGLE_RULE_ACTIONS.DUPLICATE }); const result = await executeBulkAction({ - action: BulkAction.duplicate, - setLoadingRules, - visibleRuleIds: [rule.id], - search: { ids: [rule.id] }, + type: BulkActionType.duplicate, + ids: [rule.id], }); const createdRules = result?.attributes.results.created; if (createdRules?.length) { @@ -88,11 +84,7 @@ export const useRulesTableActions = (): Array> => { name: i18n.EXPORT_RULE, onClick: async (rule: Rule) => { startTransaction({ name: SINGLE_RULE_ACTIONS.EXPORT }); - const response = await bulkExport({ - setLoadingRules, - visibleRuleIds: [rule.id], - search: { ids: [rule.id] }, - }); + const response = await bulkExport({ ids: [rule.id] }); if (response) { await downloadExportedRules({ response, @@ -111,10 +103,8 @@ export const useRulesTableActions = (): Array> => { onClick: async (rule: Rule) => { startTransaction({ name: SINGLE_RULE_ACTIONS.DELETE }); await executeBulkAction({ - action: BulkAction.delete, - setLoadingRules, - visibleRuleIds: [rule.id], - search: { ids: [rule.id] }, + type: BulkActionType.delete, + ids: [rule.id], }); }, }, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx index ce265ffb8382c5..816c2963779f92 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx @@ -120,7 +120,7 @@ describe('RuleActionsOverflow', () => { fireEvent.click(getByTestId('rules-details-duplicate-rule')); expect(executeBulkAction).toHaveBeenCalledWith( - expect.objectContaining({ action: 'duplicate' }) + expect.objectContaining({ type: 'duplicate' }) ); }); @@ -139,9 +139,7 @@ describe('RuleActionsOverflow', () => { fireEvent.click(getByTestId('rules-details-popover-button-icon')); fireEvent.click(getByTestId('rules-details-duplicate-rule')); - expect(executeBulkAction).toHaveBeenCalledWith( - expect.objectContaining({ action: 'duplicate', search: { ids: ['id'] } }) - ); + expect(executeBulkAction).toHaveBeenCalledWith({ type: 'duplicate', ids: ['id'] }); }); }); @@ -213,7 +211,7 @@ describe('RuleActionsOverflow', () => { fireEvent.click(getByTestId('rules-details-popover-button-icon')); fireEvent.click(getByTestId('rules-details-delete-rule')); - expect(executeBulkAction).toHaveBeenCalledWith(expect.objectContaining({ action: 'delete' })); + expect(executeBulkAction).toHaveBeenCalledWith(expect.objectContaining({ type: 'delete' })); }); test('it calls deleteRulesAction with the rule.id when rules-details-delete-rule is clicked', () => { @@ -228,9 +226,7 @@ describe('RuleActionsOverflow', () => { fireEvent.click(getByTestId('rules-details-popover-button-icon')); fireEvent.click(getByTestId('rules-details-delete-rule')); - expect(executeBulkAction).toHaveBeenCalledWith( - expect.objectContaining({ action: 'delete', search: { ids: ['id'] } }) - ); + expect(executeBulkAction).toHaveBeenCalledWith({ type: 'delete', ids: ['id'] }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx index c2fc45d53e1d63..898666623c4957 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx @@ -12,11 +12,10 @@ import { EuiPopover, EuiToolTip, } from '@elastic/eui'; -import { noop } from 'lodash'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { APP_UI_ID, SecurityPageName } from '../../../../../common/constants'; -import { BulkAction } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import { getRulesUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useBoolState } from '../../../../common/hooks/use_bool_state'; @@ -65,7 +64,7 @@ const RuleActionsOverflowComponent = ({ const { navigateToApp } = useKibana().services.application; const toasts = useAppToasts(); const { startTransaction } = useStartTransaction(); - const { executeBulkAction } = useExecuteBulkAction(); + const { executeBulkAction } = useExecuteBulkAction({ suppressSuccessToast: true }); const { bulkExport } = useBulkExport(); const onRuleDeletedCallback = useCallback(() => { @@ -88,9 +87,8 @@ const RuleActionsOverflowComponent = ({ startTransaction({ name: SINGLE_RULE_ACTIONS.DUPLICATE }); closePopover(); const result = await executeBulkAction({ - action: BulkAction.duplicate, - onSuccess: noop, - search: { ids: [rule.id] }, + type: BulkActionType.duplicate, + ids: [rule.id], }); const createdRules = result?.attributes.results.created; if (createdRules?.length) { @@ -117,7 +115,7 @@ const RuleActionsOverflowComponent = ({ onClick={async () => { startTransaction({ name: SINGLE_RULE_ACTIONS.EXPORT }); closePopover(); - const response = await bulkExport({ search: { ids: [rule.id] } }); + const response = await bulkExport({ ids: [rule.id] }); if (response) { await downloadExportedRules({ response, @@ -137,10 +135,11 @@ const RuleActionsOverflowComponent = ({ startTransaction({ name: SINGLE_RULE_ACTIONS.DELETE }); closePopover(); await executeBulkAction({ - action: BulkAction.delete, - onSuccess: onRuleDeletedCallback, - search: { ids: [rule.id] }, + type: BulkActionType.delete, + ids: [rule.id], }); + + onRuleDeletedCallback(); }} > {i18nActions.DELETE_RULE} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx index bcd0e74d6ef9fd..32db7db2d50932 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx @@ -7,10 +7,9 @@ import type { EuiSwitchEvent } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSwitch } from '@elastic/eui'; -import { noop } from 'lodash'; import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { BulkAction } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import { SINGLE_RULE_ACTIONS } from '../../../../common/lib/apm/user_actions'; import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; import { useExecuteBulkAction } from '../../../../detection_engine/rule_management/logic/bulk_actions/use_execute_bulk_action'; @@ -46,7 +45,7 @@ export const RuleSwitchComponent = ({ const [myIsLoading, setMyIsLoading] = useState(false); const rulesTableContext = useRulesTableContextOptional(); const { startTransaction } = useStartTransaction(); - const { executeBulkAction } = useExecuteBulkAction(); + const { executeBulkAction } = useExecuteBulkAction({ suppressSuccessToast: !rulesTableContext }); const onRuleStateChange = useCallback( async (event: EuiSwitchEvent) => { @@ -55,11 +54,8 @@ export const RuleSwitchComponent = ({ name: enabled ? SINGLE_RULE_ACTIONS.DISABLE : SINGLE_RULE_ACTIONS.ENABLE, }); const bulkActionResponse = await executeBulkAction({ - setLoadingRules: rulesTableContext?.actions.setLoadingRules, - onSuccess: rulesTableContext ? undefined : noop, - action: event.target.checked ? BulkAction.enable : BulkAction.disable, - search: { ids: [id] }, - visibleRuleIds: [], + type: event.target.checked ? BulkActionType.enable : BulkActionType.disable, + ids: [id], }); if (bulkActionResponse?.attributes.results.updated.length) { // The rule was successfully updated @@ -67,7 +63,7 @@ export const RuleSwitchComponent = ({ } setMyIsLoading(false); }, - [enabled, executeBulkAction, id, onChange, rulesTableContext, startTransaction] + [enabled, executeBulkAction, id, onChange, startTransaction] ); const showLoader = useMemo((): boolean => { diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_file_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_file_action.test.tsx index c2ad573616e3fb..d6cb46de63698a 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_file_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_file_action.test.tsx @@ -14,15 +14,20 @@ import { ConsoleManagerTestComponent, getConsoleManagerMockRenderResultQueriesAndActions, } from '../../../console/components/console_manager/mocks'; -import { getEndpointConsoleCommands } from '../..'; +import { getEndpointConsoleCommands } from '../../lib/console_commands_definition'; import React from 'react'; import { enterConsoleCommand } from '../../../console/mocks'; import { waitFor } from '@testing-library/react'; import { GET_FILE_ROUTE } from '../../../../../../common/endpoint/constants'; import { getEndpointAuthzInitialStateMock } from '../../../../../../common/endpoint/service/authz/mocks'; -import type { EndpointPrivileges } from '../../../../../../common/endpoint/types'; +import type { + ActionDetailsApiResponse, + EndpointPrivileges, + ResponseActionGetFileOutputContent, +} from '../../../../../../common/endpoint/types'; import { INSUFFICIENT_PRIVILEGES_FOR_COMMAND } from '../../../../../common/translations'; import type { HttpFetchOptionsWithPath } from '@kbn/core-http-browser'; +import { endpointActionResponseCodes } from '../../lib/endpoint_action_response_codes'; jest.mock('../../../../../common/components/user_privileges'); @@ -155,4 +160,42 @@ describe('When using get-file action from response actions console', () => { ); }); }); + + it.each([ + 'ra_get-file_error_not-found', + 'ra_get-file_error_is-directory', + 'ra_get-file_error_invalid-input', + 'ra_get-file_error_not-permitted', + 'ra_get-file_error_too-big', + 'ra_get-file_error_disk-quota', + 'ra_get-file_error_processing', + 'ra_get-file_error_upload-api-unreachable', + 'ra_get-file_error_upload-timeout', + 'ra_get-file_error_queue-timeout', + ])('should show detailed error if get-file failure returned code: %s', async (outputCode) => { + const pendingDetailResponse = apiMocks.responseProvider.actionDetails({ + path: '/api/endpoint/action/a.b.c', + }) as ActionDetailsApiResponse; + pendingDetailResponse.data.agents = ['a.b.c']; + pendingDetailResponse.data.wasSuccessful = false; + pendingDetailResponse.data.errors = ['not found']; + pendingDetailResponse.data.outputs = { + 'a.b.c': { + type: 'json', + content: { + code: outputCode, + } as unknown as ResponseActionGetFileOutputContent, + }, + }; + apiMocks.responseProvider.actionDetails.mockReturnValue(pendingDetailResponse); + await render(); + enterConsoleCommand(renderResult, 'get-file --path one'); + + await waitFor(() => { + expect(renderResult.getByTestId('getFile-actionFailure').textContent).toMatch( + // RegExp below taken from: https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js + new RegExp(endpointActionResponseCodes[outputCode].replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')) + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/kill_process_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/kill_process_action.test.tsx index b6259e788224da..e65a8d8e751e47 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/kill_process_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/kill_process_action.test.tsx @@ -19,6 +19,11 @@ import { responseActionsHttpMocks } from '../../../../mocks/response_actions_htt import { getEndpointAuthzInitialState } from '../../../../../../common/endpoint/service/authz'; import type { EndpointCapabilities } from '../../../../../../common/endpoint/service/response_actions/constants'; import { ENDPOINT_CAPABILITIES } from '../../../../../../common/endpoint/service/response_actions/constants'; +import type { + ActionDetailsApiResponse, + KillProcessActionOutputContent, +} from '../../../../../../common/endpoint/types'; +import { endpointActionResponseCodes } from '../../lib/endpoint_action_response_codes'; describe('When using the kill-process action from response actions console', () => { let render: ( @@ -233,6 +238,35 @@ describe('When using the kill-process action from response actions console', () }); }); + it.each([['ra_kill-process_error_not-found'], ['ra_kill-process_error_not-permitted']])( + 'should show detailed error if kill-process failure returned code: %s', + async (outputCode) => { + const pendingDetailResponse = apiMocks.responseProvider.actionDetails({ + path: '/api/endpoint/action/a.b.c', + }) as ActionDetailsApiResponse; + pendingDetailResponse.data.agents = ['a.b.c']; + pendingDetailResponse.data.wasSuccessful = false; + pendingDetailResponse.data.errors = ['not found']; + pendingDetailResponse.data.outputs = { + 'a.b.c': { + type: 'json', + content: { + code: outputCode, + }, + }, + }; + apiMocks.responseProvider.actionDetails.mockReturnValue(pendingDetailResponse); + await render(); + enterConsoleCommand(renderResult, 'kill-process --pid 123'); + + await waitFor(() => { + expect(renderResult.getByTestId('killProcess-actionFailure').textContent).toMatch( + new RegExp(endpointActionResponseCodes[outputCode]) + ); + }); + } + ); + it('should show error if kill-process API fails', async () => { apiMocks.responseProvider.killProcess.mockRejectedValueOnce({ status: 500, diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/suspend_process_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/suspend_process_action.test.tsx index f234ec1e22800d..e02bbcb689c571 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/suspend_process_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/suspend_process_action.test.tsx @@ -19,6 +19,11 @@ import { responseActionsHttpMocks } from '../../../../mocks/response_actions_htt import { getEndpointAuthzInitialState } from '../../../../../../common/endpoint/service/authz'; import type { EndpointCapabilities } from '../../../../../../common/endpoint/service/response_actions/constants'; import { ENDPOINT_CAPABILITIES } from '../../../../../../common/endpoint/service/response_actions/constants'; +import type { + ActionDetailsApiResponse, + KillProcessActionOutputContent, +} from '../../../../../../common/endpoint/types'; +import { endpointActionResponseCodes } from '../../lib/endpoint_action_response_codes'; describe('When using the suspend-process action from response actions console', () => { let render: ( @@ -239,6 +244,35 @@ describe('When using the suspend-process action from response actions console', }); }); + it.each([['ra_suspend-process_error_not-found'], ['ra_suspend-process_error_not-permitted']])( + 'should show detailed error if suspend-process failure returned code: %s', + async (outputCode) => { + const pendingDetailResponse = apiMocks.responseProvider.actionDetails({ + path: '/api/endpoint/action/a.b.c', + }) as ActionDetailsApiResponse; + pendingDetailResponse.data.agents = ['a.b.c']; + pendingDetailResponse.data.wasSuccessful = false; + pendingDetailResponse.data.errors = ['not found']; + pendingDetailResponse.data.outputs = { + 'a.b.c': { + type: 'json', + content: { + code: outputCode, + }, + }, + }; + apiMocks.responseProvider.actionDetails.mockReturnValue(pendingDetailResponse); + await render(); + enterConsoleCommand(renderResult, 'suspend-process --pid 123'); + + await waitFor(() => { + expect(renderResult.getByTestId('suspendProcess-actionFailure').textContent).toMatch( + new RegExp(endpointActionResponseCodes[outputCode]) + ); + }); + } + ); + describe('and when console is closed (not terminated) and then reopened', () => { beforeEach(() => { const _render = render; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/endpoint_action_response_codes.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/endpoint_action_response_codes.ts index 6694601cd89303..746b9201cd2008 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/endpoint_action_response_codes.ts +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/endpoint_action_response_codes.ts @@ -8,6 +8,69 @@ import { i18n } from '@kbn/i18n'; const CODES = Object.freeze({ + // ----------------------------------------------------------------- + // GET-FILE CODES + // ----------------------------------------------------------------- + /** file not found */ + 'ra_get-file_error_not-found': i18n.translate( + 'xpack.securitySolution.endpointActionResponseCodes.getFile.notFound', + { defaultMessage: 'The file specified was not found' } + ), + + /** path is reachable but does not point to a file */ + 'ra_get-file_error_is-directory': i18n.translate( + 'xpack.securitySolution.endpointActionResponseCodes.getFile.isDirectory', + { defaultMessage: 'The path defined is not a file' } + ), + + /** path did not pass basic validation: malformed path, unix instead of windows, invalid characters, not full path, etc */ + 'ra_get-file_error_invalid-input': i18n.translate( + 'xpack.securitySolution.endpointActionResponseCodes.getFile.invalidPath', + { defaultMessage: 'The path defined is not valid' } + ), + + /** Maybe: possible to be able to list the file but not read it's content */ + 'ra_get-file_error_not-permitted': i18n.translate( + 'xpack.securitySolution.endpointActionResponseCodes.getFile.notPermitted', + { defaultMessage: 'Endpoint unable to read file requested (not permitted)' } + ), + + /** file size exceeds hard coded limit (100MB) */ + 'ra_get-file_error_too-big': i18n.translate( + 'xpack.securitySolution.endpointActionResponseCodes.getFile.tooBig', + { defaultMessage: 'The file requested is too large and can not be retrieved' } + ), + + /** Endpoint ran out of file upload queue size */ + 'ra_get-file_error_disk-quota': i18n.translate( + 'xpack.securitySolution.endpointActionResponseCodes.getFile.diskQuota', + { defaultMessage: 'Endpoint ran out of disk quota while attempting to retrieve file' } + ), + + /** Something interrupted preparing the zip: file read error, zip error */ + 'ra_get-file_error_processing': i18n.translate( + 'xpack.securitySolution.endpointActionResponseCodes.getFile.errorProcessing', + { defaultMessage: 'File retrieval was interrupted' } + ), + + /** The fleet upload API was unreachable (not just busy) */ + 'ra_get-file_error_upload-api-unreachable': i18n.translate( + 'xpack.securitySolution.endpointActionResponseCodes.getFile.uploadApiUnreachable', + { defaultMessage: 'File upload api (fleet-server) is unreachable' } + ), + + /** Perhaps internet connection was too slow or unstable to upload all chunks before unique upload-id expired. Endpoint will re-try a bit (3 times?). */ + 'ra_get-file_error_upload-timeout': i18n.translate( + 'xpack.securitySolution.endpointActionResponseCodes.getFile.uploadTimeout', + { defaultMessage: 'File upload timed out' } + ), + + /** Upload API could be busy, endpoint should periodically re-try */ + 'ra_get-file_error_queue-timeout': i18n.translate( + 'xpack.securitySolution.endpointActionResponseCodes.getFile.queueTimeout', + { defaultMessage: 'Endpoint timed out while attempting to connect to upload API' } + ), + // ----------------------------------------------------------------- // SUSPEND-PROCESS CODES // ----------------------------------------------------------------- diff --git a/x-pack/plugins/security_solution/public/management/components/page_overlay/page_overlay.test.tsx b/x-pack/plugins/security_solution/public/management/components/page_overlay/page_overlay.test.tsx index d508fa0cf7504b..38985824661065 100644 --- a/x-pack/plugins/security_solution/public/management/components/page_overlay/page_overlay.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/page_overlay/page_overlay.test.tsx @@ -5,24 +5,158 @@ * 2.0. */ +import React from 'react'; +import type { AppContextTestRender } from '../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import type { PageOverlayProps } from './page_overlay'; +import { + PAGE_OVERLAY_DOCUMENT_BODY_FULLSCREEN_CLASSNAME, + PAGE_OVERLAY_DOCUMENT_BODY_IS_VISIBLE_CLASSNAME, + PAGE_OVERLAY_DOCUMENT_BODY_LOCK_CLASSNAME, + PageOverlay, +} from './page_overlay'; +import { act, waitFor } from '@testing-library/react'; + describe('When using PageOverlay component', () => { - it.todo('should display the overlay using minimal props'); + let render: () => ReturnType; + let renderResult: ReturnType; + let reRender: () => ReturnType; + let renderProps: jest.Mocked; + let historyMock: AppContextTestRender['history']; + + beforeEach(() => { + const appTestContext = createAppRootMockRenderer(); + + historyMock = appTestContext.history; + + renderProps = { + children:
        {'page content here'}
        , + onHide: jest.fn(), + 'data-test-subj': 'test', + }; + + render = () => { + renderResult = appTestContext.render(); + return renderResult; + }; + + reRender = () => { + renderResult.rerender(); + return renderResult; + }; + + historyMock.push('/foo'); + }); + + it('should display the overlay using minimal props', () => { + render(); + + const overlay = renderResult.getByTestId('test'); + + expect(overlay.textContent).toEqual('page content here'); + expect(overlay.classList.contains('eui-scrollBar')).toBe(true); + expect(overlay.classList.contains('scrolling')).toBe(true); + expect(overlay.classList.contains('hidden')).toBe(false); + }); + + it('should set classname on `` when visible', () => { + render(); + + const bodyClasslist = window.document.body.classList; + + expect(bodyClasslist.contains(PAGE_OVERLAY_DOCUMENT_BODY_IS_VISIBLE_CLASSNAME)).toBe(true); + expect(bodyClasslist.contains(PAGE_OVERLAY_DOCUMENT_BODY_LOCK_CLASSNAME)).toBe(true); + expect(bodyClasslist.contains(PAGE_OVERLAY_DOCUMENT_BODY_FULLSCREEN_CLASSNAME)).toBe(false); + }); + + it('should all browser window scrolling when `lockDocumentBody` is `false`', () => { + renderProps.lockDocumentBody = false; + render(); + + expect(window.document.body.classList.contains(PAGE_OVERLAY_DOCUMENT_BODY_LOCK_CLASSNAME)).toBe( + false + ); + }); + + it('should remove all classnames from `` when hidden/unmounted', () => { + renderProps.isHidden = true; + render(); + + const bodyClasslist = window.document.body.classList; + + expect(bodyClasslist.contains(PAGE_OVERLAY_DOCUMENT_BODY_IS_VISIBLE_CLASSNAME)).toBe(false); + expect(bodyClasslist.contains(PAGE_OVERLAY_DOCUMENT_BODY_LOCK_CLASSNAME)).toBe(false); + }); + + it('should move the overlay to be the last child of `` if `appendAsBodyLastNode` prop is `true`', async () => { + renderProps.isHidden = true; + render(); + + expect(renderResult.getByTestId('test')).not.toBeVisible(); + + const myDiv = document.createElement('div'); + myDiv.classList.add('my-div'); + document.body.appendChild(myDiv); + + expect(document.body.querySelector('[data-euiportal]')!.nextElementSibling).toBe(myDiv); + + renderProps.isHidden = false; + reRender(); + + await waitFor(() => { + const portalEle = document.body.querySelector('[data-euiportal]')!; + + expect(portalEle.nextElementSibling).toBe(null); + expect(portalEle.previousElementSibling).toBe(myDiv); + }); + }); + + it('should call `onHide` when `hideOnUrlPathnameChange` is `true` and url changes', () => { + render(); + + expect(renderResult.getByTestId('test')).toBeVisible(); + + act(() => { + historyMock.push('/bar'); + }); + + expect(renderProps.onHide).toHaveBeenCalled(); + }); + + it('should NOT call `onHide` when `hideOnUrlPathnameChange` is `false` and url changes', () => { + renderProps.hideOnUrlPathnameChange = false; + render(); - it.todo('should call `onHide` callback when done button is clicked'); + expect(renderResult.getByTestId('test')).toBeVisible(); - it.todo('should set classname on `` when visible'); + act(() => { + historyMock.push('/bar'); + }); - it.todo('should prevent browser window scrolling when `lockDocumentBody` is `true`'); + expect(renderProps.onHide).not.toHaveBeenCalled(); + }); - it.todo('should remove all classnames from `` when hidden/unmounted'); + it('should disable content scrolling inside of overlay', () => { + renderProps.enableScrolling = false; + render(); - it.todo( - 'should move the overlay to be the last child of `` if `appendAsBodyLastNode` prop is `true`' - ); + const overlay = renderResult.getByTestId('test'); - it.todo('should call `onHide` when `hideOnUrlPathnameChange` is `true` and url changes'); + expect(overlay.classList.contains('eui-scrollBar')).toBe(false); + expect(overlay.classList.contains('scrolling')).toBe(false); + }); - it.todo('should NOT call `onHide` when `hideOnUrlPathnameChange` is `false` and url changes'); + it.each` + size | className + ${'xs'} | ${'padding-xs'} + ${'s'} | ${'padding-s'} + ${'m'} | ${'padding-m'} + ${'l'} | ${'padding-l'} + ${'xl'} | ${'padding-xl'} + `('should add padding class names when `paddingSize` of $size is used', ({ size, className }) => { + renderProps.paddingSize = size; + render(); - it.todo('should add padding class names when `paddingSize` prop is defined'); + expect(renderResult.getByTestId('test')).toHaveClass(className); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/components/page_overlay/page_overlay.tsx b/x-pack/plugins/security_solution/public/management/components/page_overlay/page_overlay.tsx index 2d3a9c9cb7f074..59e5286167bc5c 100644 --- a/x-pack/plugins/security_solution/public/management/components/page_overlay/page_overlay.tsx +++ b/x-pack/plugins/security_solution/public/management/components/page_overlay/page_overlay.tsx @@ -77,9 +77,9 @@ const OverlayRootContainer = styled.div` `; const PAGE_OVERLAY_CSS_CLASSNAME = 'securitySolution-pageOverlay'; -const PAGE_OVERLAY_DOCUMENT_BODY_IS_VISIBLE_CLASSNAME = `${PAGE_OVERLAY_CSS_CLASSNAME}-isVisible`; -const PAGE_OVERLAY_DOCUMENT_BODY_LOCK_CLASSNAME = `${PAGE_OVERLAY_CSS_CLASSNAME}-lock`; -const PAGE_OVERLAY_DOCUMENT_BODY_FULLSCREEN_CLASSNAME = `${PAGE_OVERLAY_CSS_CLASSNAME}-fullScreen`; +export const PAGE_OVERLAY_DOCUMENT_BODY_IS_VISIBLE_CLASSNAME = `${PAGE_OVERLAY_CSS_CLASSNAME}-isVisible`; +export const PAGE_OVERLAY_DOCUMENT_BODY_LOCK_CLASSNAME = `${PAGE_OVERLAY_CSS_CLASSNAME}-lock`; +export const PAGE_OVERLAY_DOCUMENT_BODY_FULLSCREEN_CLASSNAME = `${PAGE_OVERLAY_CSS_CLASSNAME}-fullScreen`; const PageOverlayGlobalStyles = createGlobalStyle<{ theme: EuiTheme }>` body.${PAGE_OVERLAY_DOCUMENT_BODY_LOCK_CLASSNAME} { @@ -154,7 +154,7 @@ export interface PageOverlayProps { isHidden?: boolean; /** - * Setting this to `true` (defualt) will enable scrolling inside of the overlay + * Setting this to `true` (default) will enable scrolling inside of the overlay */ enableScrolling?: boolean; @@ -194,7 +194,8 @@ export interface PageOverlayProps { /** * A generic component for taking over the entire Kibana UI main content area (everything below the - * top header that includes the breadcrumbs). + * top header that includes the breadcrumbs). This component adds nothing more than a blank page - its up + * to the `children` pass to actually display any type of intractable UI for the user. */ export const PageOverlay = memo( ({ diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.test.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.test.tsx index e4cabce7780b9c..cbd4e0c59b224a 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.test.tsx @@ -13,6 +13,7 @@ import { TestProviders } from '../../../../common/mock'; import { parsedVulnerableHostsAlertsResult } from './mock_data'; import type { UseHostAlertsItems } from './use_host_alerts_items'; import { HostAlertsTable } from './host_alerts_table'; +import { openAlertsFilter } from '../utils'; const mockGetAppUrl = jest.fn(); jest.mock('../../../../common/lib/kibana/hooks', () => { @@ -25,6 +26,15 @@ jest.mock('../../../../common/lib/kibana/hooks', () => { }; }); +const mockOpenTimelineWithFilters = jest.fn(); +jest.mock('../hooks/use_navigate_to_timeline', () => { + return { + useNavigateToTimeline: () => ({ + openTimelineWithFilters: mockOpenTimelineWithFilters, + }), + }; +}); + type UseHostAlertsItemsReturn = ReturnType; const defaultUseHostAlertsItemsReturn: UseHostAlertsItemsReturn = { items: [], @@ -124,4 +134,42 @@ describe('HostAlertsTable', () => { fireEvent.click(page3); expect(mockSetPage).toHaveBeenCalledWith(2); }); + + it('should open timeline with filters when total alerts is clicked', () => { + mockUseHostAlertsItemsReturn({ items: [parsedVulnerableHostsAlertsResult[0]] }); + const { getByTestId } = renderComponent(); + + fireEvent.click(getByTestId('hostSeverityAlertsTable-totalAlertsLink')); + + expect(mockOpenTimelineWithFilters).toHaveBeenCalledWith([ + [ + { + field: 'host.name', + value: 'Host-342m5gl1g2', + }, + openAlertsFilter, + ], + ]); + }); + + it('should open timeline with filters when critical alert count is clicked', () => { + mockUseHostAlertsItemsReturn({ items: [parsedVulnerableHostsAlertsResult[0]] }); + const { getByTestId } = renderComponent(); + + fireEvent.click(getByTestId('hostSeverityAlertsTable-criticalLink')); + + expect(mockOpenTimelineWithFilters).toHaveBeenCalledWith([ + [ + { + field: 'host.name', + value: 'Host-342m5gl1g2', + }, + openAlertsFilter, + { + field: 'kibana.alert.severity', + value: 'critical', + }, + ], + ]); + }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.tsx index 555a2d7be5b4d4..c090277fc17860 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.tsx @@ -28,7 +28,7 @@ import { HostDetailsLink } from '../../../../common/components/links'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; import { useNavigateToTimeline } from '../hooks/use_navigate_to_timeline'; import * as i18n from '../translations'; -import { ITEMS_PER_PAGE, SEVERITY_COLOR } from '../utils'; +import { ITEMS_PER_PAGE, openAlertsFilter, SEVERITY_COLOR } from '../utils'; import type { HostAlertsItem } from './use_host_alerts_items'; import { useHostAlertsItems } from './use_host_alerts_items'; @@ -53,7 +53,9 @@ export const HostAlertsTable = React.memo(({ signalIndexName }: HostAlertsTableP : undefined; openTimelineWithFilters( - severityFilter ? [[hostNameFilter, severityFilter]] : [[hostNameFilter]] + severityFilter + ? [[hostNameFilter, openAlertsFilter, severityFilter]] + : [[hostNameFilter, openAlertsFilter]] ); }, [openTimelineWithFilters] @@ -133,7 +135,11 @@ const getTableColumns: GetTableColumns = (handleClick) => [ name: i18n.ALERTS_TEXT, 'data-test-subj': 'hostSeverityAlertsTable-totalAlerts', render: (totalAlerts: number, { hostName }) => ( - handleClick({ hostName })}> + handleClick({ hostName })} + > ), @@ -144,6 +150,7 @@ const getTableColumns: GetTableColumns = (handleClick) => [ render: (count: number, { hostName }) => ( handleClick({ hostName, severity: 'critical' })} > diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.test.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.test.tsx index 0776b69e96b276..fca9b4cd8e479a 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.test.tsx @@ -8,13 +8,14 @@ import moment from 'moment'; import React from 'react'; -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; import { SecurityPageName } from '../../../../../common/constants'; import { TestProviders } from '../../../../common/mock'; import type { RuleAlertsTableProps } from './rule_alerts_table'; import { RuleAlertsTable } from './rule_alerts_table'; import type { RuleAlertsItem, UseRuleAlertsItems } from './use_rule_alerts_items'; +import { openAlertsFilter } from '../utils'; const mockGetAppUrl = jest.fn(); jest.mock('../../../../common/lib/kibana/hooks', () => { @@ -27,6 +28,15 @@ jest.mock('../../../../common/lib/kibana/hooks', () => { }; }); +const mockOpenTimelineWithFilters = jest.fn(); +jest.mock('../hooks/use_navigate_to_timeline', () => { + return { + useNavigateToTimeline: () => ({ + openTimelineWithFilters: mockOpenTimelineWithFilters, + }), + }; +}); + type UseRuleAlertsItemsReturn = ReturnType; const defaultUseRuleAlertsItemsReturn: UseRuleAlertsItemsReturn = { items: [], @@ -44,10 +54,11 @@ jest.mock('./use_rule_alerts_items', () => ({ const defaultProps: RuleAlertsTableProps = { signalIndexName: '', }; +const ruleName = 'ruleName'; const items: RuleAlertsItem[] = [ { id: 'ruleId', - name: 'ruleName', + name: ruleName, last_alert_at: moment().subtract(1, 'day').format(), alert_count: 10, severity: 'high', @@ -144,4 +155,25 @@ describe('RuleAlertsTable', () => { expect(result.getByTestId('severityRuleAlertsTable-name')).toHaveAttribute('href', linkUrl); }); + + it('should open timeline with filters when total alerts is clicked', () => { + mockUseRuleAlertsItemsReturn({ items }); + const { getByTestId } = render( + + + + ); + + fireEvent.click(getByTestId('severityRuleAlertsTable-alertCountLink')); + + expect(mockOpenTimelineWithFilters).toHaveBeenCalledWith([ + [ + { + field: 'kibana.alert.rule.name', + value: ruleName, + }, + openAlertsFilter, + ], + ]); + }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx index e9ec906070f747..82ec2837e77b75 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx @@ -22,7 +22,7 @@ import { FormattedRelative } from '@kbn/i18n-react'; import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; import { HeaderSection } from '../../../../common/components/header_section'; -import { SEVERITY_COLOR } from '../utils'; +import { openAlertsFilter, SEVERITY_COLOR } from '../utils'; import * as i18n from '../translations'; import type { RuleAlertsItem } from './use_rule_alerts_items'; import { useRuleAlertsItems } from './use_rule_alerts_items'; @@ -90,7 +90,11 @@ export const getTableColumns: GetTableColumns = ({ getAppUrl, navigateTo, openRu name: i18n.RULE_ALERTS_COLUMN_ALERT_COUNT, 'data-test-subj': 'severityRuleAlertsTable-alertCount', render: (alertCount: number, { name }) => ( - openRuleInTimeline(name)}> + openRuleInTimeline(name)} + > ), @@ -118,7 +122,9 @@ export const RuleAlertsTable = React.memo(({ signalIndexNa const openRuleInTimeline = useCallback( (ruleName: string) => { - openTimelineWithFilters([[{ field: 'kibana.alert.rule.name', value: ruleName }]]); + openTimelineWithFilters([ + [{ field: 'kibana.alert.rule.name', value: ruleName }, openAlertsFilter], + ]); }, [openTimelineWithFilters] ); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.test.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.test.tsx index 9b6ed807e34e6b..1d7cb38b864acc 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.test.tsx @@ -13,7 +13,9 @@ import { TestProviders } from '../../../../common/mock'; import { parsedVulnerableUserAlertsResult } from './mock_data'; import type { UseUserAlertsItems } from './use_user_alerts_items'; import { UserAlertsTable } from './user_alerts_table'; +import { openAlertsFilter } from '../utils'; +const userName = 'crffn20qcs'; const mockGetAppUrl = jest.fn(); jest.mock('../../../../common/lib/kibana/hooks', () => { const original = jest.requireActual('../../../../common/lib/kibana/hooks'); @@ -25,6 +27,15 @@ jest.mock('../../../../common/lib/kibana/hooks', () => { }; }); +const mockOpenTimelineWithFilters = jest.fn(); +jest.mock('../hooks/use_navigate_to_timeline', () => { + return { + useNavigateToTimeline: () => ({ + openTimelineWithFilters: mockOpenTimelineWithFilters, + }), + }; +}); + type UseUserAlertsItemsReturn = ReturnType; const defaultUseUserAlertsItemsReturn: UseUserAlertsItemsReturn = { items: [], @@ -98,7 +109,7 @@ describe('UserAlertsTable', () => { mockUseUserAlertsItemsReturn({ items: [parsedVulnerableUserAlertsResult[0]] }); const { queryByTestId } = renderComponent(); - expect(queryByTestId('userSeverityAlertsTable-userName')).toHaveTextContent('crffn20qcs'); + expect(queryByTestId('userSeverityAlertsTable-userName')).toHaveTextContent(userName); expect(queryByTestId('userSeverityAlertsTable-totalAlerts')).toHaveTextContent('4'); expect(queryByTestId('userSeverityAlertsTable-critical')).toHaveTextContent('4'); expect(queryByTestId('userSeverityAlertsTable-high')).toHaveTextContent('1'); @@ -124,4 +135,42 @@ describe('UserAlertsTable', () => { fireEvent.click(page3); expect(mockSetPage).toHaveBeenCalledWith(2); }); + + it('should open timeline with filters when total alerts is clicked', () => { + mockUseUserAlertsItemsReturn({ items: [parsedVulnerableUserAlertsResult[0]] }); + const { getByTestId } = renderComponent(); + + fireEvent.click(getByTestId('userSeverityAlertsTable-totalAlertsLink')); + + expect(mockOpenTimelineWithFilters).toHaveBeenCalledWith([ + [ + { + field: 'user.name', + value: userName, + }, + openAlertsFilter, + ], + ]); + }); + + it('should open timeline with filters when critical alerts link is clicked', () => { + mockUseUserAlertsItemsReturn({ items: [parsedVulnerableUserAlertsResult[0]] }); + const { getByTestId } = renderComponent(); + + fireEvent.click(getByTestId('userSeverityAlertsTable-criticalLink')); + + expect(mockOpenTimelineWithFilters).toHaveBeenCalledWith([ + [ + { + field: 'user.name', + value: userName, + }, + openAlertsFilter, + { + field: 'kibana.alert.severity', + value: 'critical', + }, + ], + ]); + }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.tsx index c1a7ad07912219..e4a2c29caa475a 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.tsx @@ -28,7 +28,7 @@ import { UserDetailsLink } from '../../../../common/components/links'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; import { useNavigateToTimeline } from '../hooks/use_navigate_to_timeline'; import * as i18n from '../translations'; -import { ITEMS_PER_PAGE, SEVERITY_COLOR } from '../utils'; +import { ITEMS_PER_PAGE, openAlertsFilter, SEVERITY_COLOR } from '../utils'; import type { UserAlertsItem } from './use_user_alerts_items'; import { useUserAlertsItems } from './use_user_alerts_items'; @@ -53,7 +53,9 @@ export const UserAlertsTable = React.memo(({ signalIndexName }: UserAlertsTableP : undefined; openTimelineWithFilters( - severityFilter ? [[userNameFilter, severityFilter]] : [[userNameFilter]] + severityFilter + ? [[userNameFilter, openAlertsFilter, severityFilter]] + : [[userNameFilter, openAlertsFilter]] ); }, [openTimelineWithFilters] @@ -132,7 +134,11 @@ const getTableColumns: GetTableColumns = (handleClick) => [ name: i18n.ALERTS_TEXT, 'data-test-subj': 'userSeverityAlertsTable-totalAlerts', render: (totalAlerts: number, { userName }) => ( - handleClick({ userName })}> + handleClick({ userName })} + > ), @@ -143,6 +149,7 @@ const getTableColumns: GetTableColumns = (handleClick) => [ render: (count: number, { userName }) => ( handleClick({ userName, severity: 'critical' })} > diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/utils.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/utils.tsx index 76b690e0fbf0a9..b82108c50e0e2f 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/utils.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/utils.tsx @@ -21,3 +21,5 @@ const MAX_ALLOWED_RESULTS = 100; * */ export const getPageCount = (count: number = 0) => Math.ceil(Math.min(count || 0, MAX_ALLOWED_RESULTS) / ITEMS_PER_PAGE); + +export const openAlertsFilter = { field: 'kibana.alert.workflow_status', value: 'open' }; diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/index.test.tsx index 70faf174ae32f9..2222fbebf2dfab 100644 --- a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/index.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../../common/mock'; import { EntityAnalyticsRiskScores } from '.'; @@ -13,6 +13,7 @@ import type { UserRiskScore } from '../../../../../common/search_strategy'; import { RiskScoreEntity, RiskSeverity } from '../../../../../common/search_strategy'; import type { SeverityCount } from '../../../../common/components/severity/types'; import { useRiskScore, useRiskScoreKpi } from '../../../../risk_score/containers'; +import { openAlertsFilter } from '../../detection_response/utils'; const mockSeverityCount: SeverityCount = { [RiskSeverity.low]: 1, @@ -42,6 +43,15 @@ const mockUseRiskScore = useRiskScore as jest.Mock; const mockUseRiskScoreKpi = useRiskScoreKpi as jest.Mock; jest.mock('../../../../risk_score/containers'); +const mockOpenTimelineWithFilters = jest.fn(); +jest.mock('../../detection_response/hooks/use_navigate_to_timeline', () => { + return { + useNavigateToTimeline: () => ({ + openTimelineWithFilters: mockOpenTimelineWithFilters, + }), + }; +}); + describe.each([RiskScoreEntity.host, RiskScoreEntity.user])( 'EntityAnalyticsRiskScores entityType: %s', (riskEntity) => { @@ -150,5 +160,48 @@ describe.each([RiskScoreEntity.host, RiskScoreEntity.user])( expect(queryByTestId('risk-score-alerts')).toHaveTextContent(alertsCount.toString()); }); + + it('navigates to timeline with filters when alerts count is clicked', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseRiskScoreKpi.mockReturnValue({ + severityCount: mockSeverityCount, + loading: false, + }); + const name = 'testName'; + const data = [ + { + '@timestamp': '1234567899', + [riskEntity]: { + name, + risk: { + rule_risks: [], + calculated_level: RiskSeverity.high, + calculated_score_norm: 75, + multipliers: [], + }, + }, + alertsCount: 999, + }, + ]; + mockUseRiskScore.mockReturnValue({ ...defaultProps, data }); + + const { getByTestId } = render( + + + + ); + + fireEvent.click(getByTestId('risk-score-alerts')); + + expect(mockOpenTimelineWithFilters.mock.calls[0][0]).toEqual([ + [ + { + field: riskEntity === RiskScoreEntity.host ? 'host.name' : 'user.name', + value: name, + }, + openAlertsFilter, + ], + ]); + }); } ); diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/index.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/index.tsx index 40306e24fb423b..f3410e7e7b4b11 100644 --- a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/index.tsx @@ -42,6 +42,7 @@ import * as commonI18n from '../common/translations'; import { usersActions } from '../../../../users/store'; import { useNavigateToTimeline } from '../../detection_response/hooks/use_navigate_to_timeline'; import type { TimeRange } from '../../../../common/store/inputs/model'; +import { openAlertsFilter } from '../../detection_response/utils'; const HOST_RISK_TABLE_QUERY_ID = 'hostRiskDashboardTable'; const HOST_RISK_KPI_QUERY_ID = 'headerHostRiskScoreKpiQuery'; @@ -110,7 +111,7 @@ const EntityAnalyticsRiskScoresComponent = ({ riskEntity }: { riskEntity: RiskSc field: riskEntity === RiskScoreEntity.host ? 'host.name' : 'user.name', value: entityName, }; - openTimelineWithFilters([[filter]], timeRange); + openTimelineWithFilters([[filter, openAlertsFilter]], timeRange); }, [riskEntity, openTimelineWithFilters] ); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts index ddbdd660efb528..c4113f0132c053 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts @@ -133,6 +133,12 @@ export const sendEndpointActionResponse = async ( endpointResponse.error = { message: 'Endpoint encountered an error and was unable to apply action to host', }; + + if (endpointResponse.EndpointActions.data.command === 'get-file') { + ( + endpointResponse.EndpointActions.data.output?.content as ResponseActionGetFileOutputContent + ).code = endpointActionGenerator.randomGetFileFailureCode(); + } } await esClient.index({ diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts index 8624bc375cd9fa..545c4f31c36503 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts @@ -66,6 +66,7 @@ import { import { legacyMetadataSearchResponseMock } from '../metadata/support/test_support'; import { registerResponseActionRoutes } from './response_actions'; import * as ActionDetailsService from '../../services/actions/action_details_by_id'; +import { CaseStatuses } from '@kbn/cases-components'; interface CallRouteInterface { body?: ResponseActionRequestBody; @@ -694,6 +695,13 @@ describe('Response actions', () => { { id: `case-${counter++}`, title: 'case', + createdAt: '2022-10-31T11:49:48.806Z', + description: 'a description', + status: CaseStatuses.open, + totals: { + userComments: 1, + alerts: 1, + }, }, ]; }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts index 67a9a233eb807f..c3bd79322c3182 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts @@ -22,7 +22,7 @@ import { RULES_TABLE_MAX_PAGE_SIZE, } from '../../../../../../../common/constants'; import { - BulkAction, + BulkActionType, PerformBulkActionRequestBody, PerformBulkActionRequestQuery, } from '../../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; @@ -298,7 +298,7 @@ export const performBulkActionRoute = ( const isDryRun = request.query.dry_run === 'true'; // dry run is not supported for export, as it doesn't change ES state and has different response format(exported JSON file) - if (isDryRun && body.action === BulkAction.export) { + if (isDryRun && body.action === BulkActionType.export) { return siemResponse.error({ body: `Export action doesn't support dry_run mode`, statusCode: 400, @@ -335,7 +335,7 @@ export const performBulkActionRoute = ( // handling this action before switch statement as bulkEditRules fetch rules within // rulesClient method, hence there is no need to use fetchRulesByQueryOrIds utility - if (body.action === BulkAction.edit && !isDryRun) { + if (body.action === BulkActionType.edit && !isDryRun) { const { rules, errors } = await bulkEditRules({ rulesClient, filter: query, @@ -385,7 +385,7 @@ export const performBulkActionRoute = ( let deleted: RuleAlertType[] = []; switch (body.action) { - case BulkAction.enable: + case BulkActionType.enable: bulkActionOutcome = await initPromisePool({ concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, items: rules, @@ -418,7 +418,7 @@ export const performBulkActionRoute = ( .map(({ result }) => result) .filter((rule): rule is RuleAlertType => rule !== null); break; - case BulkAction.disable: + case BulkActionType.disable: bulkActionOutcome = await initPromisePool({ concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, items: rules, @@ -452,7 +452,7 @@ export const performBulkActionRoute = ( .filter((rule): rule is RuleAlertType => rule !== null); break; - case BulkAction.delete: + case BulkActionType.delete: bulkActionOutcome = await initPromisePool({ concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, items: rules, @@ -483,7 +483,7 @@ export const performBulkActionRoute = ( .filter((rule): rule is RuleAlertType => rule !== null); break; - case BulkAction.duplicate: + case BulkActionType.duplicate: bulkActionOutcome = await initPromisePool({ concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, items: rules, @@ -514,7 +514,7 @@ export const performBulkActionRoute = ( .filter((rule): rule is RuleAlertType => rule !== null); break; - case BulkAction.export: + case BulkActionType.export: const exported = await getExportByObjectIds( rulesClient, exceptionsClient, @@ -535,7 +535,7 @@ export const performBulkActionRoute = ( // will be processed only when isDryRun === true // during dry run only validation is getting performed and rule is not saved in ES - case BulkAction.edit: + case BulkActionType.edit: bulkActionOutcome = await initPromisePool({ concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, items: rules, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/components/color_palette.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/components/color_palette.tsx new file mode 100644 index 00000000000000..bafd0de5aa48a9 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/components/color_palette.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiLoadingContent } from '@elastic/eui'; +import { useTheme } from '@kbn/observability-plugin/public'; +import styled from 'styled-components'; +import { colourPalette } from './network_waterfall/step_detail/waterfall/data_formatting'; +export const ColorPalette = ({ + label, + mimeType, + percent, + value, + loading, +}: { + label: string; + mimeType: string; + percent: number; + value: string; + loading: boolean; +}) => { + return ( + + + {label} + + + + + + + {value} + + + + ); +}; + +export const ColorPaletteFlexItem = ({ + mimeType, + percent, + loading, +}: { + mimeType: string; + percent: number; + loading: boolean; +}) => { + const { eui } = useTheme(); + + const [value, setVal] = useState(0); + + useEffect(() => { + setTimeout(() => { + if (value < percent) { + setVal(value + 1); + } + }, 10); + }, [percent, value]); + + if (loading) { + return ; + } + + return ( + + + )[mimeType], + height: 20, + width: `${value}%`, + }} + /> + + + ); +}; + +const LoadingLine = styled(EuiLoadingContent)` + &&& { + > span { + height: 20px; + } + } +`; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/components/object_weight_list.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/components/object_weight_list.tsx new file mode 100644 index 00000000000000..6fd10b65451969 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/components/object_weight_list.tsx @@ -0,0 +1,57 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { ColorPalette } from './color_palette'; +import { useObjectMetrics } from '../hooks/use_object_metrics'; + +export const ObjectWeightList = () => { + const objectMetrics = useObjectMetrics(); + + return ( + <> + + + +

        {OBJECT_WEIGHT_LABEL}

        +
        +
        + + + {TOTAL_SIZE_LABEL}:{' '} + {objectMetrics.totalObjectsWeight} + + +
        + +
        + {objectMetrics.items.map(({ label, mimeType, weightPercent, weight }) => ( + <> + + {' '} + + ))} +
        + + ); +}; + +const OBJECT_WEIGHT_LABEL = i18n.translate('xpack.synthetics.stepDetails.objectWeight', { + defaultMessage: 'Object weight', +}); + +const TOTAL_SIZE_LABEL = i18n.translate('xpack.synthetics.stepDetails.totalSize', { + defaultMessage: 'Total size', +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/hooks/use_object_metrics.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/hooks/use_object_metrics.ts new file mode 100644 index 00000000000000..c80d57af307665 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/hooks/use_object_metrics.ts @@ -0,0 +1,66 @@ +/* + * 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 { useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; +import { MIME_FILTERS } from '../components/network_waterfall/step_detail/waterfall/waterfall_filter'; +import { + MimeType, + MimeTypesMap, +} from '../components/network_waterfall/step_detail/waterfall/types'; +import { networkEventsSelector } from '../../../state/network_events/selectors'; + +export const useObjectMetrics = () => { + const { checkGroupId, stepIndex } = useParams<{ checkGroupId: string; stepIndex: string }>(); + + const _networkEvents = useSelector(networkEventsSelector); + const networkEvents = _networkEvents[checkGroupId ?? '']?.[Number(stepIndex)]; + + const objectTypeCounts: Record = {}; + const objectTypeWeights: Record = {}; + + networkEvents?.events.forEach((event) => { + if (event.mimeType) { + objectTypeCounts[MimeTypesMap[event.mimeType] ?? MimeType.Other] = + (objectTypeCounts[MimeTypesMap[event.mimeType] ?? MimeType.Other] ?? 0) + 1; + objectTypeWeights[MimeTypesMap[event.mimeType] ?? MimeType.Other] = + (objectTypeWeights[MimeTypesMap[event.mimeType] ?? MimeType.Other] ?? 0) + + (event.transferSize || 0); + } + }); + + const totalObjects = Object.values(objectTypeCounts).reduce((acc, val) => acc + val, 0); + + const totalObjectsWeight = Object.values(objectTypeWeights).reduce((acc, val) => acc + val, 0); + + return { + loading: networkEvents?.loading ?? true, + totalObjects, + totalObjectsWeight: formatBytes(totalObjectsWeight), + items: MIME_FILTERS.map(({ label, mimeType }) => ({ + label, + count: objectTypeCounts[mimeType] ?? 0, + total: totalObjects, + mimeType, + percent: ((objectTypeCounts[mimeType] ?? 0) / totalObjects) * 100, + weight: formatBytes(objectTypeWeights[mimeType] ?? 0), + weightPercent: ((objectTypeWeights[mimeType] ?? 0) / totalObjectsWeight) * 100, + })), + }; +}; + +const formatBytes = (bytes: number, decimals = 0) => { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/step_detail_page.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/step_detail_page.tsx index 52af4964ee7168..f7e2752dcfd9d7 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/step_detail_page.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/step_detail_page.tsx @@ -14,11 +14,14 @@ import { EuiHorizontalRule, EuiPanel, EuiLoadingSpinner, + EuiSpacer, } from '@elastic/eui'; import { WaterfallChartContainer } from './components/network_waterfall/step_detail/waterfall/waterfall_chart_container'; +import { ObjectWeightList } from './components/object_weight_list'; import { StepImage } from './components/step_image'; import { useJourneySteps } from '../monitor_details/hooks/use_journey_steps'; import { MonitorDetailsLinkPortal } from '../monitor_add_edit/monitor_details_portal'; + import { useStepDetailsBreadcrumbs } from './hooks/use_step_details_breadcrumbs'; export const StepDetailPage = () => { @@ -51,9 +54,9 @@ export const StepDetailPage = () => { name={data.details.journey.monitor.name!} /> )} - + - + {data?.details?.journey && currentStep && ( { - + {/* TODO: Add breakdown of network timings donut*/} @@ -77,15 +80,18 @@ export const StepDetailPage = () => { - + + - {/* TODO: Add step metrics*/} + + {/* TODO: Add step metrics*/}{' '} + - + - - {/* TODO: Add breakdown of object list*/} + + {/* TODO: Add breakdown of object weight*/} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 087c56fab47b92..faacde782d5091 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -11678,7 +11678,7 @@ "xpack.enterpriseSearch.nav.contentTitle": "Contenu", "xpack.enterpriseSearch.nav.elasticsearchTitle": "Elasticsearch", "xpack.enterpriseSearch.nav.enterpriseSearchOverviewTitle": "Aperçu", - "xpack.enterpriseSearch.nav.searchExperiencesTitle": "Recherche", + "xpack.enterpriseSearch.nav.searchTitle": "Recherche", "xpack.enterpriseSearch.nav.searchIndicesTitle": "Index", "xpack.enterpriseSearch.nav.workplaceSearchTitle": "Workplace Search", "xpack.enterpriseSearch.notFound.action1": "Retour à votre tableau de bord", @@ -17250,7 +17250,6 @@ "xpack.lens.indexPattern.timeShiftSmallWarning": "{label} utilise un décalage temporel de {columnTimeShift} qui est inférieur à l'intervalle de l'histogramme des dates de {interval}. Pour éviter une non-correspondance des données, utilisez un multiple de {interval} comme décalage.", "xpack.lens.indexPattern.uniqueLabel": "{label} [{num}]", "xpack.lens.indexPattern.valueCountOf": "Nombre de {name}", - "xpack.lens.indexPatterns.fieldSearchLiveRegion": "{availableFields} {availableFields, plural, one {champ} other {champs}} disponible(s). {emptyFields} {emptyFields, plural, one {champ} other {champs}} vide(s). {metaFields} {metaFields, plural, one {champ} other {champs}} méta.", "xpack.lens.indexPatternSuggestion.removeLayerLabel": "Afficher uniquement {indexPatternTitle}", "xpack.lens.indexPatternSuggestion.removeLayerPositionLabel": "Afficher uniquement le calque {layerNumber}", "xpack.lens.modalTitle.title.clear": "Effacer le calque {layerType} ?", @@ -17558,7 +17557,6 @@ "xpack.lens.heatmapVisualization.missingXAccessorLongMessage": "La configuration de l'axe horizontal est manquante.", "xpack.lens.heatmapVisualization.missingXAccessorShortMessage": "Axe horizontal manquant.", "xpack.lens.indexPattern.advancedSettings": "Avancé", - "xpack.lens.indexPattern.allFieldsLabel": "Tous les champs", "xpack.lens.indexPattern.allFieldsLabelHelp": "Glissez-déposez les champs disponibles dans l’espace de travail et créez des visualisations. Pour modifier les champs disponibles, sélectionnez une vue de données différente, modifiez vos requêtes ou utilisez une plage temporelle différente. Certains types de champ ne peuvent pas être visualisés dans Lens, y compris les champ de texte intégral et champs géographiques.", "xpack.lens.indexPattern.allFieldsSamplingLabelHelp": "Les champs disponibles contiennent les données des 500 premiers documents correspondant aux filtres. Pour afficher tous les filtres, développez les champs vides. Vous ne pouvez pas créer de visualisations avec des champs de texte intégral, géographiques, lissés et d’objet.", "xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning.link": "veuillez consulter la documentation", @@ -17605,12 +17603,7 @@ "xpack.lens.indexPattern.differences.signature": "indicateur : nombre", "xpack.lens.indexPattern.emptyDimensionButton": "Dimension vide", "xpack.lens.indexPattern.emptyFieldsLabel": "Champs vides", - "xpack.lens.indexPattern.emptyFieldsLabelHelp": "Les champs vides ne contenaient aucune valeur dans les 500 premiers documents basés sur vos filtres.", "xpack.lens.indexPattern.enableAccuracyMode": "Activer le mode de précision", - "xpack.lens.indexPattern.existenceErrorAriaLabel": "La récupération de l'existence a échoué", - "xpack.lens.indexPattern.existenceErrorLabel": "Impossible de charger les informations de champ", - "xpack.lens.indexPattern.existenceTimeoutAriaLabel": "La récupération de l'existence a expiré", - "xpack.lens.indexPattern.existenceTimeoutLabel": "Les informations de champ ont pris trop de temps", "xpack.lens.indexPattern.fieldItemTooltip": "Effectuez un glisser-déposer pour visualiser.", "xpack.lens.indexPattern.fieldPlaceholder": "Champ", "xpack.lens.indexPattern.fieldStatsButtonEmptyLabel": "Ce champ ne comporte aucune donnée mais vous pouvez toujours effectuer un glisser-déposer pour visualiser.", @@ -17788,16 +17781,6 @@ "xpack.lens.indexPattern.useAsTopLevelAgg": "Regrouper d'abord en fonction de ce champ", "xpack.lens.indexPatterns.clearFiltersLabel": "Effacer le nom et saisissez les filtres", "xpack.lens.indexPatterns.filterByNameLabel": "Rechercher les noms de champs", - "xpack.lens.indexPatterns.noAvailableDataLabel": "Aucun champ disponible ne contient de données.", - "xpack.lens.indexPatterns.noDataLabel": "Aucun champ.", - "xpack.lens.indexPatterns.noEmptyDataLabel": "Aucun champ vide.", - "xpack.lens.indexPatterns.noFields.extendTimeBullet": "Extension de la plage temporelle", - "xpack.lens.indexPatterns.noFields.fieldTypeFilterBullet": "Utilisation de différents filtres de champ", - "xpack.lens.indexPatterns.noFields.globalFiltersBullet": "Modification des filtres globaux", - "xpack.lens.indexPatterns.noFields.tryText": "Essayer :", - "xpack.lens.indexPatterns.noFieldsLabel": "Aucun champ n'existe dans cette vue de données.", - "xpack.lens.indexPatterns.noFilteredFieldsLabel": "Aucun champ ne correspond aux filtres sélectionnés.", - "xpack.lens.indexPatterns.noMetaDataLabel": "Aucun champ méta.", "xpack.lens.label.gauge.labelMajor.header": "Titre", "xpack.lens.label.gauge.labelMinor.header": "Sous-titre", "xpack.lens.label.header": "Étiquette", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f4c281167d8b6c..59e637a55f71c0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11664,7 +11664,7 @@ "xpack.enterpriseSearch.nav.contentTitle": "コンテンツ", "xpack.enterpriseSearch.nav.elasticsearchTitle": "Elasticsearch", "xpack.enterpriseSearch.nav.enterpriseSearchOverviewTitle": "概要", - "xpack.enterpriseSearch.nav.searchExperiencesTitle": "検索", + "xpack.enterpriseSearch.nav.searchTitle": "検索", "xpack.enterpriseSearch.nav.searchIndicesTitle": "インデックス", "xpack.enterpriseSearch.nav.workplaceSearchTitle": "Workplace Search", "xpack.enterpriseSearch.notFound.action1": "ダッシュボードに戻す", @@ -17231,7 +17231,6 @@ "xpack.lens.indexPattern.timeShiftSmallWarning": "{label}は{columnTimeShift}の時間シフトを使用しています。これは{interval}の日付ヒストグラム間隔よりも小さいです。不一致のデータを防止するには、時間シフトとして{interval}を使用します。", "xpack.lens.indexPattern.uniqueLabel": "{label} [{num}]", "xpack.lens.indexPattern.valueCountOf": "{name}のカウント", - "xpack.lens.indexPatterns.fieldSearchLiveRegion": "{availableFields}使用可能な{availableFields, plural, other {フィールド}}。{emptyFields}空の{emptyFields, plural, other {フィールド}}。 {metaFields}メタ{metaFields, plural, other {フィールド}}。", "xpack.lens.indexPatternSuggestion.removeLayerLabel": "{indexPatternTitle}のみを表示", "xpack.lens.indexPatternSuggestion.removeLayerPositionLabel": "レイヤー{layerNumber}のみを表示", "xpack.lens.modalTitle.title.clear": "{layerType}レイヤーをクリアしますか?", @@ -17541,7 +17540,6 @@ "xpack.lens.heatmapVisualization.missingXAccessorLongMessage": "横軸の構成がありません。", "xpack.lens.heatmapVisualization.missingXAccessorShortMessage": "横軸がありません。", "xpack.lens.indexPattern.advancedSettings": "高度な設定", - "xpack.lens.indexPattern.allFieldsLabel": "すべてのフィールド", "xpack.lens.indexPattern.allFieldsLabelHelp": "使用可能なフィールドをワークスペースまでドラッグし、ビジュアライゼーションを作成します。使用可能なフィールドを変更するには、別のデータビューを選択するか、クエリを編集するか、別の時間範囲を使用します。一部のフィールドタイプは、完全なテキストおよびグラフィックフィールドを含む Lens では、ビジュアライゼーションできません。", "xpack.lens.indexPattern.allFieldsSamplingLabelHelp": "使用可能なフィールドには、フィルターと一致する最初の 500 件のドキュメントのデータがあります。すべてのフィールドを表示するには、空のフィールドを展開します。全文、地理、フラット化、オブジェクトフィールドでビジュアライゼーションを作成できません。", "xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning.link": "ドキュメントをご覧ください", @@ -17588,12 +17586,7 @@ "xpack.lens.indexPattern.differences.signature": "メトリック:数値", "xpack.lens.indexPattern.emptyDimensionButton": "空のディメンション", "xpack.lens.indexPattern.emptyFieldsLabel": "空のフィールド", - "xpack.lens.indexPattern.emptyFieldsLabelHelp": "空のフィールドには、フィルターに基づく最初の 500 件のドキュメントの値が含まれていませんでした。", "xpack.lens.indexPattern.enableAccuracyMode": "精度モードを有効にする", - "xpack.lens.indexPattern.existenceErrorAriaLabel": "存在の取り込みに失敗しました", - "xpack.lens.indexPattern.existenceErrorLabel": "フィールド情報を読み込めません", - "xpack.lens.indexPattern.existenceTimeoutAriaLabel": "存在の取り込みがタイムアウトしました", - "xpack.lens.indexPattern.existenceTimeoutLabel": "フィールド情報に時間がかかりすぎました", "xpack.lens.indexPattern.fieldItemTooltip": "可視化するには、ドラッグアンドドロップします。", "xpack.lens.indexPattern.fieldPlaceholder": "フィールド", "xpack.lens.indexPattern.fieldStatsButtonEmptyLabel": "このフィールドにはデータがありませんが、ドラッグアンドドロップで可視化できます。", @@ -17771,16 +17764,6 @@ "xpack.lens.indexPattern.useAsTopLevelAgg": "最初にこのフィールドでグループ化", "xpack.lens.indexPatterns.clearFiltersLabel": "名前とタイプフィルターを消去", "xpack.lens.indexPatterns.filterByNameLabel": "検索フィールド名", - "xpack.lens.indexPatterns.noAvailableDataLabel": "データを含むフィールドはありません。", - "xpack.lens.indexPatterns.noDataLabel": "フィールドがありません。", - "xpack.lens.indexPatterns.noEmptyDataLabel": "空のフィールドがありません。", - "xpack.lens.indexPatterns.noFields.extendTimeBullet": "時間範囲を拡張中", - "xpack.lens.indexPatterns.noFields.fieldTypeFilterBullet": "別のフィールドフィルターを使用", - "xpack.lens.indexPatterns.noFields.globalFiltersBullet": "グローバルフィルターを変更", - "xpack.lens.indexPatterns.noFields.tryText": "試行対象:", - "xpack.lens.indexPatterns.noFieldsLabel": "このデータビューにはフィールドがありません。", - "xpack.lens.indexPatterns.noFilteredFieldsLabel": "選択したフィルターと一致するフィールドはありません。", - "xpack.lens.indexPatterns.noMetaDataLabel": "メタフィールドがありません。", "xpack.lens.label.gauge.labelMajor.header": "タイトル", "xpack.lens.label.gauge.labelMinor.header": "サブタイトル", "xpack.lens.label.header": "ラベル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ce903ab668bef6..921639d7cdab45 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11683,7 +11683,7 @@ "xpack.enterpriseSearch.nav.contentTitle": "内容", "xpack.enterpriseSearch.nav.elasticsearchTitle": "Elasticsearch", "xpack.enterpriseSearch.nav.enterpriseSearchOverviewTitle": "概览", - "xpack.enterpriseSearch.nav.searchExperiencesTitle": "搜索", + "xpack.enterpriseSearch.nav.searchTitle": "搜索", "xpack.enterpriseSearch.nav.searchIndicesTitle": "索引", "xpack.enterpriseSearch.nav.workplaceSearchTitle": "Workplace Search", "xpack.enterpriseSearch.notFound.action1": "返回到您的仪表板", @@ -17256,7 +17256,6 @@ "xpack.lens.indexPattern.timeShiftSmallWarning": "{label} 使用的时间偏移 {columnTimeShift} 小于 Date Histogram 时间间隔 {interval} 。要防止数据不匹配,请使用 {interval} 的倍数作为时间偏移。", "xpack.lens.indexPattern.uniqueLabel": "{label} [{num}]", "xpack.lens.indexPattern.valueCountOf": "{name} 的计数", - "xpack.lens.indexPatterns.fieldSearchLiveRegion": "{availableFields} 个可用{availableFields, plural, other {字段}}。{emptyFields} 个空{emptyFields, plural, other {字段}}。{metaFields} 个元{metaFields, plural,other {字段}}。", "xpack.lens.indexPatternSuggestion.removeLayerLabel": "仅显示 {indexPatternTitle}", "xpack.lens.indexPatternSuggestion.removeLayerPositionLabel": "仅显示图层 {layerNumber}", "xpack.lens.modalTitle.title.clear": "清除 {layerType} 图层?", @@ -17566,7 +17565,6 @@ "xpack.lens.heatmapVisualization.missingXAccessorLongMessage": "水平轴配置缺失。", "xpack.lens.heatmapVisualization.missingXAccessorShortMessage": "缺失水平轴。", "xpack.lens.indexPattern.advancedSettings": "高级", - "xpack.lens.indexPattern.allFieldsLabel": "所有字段", "xpack.lens.indexPattern.allFieldsLabelHelp": "将可用字段拖放到工作区并创建可视化。要更改可用字段,请选择不同数据视图,编辑您的查询或使用不同时间范围。一些字段类型无法在 Lens 中可视化,包括全文本字段和地理字段。", "xpack.lens.indexPattern.allFieldsSamplingLabelHelp": "可用字段包含与您的筛选匹配的前 500 个文档中的数据。要查看所有字段,请展开空字段。无法使用全文本、地理、扁平和对象字段创建可视化。", "xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning.link": "访问文档", @@ -17613,12 +17611,7 @@ "xpack.lens.indexPattern.differences.signature": "指标:数字", "xpack.lens.indexPattern.emptyDimensionButton": "空维度", "xpack.lens.indexPattern.emptyFieldsLabel": "空字段", - "xpack.lens.indexPattern.emptyFieldsLabelHelp": "空字段在基于您的筛选的前 500 个文档中不包含任何值。", "xpack.lens.indexPattern.enableAccuracyMode": "启用准确性模式", - "xpack.lens.indexPattern.existenceErrorAriaLabel": "现有内容提取失败", - "xpack.lens.indexPattern.existenceErrorLabel": "无法加载字段信息", - "xpack.lens.indexPattern.existenceTimeoutAriaLabel": "现有内容提取超时", - "xpack.lens.indexPattern.existenceTimeoutLabel": "字段信息花费时间过久", "xpack.lens.indexPattern.fieldItemTooltip": "拖放以可视化。", "xpack.lens.indexPattern.fieldPlaceholder": "字段", "xpack.lens.indexPattern.fieldStatsButtonEmptyLabel": "此字段不包含任何数据,但您仍然可以拖放以进行可视化。", @@ -17796,16 +17789,6 @@ "xpack.lens.indexPattern.useAsTopLevelAgg": "先按此字段分组", "xpack.lens.indexPatterns.clearFiltersLabel": "清除名称和类型筛选", "xpack.lens.indexPatterns.filterByNameLabel": "搜索字段名称", - "xpack.lens.indexPatterns.noAvailableDataLabel": "没有包含数据的可用字段。", - "xpack.lens.indexPatterns.noDataLabel": "无字段。", - "xpack.lens.indexPatterns.noEmptyDataLabel": "无空字段。", - "xpack.lens.indexPatterns.noFields.extendTimeBullet": "延伸时间范围", - "xpack.lens.indexPatterns.noFields.fieldTypeFilterBullet": "使用不同的字段筛选", - "xpack.lens.indexPatterns.noFields.globalFiltersBullet": "更改全局筛选", - "xpack.lens.indexPatterns.noFields.tryText": "尝试:", - "xpack.lens.indexPatterns.noFieldsLabel": "在此数据视图中不存在任何字段。", - "xpack.lens.indexPatterns.noFilteredFieldsLabel": "没有字段匹配选定筛选。", - "xpack.lens.indexPatterns.noMetaDataLabel": "无元字段。", "xpack.lens.label.gauge.labelMajor.header": "标题", "xpack.lens.label.gauge.labelMinor.header": "子标题", "xpack.lens.label.header": "标签", diff --git a/x-pack/test/apm_api_integration/tests/traces/critical_path.spec.ts b/x-pack/test/apm_api_integration/tests/traces/critical_path.spec.ts new file mode 100644 index 00000000000000..ca2a1f8d72f89d --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/traces/critical_path.spec.ts @@ -0,0 +1,426 @@ +/* + * 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 { getAggregatedCriticalPathRootNodes } from '@kbn/apm-plugin/common'; +import { apm, EntityArrayIterable, EntityIterable, timerange } from '@kbn/apm-synthtrace'; +import expect from '@kbn/expect'; +import { Assign } from '@kbn/utility-types'; +import { invert, sortBy, uniq } from 'lodash'; +import { SupertestReturnType } from '../../common/apm_api_supertest'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const start = new Date('2022-01-01T00:00:00.000Z').getTime(); + const end = new Date('2022-01-01T00:15:00.000Z').getTime() - 1; + + type Node = ReturnType['rootNodes'][0]; + type Metadata = NonNullable< + SupertestReturnType<'POST /internal/apm/traces/aggregated_critical_path'>['body']['criticalPath'] + >['metadata'][string]; + type HydratedNode = Assign; + + interface FormattedNode { + name: string; + value: number; + children: FormattedNode[]; + } + + // format tree in somewhat concise format for easier testing + function formatTree(nodes: HydratedNode[]): FormattedNode[] { + return sortBy( + nodes.map((node) => { + const name = + node.metadata?.['processor.event'] === 'transaction' + ? node.metadata['transaction.name'] + : node.metadata?.['span.name'] || 'root'; + return { name, value: node.countExclusive, children: formatTree(node.children) }; + }), + (node) => node.name + ); + } + + async function fetchAndBuildCriticalPathTree( + options: { fn: () => EntityIterable } & ({ serviceName: string; transactionName: string } | {}) + ) { + const { fn } = options; + + const generator = fn(); + + const events = generator.toArray(); + + const traceIds = uniq(events.map((event) => event['trace.id']!)); + + await synthtraceEsClient.index(new EntityArrayIterable(events)); + + return apmApiClient + .readUser({ + endpoint: 'POST /internal/apm/traces/aggregated_critical_path', + params: { + body: { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + traceIds, + serviceName: 'serviceName' in options ? options.serviceName : null, + transactionName: 'transactionName' in options ? options.transactionName : null, + }, + }, + }) + .then((response) => { + const criticalPath = response.body.criticalPath!; + + const nodeIdByOperationId = invert(criticalPath.operationIdByNodeId); + + const { rootNodes, maxDepth } = getAggregatedCriticalPathRootNodes({ + criticalPath, + }); + + function hydrateNode(node: Node): HydratedNode { + return { + ...node, + metadata: criticalPath.metadata[criticalPath.operationIdByNodeId[node.nodeId]], + children: node.children.map(hydrateNode), + }; + } + + return { + rootNodes: rootNodes.map(hydrateNode), + maxDepth, + criticalPath, + nodeIdByOperationId, + }; + }); + } + + registry.when('Aggregated critical path', { config: 'basic', archives: [] }, () => { + it('builds up the correct tree for a single transaction', async () => { + const java = apm + .service({ name: 'java', environment: 'production', agentName: 'java' }) + .instance('java'); + + const duration = 1000; + const rate = 10; + + const { rootNodes } = await fetchAndBuildCriticalPathTree({ + fn: () => + timerange(start, end) + .interval('15m') + .rate(rate) + .generator((timestamp) => { + return java.transaction('GET /api').timestamp(timestamp).duration(duration); + }), + }); + + expect(rootNodes.length).to.be(1); + + expect(rootNodes[0].countInclusive).to.eql(duration * rate * 1000); + expect(rootNodes[0].countExclusive).to.eql(duration * rate * 1000); + + expect(rootNodes[0].metadata).to.eql({ + 'processor.event': 'transaction', + 'transaction.type': 'request', + 'service.name': 'java', + 'agent.name': 'java', + 'transaction.name': 'GET /api', + }); + }); + + it('builds up the correct tree for a complicated trace', async () => { + const java = apm + .service({ name: 'java', environment: 'production', agentName: 'java' }) + .instance('java'); + + const rate = 10; + + const { rootNodes } = await fetchAndBuildCriticalPathTree({ + fn: () => + timerange(start, end) + .interval('15m') + .rate(rate) + .generator((timestamp) => { + return java + .transaction('GET /api') + .timestamp(timestamp) + .duration(1000) + .children( + java + .span('GET /_search', 'db', 'elasticsearch') + .timestamp(timestamp) + .duration(400), + java + .span('get index stats', 'custom') + .timestamp(timestamp) + .duration(500) + .children( + java + .span('GET /*/_stats', 'db', 'elasticsearch') + .timestamp(timestamp + 50) + .duration(450) + ) + ); + }), + }); + + expect(rootNodes.length).to.be(1); + + expect(rootNodes[0].countInclusive).to.eql(1000 * rate * 1000); + + expect(rootNodes[0].children.length).to.eql(1); + + expect(formatTree(rootNodes)).to.eql([ + { + name: 'GET /api', + value: 500 * 1000 * rate, + children: [ + { + name: 'get index stats', + value: 50 * 1000 * rate, + children: [{ name: 'GET /*/_stats', value: 450 * 1000 * rate, children: [] }], + }, + ], + }, + ]); + }); + + it('slices traces and merges root nodes if service name and transaction name are set', async () => { + // this test also fails when hashCode() is used in the scripted metric aggregation, + // due to collisions. + + const upstreamA = apm + .service({ name: 'upstreamA', environment: 'production', agentName: 'java' }) + .instance('upstreamA'); + + const upstreamB = apm + .service({ name: 'upstreamB', environment: 'production', agentName: 'java' }) + .instance('upstreamB'); + + const downstream = apm + .service({ name: 'downstream', environment: 'production', agentName: 'java' }) + .instance('downstream'); + + const rate = 10; + + function generateTrace() { + return timerange(start, end) + .interval('15m') + .rate(rate) + .generator((timestamp) => { + return [ + upstreamA + .transaction('GET /upstreamA') + .timestamp(timestamp) + .duration(500) + .children( + upstreamA + .span('GET /downstream', 'external', 'http') + .timestamp(timestamp) + .duration(500) + .children( + downstream + .transaction('downstream') + .timestamp(timestamp + 50) + .duration(400) + .children( + downstream + .span('from upstreamA', 'custom') + .timestamp(timestamp + 100) + .duration(300) + ) + ) + ), + upstreamB + .transaction('GET /upstreamB') + .timestamp(timestamp) + .duration(500) + .children( + upstreamB + .span('GET /downstream', 'external', 'http') + .timestamp(timestamp) + .duration(500) + .children( + downstream + .transaction('downstream') + .timestamp(timestamp + 50) + .duration(400) + .children( + downstream + .span('from upstreamB', 'custom') + .timestamp(timestamp + 100) + .duration(300) + ) + ) + ), + ]; + }); + } + + const { rootNodes: unfilteredRootNodes } = await fetchAndBuildCriticalPathTree({ + fn: () => generateTrace(), + }); + + await synthtraceEsClient.clean(); + + const { rootNodes: filteredRootNodes } = await fetchAndBuildCriticalPathTree({ + fn: () => generateTrace(), + serviceName: 'downstream', + transactionName: 'downstream', + }); + + expect(formatTree(unfilteredRootNodes)).eql([ + { + name: 'GET /upstreamA', + value: 0, + children: [ + { + name: 'GET /downstream', + value: 100 * 1000 * rate, + children: [ + { + name: 'downstream', + value: 100 * 1000 * rate, + children: [ + { + name: 'from upstreamA', + value: 300 * 1000 * rate, + children: [], + }, + ], + }, + ], + }, + ], + }, + { + name: 'GET /upstreamB', + value: 0, + children: [ + { + name: 'GET /downstream', + value: 100 * 1000 * rate, + children: [ + { + name: 'downstream', + value: 100 * 1000 * rate, + children: [ + { + name: 'from upstreamB', + value: 300 * 1000 * rate, + children: [], + }, + ], + }, + ], + }, + ], + }, + ]); + + expect(formatTree(filteredRootNodes)).eql([ + { + name: 'downstream', + value: 2 * 100 * 1000 * rate, + children: [ + { + name: 'from upstreamA', + value: 300 * 1000 * rate, + children: [], + }, + { + name: 'from upstreamB', + value: 300 * 1000 * rate, + children: [], + }, + ], + }, + ]); + }); + + it('calculates the critical path for a specific transaction if its not part of the critical path of the entire trace', async () => { + const upstream = apm + .service({ name: 'upstream', environment: 'production', agentName: 'java' }) + .instance('upstream'); + + const downstreamA = apm + .service({ name: 'downstreamA', environment: 'production', agentName: 'java' }) + .instance('downstreamB'); + + const downstreamB = apm + .service({ name: 'downstreamB', environment: 'production', agentName: 'java' }) + .instance('downstreamB'); + + const rate = 10; + + function generateTrace() { + return timerange(start, end) + .interval('15m') + .rate(rate) + .generator((timestamp) => { + return [ + upstream + .transaction('GET /upstream') + .timestamp(timestamp) + .duration(500) + .children( + upstream + .span('GET /downstreamA', 'external', 'http') + .timestamp(timestamp) + .duration(500) + .children( + downstreamA + .transaction('downstreamA') + .timestamp(timestamp + 50) + .duration(400) + ), + upstream + .span('GET /downstreamB', 'external', 'http') + .timestamp(timestamp) + .duration(400) + .children( + downstreamB + .transaction('downstreamB') + .timestamp(timestamp + 50) + .duration(400) + ) + ), + ]; + }); + } + + const { rootNodes: unfilteredRootNodes } = await fetchAndBuildCriticalPathTree({ + fn: () => generateTrace(), + }); + + expect(formatTree(unfilteredRootNodes)[0].children[0].children).to.eql([ + { + name: 'downstreamA', + value: 400 * rate * 1000, + children: [], + }, + ]); + + await synthtraceEsClient.clean(); + + const { rootNodes: filteredRootNodes } = await fetchAndBuildCriticalPathTree({ + fn: () => generateTrace(), + serviceName: 'downstreamB', + transactionName: 'downstreamB', + }); + + expect(formatTree(filteredRootNodes)).to.eql([ + { + name: 'downstreamB', + value: 400 * rate * 1000, + children: [], + }, + ]); + }); + + after(() => synthtraceEsClient.clean()); + }); +} diff --git a/x-pack/test/apm_api_integration/tests/traces/find_traces.spec.ts b/x-pack/test/apm_api_integration/tests/traces/find_traces.spec.ts index 470a68aeacc197..93416c7243f2db 100644 --- a/x-pack/test/apm_api_integration/tests/traces/find_traces.spec.ts +++ b/x-pack/test/apm_api_integration/tests/traces/find_traces.spec.ts @@ -1,9 +1,3 @@ -/* - * 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. - */ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License diff --git a/x-pack/test/cases_api_integration/common/lib/validation.ts b/x-pack/test/cases_api_integration/common/lib/validation.ts index a84c733586ec06..e022beb5a597a5 100644 --- a/x-pack/test/cases_api_integration/common/lib/validation.ts +++ b/x-pack/test/cases_api_integration/common/lib/validation.ts @@ -6,26 +6,57 @@ */ import expect from '@kbn/expect'; -import { CaseResponse, CasesByAlertId } from '@kbn/cases-plugin/common/api'; +import { + AttachmentTotals, + CaseResponse, + CasesByAlertId, + RelatedCaseInfo, +} from '@kbn/cases-plugin/common/api'; import { xorWith, isEqual } from 'lodash'; +type AttachmentTotalsKeys = keyof AttachmentTotals; + +export interface TestCaseWithTotals { + caseInfo: CaseResponse; + totals?: Partial; +} + /** * Ensure that the result of the alerts API request matches with the cases created for the test. */ export function validateCasesFromAlertIDResponse( casesFromAPIResponse: CasesByAlertId, - createdCasesForTest: CaseResponse[] + createdCasesForTest: TestCaseWithTotals[] ) { - const idToTitle = new Map( - createdCasesForTest.map((caseInfo) => [caseInfo.id, caseInfo.title]) + const idToResponse = new Map( + casesFromAPIResponse.map((response) => [response.id, response]) ); - for (const apiResCase of casesFromAPIResponse) { - // check that the title in the api response matches the title in the map from the created cases - expect(apiResCase.title).to.be(idToTitle.get(apiResCase.id)); + expect(idToResponse.size).to.be(createdCasesForTest.length); + + // only iterate over the test cases not the api response values + for (const expectedTestInfo of createdCasesForTest) { + expect(idToResponse.get(expectedTestInfo.caseInfo.id)?.title).to.be( + expectedTestInfo.caseInfo.title + ); + expect(idToResponse.get(expectedTestInfo.caseInfo.id)?.description).to.be( + expectedTestInfo.caseInfo.description + ); + expect(idToResponse.get(expectedTestInfo.caseInfo.id)?.status).to.be( + expectedTestInfo.caseInfo.status + ); + expect(idToResponse.get(expectedTestInfo.caseInfo.id)?.createdAt).to.be( + expectedTestInfo.caseInfo.created_at + ); + + // only check the totals that are defined in the test case + for (const totalKey of Object.keys(expectedTestInfo.totals ?? {}) as AttachmentTotalsKeys[]) { + expect(idToResponse.get(expectedTestInfo.caseInfo.id)?.totals[totalKey]).to.be( + expectedTestInfo.totals?.[totalKey] + ); + } } } - /** * Compares two arrays to determine if they are sort of equal. This function returns true if the arrays contain the same * elements but the ordering does not matter. diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts index 4d6c20192442a2..d0a5f45dae8518 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts @@ -10,7 +10,11 @@ import { CASES_URL } from '@kbn/cases-plugin/common/constants'; import { CaseResponse } from '@kbn/cases-plugin/common/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; -import { getPostCaseRequest, postCommentAlertReq } from '../../../../common/lib/mock'; +import { + getPostCaseRequest, + postCommentAlertReq, + postCommentUserReq, +} from '../../../../common/lib/mock'; import { createCase, createComment, @@ -56,7 +60,11 @@ export default ({ getService }: FtrProviderContext): void => { const caseIDsWithAlert = await getCasesByAlert({ supertest, alertID: 'test-id' }); expect(caseIDsWithAlert.length).to.eql(3); - validateCasesFromAlertIDResponse(caseIDsWithAlert, [case1, case2, case3]); + validateCasesFromAlertIDResponse(caseIDsWithAlert, [ + { caseInfo: case1, totals: { alerts: 1, userComments: 0 } }, + { caseInfo: case2, totals: { alerts: 1, userComments: 0 } }, + { caseInfo: case3, totals: { alerts: 1, userComments: 0 } }, + ]); }); it('should return all cases with the same alert ID when more than 100 cases', async () => { @@ -83,7 +91,15 @@ export default ({ getService }: FtrProviderContext): void => { expect(caseIDsWithAlert.length).to.eql(numCases); - validateCasesFromAlertIDResponse(caseIDsWithAlert, cases); + const testResults = cases.map((caseInfo) => ({ + caseInfo, + totals: { + alerts: 1, + userComments: 0, + }, + })); + + validateCasesFromAlertIDResponse(caseIDsWithAlert, testResults); }); it('should return no cases when the alert ID is not found', async () => { @@ -131,6 +147,55 @@ export default ({ getService }: FtrProviderContext): void => { await supertest.get(`${CASES_URL}/alerts/`).expect(302); }); + describe('attachment stats', () => { + it('should only count unique alert ids in the alert totals', async () => { + const [case1, case2] = await Promise.all([ + createCase(supertest, getPostCaseRequest({ title: 'a' })), + createCase(supertest, getPostCaseRequest({ title: 'b' })), + ]); + + await Promise.all([ + createComment({ supertest, caseId: case1.id, params: postCommentAlertReq }), + createComment({ supertest, caseId: case2.id, params: postCommentAlertReq }), + ]); + + // two alerts with same alert id attached to case1 + await createComment({ supertest, caseId: case1.id, params: postCommentAlertReq }); + + const caseIDsWithAlert = await getCasesByAlert({ supertest, alertID: 'test-id' }); + + expect(caseIDsWithAlert.length).to.eql(2); + validateCasesFromAlertIDResponse(caseIDsWithAlert, [ + { caseInfo: case1, totals: { alerts: 1, userComments: 0 } }, + { caseInfo: case2, totals: { alerts: 1, userComments: 0 } }, + ]); + }); + + it('should get the total number of user comments attached to a case', async () => { + const [case1, case2] = await Promise.all([ + createCase(supertest, getPostCaseRequest({ title: 'a' })), + createCase(supertest, getPostCaseRequest({ title: 'b' })), + ]); + + await Promise.all([ + createComment({ supertest, caseId: case1.id, params: postCommentAlertReq }), + createComment({ supertest, caseId: case2.id, params: postCommentAlertReq }), + ]); + + // two user comments attached to case2 + await createComment({ supertest, caseId: case2.id, params: postCommentUserReq }); + await createComment({ supertest, caseId: case2.id, params: postCommentUserReq }); + + const caseIDsWithAlert = await getCasesByAlert({ supertest, alertID: 'test-id' }); + + expect(caseIDsWithAlert.length).to.eql(2); + validateCasesFromAlertIDResponse(caseIDsWithAlert, [ + { caseInfo: case1, totals: { alerts: 1, userComments: 0 } }, + { caseInfo: case2, totals: { alerts: 1, userComments: 2 } }, + ]); + }); + }); + describe('rbac', () => { const supertestWithoutAuth = getService('supertestWithoutAuth'); @@ -197,7 +262,15 @@ export default ({ getService }: FtrProviderContext): void => { }); expect(res.length).to.eql(scenario.cases.length); - validateCasesFromAlertIDResponse(res, scenario.cases); + const testResults = scenario.cases.map((caseInfo) => ({ + caseInfo, + totals: { + alerts: 1, + userComments: 0, + }, + })); + + validateCasesFromAlertIDResponse(res, testResults); } }); @@ -263,7 +336,19 @@ export default ({ getService }: FtrProviderContext): void => { query: { owner: 'securitySolutionFixture' }, }); - expect(res).to.eql([{ id: case1.id, title: case1.title }]); + expect(res).to.eql([ + { + id: case1.id, + title: case1.title, + description: case1.description, + status: case1.status, + createdAt: case1.created_at, + totals: { + userComments: 0, + alerts: 1, + }, + }, + ]); }); it('should return the correct cases info when the owner query parameter contains unprivileged values', async () => { @@ -301,7 +386,19 @@ export default ({ getService }: FtrProviderContext): void => { query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, }); - expect(res).to.eql([{ id: case1.id, title: case1.title }]); + expect(res).to.eql([ + { + id: case1.id, + title: case1.title, + description: case1.description, + status: case1.status, + createdAt: case1.created_at, + totals: { + userComments: 0, + alerts: 1, + }, + }, + ]); }); }); }); diff --git a/x-pack/test/cases_api_integration/spaces_only/tests/common/alerts/get_cases.ts b/x-pack/test/cases_api_integration/spaces_only/tests/common/alerts/get_cases.ts index 691d132aa1edec..be0a698a6b5252 100644 --- a/x-pack/test/cases_api_integration/spaces_only/tests/common/alerts/get_cases.ts +++ b/x-pack/test/cases_api_integration/spaces_only/tests/common/alerts/get_cases.ts @@ -65,7 +65,11 @@ export default ({ getService }: FtrProviderContext): void => { }); expect(cases.length).to.eql(3); - validateCasesFromAlertIDResponse(cases, [case1, case2, case3]); + validateCasesFromAlertIDResponse(cases, [ + { caseInfo: case1, totals: { alerts: 1, userComments: 0 } }, + { caseInfo: case2, totals: { alerts: 1, userComments: 0 } }, + { caseInfo: case3, totals: { alerts: 1, userComments: 0 } }, + ]); }); it('should return 1 case in space2 when 2 cases were created in space1 and 1 in space2', async () => { @@ -103,7 +107,19 @@ export default ({ getService }: FtrProviderContext): void => { }); expect(casesByAlert.length).to.eql(1); - expect(casesByAlert).to.eql([{ id: case3.id, title: case3.title }]); + expect(casesByAlert).to.eql([ + { + id: case3.id, + title: case3.title, + description: case3.description, + status: case3.status, + createdAt: case3.created_at, + totals: { + userComments: 0, + alerts: 1, + }, + }, + ]); }); }); }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts index 30b378d2a7eeac..82cb42c0039c32 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts @@ -14,7 +14,7 @@ import { } from '@kbn/security-solution-plugin/common/constants'; import type { RuleResponse } from '@kbn/security-solution-plugin/common/detection_engine/rule_schema'; import { - BulkAction, + BulkActionType, BulkActionEditType, } from '@kbn/security-solution-plugin/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -83,7 +83,7 @@ export default ({ getService }: FtrProviderContext): void => { await createRule(supertest, log, getSimpleRule()); const { body } = await postBulkAction() - .send({ query: '', action: BulkAction.export }) + .send({ query: '', action: BulkActionType.export }) .expect(200) .expect('Content-Type', 'application/ndjson') .expect('Content-Disposition', 'attachment; filename="rules_export.ndjson"') @@ -115,7 +115,7 @@ export default ({ getService }: FtrProviderContext): void => { await createRule(supertest, log, testRule); const { body } = await postBulkAction() - .send({ query: '', action: BulkAction.delete }) + .send({ query: '', action: BulkActionType.delete }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 }); @@ -150,7 +150,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(sidecarActionsResults.hits.hits[0]?._source?.references[0].id).to.eql(rule1.id); const { body } = await postBulkAction() - .send({ query: '', action: BulkAction.delete }) + .send({ query: '', action: BulkActionType.delete }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 }); @@ -171,7 +171,7 @@ export default ({ getService }: FtrProviderContext): void => { await createRule(supertest, log, getSimpleRule(ruleId)); const { body } = await postBulkAction() - .send({ query: '', action: BulkAction.enable }) + .send({ query: '', action: BulkActionType.enable }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 }); @@ -207,7 +207,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(sidecarActionsResults.hits.hits[0]?._source?.references[0].id).to.eql(rule1.id); const { body } = await postBulkAction() - .send({ query: '', action: BulkAction.enable }) + .send({ query: '', action: BulkActionType.enable }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 }); @@ -240,7 +240,7 @@ export default ({ getService }: FtrProviderContext): void => { await createRule(supertest, log, getSimpleRule(ruleId, true)); const { body } = await postBulkAction() - .send({ query: '', action: BulkAction.disable }) + .send({ query: '', action: BulkActionType.disable }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 }); @@ -276,7 +276,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(sidecarActionsResults.hits.hits[0]?._source?.references[0].id).to.eql(rule1.id); const { body } = await postBulkAction() - .send({ query: '', action: BulkAction.disable }) + .send({ query: '', action: BulkActionType.disable }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 }); @@ -310,7 +310,7 @@ export default ({ getService }: FtrProviderContext): void => { await createRule(supertest, log, ruleToDuplicate); const { body } = await postBulkAction() - .send({ query: '', action: BulkAction.duplicate }) + .send({ query: '', action: BulkActionType.duplicate }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 }); @@ -352,7 +352,7 @@ export default ({ getService }: FtrProviderContext): void => { ); const { body } = await postBulkAction() - .send({ query: '', action: BulkAction.duplicate }) + .send({ query: '', action: BulkActionType.duplicate }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 }); @@ -432,8 +432,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body: bulkEditResponse } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_tags, value: tagsToOverwrite, @@ -506,8 +506,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body: bulkEditResponse } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.delete_tags, value: tagsToDelete, @@ -573,8 +573,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body: bulkEditResponse } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_tags, value: addedTags, @@ -608,8 +608,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body: bulkEditResponse } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_index_patterns, value: ['initial-index-*'], @@ -638,8 +638,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body: bulkEditResponse } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_index_patterns, value: ['index3-*'], @@ -670,8 +670,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body: bulkEditResponse } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.delete_index_patterns, value: ['index2-*'], @@ -699,8 +699,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [mlRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_index_patterns, value: ['index-*'], @@ -732,8 +732,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [rule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.delete_index_patterns, value: ['simple-index-*'], @@ -765,8 +765,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [rule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_index_patterns, value: [], @@ -820,8 +820,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body: setTagsBody } = await postBulkAction().send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_tags, value: ['reset-tag'], @@ -862,8 +862,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_timeline, value: { @@ -905,8 +905,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_timeline, value: { @@ -937,8 +937,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [mlRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_index_patterns, value: ['index-*'], @@ -970,8 +970,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [rule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.delete_index_patterns, value: ['simple-index-*'], @@ -999,8 +999,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [rule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_tags, value: ['test'], @@ -1060,8 +1060,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [prebuiltRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type, value, @@ -1104,8 +1104,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [createdRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_rule_actions, value: { @@ -1160,8 +1160,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [createdRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_rule_actions, value: { @@ -1218,8 +1218,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [createdRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_rule_actions, value: { @@ -1252,8 +1252,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [createdRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_rule_actions, value: { @@ -1310,8 +1310,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [createdRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_rule_actions, value: { @@ -1377,8 +1377,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [createdRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_rule_actions, value: { @@ -1436,8 +1436,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [createdRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_rule_actions, value: { @@ -1481,8 +1481,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [createdRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_rule_actions, value: { @@ -1522,8 +1522,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [prebuiltRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type, value: { @@ -1576,8 +1576,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [prebuiltRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_rule_actions, value: { @@ -1641,8 +1641,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [createdRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_rule_actions, value: { @@ -1697,8 +1697,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [createdRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_rule_actions, value: { @@ -1749,8 +1749,8 @@ export default ({ getService }: FtrProviderContext): void => { await postBulkAction() .send({ ids: [createdRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_rule_actions, value: { @@ -1783,8 +1783,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_schedule, value: { @@ -1813,8 +1813,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_schedule, value: { @@ -1851,8 +1851,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body: setIndexBody } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_index_patterns, value: ['initial-index-*'], @@ -1887,8 +1887,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body: setIndexBody } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.add_index_patterns, value: ['initial-index-*'], @@ -1924,8 +1924,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body: setIndexBody } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_index_patterns, value: ['initial-index-*'], @@ -1960,8 +1960,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_index_patterns, value: [], @@ -1997,8 +1997,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body: setIndexBody } = await postBulkAction() .send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_index_patterns, value: ['initial-index-*'], @@ -2035,8 +2035,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [rule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.delete_index_patterns, value: ['simple-index-*'], @@ -2071,8 +2071,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [rule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.delete_index_patterns, value: ['simple-index-*'], @@ -2107,8 +2107,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [rule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.delete_index_patterns, value: ['simple-index-*'], @@ -2142,8 +2142,8 @@ export default ({ getService }: FtrProviderContext): void => { Array.from({ length: 10 }).map(() => postBulkAction().send({ query: '', - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_timeline, value: { @@ -2171,8 +2171,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postBulkAction() .send({ ids: [id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_timeline, value: { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action_dry_run.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action_dry_run.ts index b893f38f20310e..741839e707ea9e 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action_dry_run.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action_dry_run.ts @@ -10,7 +10,7 @@ import { } from '@kbn/security-solution-plugin/common/constants'; import expect from 'expect'; import { - BulkAction, + BulkActionType, BulkActionEditType, } from '@kbn/security-solution-plugin/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -54,7 +54,9 @@ export default ({ getService }: FtrProviderContext): void => { it('should not support export action', async () => { await createRule(supertest, log, getSimpleRule()); - const { body } = await postDryRunBulkAction().send({ action: BulkAction.export }).expect(400); + const { body } = await postDryRunBulkAction() + .send({ action: BulkActionType.export }) + .expect(400); expect(body).toEqual({ message: "Export action doesn't support dry_run mode", @@ -67,7 +69,9 @@ export default ({ getService }: FtrProviderContext): void => { const testRule = getSimpleRule(ruleId); await createRule(supertest, log, testRule); - const { body } = await postDryRunBulkAction().send({ action: BulkAction.delete }).expect(200); + const { body } = await postDryRunBulkAction() + .send({ action: BulkActionType.delete }) + .expect(200); expect(body.attributes.summary).toEqual({ failed: 0, succeeded: 1, total: 1 }); // dry_run mode shouldn't return any rules in results @@ -81,7 +85,9 @@ export default ({ getService }: FtrProviderContext): void => { const ruleId = 'ruleId'; await createRule(supertest, log, getSimpleRule(ruleId)); - const { body } = await postDryRunBulkAction().send({ action: BulkAction.enable }).expect(200); + const { body } = await postDryRunBulkAction() + .send({ action: BulkActionType.enable }) + .expect(200); expect(body.attributes.summary).toEqual({ failed: 0, succeeded: 1, total: 1 }); // dry_run mode shouldn't return any rules in results @@ -97,7 +103,7 @@ export default ({ getService }: FtrProviderContext): void => { await createRule(supertest, log, getSimpleRule(ruleId, true)); const { body } = await postDryRunBulkAction() - .send({ action: BulkAction.disable }) + .send({ action: BulkActionType.disable }) .expect(200); expect(body.attributes.summary).toEqual({ failed: 0, succeeded: 1, total: 1 }); @@ -115,7 +121,7 @@ export default ({ getService }: FtrProviderContext): void => { await createRule(supertest, log, ruleToDuplicate); const { body } = await postDryRunBulkAction() - .send({ action: BulkAction.disable }) + .send({ action: BulkActionType.disable }) .expect(200); expect(body.attributes.summary).toEqual({ failed: 0, succeeded: 1, total: 1 }); @@ -136,8 +142,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postDryRunBulkAction() .send({ - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_tags, value: ['reset-tag'], @@ -167,8 +173,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postDryRunBulkAction() .send({ ids: [immutableRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: BulkActionEditType.set_tags, value: ['reset-tag'], @@ -208,8 +214,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body } = await postDryRunBulkAction() .send({ ids: [mlRule.id], - action: BulkAction.edit, - [BulkAction.edit]: [ + action: BulkActionType.edit, + [BulkActionType.edit]: [ { type: editAction, value: [], diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts b/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts index b89c036e84a9d8..7b691ca10b5a6c 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts @@ -123,6 +123,7 @@ export default function (providerContext: FtrProviderContext) { }, { meta: true } )); + // omit routings delete body.template.settings.index.routing; @@ -131,6 +132,7 @@ export default function (providerContext: FtrProviderContext) { settings: { index: { codec: 'best_compression', + default_pipeline: 'logs-overrides.test-0.1.0', lifecycle: { name: 'overridden by user', }, diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index 9d01797db5b91c..c8bde9dd076f8f 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -217,6 +217,7 @@ export default function (providerContext: FtrProviderContext) { expect(resPackage.body.component_templates[0].component_template.template.settings).eql({ index: { codec: 'best_compression', + default_pipeline: 'logs-all_assets.test_logs-0.2.0', lifecycle: { name: 'reference2', }, diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 1cd6614bf58338..43319d48ec0bd9 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -35,13 +35,13 @@ export default function ({ getService }: FtrProviderContext) { // This test is meant to fail when any change is made in task manager registered types. // The intent is to trigger a code review from the Response Ops team to review the new task type changes. - // Failing: See https://github.com/elastic/kibana/issues/144369 - describe.skip('check_registered_task_types', () => { + describe('check_registered_task_types', () => { it('should check changes on all registered task types', async () => { const types = (await getRegisteredTypes()) .filter((t: string) => !TEST_TYPES.includes(t)) .sort(); expect(types).to.eql([ + 'Fleet-Usage-Logger', 'Fleet-Usage-Sender', 'ML:saved-objects-sync', 'UPTIME:SyntheticsService:Sync-Saved-Monitor-Objects', @@ -125,6 +125,7 @@ export default function ({ getService }: FtrProviderContext) { 'security:endpoint-meta-telemetry', 'security:telemetry-configuration', 'security:telemetry-detection-rules', + 'security:telemetry-filterlist-artifact', 'security:telemetry-lists', 'security:telemetry-prebuilt-rule-alerts', 'security:telemetry-timelines', diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts index 3791de8e8d7efa..2844483018e10b 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts @@ -68,6 +68,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { 'elasticsearch', 'appSearch', 'workplaceSearch', + 'searchExperiences', 'spaces', ...esFeatureExceptions, ]; @@ -94,6 +95,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { 'elasticsearch', 'appSearch', 'workplaceSearch', + 'searchExperiences', 'spaces', ...esFeatureExceptions, ]; diff --git a/x-pack/test/ui_capabilities/spaces_only/tests/catalogue.ts b/x-pack/test/ui_capabilities/spaces_only/tests/catalogue.ts index c2f6087420e262..4b585e4b95c819 100644 --- a/x-pack/test/ui_capabilities/spaces_only/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/spaces_only/tests/catalogue.ts @@ -32,6 +32,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { 'elasticsearch', 'appSearch', 'workplaceSearch', + 'searchExperiences', ]; describe('catalogue', () => {