From c0baf834cc9c097a878e891afa4d7d5c15e45060 Mon Sep 17 00:00:00 2001 From: Vadim Yakhin Date: Wed, 11 Aug 2021 15:27:10 -0300 Subject: [PATCH 01/20] [Enterprise Search] Fix search not working on some table columns on Users and Roles page (#108228) * Fix role mappings table search not working on some columns Not searchable columns had non-string data: arrays, objects. The default implementation of search doesn't perform a search on non-string data. The solution used here is to have a custom search callback that converts the entire role mapping object to string and then checks if user query exists in this string. It was copied and adjusted from example in EUI docs: https://elastic.github.io/eui/#/tabular-content/in-memory-tables#in-memory-table-with-search-callback * Fix copy * Fix the same issue for Users table The search was not performed on Engines/Groups column. Also adjust the variable names in role_mappings_table to closely match variable names in users_table --- .../shared/role_mapping/constants.ts | 2 +- .../role_mapping/role_mappings_table.test.tsx | 27 ++++++++++++++- .../role_mapping/role_mappings_table.tsx | 22 ++++++++++-- .../shared/role_mapping/users_table.test.tsx | 34 +++++++++++++++++-- .../shared/role_mapping/users_table.tsx | 22 ++++++++++-- 5 files changed, 99 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts index 583652de1fa028..25a1e084a3a603 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts @@ -219,7 +219,7 @@ export const ROLE_MAPPINGS_HEADING_BUTTON = i18n.translate( export const ROLE_MAPPINGS_NO_RESULTS_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.roleMapping.noResults.message', - { defaultMessage: 'Create a new role mapping' } + { defaultMessage: 'No matching role mappings found' } ); export const ROLES_DISABLED_TITLE = i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx index 61043aa6ad9a83..003848d1da8c5f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx @@ -10,8 +10,10 @@ import { wsRoleMapping, asRoleMapping } from './__mocks__/roles'; import React from 'react'; import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; -import { EuiInMemoryTable, EuiTableHeaderCell } from '@elastic/eui'; +import { EuiInMemoryTable, EuiTableHeaderCell, EuiTableRow } from '@elastic/eui'; +import type { EuiSearchBarProps } from '@elastic/eui'; import { engines } from '../../app_search/__mocks__/engines.mock'; @@ -106,4 +108,27 @@ describe('RoleMappingsTable', () => { `${engines[0].name}, ${engines[1].name} + 1` ); }); + + it('handles search', () => { + const wrapper = mount( + + ); + const roleMappingsTable = wrapper.find('[data-test-subj="RoleMappingsTable"]').first(); + const searchProp = roleMappingsTable.prop('search') as EuiSearchBarProps; + + act(() => { + if (searchProp.onChange) { + searchProp.onChange({ queryText: 'admin' } as any); + } + }); + wrapper.update(); + + expect(wrapper.find(EuiTableRow)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx index a98d36f04b4ad0..d6299bc1b3896a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx @@ -5,9 +5,10 @@ * 2.0. */ -import React from 'react'; +import React, { useState } from 'react'; import { EuiIconTip, EuiInMemoryTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui'; +import type { EuiSearchBarOnChangeArgs } from '@elastic/eui'; import { ASRoleMapping } from '../../app_search/types'; import { WSRoleMapping } from '../../workplace_search/types'; @@ -70,6 +71,8 @@ export const RoleMappingsTable: React.FC = ({ return _rm; }) as SharedRoleMapping[]; + const [items, setItems] = useState(standardizedRoleMappings); + const attributeNameCol: EuiBasicTableColumn = { field: 'attribute', name: ( @@ -161,7 +164,22 @@ export const RoleMappingsTable: React.FC = ({ pageSize: 10, }; + const onQueryChange = ({ queryText }: EuiSearchBarOnChangeArgs) => { + const filteredItems = standardizedRoleMappings.filter((rm) => { + // JSON.stringify allows us to search all the object fields + // without converting all the nested arrays and objects to strings manually + // Some false-positives are possible, because the search is also performed on + // object keys, but the simplicity of JSON.stringify seems to worth the tradeoff. + const normalizedTableItemString = JSON.stringify(rm).toLowerCase(); + const normalizedQuery = queryText.toLowerCase(); + return normalizedTableItemString.indexOf(normalizedQuery) !== -1; + }); + + setItems(filteredItems); + }; + const search = { + onChange: onQueryChange, box: { incremental: true, fullWidth: false, @@ -173,7 +191,7 @@ export const RoleMappingsTable: React.FC = ({ { expect(cell.find(EuiBadge)).toHaveLength(1); }); + + it('handles search', () => { + const wrapper = mount( + + ); + const roleMappingsTable = wrapper.find('[data-test-subj="UsersTable"]').first(); + const searchProp = roleMappingsTable.prop('search') as EuiSearchBarProps; + + act(() => { + if (searchProp.onChange) { + searchProp.onChange({ queryText: 'admin' } as any); + } + }); + wrapper.update(); + + expect(wrapper.find(EuiTableRow)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx index 25a9eee38f93f8..3b6e2dc440a76c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx @@ -5,9 +5,10 @@ * 2.0. */ -import React from 'react'; +import React, { useState } from 'react'; import { EuiBadge, EuiBasicTableColumn, EuiInMemoryTable, EuiTextColor } from '@elastic/eui'; +import type { EuiSearchBarOnChangeArgs } from '@elastic/eui'; import { ASRoleMapping } from '../../app_search/types'; import { SingleUserRoleMapping } from '../../shared/types'; @@ -73,6 +74,8 @@ export const UsersTable: React.FC = ({ invitation: user.invitation, })) as unknown) as Array>; + const [items, setItems] = useState(users); + const columns: Array> = [ { field: 'username', @@ -134,7 +137,22 @@ export const UsersTable: React.FC = ({ pageSize: 10, }; + const onQueryChange = ({ queryText }: EuiSearchBarOnChangeArgs) => { + const filteredItems = users.filter((user) => { + // JSON.stringify allows us to search all the object fields + // without converting all the nested arrays and objects to strings manually + // Some false-positives are possible, because the search is also performed on + // object keys, but the simplicity of JSON.stringify seems to worth the tradeoff. + const normalizedTableItemString = JSON.stringify(user).toLowerCase(); + const normalizedQuery = queryText.toLowerCase(); + return normalizedTableItemString.indexOf(normalizedQuery) !== -1; + }); + + setItems(filteredItems); + }; + const search = { + onChange: onQueryChange, box: { incremental: true, fullWidth: false, @@ -147,7 +165,7 @@ export const UsersTable: React.FC = ({ Date: Wed, 11 Aug 2021 14:49:38 -0400 Subject: [PATCH 02/20] [Alerting] Alerting authorization should always exempt `alerts` consumer (#108220) * Reverting changes to genericize exempt consumer id * Adding unit test for find auth filter when user has no privileges --- ...rting_authorization_client_factory.test.ts | 30 -- .../alerting_authorization_client_factory.ts | 3 +- .../alerting_authorization.test.ts | 308 ++++-------------- .../authorization/alerting_authorization.ts | 36 +- .../server/rules_client_factory.test.ts | 9 +- .../alerting/server/rules_client_factory.ts | 3 +- 6 files changed, 87 insertions(+), 302 deletions(-) diff --git a/x-pack/plugins/alerting/server/alerting_authorization_client_factory.test.ts b/x-pack/plugins/alerting/server/alerting_authorization_client_factory.test.ts index 77a15eda79cef0..2ba3580745d594 100644 --- a/x-pack/plugins/alerting/server/alerting_authorization_client_factory.test.ts +++ b/x-pack/plugins/alerting/server/alerting_authorization_client_factory.test.ts @@ -75,35 +75,6 @@ test('creates an alerting authorization client with proper constructor arguments auditLogger: expect.any(AlertingAuthorizationAuditLogger), getSpace: expect.any(Function), getSpaceId: expect.any(Function), - exemptConsumerIds: [], - }); - - expect(AlertingAuthorizationAuditLogger).toHaveBeenCalled(); - expect(securityPluginSetup.audit.getLogger).toHaveBeenCalledWith(ALERTS_FEATURE_ID); -}); - -test('creates an alerting authorization client with proper constructor arguments when exemptConsumerIds are specified', async () => { - const factory = new AlertingAuthorizationClientFactory(); - factory.initialize({ - securityPluginSetup, - securityPluginStart, - ...alertingAuthorizationClientFactoryParams, - }); - const request = KibanaRequest.from(fakeRequest); - const { AlertingAuthorizationAuditLogger } = jest.requireMock('./authorization/audit_logger'); - - factory.create(request, ['exemptConsumerA', 'exemptConsumerB']); - - const { AlertingAuthorization } = jest.requireMock('./authorization/alerting_authorization'); - expect(AlertingAuthorization).toHaveBeenCalledWith({ - request, - authorization: securityPluginStart.authz, - ruleTypeRegistry: alertingAuthorizationClientFactoryParams.ruleTypeRegistry, - features: alertingAuthorizationClientFactoryParams.features, - auditLogger: expect.any(AlertingAuthorizationAuditLogger), - getSpace: expect.any(Function), - getSpaceId: expect.any(Function), - exemptConsumerIds: ['exemptConsumerA', 'exemptConsumerB'], }); expect(AlertingAuthorizationAuditLogger).toHaveBeenCalled(); @@ -126,7 +97,6 @@ test('creates an alerting authorization client with proper constructor arguments auditLogger: expect.any(AlertingAuthorizationAuditLogger), getSpace: expect.any(Function), getSpaceId: expect.any(Function), - exemptConsumerIds: [], }); expect(AlertingAuthorizationAuditLogger).toHaveBeenCalled(); diff --git a/x-pack/plugins/alerting/server/alerting_authorization_client_factory.ts b/x-pack/plugins/alerting/server/alerting_authorization_client_factory.ts index 1df67ed8d4b792..27b2d92eba2561 100644 --- a/x-pack/plugins/alerting/server/alerting_authorization_client_factory.ts +++ b/x-pack/plugins/alerting/server/alerting_authorization_client_factory.ts @@ -45,7 +45,7 @@ export class AlertingAuthorizationClientFactory { this.getSpaceId = options.getSpaceId; } - public create(request: KibanaRequest, exemptConsumerIds: string[] = []): AlertingAuthorization { + public create(request: KibanaRequest): AlertingAuthorization { const { securityPluginSetup, securityPluginStart, features } = this; return new AlertingAuthorization({ authorization: securityPluginStart?.authz, @@ -57,7 +57,6 @@ export class AlertingAuthorizationClientFactory { auditLogger: new AlertingAuthorizationAuditLogger( securityPluginSetup?.audit.getLogger(ALERTS_FEATURE_ID) ), - exemptConsumerIds, }); } } diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts index 6314488af88d75..7f5b06031c18cd 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts @@ -37,8 +37,6 @@ const realAuditLogger = new AlertingAuthorizationAuditLogger(); const getSpace = jest.fn(); const getSpaceId = () => 'space1'; -const exemptConsumerIds: string[] = []; - const mockAuthorizationAction = ( type: string, app: string, @@ -235,7 +233,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); expect(getSpace).toHaveBeenCalledWith(request); @@ -251,7 +248,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); await alertAuthorization.ensureAuthorized({ @@ -275,7 +271,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); await alertAuthorization.ensureAuthorized({ @@ -302,7 +297,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); checkPrivileges.mockResolvedValueOnce({ @@ -359,7 +353,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); checkPrivileges.mockResolvedValueOnce({ @@ -402,7 +395,7 @@ describe('AlertingAuthorization', () => { `); }); - test('ensures the user has privileges to execute rules for the specified rule type and operation without consumer when consumer is exempt', async () => { + test('ensures the user has privileges to execute rules for the specified rule type and operation without consumer when consumer is alerts', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< ReturnType @@ -416,7 +409,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds: ['exemptConsumer'], }); checkPrivileges.mockResolvedValueOnce({ @@ -427,7 +419,7 @@ describe('AlertingAuthorization', () => { await alertAuthorization.ensureAuthorized({ ruleTypeId: 'myType', - consumer: 'exemptConsumer', + consumer: 'alerts', operation: WriteOperations.Create, entity: AlertingAuthorizationEntity.Rule, }); @@ -437,7 +429,7 @@ describe('AlertingAuthorization', () => { expect(authorization.actions.alerting.get).toHaveBeenCalledTimes(2); expect(authorization.actions.alerting.get).toHaveBeenCalledWith( 'myType', - 'exemptConsumer', + 'alerts', 'rule', 'create' ); @@ -458,14 +450,14 @@ describe('AlertingAuthorization', () => { "some-user", "myType", 0, - "exemptConsumer", + "alerts", "create", "rule", ] `); }); - test('ensures the user has privileges to execute alerts for the specified rule type and operation without consumer when consumer is exempt', async () => { + test('ensures the user has privileges to execute alerts for the specified rule type and operation without consumer when consumer is alerts', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< ReturnType @@ -479,7 +471,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds: ['exemptConsumer'], }); checkPrivileges.mockResolvedValueOnce({ @@ -490,7 +481,7 @@ describe('AlertingAuthorization', () => { await alertAuthorization.ensureAuthorized({ ruleTypeId: 'myType', - consumer: 'exemptConsumer', + consumer: 'alerts', operation: WriteOperations.Update, entity: AlertingAuthorizationEntity.Alert, }); @@ -500,7 +491,7 @@ describe('AlertingAuthorization', () => { expect(authorization.actions.alerting.get).toHaveBeenCalledTimes(2); expect(authorization.actions.alerting.get).toHaveBeenCalledWith( 'myType', - 'exemptConsumer', + 'alerts', 'alert', 'update' ); @@ -521,7 +512,7 @@ describe('AlertingAuthorization', () => { "some-user", "myType", 0, - "exemptConsumer", + "alerts", "update", "alert", ] @@ -548,7 +539,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); await alertAuthorization.ensureAuthorized({ @@ -614,7 +604,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); await alertAuthorization.ensureAuthorized({ @@ -674,7 +663,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); checkPrivileges.mockResolvedValueOnce({ @@ -733,7 +721,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); checkPrivileges.mockResolvedValueOnce({ @@ -796,7 +783,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); checkPrivileges.mockResolvedValueOnce({ @@ -855,7 +841,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); checkPrivileges.mockResolvedValueOnce({ @@ -947,7 +932,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); const { filter, @@ -970,7 +954,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); const { ensureRuleTypeIsAuthorized } = await alertAuthorization.getFindAuthorizationFilter( AlertingAuthorizationEntity.Rule, @@ -1005,7 +988,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); expect( @@ -1020,11 +1002,54 @@ describe('AlertingAuthorization', () => { ).filter ).toEqual( esKuery.fromKueryExpression( - `((path.to.rule_type_id:myAppAlertType and consumer-field:(myApp or myOtherApp or myAppWithSubFeature)) or (path.to.rule_type_id:myOtherAppAlertType and consumer-field:(myApp or myOtherApp or myAppWithSubFeature)) or (path.to.rule_type_id:mySecondAppAlertType and consumer-field:(myApp or myOtherApp or myAppWithSubFeature)))` + `((path.to.rule_type_id:myAppAlertType and consumer-field:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (path.to.rule_type_id:myOtherAppAlertType and consumer-field:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (path.to.rule_type_id:mySecondAppAlertType and consumer-field:(alerts or myApp or myOtherApp or myAppWithSubFeature)))` ) ); expect(auditLogger.logAuthorizationSuccess).not.toHaveBeenCalled(); }); + test('throws if user has no privileges to any rule type', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction< + ReturnType + > = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myType', 'myOtherApp', 'rule', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myType', 'myApp', 'rule', 'create'), + authorized: false, + }, + ], + }, + }); + const alertAuthorization = new AlertingAuthorization({ + request, + authorization, + ruleTypeRegistry, + features, + auditLogger, + getSpace, + getSpaceId, + }); + ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); + await expect( + alertAuthorization.getFindAuthorizationFilter(AlertingAuthorizationEntity.Rule, { + type: AlertingAuthorizationFilterType.KQL, + fieldNames: { + ruleTypeId: 'path.to.rule_type_id', + consumer: 'consumer-field', + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Unauthorized some-user/find"`); + expect(auditLogger.logAuthorizationSuccess).not.toHaveBeenCalled(); + }); test('creates an `ensureRuleTypeIsAuthorized` function which throws if type is unauthorized', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< @@ -1068,7 +1093,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); const { ensureRuleTypeIsAuthorized } = await alertAuthorization.getFindAuthorizationFilter( @@ -1142,7 +1166,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); const { ensureRuleTypeIsAuthorized } = await alertAuthorization.getFindAuthorizationFilter( @@ -1217,7 +1240,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); const { @@ -1270,7 +1292,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); const { filter } = await alertAuthorization.getFindAuthorizationFilter( AlertingAuthorizationEntity.Alert, @@ -1325,89 +1346,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, - }); - ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); - - await expect( - alertAuthorization.filterByRuleTypeAuthorization( - new Set([myAppAlertType, myOtherAppAlertType]), - [WriteOperations.Create], - AlertingAuthorizationEntity.Rule - ) - ).resolves.toMatchInlineSnapshot(` - Set { - Object { - "actionGroups": Array [], - "actionVariables": undefined, - "authorizedConsumers": Object { - "myApp": Object { - "all": true, - "read": true, - }, - "myAppWithSubFeature": Object { - "all": true, - "read": true, - }, - "myOtherApp": Object { - "all": true, - "read": true, - }, - }, - "defaultActionGroupId": "default", - "enabledInLicense": true, - "id": "myAppAlertType", - "isExportable": true, - "minimumLicenseRequired": "basic", - "name": "myAppAlertType", - "producer": "myApp", - "recoveryActionGroup": Object { - "id": "recovered", - "name": "Recovered", - }, - }, - Object { - "actionGroups": Array [], - "actionVariables": undefined, - "authorizedConsumers": Object { - "myApp": Object { - "all": true, - "read": true, - }, - "myAppWithSubFeature": Object { - "all": true, - "read": true, - }, - "myOtherApp": Object { - "all": true, - "read": true, - }, - }, - "defaultActionGroupId": "default", - "enabledInLicense": true, - "id": "myOtherAppAlertType", - "isExportable": true, - "minimumLicenseRequired": "basic", - "name": "myOtherAppAlertType", - "producer": "myOtherApp", - "recoveryActionGroup": Object { - "id": "recovered", - "name": "Recovered", - }, - }, - } - `); - }); - - test('augments a list of types with all features and exempt consumer ids when there is no authorization api', async () => { - const alertAuthorization = new AlertingAuthorization({ - request, - ruleTypeRegistry, - features, - auditLogger, - getSpace, - getSpaceId, - exemptConsumerIds: ['exemptConsumerA', 'exemptConsumerB'], }); ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); @@ -1423,11 +1361,7 @@ describe('AlertingAuthorization', () => { "actionGroups": Array [], "actionVariables": undefined, "authorizedConsumers": Object { - "exemptConsumerA": Object { - "all": true, - "read": true, - }, - "exemptConsumerB": Object { + "alerts": Object { "all": true, "read": true, }, @@ -1460,11 +1394,7 @@ describe('AlertingAuthorization', () => { "actionGroups": Array [], "actionVariables": undefined, "authorizedConsumers": Object { - "exemptConsumerA": Object { - "all": true, - "read": true, - }, - "exemptConsumerB": Object { + "alerts": Object { "all": true, "read": true, }, @@ -1541,7 +1471,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); @@ -1578,113 +1507,7 @@ describe('AlertingAuthorization', () => { "actionGroups": Array [], "actionVariables": undefined, "authorizedConsumers": Object { - "myApp": Object { - "all": true, - "read": true, - }, - "myOtherApp": Object { - "all": true, - "read": true, - }, - }, - "defaultActionGroupId": "default", - "enabledInLicense": true, - "id": "myAppAlertType", - "isExportable": true, - "minimumLicenseRequired": "basic", - "name": "myAppAlertType", - "producer": "myApp", - "recoveryActionGroup": Object { - "id": "recovered", - "name": "Recovered", - }, - }, - } - `); - }); - - test('augments a list of types with consumers and exempt consumer ids under which the operation is authorized', async () => { - const { authorization } = mockSecurity(); - const checkPrivileges: jest.MockedFunction< - ReturnType - > = jest.fn(); - authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); - checkPrivileges.mockResolvedValueOnce({ - username: 'some-user', - hasAllRequested: false, - privileges: { - kibana: [ - { - privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'rule', 'create'), - authorized: true, - }, - { - privilege: mockAuthorizationAction( - 'myOtherAppAlertType', - 'myOtherApp', - 'rule', - 'create' - ), - authorized: false, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'rule', 'create'), - authorized: true, - }, - { - privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'rule', 'create'), - authorized: true, - }, - ], - }, - }); - - const alertAuthorization = new AlertingAuthorization({ - request, - authorization, - ruleTypeRegistry, - features, - auditLogger, - getSpace, - getSpaceId, - exemptConsumerIds: ['exemptConsumerA'], - }); - ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); - - await expect( - alertAuthorization.filterByRuleTypeAuthorization( - new Set([myAppAlertType, myOtherAppAlertType]), - [WriteOperations.Create], - AlertingAuthorizationEntity.Rule - ) - ).resolves.toMatchInlineSnapshot(` - Set { - Object { - "actionGroups": Array [], - "actionVariables": undefined, - "authorizedConsumers": Object { - "myApp": Object { - "all": true, - "read": true, - }, - }, - "defaultActionGroupId": "default", - "enabledInLicense": true, - "id": "myOtherAppAlertType", - "isExportable": true, - "minimumLicenseRequired": "basic", - "name": "myOtherAppAlertType", - "producer": "myOtherApp", - "recoveryActionGroup": Object { - "id": "recovered", - "name": "Recovered", - }, - }, - Object { - "actionGroups": Array [], - "actionVariables": undefined, - "authorizedConsumers": Object { - "exemptConsumerA": Object { + "alerts": Object { "all": true, "read": true, }, @@ -1713,7 +1536,7 @@ describe('AlertingAuthorization', () => { `); }); - test('authorizes user under exempt consumers when they are authorized by the producer', async () => { + test('authorizes user under the `alerts` consumer when they are authorized by the producer', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< ReturnType @@ -1744,7 +1567,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds: ['exemptConsumerA'], }); ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); @@ -1760,7 +1582,7 @@ describe('AlertingAuthorization', () => { "actionGroups": Array [], "actionVariables": undefined, "authorizedConsumers": Object { - "exemptConsumerA": Object { + "alerts": Object { "all": true, "read": true, }, @@ -1850,7 +1672,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); @@ -1866,6 +1687,10 @@ describe('AlertingAuthorization', () => { "actionGroups": Array [], "actionVariables": undefined, "authorizedConsumers": Object { + "alerts": Object { + "all": false, + "read": true, + }, "myApp": Object { "all": true, "read": true, @@ -1891,6 +1716,10 @@ describe('AlertingAuthorization', () => { "actionGroups": Array [], "actionVariables": undefined, "authorizedConsumers": Object { + "alerts": Object { + "all": false, + "read": true, + }, "myApp": Object { "all": false, "read": true, @@ -1960,7 +1789,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); @@ -1976,6 +1804,10 @@ describe('AlertingAuthorization', () => { "actionGroups": Array [], "actionVariables": undefined, "authorizedConsumers": Object { + "alerts": Object { + "all": true, + "read": true, + }, "myApp": Object { "all": true, "read": true, @@ -2067,7 +1899,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); @@ -2142,7 +1973,6 @@ describe('AlertingAuthorization', () => { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }); ruleTypeRegistry.list.mockReturnValue(setOfAlertTypes); diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts index ed14c2dd7f0ae3..101ab675bb5530 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts @@ -9,7 +9,7 @@ import Boom from '@hapi/boom'; import { map, mapValues, fromPairs, has } from 'lodash'; import { KibanaRequest } from 'src/core/server'; import { JsonObject } from '@kbn/utility-types'; -import { RuleTypeRegistry } from '../types'; +import { ALERTS_FEATURE_ID, RuleTypeRegistry } from '../types'; import { SecurityPluginSetup } from '../../../security/server'; import { RegistryRuleType } from '../rule_type_registry'; import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; @@ -71,7 +71,6 @@ export interface ConstructorOptions { getSpace: (request: KibanaRequest) => Promise; getSpaceId: (request: KibanaRequest) => string | undefined; auditLogger: AlertingAuthorizationAuditLogger; - exemptConsumerIds: string[]; authorization?: SecurityPluginSetup['authz']; } @@ -82,7 +81,6 @@ export class AlertingAuthorization { private readonly auditLogger: AlertingAuthorizationAuditLogger; private readonly featuresIds: Promise>; private readonly allPossibleConsumers: Promise; - private readonly exemptConsumerIds: string[]; private readonly spaceId: string | undefined; constructor({ @@ -93,18 +91,12 @@ export class AlertingAuthorization { auditLogger, getSpace, getSpaceId, - exemptConsumerIds, }: ConstructorOptions) { this.request = request; this.authorization = authorization; this.ruleTypeRegistry = ruleTypeRegistry; this.auditLogger = auditLogger; - // List of consumer ids that are exempt from privilege check. This should be used sparingly. - // An example of this is the Rules Management `consumer` as we don't want to have to - // manually authorize each rule type in the management UI. - this.exemptConsumerIds = exemptConsumerIds; - this.spaceId = getSpaceId(request); this.featuresIds = getSpace(request) @@ -132,7 +124,7 @@ export class AlertingAuthorization { this.allPossibleConsumers = this.featuresIds.then((featuresIds) => { return featuresIds.size - ? asAuthorizedConsumers([...this.exemptConsumerIds, ...featuresIds], { + ? asAuthorizedConsumers([ALERTS_FEATURE_ID, ...featuresIds], { read: true, all: true, }) @@ -185,8 +177,10 @@ export class AlertingAuthorization { ), }; - // Skip authorizing consumer if it is in the list of exempt consumer ids - const shouldAuthorizeConsumer = !this.exemptConsumerIds.includes(consumer); + // Skip authorizing consumer if consumer is the Rules Management consumer (`alerts`) + // This means that rules and their derivative alerts created in the Rules Management UI + // will only be subject to checking if user has access to the rule producer. + const shouldAuthorizeConsumer = consumer !== ALERTS_FEATURE_ID; const checkPrivileges = authorization.checkPrivilegesDynamicallyWithRequest(this.request); const { hasAllRequested, username, privileges } = await checkPrivileges({ @@ -199,8 +193,8 @@ export class AlertingAuthorization { requiredPrivilegesByScope.producer, ] : [ - // skip consumer privilege checks for exempt consumer ids as all rule types can - // be created for exempt consumers if user has producer level privileges + // skip consumer privilege checks under `alerts` as all rule types can + // be created under `alerts` if you have producer level privileges requiredPrivilegesByScope.producer, ], }); @@ -448,14 +442,12 @@ export class AlertingAuthorization { ruleType.authorizedConsumers[feature] ); - if (isAuthorizedAtProducerLevel && this.exemptConsumerIds.length > 0) { - // granting privileges under the producer automatically authorized exempt consumer IDs as well - this.exemptConsumerIds.forEach((exemptId: string) => { - ruleType.authorizedConsumers[exemptId] = mergeHasPrivileges( - hasPrivileges, - ruleType.authorizedConsumers[exemptId] - ); - }); + if (isAuthorizedAtProducerLevel) { + // granting privileges under the producer automatically authorized the Rules Management UI as well + ruleType.authorizedConsumers[ALERTS_FEATURE_ID] = mergeHasPrivileges( + hasPrivileges, + ruleType.authorizedConsumers[ALERTS_FEATURE_ID] + ); } authorizedRuleTypes.add(ruleType); } diff --git a/x-pack/plugins/alerting/server/rules_client_factory.test.ts b/x-pack/plugins/alerting/server/rules_client_factory.test.ts index df59205cf10b3e..188ec652f40ce3 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.test.ts @@ -21,7 +21,6 @@ import { securityMock } from '../../security/server/mocks'; import { PluginStartContract as ActionsStartContract } from '../../actions/server'; import { actionsMock, actionsAuthorizationMock } from '../../actions/server/mocks'; import { LegacyAuditLogger } from '../../security/server'; -import { ALERTS_FEATURE_ID } from '../common'; import { eventLogMock } from '../../event_log/server/mocks'; import { alertingAuthorizationMock } from './authorization/alerting_authorization.mock'; import { alertingAuthorizationClientFactoryMock } from './alerting_authorization_client_factory.mock'; @@ -105,9 +104,7 @@ test('creates an alerts client with proper constructor arguments when security i includedHiddenTypes: ['alert', 'api_key_pending_invalidation'], }); - expect(alertingAuthorizationClientFactory.create).toHaveBeenCalledWith(request, [ - ALERTS_FEATURE_ID, - ]); + expect(alertingAuthorizationClientFactory.create).toHaveBeenCalledWith(request); expect(rulesClientFactoryParams.actions.getActionsAuthorizationWithRequest).toHaveBeenCalledWith( request @@ -148,9 +145,7 @@ test('creates an alerts client with proper constructor arguments', async () => { includedHiddenTypes: ['alert', 'api_key_pending_invalidation'], }); - expect(alertingAuthorizationClientFactory.create).toHaveBeenCalledWith(request, [ - ALERTS_FEATURE_ID, - ]); + expect(alertingAuthorizationClientFactory.create).toHaveBeenCalledWith(request); expect(jest.requireMock('./rules_client').RulesClient).toHaveBeenCalledWith({ unsecuredSavedObjectsClient: savedObjectsClient, diff --git a/x-pack/plugins/alerting/server/rules_client_factory.ts b/x-pack/plugins/alerting/server/rules_client_factory.ts index 336c8e6de20e6f..7961d3761d3efe 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.ts @@ -19,7 +19,6 @@ import { EncryptedSavedObjectsClient } from '../../encrypted_saved_objects/serve import { TaskManagerStartContract } from '../../task_manager/server'; import { IEventLogClientService } from '../../../plugins/event_log/server'; import { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory'; -import { ALERTS_FEATURE_ID } from '../common'; export interface RulesClientFactoryOpts { logger: Logger; taskManager: TaskManagerStartContract; @@ -87,7 +86,7 @@ export class RulesClientFactory { excludedWrappers: ['security'], includedHiddenTypes: ['alert', 'api_key_pending_invalidation'], }), - authorization: this.authorization.create(request, [ALERTS_FEATURE_ID]), + authorization: this.authorization.create(request), actionsAuthorization: actions.getActionsAuthorizationWithRequest(request), namespace: this.spaceIdToNamespace(spaceId), encryptedSavedObjectsClient: this.encryptedSavedObjectsClient, From 900052f32c86a0cc93cbc499c5537b72437f4d57 Mon Sep 17 00:00:00 2001 From: Kerry Gallagher <471693+Kerry350@users.noreply.github.com> Date: Wed, 11 Aug 2021 19:51:32 +0100 Subject: [PATCH 03/20] [APM] Add a logs tab for services (#107664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a logs tab for APM services Co-authored-by: Søren Louv-Jansen --- .../elasticsearch_fieldnames.test.ts.snap | 6 + .../apm/common/elasticsearch_fieldnames.ts | 1 + .../components/app/service_logs/index.tsx | 104 ++++++++++++++++++ .../routing/service_detail/index.tsx | 9 ++ .../templates/apm_service_template/index.tsx | 13 +++ .../services/get_service_infrastructure.ts | 86 +++++++++++++++ x-pack/plugins/apm/server/routes/services.ts | 33 +++++- .../log_stream/log_stream.stories.mdx | 35 +++++- .../components/log_stream/log_stream.tsx | 8 +- 9 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/service_logs/index.tsx create mode 100644 x-pack/plugins/apm/server/lib/services/get_service_infrastructure.ts diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 52f1c3b44d7951..141d94e2b168f7 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -75,6 +75,8 @@ exports[`Error HOST_NAME 1`] = `"my hostname"`; exports[`Error HOST_OS_PLATFORM 1`] = `undefined`; +exports[`Error HOSTNAME 1`] = `undefined`; + exports[`Error HTTP_REQUEST_METHOD 1`] = `undefined`; exports[`Error HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`; @@ -314,6 +316,8 @@ exports[`Span HOST_NAME 1`] = `undefined`; exports[`Span HOST_OS_PLATFORM 1`] = `undefined`; +exports[`Span HOSTNAME 1`] = `undefined`; + exports[`Span HTTP_REQUEST_METHOD 1`] = `undefined`; exports[`Span HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`; @@ -553,6 +557,8 @@ exports[`Transaction HOST_NAME 1`] = `"my hostname"`; exports[`Transaction HOST_OS_PLATFORM 1`] = `undefined`; +exports[`Transaction HOSTNAME 1`] = `undefined`; + exports[`Transaction HTTP_REQUEST_METHOD 1`] = `"GET"`; exports[`Transaction HTTP_RESPONSE_STATUS_CODE 1`] = `200`; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 82a592cd7e4d1b..d1f07c28bc8084 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -114,6 +114,7 @@ export const LABEL_NAME = 'labels.name'; export const HOST = 'host'; export const HOST_NAME = 'host.hostname'; +export const HOSTNAME = 'host.name'; export const HOST_OS_PLATFORM = 'host.os.platform'; export const CONTAINER_ID = 'container.id'; export const KUBERNETES = 'kubernetes'; diff --git a/x-pack/plugins/apm/public/components/app/service_logs/index.tsx b/x-pack/plugins/apm/public/components/app/service_logs/index.tsx new file mode 100644 index 00000000000000..8e642c1f27e1ad --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_logs/index.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash'; +import { EuiLoadingSpinner, EuiEmptyPrompt } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { LogStream } from '../../../../../infra/public'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; + +import { + CONTAINER_ID, + HOSTNAME, + POD_NAME, +} from '../../../../common/elasticsearch_fieldnames'; + +export function ServiceLogs() { + const { serviceName } = useApmServiceContext(); + const { + urlParams: { environment, kuery, start, end }, + } = useUrlParams(); + + const { data, status } = useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/infrastructure', + params: { + path: { serviceName }, + query: { + environment, + kuery, + start, + end, + }, + }, + }); + } + }, + [environment, kuery, serviceName, start, end] + ); + + const noInfrastructureData = useMemo(() => { + return ( + isEmpty(data?.serviceInfrastructure?.containerIds) && + isEmpty(data?.serviceInfrastructure?.hostNames) && + isEmpty(data?.serviceInfrastructure?.podNames) + ); + }, [data]); + + if (status === FETCH_STATUS.LOADING) { + return ( +
+ +
+ ); + } + + if (status === FETCH_STATUS.SUCCESS && noInfrastructureData) { + return ( + + {i18n.translate('xpack.apm.serviceLogs.noInfrastructureMessage', { + defaultMessage: 'There are no log messages to display.', + })} + + } + /> + ); + } + + return ( + + ); +} + +const getInfrastructureKQLFilter = ( + data?: APIReturnType<'GET /api/apm/services/{serviceName}/infrastructure'> +) => { + const containerIds = data?.serviceInfrastructure?.containerIds ?? []; + const hostNames = data?.serviceInfrastructure?.hostNames ?? []; + const podNames = data?.serviceInfrastructure?.podNames ?? []; + + return [ + ...containerIds.map((id) => `${CONTAINER_ID}: "${id}"`), + ...hostNames.map((id) => `${HOSTNAME}: "${id}"`), + ...podNames.map((id) => `${POD_NAME}: "${id}"`), + ].join(' or '); +}; diff --git a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx index 9716dd01561e59..5aeae224d0d5d1 100644 --- a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx @@ -22,6 +22,7 @@ import { ServiceMap } from '../../app/service_map'; import { TransactionDetails } from '../../app/transaction_details'; import { ServiceProfiling } from '../../app/service_profiling'; import { ServiceDependencies } from '../../app/service_dependencies'; +import { ServiceLogs } from '../../app/service_logs'; function page({ path, @@ -233,6 +234,14 @@ export const serviceDetail = { hidden: true, }, }), + page({ + path: '/logs', + tab: 'logs', + title: i18n.translate('xpack.apm.views.logs.title', { + defaultMessage: 'Logs', + }), + element: , + }), page({ path: '/profiling', tab: 'profiling', diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx index d92d7a8d949229..d332048338cc0c 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx @@ -41,6 +41,7 @@ type Tab = NonNullable[0] & { | 'metrics' | 'nodes' | 'service-map' + | 'logs' | 'profiling'; hidden?: boolean; }; @@ -218,6 +219,18 @@ function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) { defaultMessage: 'Service Map', }), }, + { + key: 'logs', + href: router.link('/services/:serviceName/logs', { + path: { serviceName }, + query, + }), + label: i18n.translate('xpack.apm.home.serviceLogsTabLabel', { + defaultMessage: 'Logs', + }), + hidden: + !agentName || isRumAgentName(agentName) || isIosAgentName(agentName), + }, { key: 'profiling', href: router.link('/services/:serviceName/profiling', { diff --git a/x-pack/plugins/apm/server/lib/services/get_service_infrastructure.ts b/x-pack/plugins/apm/server/lib/services/get_service_infrastructure.ts new file mode 100644 index 00000000000000..79ecab45c75b39 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_infrastructure.ts @@ -0,0 +1,86 @@ +/* + * 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 { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { ESFilter } from '../../../../../../src/core/types/elasticsearch'; +import { rangeQuery, kqlQuery } from '../../../../observability/server'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { + SERVICE_NAME, + CONTAINER_ID, + HOSTNAME, + POD_NAME, +} from '../../../common/elasticsearch_fieldnames'; + +export const getServiceInfrastructure = async ({ + kuery, + serviceName, + environment, + setup, +}: { + kuery?: string; + serviceName: string; + environment?: string; + setup: Setup & SetupTimeRange; +}) => { + const { apmEventClient, start, end } = setup; + + const filter: ESFilter[] = [ + { term: { [SERVICE_NAME]: serviceName } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ]; + + const response = await apmEventClient.search('get_service_infrastructure', { + apm: { + events: [ProcessorEvent.metric], + }, + body: { + size: 0, + query: { + bool: { + filter, + }, + }, + aggs: { + containerIds: { + terms: { + field: CONTAINER_ID, + size: 500, + }, + }, + hostNames: { + terms: { + field: HOSTNAME, + size: 500, + }, + }, + podNames: { + terms: { + field: POD_NAME, + size: 500, + }, + }, + }, + }, + }); + + return { + containerIds: + response.aggregations?.containerIds?.buckets.map( + (bucket) => bucket.key + ) ?? [], + hostNames: + response.aggregations?.hostNames?.buckets.map((bucket) => bucket.key) ?? + [], + podNames: + response.aggregations?.podNames?.buckets.map((bucket) => bucket.key) ?? + [], + }; +}; diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 6509c5764edb8c..10d5bc5e3abdbc 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -31,6 +31,7 @@ import { getServiceTransactionTypes } from '../lib/services/get_service_transact import { getThroughput } from '../lib/services/get_throughput'; import { getServiceProfilingStatistics } from '../lib/services/profiling/get_service_profiling_statistics'; import { getServiceProfilingTimeline } from '../lib/services/profiling/get_service_profiling_timeline'; +import { getServiceInfrastructure } from '../lib/services/get_service_infrastructure'; import { withApmSpan } from '../utils/with_apm_span'; import { createApmServerRoute } from './create_apm_server_route'; import { createApmServerRouteRepository } from './create_apm_server_route_repository'; @@ -853,6 +854,35 @@ const serviceAlertsRoute = createApmServerRoute({ }, }); +const serviceInfrastructureRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/infrastructure', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([kueryRt, rangeRt, environmentRt]), + }), + options: { tags: ['access:apm'] }, + handler: async (resources) => { + const setup = await setupRequest(resources); + + const { params } = resources; + + const { + path: { serviceName }, + query: { environment, kuery }, + } = params; + + const serviceInfrastructure = await getServiceInfrastructure({ + setup, + serviceName, + environment, + kuery, + }); + return { serviceInfrastructure }; + }, +}); + export const serviceRouteRepository = createApmServerRouteRepository() .add(servicesRoute) .add(servicesDetailedStatisticsRoute) @@ -873,4 +903,5 @@ export const serviceRouteRepository = createApmServerRouteRepository() .add(serviceDependenciesBreakdownRoute) .add(serviceProfilingTimelineRoute) .add(serviceProfilingStatisticsRoute) - .add(serviceAlertsRoute); + .add(serviceAlertsRoute) + .add(serviceInfrastructureRoute); diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx b/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx index 87419a9bfbe782..b241147c8d6755 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx @@ -195,7 +195,11 @@ This will show a list of log entries between the specified timestamps. ## Query log entries -You might want to show specific log entries in your plugin. Maybe you want to show log lines from a specific host, or for an AMP trace. The component has a `query` prop that accepts valid KQL expressions. +You might want to show specific log entries in your plugin. Maybe you want to show log lines from a specific host, or for an AMP trace. The LogStream component supports both `query` and `filters`, and these are the standard `es-query` types. + +### Query + +The component has a `query` prop that accepts a valid es-query `query`. You can either supply this with a `language` and `query` property, or you can just supply a string which is a shortcut for KQL expressions. ```tsx ``` +### Filters + +The component also has a `filters` prop that accepts valid es-query `filters`. This example would specifiy that we want the `message` field to exist: + +```tsx + +``` + ## Center the view on a specific entry By default the component will load at the bottom of the list, showing the newest entries. You can change the rendering point with the `center` prop. The prop takes a [`LogEntriesCursor`](https://github.com/elastic/kibana/blob/0a6c748cc837c016901f69ff05d81395aa2d41c8/x-pack/plugins/infra/common/http_api/log_entries/common.ts#L9-L13). @@ -431,3 +460,7 @@ class MyPlugin { endTimestamp={...} /> ``` + +### Setting component height + +It's possible to pass a `height` prop, e.g. `60vh` or `300px`, to specify how much vertical space the component should consume. diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx index b927505a42c8ad..2698e975cebcab 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx @@ -5,10 +5,11 @@ * 2.0. */ +import { buildEsQuery, Query, Filter } from '@kbn/es-query'; import React, { useMemo, useCallback, useEffect } from 'react'; import { noop } from 'lodash'; import { JsonValue } from '@kbn/utility-types'; -import { DataPublicPluginStart, esQuery, Filter } from '../../../../../../src/plugins/data/public'; +import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; import { LogEntryCursor } from '../../../common/log_entry'; @@ -18,7 +19,6 @@ import { BuiltEsQuery, useLogStream } from '../../containers/logs/log_stream'; import { ScrollableLogTextStreamView } from '../logging/log_text_stream'; import { LogColumnRenderConfiguration } from '../../utils/log_column_render_configuration'; -import { Query } from '../../../../../../src/plugins/data/common'; import { LogStreamErrorBoundary } from './log_stream_error_boundary'; interface LogStreamPluginDeps { @@ -123,9 +123,9 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re const parsedQuery = useMemo(() => { if (typeof query === 'object' && 'bool' in query) { - return mergeBoolQueries(query, esQuery.buildEsQuery(derivedIndexPattern, [], filters ?? [])); + return mergeBoolQueries(query, buildEsQuery(derivedIndexPattern, [], filters ?? [])); } else { - return esQuery.buildEsQuery(derivedIndexPattern, coerceToQueries(query), filters ?? []); + return buildEsQuery(derivedIndexPattern, coerceToQueries(query), filters ?? []); } }, [derivedIndexPattern, filters, query]); From f236286b62529449ee7385af890e8004320eb854 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 11 Aug 2021 11:51:48 -0700 Subject: [PATCH 04/20] [kbn/es-archiver] fix flaky test (#108143) Co-authored-by: spalger --- .../src/lib/docs/index_doc_records_stream.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.test.ts b/packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.test.ts index 91cec7dc490942..bcf28a4976a1c3 100644 --- a/packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.test.ts +++ b/packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.test.ts @@ -99,10 +99,8 @@ const testRecords = [ }, ]; -// FLAKY: https://github.com/elastic/kibana/issues/108043 -it.skip('indexes documents using the bulk client helper', async () => { +it('indexes documents using the bulk client helper', async () => { const client = new MockClient(); - client.helpers.bulk.mockImplementation(async () => {}); const progress = new Progress(); const stats = createStats('test', log); @@ -186,11 +184,11 @@ it.skip('indexes documents using the bulk client helper', async () => { "results": Array [ Object { "type": "return", - "value": Promise {}, + "value": undefined, }, Object { "type": "return", - "value": Promise {}, + "value": undefined, }, ], } From 12115d680c88d0b4306056f208e393e6d48dcc77 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Wed, 11 Aug 2021 12:42:10 -0700 Subject: [PATCH 05/20] [data.search] Add owner/description properties to kibana.json (#107954) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- examples/search_examples/kibana.json | 7 ++++++- x-pack/plugins/data_enhanced/kibana.json | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/examples/search_examples/kibana.json b/examples/search_examples/kibana.json index 227fd7f1c6261e..87839f2037f922 100644 --- a/examples/search_examples/kibana.json +++ b/examples/search_examples/kibana.json @@ -11,5 +11,10 @@ "ui": true, "requiredPlugins": ["navigation", "data", "developerExamples", "kibanaUtils", "share"], "optionalPlugins": [], - "requiredBundles": ["kibanaReact"] + "requiredBundles": ["kibanaReact"], + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, + "description": "Examples for using the data plugin search service. Includes examples for searching using the high level search source, or low-level search services, as well as integrating with search sessions." } diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index a0489ecd30aaac..da83ded471d0b3 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -1,3 +1,4 @@ + { "id": "dataEnhanced", "version": "8.0.0", @@ -7,5 +8,10 @@ "optionalPlugins": ["kibanaUtils", "usageCollection", "security"], "server": true, "ui": true, - "requiredBundles": ["kibanaUtils", "kibanaReact"] + "requiredBundles": ["kibanaUtils", "kibanaReact"], + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, + "description": "Enhanced data plugin. (See src/plugins/data.) Enhances the main data plugin with a search session management UI. Includes a reusable search session indicator component to use in other applications. Exposes routes for managing search sessions. Includes a service that monitors, updates, and cleans up search session saved objects." } From 2ce80869bedcd849b32770a300406eba1ef7ffcf Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 11 Aug 2021 13:48:22 -0600 Subject: [PATCH 06/20] [maps] add indication in layer TOC when layer is filtered by map bounds (#107662) * [maps] add indication in layer TOC when layer is filtered by map bounds * fix i18n id collision * use ghost color so icons are more visible * revert icon color change Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../classes/sources/vector_source/vector_source.tsx | 1 + .../toc_entry/toc_entry_button/toc_entry_button.tsx | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx index 7dc816f6e1b6c6..4dbf5f16b06735 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx @@ -71,6 +71,7 @@ export interface IVectorSource extends ISource { supportsFeatureEditing(): Promise; addFeature(geometry: Geometry | Position[]): Promise; deleteFeature(featureId: string): Promise; + isFilterByMapBounds(): boolean; } export class AbstractVectorSource extends AbstractSource implements IVectorSource { diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_button/toc_entry_button.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_button/toc_entry_button.tsx index ffad34454bb61c..eabb2b782272b1 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_button/toc_entry_button.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_button/toc_entry_button.tsx @@ -10,6 +10,7 @@ import React, { Component, Fragment, ReactNode } from 'react'; import { EuiButtonEmpty, EuiIcon, EuiToolTip, EuiLoadingSpinner } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ILayer } from '../../../../../../classes/layers/layer'; +import { IVectorSource } from '../../../../../../classes/sources/vector_source'; interface Footnote { icon: ReactNode; @@ -128,6 +129,18 @@ export class TOCEntryButton extends Component { }), }); } + const source = this.props.layer.getSource(); + if ( + typeof source.isFilterByMapBounds === 'function' && + (source as IVectorSource).isFilterByMapBounds() + ) { + footnotes.push({ + icon: , + message: i18n.translate('xpack.maps.layer.isUsingBoundsFilter', { + defaultMessage: 'Results narrowed by visible map area', + }), + }); + } } return { From 124c85ed8cadca04abccdc372b25a18e1c5bded9 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Wed, 11 Aug 2021 16:07:41 -0400 Subject: [PATCH 07/20] [ML] Data Frame Analytics: add evaluation quality metrics to Classification exploration view (#107862) * add evaluation quality metrics to Classification exploration view * move type to common file * fix path * switch accuracy and recall columns and update MetricItem name * add evaluation metrics title * ensure evaluation metrics section is left aligned --- .../data_frame_analytics/common/analytics.ts | 6 ++ .../_classification_exploration.scss | 4 + .../evaluate_panel.tsx | 98 ++++++++++++------- .../evaluation_quality_metrics_table.tsx | 72 ++++++++++++++ .../use_confusion_matrix.ts | 47 ++++++++- 5 files changed, 189 insertions(+), 38 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluation_quality_metrics_table.tsx diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index bac6b0b9274f52..c2c2563c5ba7c8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -65,6 +65,12 @@ export interface LoadExploreDataArg { searchQuery: SavedSearchQuery; } +export interface ClassificationMetricItem { + className: string; + accuracy?: number; + recall?: number; +} + export const SEARCH_SIZE = 1000; export const TRAINING_PERCENT_MIN = 1; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss index 73ced778821cfa..c429daaf3c8dcd 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss @@ -41,3 +41,7 @@ $labelColumnWidth: 80px; text-transform: none; } } + +.mlDataFrameAnalyticsClassification__evaluationMetrics { + width: 60%; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index 086adcecd077a3..fb103886635a9e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -34,6 +34,7 @@ import { ResultsSearchQuery } from '../../../../common/analytics'; import { ExpandableSection, HEADER_ITEMS_LOADING } from '../expandable_section'; import { EvaluateStat } from './evaluate_stat'; +import { EvaluationQualityMetricsTable } from './evaluation_quality_metrics_table'; import { getRocCurveChartVegaLiteSpec } from './get_roc_curve_chart_vega_lite_spec'; @@ -85,6 +86,13 @@ const trainingDatasetHelpText = i18n.translate( } ); +const evaluationQualityMetricsHelpText = i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.evaluationQualityMetricsHelpText', + { + defaultMessage: 'Evaluation quality metrics', + } +); + function getHelpText(dataSubsetTitle: string): string { let helpText = entireDatasetHelpText; if (dataSubsetTitle === SUBSET_TITLE.TESTING) { @@ -120,6 +128,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se error: errorConfusionMatrix, isLoading: isLoadingConfusionMatrix, overallAccuracy, + evaluationMetricsItems, } = useConfusionMatrix(jobConfig, searchQuery); useEffect(() => { @@ -365,46 +374,61 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se ) : null} {/* Accuracy and Recall */} - + + {evaluationQualityMetricsHelpText} + + + - + + + + + + + + - + {/* AUC ROC Chart */} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluation_quality_metrics_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluation_quality_metrics_table.tsx new file mode 100644 index 00000000000000..32820b69b8a8b2 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluation_quality_metrics_table.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, { FC } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiAccordion, EuiInMemoryTable, EuiPanel } from '@elastic/eui'; + +import { ClassificationMetricItem } from '../../../../common/analytics'; + +const columns = [ + { + field: 'className', + name: i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.recallAndAccuracyClassColumn', + { + defaultMessage: 'Class', + } + ), + sortable: true, + truncateText: true, + }, + { + field: 'accuracy', + name: i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.recallAndAccuracyAccuracyColumn', + { + defaultMessage: 'Accuracy', + } + ), + render: (value: number) => Math.round(value * 1000) / 1000, + }, + { + field: 'recall', + name: i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.recallAndAccuracyRecallColumn', + { + defaultMessage: 'Recall', + } + ), + render: (value: number) => Math.round(value * 1000) / 1000, + }, +]; + +export const EvaluationQualityMetricsTable: FC<{ + evaluationMetricsItems: ClassificationMetricItem[]; +}> = ({ evaluationMetricsItems }) => ( + <> + + } + > + + + items={evaluationMetricsItems} + columns={columns} + pagination + sorting + /> + + + +); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts index df48d2c5ab44ff..2a75acf823e881 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts @@ -9,9 +9,11 @@ import { useState, useEffect } from 'react'; import { isClassificationEvaluateResponse, + ClassificationEvaluateResponse, ConfusionMatrix, ResultsSearchQuery, ANALYSIS_CONFIG_TYPE, + ClassificationMetricItem, } from '../../../../common/analytics'; import { isKeywordAndTextType } from '../../../../common/fields'; @@ -25,6 +27,37 @@ import { import { isTrainingFilter } from './is_training_filter'; +function getEvalutionMetricsItems(evalMetrics?: ClassificationEvaluateResponse['classification']) { + if (evalMetrics === undefined) return []; + + const accuracyMetrics = evalMetrics.accuracy?.classes || []; + const recallMetrics = evalMetrics.recall?.classes || []; + + const metricsMap = accuracyMetrics.reduce((acc, accuracyMetric) => { + acc[accuracyMetric.class_name] = { + className: accuracyMetric.class_name, + accuracy: accuracyMetric.value, + }; + return acc; + }, {} as Record); + + recallMetrics.forEach((recallMetric) => { + if (metricsMap[recallMetric.class_name] !== undefined) { + metricsMap[recallMetric.class_name] = { + recall: recallMetric.value, + ...metricsMap[recallMetric.class_name], + }; + } else { + metricsMap[recallMetric.class_name] = { + className: recallMetric.class_name, + recall: recallMetric.value, + }; + } + }); + + return Object.values(metricsMap); +} + export const useConfusionMatrix = ( jobConfig: DataFrameAnalyticsConfig, searchQuery: ResultsSearchQuery @@ -32,6 +65,9 @@ export const useConfusionMatrix = ( const [confusionMatrixData, setConfusionMatrixData] = useState([]); const [overallAccuracy, setOverallAccuracy] = useState(null); const [avgRecall, setAvgRecall] = useState(null); + const [evaluationMetricsItems, setEvaluationMetricsItems] = useState( + [] + ); const [isLoading, setIsLoading] = useState(false); const [docsCount, setDocsCount] = useState(null); const [error, setError] = useState(null); @@ -81,6 +117,7 @@ export const useConfusionMatrix = ( setConfusionMatrixData(confusionMatrix || []); setAvgRecall(evalData.eval?.classification?.recall?.avg_recall || null); setOverallAccuracy(evalData.eval?.classification?.accuracy?.overall_accuracy || null); + setEvaluationMetricsItems(getEvalutionMetricsItems(evalData.eval?.classification)); setIsLoading(false); } else { setIsLoading(false); @@ -98,5 +135,13 @@ export const useConfusionMatrix = ( loadConfusionMatrixData(); }, [JSON.stringify([jobConfig, searchQuery])]); - return { avgRecall, confusionMatrixData, docsCount, error, isLoading, overallAccuracy }; + return { + avgRecall, + confusionMatrixData, + docsCount, + error, + isLoading, + overallAccuracy, + evaluationMetricsItems, + }; }; From c39c1292eb11f1913c4ba3aac8bee3966198aec4 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 11 Aug 2021 13:08:10 -0700 Subject: [PATCH 08/20] [build-ts-refs] normalize paths before writing them to the FS (#108246) Co-authored-by: spalger --- src/dev/typescript/root_refs_config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/dev/typescript/root_refs_config.ts b/src/dev/typescript/root_refs_config.ts index c297a9288ddd56..f4aa88f1ea6b22 100644 --- a/src/dev/typescript/root_refs_config.ts +++ b/src/dev/typescript/root_refs_config.ts @@ -11,6 +11,7 @@ import Fs from 'fs/promises'; import dedent from 'dedent'; import { REPO_ROOT, ToolingLog } from '@kbn/dev-utils'; +import normalize from 'normalize-path'; import { PROJECTS } from './projects'; @@ -53,7 +54,7 @@ export async function updateRootRefsConfig(log: ToolingLog) { } const refs = PROJECTS.filter((p) => p.isCompositeProject()) - .map((p) => `./${Path.relative(REPO_ROOT, p.tsConfigPath)}`) + .map((p) => `./${normalize(Path.relative(REPO_ROOT, p.tsConfigPath))}`) .sort((a, b) => a.localeCompare(b)); log.debug('updating', ROOT_REFS_CONFIG_PATH); From 6563fad7be3b879dbb9e844decbf7edcfa591cc2 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 11 Aug 2021 13:13:40 -0700 Subject: [PATCH 09/20] [Reporting] implement content changes per feedback (#108068) --- .../components/download_options.tsx | 2 +- .../public/components/table_vis_controls.tsx | 2 +- .../lens/public/app_plugin/lens_top_nav.tsx | 2 +- x-pack/plugins/reporting/public/lib/job.tsx | 9 +++--- .../reporting_api_client.ts | 2 +- .../report_info_button.test.tsx.snap | 4 +-- .../report_listing.test.tsx.snap | 29 ++++++++++++------- .../public/management/report_info_button.tsx | 28 +++++++++--------- .../reporting_management/report_listing.ts | 14 ++++----- 9 files changed, 50 insertions(+), 42 deletions(-) diff --git a/src/plugins/data/public/utils/table_inspector_view/components/download_options.tsx b/src/plugins/data/public/utils/table_inspector_view/components/download_options.tsx index 57e586eaf12f84..03d9a8d2b35bc2 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/download_options.tsx +++ b/src/plugins/data/public/utils/table_inspector_view/components/download_options.tsx @@ -122,7 +122,7 @@ class DataDownloadOptions extends Component {button} diff --git a/src/plugins/vis_type_table/public/components/table_vis_controls.tsx b/src/plugins/vis_type_table/public/components/table_vis_controls.tsx index 458bca4a540611..01dd693a31ff82 100644 --- a/src/plugins/vis_type_table/public/components/table_vis_controls.tsx +++ b/src/plugins/vis_type_table/public/components/table_vis_controls.tsx @@ -99,7 +99,7 @@ export const TableVisControls = memo( position="top" content={i18n.translate('visTypeTable.vis.controls.exportButtonFormulasWarning', { defaultMessage: - 'Your CSV contains characters which spreadsheet applications can interpret as formulas', + 'Your CSV contains characters that spreadsheet applications might interpret as formulas.', })} > {button} diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index 06420051678ee4..f777d053b314b9 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -233,7 +233,7 @@ export const LensTopNavMenu = ({ if (formulaDetected) { return i18n.translate('xpack.lens.app.downloadButtonFormulasWarning', { defaultMessage: - 'Your CSV contains characters which spreadsheet applications can interpret as formulas', + 'Your CSV contains characters that spreadsheet applications might interpret as formulas.', }); } } diff --git a/x-pack/plugins/reporting/public/lib/job.tsx b/x-pack/plugins/reporting/public/lib/job.tsx index 96967dc9226c90..86d618c57379b6 100644 --- a/x-pack/plugins/reporting/public/lib/job.tsx +++ b/x-pack/plugins/reporting/public/lib/job.tsx @@ -86,7 +86,7 @@ export class Job { let smallMessage; if (status === PENDING) { smallMessage = i18n.translate('xpack.reporting.jobStatusDetail.pendingStatusReachedText', { - defaultMessage: 'Waiting for job to be processed.', + defaultMessage: 'Waiting for job to process.', }); } else if (status === PROCESSING) { smallMessage = i18n.translate('xpack.reporting.jobStatusDetail.attemptXofY', { @@ -139,8 +139,7 @@ export class Job { getStatusLabel() { return ( <> - {this.getStatus()} - {this.getStatusMessage()} + {this.getStatus()} {this.getStatusMessage()} ); } @@ -184,14 +183,14 @@ export class Job { warnings.push( i18n.translate('xpack.reporting.jobWarning.csvContainsFormulas', { defaultMessage: - 'Your CSV contains characters which spreadsheet applications can interpret as formulas.', + 'Your CSV contains characters that spreadsheet applications might interpret as formulas.', }) ); } if (this.max_size_reached) { warnings.push( i18n.translate('xpack.reporting.jobWarning.maxSizeReachedTooltip', { - defaultMessage: 'Max size reached, contains partial data.', + defaultMessage: 'Your search reached the max size and contains partial data.', }) ); } diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts index 5c618ba8261fa6..151519b0b6b8fa 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts @@ -123,7 +123,7 @@ export class ReportingAPIClient implements IReportingAPI { } return i18n.translate('xpack.reporting.apiClient.unknownError', { - defaultMessage: `Report job {job} failed: Unknown error.`, + defaultMessage: `Report job {job} failed. Error unknown.`, values: { job: jobId }, }); } diff --git a/x-pack/plugins/reporting/public/management/__snapshots__/report_info_button.test.tsx.snap b/x-pack/plugins/reporting/public/management/__snapshots__/report_info_button.test.tsx.snap index 4ab50750bbc528..e8b9362db75250 100644 --- a/x-pack/plugins/reporting/public/management/__snapshots__/report_info_button.test.tsx.snap +++ b/x-pack/plugins/reporting/public/management/__snapshots__/report_info_button.test.tsx.snap @@ -61,7 +61,7 @@ Array [ className="euiTitle euiTitle--medium" id="flyoutTitle" > - Unable to fetch report info + Unable to fetch report info. @@ -113,7 +113,7 @@ Array [ className="euiTitle euiTitle--medium" id="flyoutTitle" > - Unable to fetch report info + Unable to fetch report info. diff --git a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap index 89511aaf96a675..926ca6e0b53dc8 100644 --- a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap +++ b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap @@ -549,6 +549,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` 2020-04-14 @ 05:01 PM + @@ -562,7 +563,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` className="euiTextColor euiTextColor--subdued" style={Object {}} > - Waiting for job to be processed. + Waiting for job to process. @@ -1293,7 +1294,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` } > @@ -1562,6 +1563,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` 2020-04-14 @ 05:01 PM + @@ -2306,7 +2308,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` } > @@ -2575,6 +2577,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` 2020-04-14 @ 04:19 PM + @@ -3348,7 +3351,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` } > @@ -3617,6 +3620,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` 2020-04-14 @ 01:21 PM + @@ -4418,7 +4422,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` } > @@ -4687,6 +4691,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` 2020-04-14 @ 01:19 PM + @@ -5460,7 +5465,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` } > @@ -5729,6 +5734,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` 2020-04-14 @ 01:19 PM + @@ -6502,7 +6508,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` } > @@ -6771,6 +6777,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` 2020-04-14 @ 01:18 PM + @@ -7544,7 +7551,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` } > @@ -7813,6 +7820,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` 2020-04-14 @ 01:13 PM + @@ -8586,7 +8594,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` } > @@ -8855,6 +8863,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` 2020-04-09 @ 03:10 PM + @@ -9628,7 +9637,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` } > diff --git a/x-pack/plugins/reporting/public/management/report_info_button.tsx b/x-pack/plugins/reporting/public/management/report_info_button.tsx index 8513558fb89ccc..7a70286785e4f1 100644 --- a/x-pack/plugins/reporting/public/management/report_info_button.tsx +++ b/x-pack/plugins/reporting/public/management/report_info_button.tsx @@ -92,7 +92,7 @@ class ReportInfoButtonUi extends Component { { title: this.props.intl.formatMessage({ id: 'xpack.reporting.listing.infoPanel.createdAtInfo', - defaultMessage: 'Created At', + defaultMessage: 'Created at', }), description: info.getCreatedAtLabel(), }, @@ -106,7 +106,7 @@ class ReportInfoButtonUi extends Component { { title: this.props.intl.formatMessage({ id: 'xpack.reporting.listing.infoPanel.tzInfo', - defaultMessage: 'Timezone', + defaultMessage: 'Time zone', }), description: info.browserTimezone || NA, }, @@ -116,21 +116,21 @@ class ReportInfoButtonUi extends Component { { title: this.props.intl.formatMessage({ id: 'xpack.reporting.listing.infoPanel.startedAtInfo', - defaultMessage: 'Started At', + defaultMessage: 'Started at', }), description: info.started_at || NA, }, { title: this.props.intl.formatMessage({ id: 'xpack.reporting.listing.infoPanel.completedAtInfo', - defaultMessage: 'Completed At', + defaultMessage: 'Completed at', }), description: info.completed_at || NA, }, { title: this.props.intl.formatMessage({ id: 'xpack.reporting.listing.infoPanel.processedByInfo', - defaultMessage: 'Processed By', + defaultMessage: 'Processed by', }), description: info.kibana_name && info.kibana_id ? `${info.kibana_name} (${info.kibana_id})` : NA, @@ -138,14 +138,14 @@ class ReportInfoButtonUi extends Component { { title: this.props.intl.formatMessage({ id: 'xpack.reporting.listing.infoPanel.contentTypeInfo', - defaultMessage: 'Content Type', + defaultMessage: 'Content type', }), description: info.content_type || NA, }, { title: this.props.intl.formatMessage({ id: 'xpack.reporting.listing.infoPanel.sizeInfo', - defaultMessage: 'Size in Bytes', + defaultMessage: 'Size in bytes', }), description: info.size?.toString() || NA, }, @@ -159,7 +159,7 @@ class ReportInfoButtonUi extends Component { { title: this.props.intl.formatMessage({ id: 'xpack.reporting.listing.infoPanel.maxAttemptsInfo', - defaultMessage: 'Max Attempts', + defaultMessage: 'Max attempts', }), description: info.max_attempts?.toString() || NA, }, @@ -173,7 +173,7 @@ class ReportInfoButtonUi extends Component { { title: this.props.intl.formatMessage({ id: 'xpack.reporting.listing.infoPanel.exportTypeInfo', - defaultMessage: 'Export Type', + defaultMessage: 'Export type', }), description: info.isDeprecated ? this.props.intl.formatMessage( @@ -207,7 +207,7 @@ class ReportInfoButtonUi extends Component { { title: this.props.intl.formatMessage({ id: 'xpack.reporting.listing.infoPanel.browserTypeInfo', - defaultMessage: 'Browser Type', + defaultMessage: 'Browser type', }), description: info.browser_type || NA, }, @@ -293,17 +293,17 @@ class ReportInfoButtonUi extends Component { let message = this.props.intl.formatMessage({ id: 'xpack.reporting.listing.table.reportInfoButtonTooltip', - defaultMessage: 'See report info', + defaultMessage: 'See report info.', }); if (job.getError()) { message = this.props.intl.formatMessage({ id: 'xpack.reporting.listing.table.reportInfoAndErrorButtonTooltip', - defaultMessage: 'See report info and error message', + defaultMessage: 'See report info and error message.', }); } else if (job.getWarnings()) { message = this.props.intl.formatMessage({ id: 'xpack.reporting.listing.table.reportInfoAndWarningsButtonTooltip', - defaultMessage: 'See report info and warnings', + defaultMessage: 'See report info and warnings.', }); } @@ -349,7 +349,7 @@ class ReportInfoButtonUi extends Component { isLoading: false, calloutTitle: this.props.intl.formatMessage({ id: 'xpack.reporting.listing.table.reportInfoUnableToFetch', - defaultMessage: 'Unable to fetch report info', + defaultMessage: 'Unable to fetch report info.', }), info: null, error: err, diff --git a/x-pack/test/functional/apps/reporting_management/report_listing.ts b/x-pack/test/functional/apps/reporting_management/report_listing.ts index eb2e339e9be662..dd5c1e63f1bead 100644 --- a/x-pack/test/functional/apps/reporting_management/report_listing.ts +++ b/x-pack/test/functional/apps/reporting_management/report_listing.ts @@ -92,7 +92,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { "actions": "", "createdAt": "2021-07-19 @ 10:29 PMtest_user", "report": "Automated reportsearch", - "status": "Completed at 2021-07-19 @ 10:29 PMSee report info for warnings.", + "status": "Completed at 2021-07-19 @ 10:29 PM See report info for warnings.", }, Object { "actions": "", @@ -104,37 +104,37 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { "actions": "", "createdAt": "2021-07-19 @ 06:46 PMtest_user", "report": "Discover search [2021-07-19T11:46:00.132-07:00]search", - "status": "Completed at 2021-07-19 @ 06:46 PMSee report info for warnings.", + "status": "Completed at 2021-07-19 @ 06:46 PM See report info for warnings.", }, Object { "actions": "", "createdAt": "2021-07-19 @ 06:44 PMtest_user", "report": "Discover search [2021-07-19T11:44:48.670-07:00]search", - "status": "Completed at 2021-07-19 @ 06:44 PMSee report info for warnings.", + "status": "Completed at 2021-07-19 @ 06:44 PM See report info for warnings.", }, Object { "actions": "", "createdAt": "2021-07-19 @ 06:41 PMtest_user", "report": "[Flights] Global Flight Dashboarddashboard", - "status": "Pending at 2021-07-19 @ 06:41 PMWaiting for job to be processed.", + "status": "Pending at 2021-07-19 @ 06:41 PM Waiting for job to process.", }, Object { "actions": "", "createdAt": "2021-07-19 @ 06:41 PMtest_user", "report": "[Flights] Global Flight Dashboarddashboard", - "status": "Failed at 2021-07-19 @ 06:43 PMSee report info for error details.", + "status": "Failed at 2021-07-19 @ 06:43 PM See report info for error details.", }, Object { "actions": "", "createdAt": "2021-07-19 @ 06:41 PMtest_user", "report": "[Flights] Global Flight Dashboarddashboard", - "status": "Completed at 2021-07-19 @ 06:41 PMSee report info for warnings.", + "status": "Completed at 2021-07-19 @ 06:41 PM See report info for warnings.", }, Object { "actions": "", "createdAt": "2021-07-19 @ 06:38 PMtest_user", "report": "[Flights] Global Flight Dashboarddashboard", - "status": "Completed at 2021-07-19 @ 06:39 PMSee report info for warnings.", + "status": "Completed at 2021-07-19 @ 06:39 PM See report info for warnings.", }, Object { "actions": "", From 1eae08e2c1b1661bcc14561c0e141b47dc981ce5 Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Wed, 11 Aug 2021 22:16:53 +0200 Subject: [PATCH 10/20] [Expressions] Add support of partial results to the switch expression function (#108086) --- .../functions/common/switch.test.js | 68 +++++++++++++------ .../functions/common/switch.ts | 19 +++--- 2 files changed, 59 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.test.js index ffa1557d2b54ed..d0ea8fff0a45d3 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.test.js @@ -56,25 +56,6 @@ describe('switch', () => { }); describe('function', () => { - describe('with no cases', () => { - it('should return the context if no default is provided', () => { - const context = 'foo'; - - testScheduler.run(({ expectObservable }) => - expectObservable(fn(context, {})).toBe('(0|)', [context]) - ); - }); - - it('should return the default if provided', () => { - const context = 'foo'; - const args = { default: () => of('bar') }; - - testScheduler.run(({ expectObservable }) => - expectObservable(fn(context, args)).toBe('(0|)', ['bar']) - ); - }); - }); - describe('with no matching cases', () => { it('should return the context if no default is provided', () => { const context = 'foo'; @@ -108,6 +89,55 @@ describe('switch', () => { expectObservable(fn(context, args)).toBe('(0|)', [result]) ); }); + + it('should support partial results', () => { + testScheduler.run(({ cold, expectObservable }) => { + const context = 'foo'; + const case1 = cold('--ab-c-', { + a: { + type: 'case', + matches: false, + result: 1, + }, + b: { + type: 'case', + matches: true, + result: 2, + }, + c: { + type: 'case', + matches: false, + result: 3, + }, + }); + const case2 = cold('-a--bc-', { + a: { + type: 'case', + matches: true, + result: 4, + }, + b: { + type: 'case', + matches: true, + result: 5, + }, + c: { + type: 'case', + matches: true, + result: 6, + }, + }); + const expected = ' --abc(de)-'; + const args = { case: [() => case1, () => case2] }; + expectObservable(fn(context, args)).toBe(expected, { + a: 4, + b: 2, + c: 2, + d: 5, + e: 6, + }); + }); + }); }); }); }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.ts index f4e6c92c91cb60..3e676c829a3014 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { Observable, defer, from, of } from 'rxjs'; -import { concatMap, filter, merge, pluck, take } from 'rxjs/operators'; +import { Observable, combineLatest, defer, of } from 'rxjs'; +import { concatMap } from 'rxjs/operators'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { Case } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { - case?: Array<() => Observable>; + case: Array<() => Observable>; default?(): Observable; } @@ -43,12 +43,13 @@ export function switchFn(): ExpressionFunctionDefinition< }, }, fn(input, args) { - return from(args.case ?? []).pipe( - concatMap((item) => item()), - filter(({ matches }) => matches), - pluck('result'), - merge(defer(() => args.default?.() ?? of(input))), - take(1) + return combineLatest(args.case.map((item) => defer(() => item()))).pipe( + concatMap((items) => { + const item = items.find(({ matches }) => matches); + const item$ = item && of(item.result); + + return item$ ?? args.default?.() ?? of(input); + }) ); }, }; From 9dce033408cb0a00b754f996859a3a3171babf02 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Wed, 11 Aug 2021 16:23:58 -0400 Subject: [PATCH 11/20] [Task Manager] [8.0] Remove `xpack.task_manager.index` (#108111) * Remove support for the config field index * Fix type issues * Remove references from a few more places --- .../advanced/running-elasticsearch.asciidoc | 1 - docs/settings/task-manager-settings.asciidoc | 3 --- ...task-manager-production-considerations.asciidoc | 2 +- .../resources/base/bin/kibana-docker | 1 - x-pack/plugins/task_manager/server/config.test.ts | 14 -------------- x-pack/plugins/task_manager/server/config.ts | 9 --------- x-pack/plugins/task_manager/server/constants.ts | 8 ++++++++ .../server/ephemeral_task_lifecycle.test.ts | 1 - x-pack/plugins/task_manager/server/index.test.ts | 11 ----------- x-pack/plugins/task_manager/server/index.ts | 12 ------------ .../managed_configuration.test.ts | 1 - .../monitoring/configuration_statistics.test.ts | 1 - .../monitoring/monitoring_stats_stream.test.ts | 1 - x-pack/plugins/task_manager/server/plugin.test.ts | 2 -- x-pack/plugins/task_manager/server/plugin.ts | 5 +++-- .../task_manager/server/polling_lifecycle.test.ts | 1 - .../task_manager/server/saved_objects/index.ts | 5 +++-- 17 files changed, 15 insertions(+), 63 deletions(-) create mode 100644 x-pack/plugins/task_manager/server/constants.ts diff --git a/docs/developer/advanced/running-elasticsearch.asciidoc b/docs/developer/advanced/running-elasticsearch.asciidoc index 324d2af2ed3af5..36f9ee420d41db 100644 --- a/docs/developer/advanced/running-elasticsearch.asciidoc +++ b/docs/developer/advanced/running-elasticsearch.asciidoc @@ -76,7 +76,6 @@ If many other users will be interacting with your remote cluster, you'll want to [source,bash] ---- kibana.index: '.{YourGitHubHandle}-kibana' -xpack.task_manager.index: '.{YourGitHubHandle}-task-manager-kibana' ---- ==== Running remote clusters diff --git a/docs/settings/task-manager-settings.asciidoc b/docs/settings/task-manager-settings.asciidoc index fa89b7780e475f..387d2308aa5e80 100644 --- a/docs/settings/task-manager-settings.asciidoc +++ b/docs/settings/task-manager-settings.asciidoc @@ -22,9 +22,6 @@ Task Manager runs background tasks by polling for work on an interval. You can | `xpack.task_manager.request_capacity` | How many requests can Task Manager buffer before it rejects new requests. Defaults to 1000. -| `xpack.task_manager.index` - | The name of the index used to store task information. Defaults to `.kibana_task_manager`. - | `xpack.task_manager.max_workers` | The maximum number of tasks that this Kibana instance will run simultaneously. Defaults to 10. Starting in 8.0, it will not be possible to set the value greater than 100. diff --git a/docs/user/production-considerations/task-manager-production-considerations.asciidoc b/docs/user/production-considerations/task-manager-production-considerations.asciidoc index 17eae59ff2f9c2..36745b913544b7 100644 --- a/docs/user/production-considerations/task-manager-production-considerations.asciidoc +++ b/docs/user/production-considerations/task-manager-production-considerations.asciidoc @@ -12,7 +12,7 @@ This has three major benefits: [IMPORTANT] ============================================== -Task definitions for alerts and actions are stored in the index specified by <>. The default is `.kibana_task_manager`. +Task definitions for alerts and actions are stored in the index `.kibana_task_manager`. You must have at least one replica of this index for production deployments. diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index e2d81c5ae17520..e65c5542cce7e9 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -395,7 +395,6 @@ kibana_vars=( xpack.spaces.enabled xpack.spaces.maxSpaces xpack.task_manager.enabled - xpack.task_manager.index xpack.task_manager.max_attempts xpack.task_manager.max_poll_inactivity_cycles xpack.task_manager.max_workers diff --git a/x-pack/plugins/task_manager/server/config.test.ts b/x-pack/plugins/task_manager/server/config.test.ts index 14d95e3fd22264..e237f5592419b6 100644 --- a/x-pack/plugins/task_manager/server/config.test.ts +++ b/x-pack/plugins/task_manager/server/config.test.ts @@ -17,7 +17,6 @@ describe('config validation', () => { "enabled": false, "request_capacity": 10, }, - "index": ".kibana_task_manager", "max_attempts": 3, "max_poll_inactivity_cycles": 10, "max_workers": 10, @@ -42,17 +41,6 @@ describe('config validation', () => { `); }); - test('the ElastiSearch Tasks index cannot be used for task manager', () => { - const config: Record = { - index: '.tasks', - }; - expect(() => { - configSchema.validate(config); - }).toThrowErrorMatchingInlineSnapshot( - `"[index]: \\".tasks\\" is an invalid Kibana Task Manager index, as it is already in use by the ElasticSearch Tasks Manager"` - ); - }); - test('the required freshness of the monitored stats config must always be less-than-equal to the poll interval', () => { const config: Record = { monitored_stats_required_freshness: 100, @@ -73,7 +61,6 @@ describe('config validation', () => { "enabled": false, "request_capacity": 10, }, - "index": ".kibana_task_manager", "max_attempts": 3, "max_poll_inactivity_cycles": 10, "max_workers": 10, @@ -116,7 +103,6 @@ describe('config validation', () => { "enabled": false, "request_capacity": 10, }, - "index": ".kibana_task_manager", "max_attempts": 3, "max_poll_inactivity_cycles": 10, "max_workers": 10, diff --git a/x-pack/plugins/task_manager/server/config.ts b/x-pack/plugins/task_manager/server/config.ts index 9b4f4856bf8a99..7c541cd24cefd2 100644 --- a/x-pack/plugins/task_manager/server/config.ts +++ b/x-pack/plugins/task_manager/server/config.ts @@ -65,15 +65,6 @@ export const configSchema = schema.object( defaultValue: 1000, min: 1, }), - /* The name of the index used to store task information. */ - index: schema.string({ - defaultValue: '.kibana_task_manager', - validate: (val) => { - if (val.toLowerCase() === '.tasks') { - return `"${val}" is an invalid Kibana Task Manager index, as it is already in use by the ElasticSearch Tasks Manager`; - } - }, - }), /* The maximum number of tasks that this Kibana instance will run simultaneously. */ max_workers: schema.number({ defaultValue: DEFAULT_MAX_WORKERS, diff --git a/x-pack/plugins/task_manager/server/constants.ts b/x-pack/plugins/task_manager/server/constants.ts new file mode 100644 index 00000000000000..9334fbede3176e --- /dev/null +++ b/x-pack/plugins/task_manager/server/constants.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 TASK_MANAGER_INDEX = '.kibana_task_manager'; diff --git a/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts index 182e7cd5bcabfe..859f242f2f0a65 100644 --- a/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts @@ -40,7 +40,6 @@ describe('EphemeralTaskLifecycle', () => { config: { enabled: true, max_workers: 10, - index: 'foo', max_attempts: 9, poll_interval: 6000000, version_conflict_threshold: 80, diff --git a/x-pack/plugins/task_manager/server/index.test.ts b/x-pack/plugins/task_manager/server/index.test.ts index 8eb98c39a2ccd2..74d86c31e1bd12 100644 --- a/x-pack/plugins/task_manager/server/index.test.ts +++ b/x-pack/plugins/task_manager/server/index.test.ts @@ -31,17 +31,6 @@ const applyTaskManagerDeprecations = (settings: Record = {}) => }; describe('deprecations', () => { - ['.foo', '.kibana_task_manager'].forEach((index) => { - it('logs a warning if index is set', () => { - const { messages } = applyTaskManagerDeprecations({ index }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"xpack.task_manager.index\\" is deprecated. Multitenancy by changing \\"kibana.index\\" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details", - ] - `); - }); - }); - it('logs a warning if max_workers is over limit', () => { const { messages } = applyTaskManagerDeprecations({ max_workers: 1000 }); expect(messages).toMatchInlineSnapshot(` diff --git a/x-pack/plugins/task_manager/server/index.ts b/x-pack/plugins/task_manager/server/index.ts index cc4217f41c5ef3..067082955b3b13 100644 --- a/x-pack/plugins/task_manager/server/index.ts +++ b/x-pack/plugins/task_manager/server/index.ts @@ -41,18 +41,6 @@ export const config: PluginConfigDescriptor = { deprecations: () => [ (settings, fromPath, addDeprecation) => { const taskManager = get(settings, fromPath); - if (taskManager?.index) { - addDeprecation({ - documentationUrl: 'https://ela.st/kbn-remove-legacy-multitenancy', - message: `"${fromPath}.index" is deprecated. Multitenancy by changing "kibana.index" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details`, - correctiveActions: { - manualSteps: [ - `If you rely on this setting to achieve multitenancy you should use Spaces, cross-cluster replication, or cross-cluster search instead.`, - `To migrate to Spaces, we encourage using saved object management to export your saved objects from a tenant into the default tenant in a space.`, - ], - }, - }); - } if (taskManager?.max_workers > MAX_WORKERS_LIMIT) { addDeprecation({ message: `setting "${fromPath}.max_workers" (${taskManager?.max_workers}) greater than ${MAX_WORKERS_LIMIT} is deprecated. Values greater than ${MAX_WORKERS_LIMIT} will not be supported starting in 8.0.`, diff --git a/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts b/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts index 496c0138cb1e5f..ce49466ff387c8 100644 --- a/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts +++ b/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts @@ -31,7 +31,6 @@ describe('managed configuration', () => { const context = coreMock.createPluginInitializerContext({ enabled: true, max_workers: 10, - index: 'foo', max_attempts: 9, poll_interval: 3000, version_conflict_threshold: 80, diff --git a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts index 82a111305927f2..e63beee7201fed 100644 --- a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts @@ -15,7 +15,6 @@ describe('Configuration Statistics Aggregator', () => { const configuration: TaskManagerConfig = { enabled: true, max_workers: 10, - index: 'foo', max_attempts: 9, poll_interval: 6000000, version_conflict_threshold: 80, diff --git a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts index 50d4b6af9a4cff..d59d4461446328 100644 --- a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts @@ -19,7 +19,6 @@ describe('createMonitoringStatsStream', () => { const configuration: TaskManagerConfig = { enabled: true, max_workers: 10, - index: 'foo', max_attempts: 9, poll_interval: 6000000, version_conflict_threshold: 80, diff --git a/x-pack/plugins/task_manager/server/plugin.test.ts b/x-pack/plugins/task_manager/server/plugin.test.ts index dff94259dbe62f..de21b653823c9b 100644 --- a/x-pack/plugins/task_manager/server/plugin.test.ts +++ b/x-pack/plugins/task_manager/server/plugin.test.ts @@ -18,7 +18,6 @@ describe('TaskManagerPlugin', () => { const pluginInitializerContext = coreMock.createPluginInitializerContext({ enabled: true, max_workers: 10, - index: 'foo', max_attempts: 9, poll_interval: 3000, version_conflict_threshold: 80, @@ -58,7 +57,6 @@ describe('TaskManagerPlugin', () => { const pluginInitializerContext = coreMock.createPluginInitializerContext({ enabled: true, max_workers: 10, - index: 'foo', max_attempts: 9, poll_interval: 3000, version_conflict_threshold: 80, diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts index 3d3d180fc06653..c41bc8109ef4ce 100644 --- a/x-pack/plugins/task_manager/server/plugin.ts +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -31,6 +31,7 @@ import { createMonitoringStats, MonitoringStats } from './monitoring'; import { EphemeralTaskLifecycle } from './ephemeral_task_lifecycle'; import { EphemeralTask } from './task'; import { registerTaskManagerUsageCollector } from './usage'; +import { TASK_MANAGER_INDEX } from './constants'; export type TaskManagerSetupContract = { /** @@ -114,7 +115,7 @@ export class TaskManagerPlugin } return { - index: this.config.index, + index: TASK_MANAGER_INDEX, addMiddleware: (middleware: Middleware) => { this.assertStillInSetup('add Middleware'); this.middleware = addMiddlewareToChain(this.middleware, middleware); @@ -134,7 +135,7 @@ export class TaskManagerPlugin serializer, savedObjectsRepository, esClient: elasticsearch.createClient('taskManager').asInternalUser, - index: this.config!.index, + index: TASK_MANAGER_INDEX, definitions: this.definitions, taskManagerId: `kibana:${this.taskManagerId!}`, }); diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts index aad03951bbb9b1..1420a81b2dcaac 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts @@ -38,7 +38,6 @@ describe('TaskPollingLifecycle', () => { config: { enabled: true, max_workers: 10, - index: 'foo', max_attempts: 9, poll_interval: 6000000, version_conflict_threshold: 80, diff --git a/x-pack/plugins/task_manager/server/saved_objects/index.ts b/x-pack/plugins/task_manager/server/saved_objects/index.ts index d2d079c7747b1b..e98a02b220d582 100644 --- a/x-pack/plugins/task_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/task_manager/server/saved_objects/index.ts @@ -11,6 +11,7 @@ import mappings from './mappings.json'; import { migrations } from './migrations'; import { TaskManagerConfig } from '../config.js'; import { getOldestIdleActionTask } from '../queries/oldest_idle_action_task'; +import { TASK_MANAGER_INDEX } from '../constants'; export function setupSavedObjects( savedObjects: SavedObjectsServiceSetup, @@ -23,11 +24,11 @@ export function setupSavedObjects( convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id; ctx._source.remove("kibana")`, mappings: mappings.task as SavedObjectsTypeMappingDefinition, migrations, - indexPattern: config.index, + indexPattern: TASK_MANAGER_INDEX, excludeOnUpgrade: async ({ readonlyEsClient }) => { const oldestNeededActionParams = await getOldestIdleActionTask( readonlyEsClient, - config.index + TASK_MANAGER_INDEX ); // Delete all action tasks that have failed and are no longer needed From a4f261a18714118cdfb0262225959556c87bb754 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 11 Aug 2021 14:53:30 -0600 Subject: [PATCH 12/20] [Maps] convert Join resources folder to TS (#108090) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../join_editor/join_editor.tsx | 1 - ....snap => metrics_expression.test.tsx.snap} | 26 +++--- .../resources/{join.js => join.tsx} | 88 +++++++++++++------ ...join_expression.js => join_expression.tsx} | 81 ++++++++++------- ...on.test.js => metrics_expression.test.tsx} | 10 ++- ...s_expression.js => metrics_expression.tsx} | 45 +++++----- ...ere_expression.js => where_expression.tsx} | 19 +++- 7 files changed, 165 insertions(+), 105 deletions(-) rename x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/__snapshots__/{metrics_expression.test.js.snap => metrics_expression.test.tsx.snap} (90%) rename x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/{join.js => join.tsx} (75%) rename x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/{join_expression.js => join_expression.tsx} (87%) rename x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/{metrics_expression.test.js => metrics_expression.test.tsx} (67%) rename x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/{metrics_expression.js => metrics_expression.tsx} (80%) rename x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/{where_expression.js => where_expression.tsx} (86%) diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/join_editor.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/join_editor.tsx index d07b39be6f6ab2..e0d630994566d4 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/join_editor.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/join_editor.tsx @@ -19,7 +19,6 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -// @ts-expect-error import { Join } from './resources/join'; import { ILayer } from '../../../classes/layers/layer'; import { JoinDescriptor } from '../../../../common/descriptor_types'; diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.js.snap b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.tsx.snap similarity index 90% rename from x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.js.snap rename to x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.tsx.snap index a9a1afabfc193b..91eec4d8aac298 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.js.snap +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.tsx.snap @@ -43,13 +43,18 @@ exports[`Should render default props 1`] = ` values={Object {}} /> - - - + `; @@ -99,12 +104,7 @@ exports[`Should render metrics expression for metrics 1`] = ` void; + onRemove: () => void; + leftFields: JoinField[]; + leftSourceName: string; +} + +interface State { + rightFields: IFieldType[]; + indexPattern?: IndexPattern; + loadError?: string; +} + +export class Join extends Component { + private _isMounted = false; + + state: State = { + rightFields: [], indexPattern: undefined, loadError: undefined, }; @@ -36,7 +61,7 @@ export class Join extends Component { this._isMounted = false; } - async _loadRightFields(indexPatternId) { + async _loadRightFields(indexPatternId: string) { if (!indexPatternId) { return; } @@ -66,21 +91,26 @@ export class Join extends Component { }); } - _onLeftFieldChange = (leftField) => { + _onLeftFieldChange = (leftField: string) => { this.props.onChange({ - leftField: leftField, + leftField, right: this.props.join.right, }); }; - _onRightSourceChange = ({ indexPatternId, indexPatternTitle }) => { + _onRightSourceChange = ({ + indexPatternId, + indexPatternTitle, + }: { + indexPatternId: string; + indexPatternTitle: string; + }) => { this.setState({ - rightFields: undefined, + rightFields: [], loadError: undefined, }); this._loadRightFields(indexPatternId); - // eslint-disable-next-line no-unused-vars - const { term, ...restOfRight } = this.props.join.right; + const { term, ...restOfRight } = this.props.join.right as ESTermSourceDescriptor; this.props.onChange({ leftField: this.props.join.leftField, right: { @@ -88,74 +118,74 @@ export class Join extends Component { indexPatternId, indexPatternTitle, type: SOURCE_TYPES.ES_TERM_SOURCE, - }, + } as ESTermSourceDescriptor, }); }; - _onRightFieldChange = (term) => { + _onRightFieldChange = (term?: string) => { this.props.onChange({ leftField: this.props.join.leftField, right: { ...this.props.join.right, term, - }, + } as ESTermSourceDescriptor, }); }; - _onRightSizeChange = (size) => { + _onRightSizeChange = (size: number) => { this.props.onChange({ leftField: this.props.join.leftField, right: { ...this.props.join.right, size, - }, + } as ESTermSourceDescriptor, }); }; - _onMetricsChange = (metrics) => { + _onMetricsChange = (metrics: AggDescriptor[]) => { this.props.onChange({ leftField: this.props.join.leftField, right: { ...this.props.join.right, metrics, - }, + } as ESTermSourceDescriptor, }); }; - _onWhereQueryChange = (whereQuery) => { + _onWhereQueryChange = (whereQuery?: Query) => { this.props.onChange({ leftField: this.props.join.leftField, right: { ...this.props.join.right, whereQuery, - }, + } as ESTermSourceDescriptor, }); }; - _onApplyGlobalQueryChange = (applyGlobalQuery) => { + _onApplyGlobalQueryChange = (applyGlobalQuery: boolean) => { this.props.onChange({ leftField: this.props.join.leftField, right: { ...this.props.join.right, applyGlobalQuery, - }, + } as ESTermSourceDescriptor, }); }; - _onApplyGlobalTimeChange = (applyGlobalTime) => { + _onApplyGlobalTimeChange = (applyGlobalTime: boolean) => { this.props.onChange({ leftField: this.props.join.leftField, right: { ...this.props.join.right, applyGlobalTime, - }, + } as ESTermSourceDescriptor, }); }; render() { const { join, onRemove, leftFields, leftSourceName } = this.props; const { rightFields, indexPattern } = this.state; - const right = _.get(join, 'right', {}); + const right = _.get(join, 'right', {}) as ESTermSourceDescriptor; const rightSourceName = right.indexPatternTitle ? right.indexPatternTitle : right.indexPatternId; @@ -168,7 +198,7 @@ export class Join extends Component { metricsExpression = ( @@ -176,7 +206,9 @@ export class Join extends Component { ); globalFilterCheckbox = ( diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_expression.js b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_expression.tsx similarity index 87% rename from x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_expression.js rename to x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_expression.tsx index 58e3e3aac0d6ab..f2073a9f6e650d 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_expression.js +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_expression.tsx @@ -7,30 +7,64 @@ import _ from 'lodash'; import React, { Component } from 'react'; -import PropTypes from 'prop-types'; import { EuiPopover, EuiPopoverTitle, EuiExpression, EuiFormRow, EuiComboBox, + EuiComboBoxOptionOption, EuiFormHelpText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { IFieldType } from 'src/plugins/data/public'; +import { FormattedMessage } from '@kbn/i18n/react'; import { DEFAULT_MAX_BUCKETS_LIMIT } from '../../../../../common/constants'; import { SingleFieldSelect } from '../../../../components/single_field_select'; import { ValidatedNumberInput } from '../../../../components/validated_number_input'; -import { FormattedMessage } from '@kbn/i18n/react'; import { getTermsFields } from '../../../../index_pattern_util'; import { getIndexPatternService, getIndexPatternSelectComponent, } from '../../../../kibana_services'; +import type { JoinField } from '../join_editor'; + +interface Props { + // Left source props (static - can not change) + leftSourceName?: string; + + // Left field props + leftValue?: string; + leftFields: JoinField[]; + onLeftFieldChange: (leftField: string) => void; -export class JoinExpression extends Component { - state = { + // Right source props + rightSourceIndexPatternId: string; + rightSourceName: string; + onRightSourceChange: ({ + indexPatternId, + indexPatternTitle, + }: { + indexPatternId: string; + indexPatternTitle: string; + }) => void; + + // Right field props + rightValue: string; + rightSize?: number; + rightFields: IFieldType[]; + onRightFieldChange: (term?: string) => void; + onRightSizeChange: (size: number) => void; +} + +interface State { + isPopoverOpen: boolean; +} + +export class JoinExpression extends Component { + state: State = { isPopoverOpen: false, }; @@ -46,7 +80,11 @@ export class JoinExpression extends Component { }); }; - _onRightSourceChange = async (indexPatternId) => { + _onRightSourceChange = async (indexPatternId?: string) => { + if (!indexPatternId || indexPatternId.length === 0) { + return; + } + try { const indexPattern = await getIndexPatternService().get(indexPatternId); this.props.onRightSourceChange({ @@ -58,7 +96,7 @@ export class JoinExpression extends Component { } }; - _onLeftFieldChange = (selectedFields) => { + _onLeftFieldChange = (selectedFields: Array>) => { this.props.onLeftFieldChange(_.get(selectedFields, '[0].value.name', null)); }; @@ -246,7 +284,9 @@ export class JoinExpression extends Component { })} > @@ -263,33 +303,6 @@ export class JoinExpression extends Component { } } -JoinExpression.propTypes = { - // Left source props (static - can not change) - leftSourceName: PropTypes.string, - - // Left field props - leftValue: PropTypes.string, - leftFields: PropTypes.arrayOf( - PropTypes.shape({ - label: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - }) - ), - onLeftFieldChange: PropTypes.func.isRequired, - - // Right source props - rightSourceIndexPatternId: PropTypes.string, - rightSourceName: PropTypes.string, - onRightSourceChange: PropTypes.func.isRequired, - - // Right field props - rightValue: PropTypes.string, - rightSize: PropTypes.number, - rightFields: PropTypes.array, - onRightFieldChange: PropTypes.func.isRequired, - onRightSizeChange: PropTypes.func.isRequired, -}; - function getSelectFieldPlaceholder() { return i18n.translate('xpack.maps.layerPanel.joinExpression.selectFieldPlaceholder', { defaultMessage: 'Select field', diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.test.js b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.test.tsx similarity index 67% rename from x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.test.js rename to x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.test.tsx index 8140d7a36ea9be..aa696383fa37ca 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.test.js +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.test.tsx @@ -8,9 +8,12 @@ import React from 'react'; import { shallow } from 'enzyme'; import { MetricsExpression } from './metrics_expression'; +import { AGG_TYPE } from '../../../../../common/constants'; const defaultProps = { onChange: () => {}, + metrics: [{ type: AGG_TYPE.COUNT }], + rightFields: [], }; test('Should render default props', () => { @@ -23,11 +26,10 @@ test('Should render metrics expression for metrics', () => { const component = shallow( ); diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.js b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.tsx similarity index 80% rename from x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.js rename to x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.tsx index 581cb75b4500a7..899430f3c2f2d0 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.js +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.tsx @@ -6,7 +6,6 @@ */ import React, { Component } from 'react'; -import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { EuiPopover, @@ -16,12 +15,24 @@ import { EuiFormHelpText, } from '@elastic/eui'; -import { MetricsEditor } from '../../../../components/metrics_editor'; +import { IFieldType } from 'src/plugins/data/public'; import { FormattedMessage } from '@kbn/i18n/react'; +import { MetricsEditor } from '../../../../components/metrics_editor'; import { AGG_TYPE } from '../../../../../common/constants'; +import { AggDescriptor, FieldedAggDescriptor } from '../../../../../common/descriptor_types'; + +interface Props { + metrics: AggDescriptor[]; + rightFields: IFieldType[]; + onChange: (metrics: AggDescriptor[]) => void; +} -export class MetricsExpression extends Component { - state = { +interface State { + isPopoverOpen: boolean; +} + +export class MetricsExpression extends Component { + state: State = { isPopoverOpen: false, }; @@ -61,23 +72,23 @@ export class MetricsExpression extends Component { render() { const metricExpressions = this.props.metrics - .filter(({ type, field }) => { - if (type === AGG_TYPE.COUNT) { + .filter((metric: AggDescriptor) => { + if (metric.type === AGG_TYPE.COUNT) { return true; } - if (field) { + if ((metric as FieldedAggDescriptor).field) { return true; } return false; }) - .map(({ type, field }) => { + .map((metric: AggDescriptor) => { // do not use metric label so field and aggregation are not obscured. - if (type === AGG_TYPE.COUNT) { - return 'count'; + if (metric.type === AGG_TYPE.COUNT) { + return AGG_TYPE.COUNT; } - return `${type} ${field}`; + return `${metric.type} ${(metric as FieldedAggDescriptor).field}`; }); const useMetricDescription = i18n.translate( 'xpack.maps.layerPanel.metricsExpression.useMetricsDescription', @@ -101,7 +112,7 @@ export class MetricsExpression extends Component { onClick={this._togglePopover} description={useMetricDescription} uppercase={false} - value={metricExpressions.length > 0 ? metricExpressions.join(', ') : 'count'} + value={metricExpressions.length > 0 ? metricExpressions.join(', ') : AGG_TYPE.COUNT} /> } > @@ -124,13 +135,3 @@ export class MetricsExpression extends Component { ); } } - -MetricsExpression.propTypes = { - metrics: PropTypes.array, - rightFields: PropTypes.array, - onChange: PropTypes.func.isRequired, -}; - -MetricsExpression.defaultProps = { - metrics: [{ type: AGG_TYPE.COUNT }], -}; diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/where_expression.js b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/where_expression.tsx similarity index 86% rename from x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/where_expression.js rename to x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/where_expression.tsx index 93ff3c95d184e6..16cef0d5bdad6f 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/where_expression.js +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/where_expression.tsx @@ -9,10 +9,22 @@ import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButton, EuiPopover, EuiExpression, EuiFormHelpText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { IndexPattern, Query } from 'src/plugins/data/public'; +import { APP_ID } from '../../../../../common/constants'; import { getData } from '../../../../kibana_services'; -export class WhereExpression extends Component { - state = { +interface Props { + indexPattern: IndexPattern; + onChange: (whereQuery?: Query) => void; + whereQuery?: Query; +} + +interface State { + isPopoverOpen: boolean; +} + +export class WhereExpression extends Component { + state: State = { isPopoverOpen: false, }; @@ -28,7 +40,7 @@ export class WhereExpression extends Component { }); }; - _onQueryChange = ({ query }) => { + _onQueryChange = ({ query }: { query?: Query }) => { this.props.onChange(query); this._closePopover(); }; @@ -73,6 +85,7 @@ export class WhereExpression extends Component { /> Date: Wed, 11 Aug 2021 17:18:04 -0400 Subject: [PATCH 13/20] [Security Solution][RAC] - Add reason field (#107532) --- .../detection_alerts/alerts_details.spec.ts | 2 +- .../detection_rules/custom_query_rule.spec.ts | 4 - .../event_correlation_rule.spec.ts | 6 -- .../indicator_match_rule.spec.ts | 4 - .../detection_rules/override.spec.ts | 4 - .../detection_rules/threshold_rule.spec.ts | 4 - .../security_solution_detections/columns.ts | 30 ++----- .../get_signals_template.test.ts.snap | 14 ++++ .../routes/index/signal_aad_mapping.json | 2 + .../routes/index/signal_extra_fields.json | 3 + .../routes/index/signals_mapping.json | 6 ++ .../factories/utils/build_alert.test.ts | 9 ++- .../rule_types/factories/utils/build_alert.ts | 5 +- .../factories/utils/build_bulk_body.ts | 11 ++- .../rule_types/factories/wrap_hits_factory.ts | 11 ++- .../rule_types/field_maps/alerts.ts | 5 ++ .../signals/build_bulk_body.test.ts | 56 ++++++++++--- .../signals/build_bulk_body.ts | 36 ++++++--- .../signals/build_signal.test.ts | 9 ++- .../detection_engine/signals/build_signal.ts | 3 +- .../signals/bulk_create_ml_signals.ts | 3 +- .../detection_engine/signals/executors/eql.ts | 5 +- .../signals/executors/query.ts | 2 + .../signals/reason_formatter.test.ts | 78 +++++++++++++++++++ .../signals/reason_formatters.ts | 73 +++++++++++++++++ .../signals/search_after_bulk_create.test.ts | 16 ++++ .../signals/search_after_bulk_create.ts | 3 +- .../threat_mapping/create_threat_signal.ts | 2 + .../bulk_create_threshold_signals.ts | 5 +- .../lib/detection_engine/signals/types.ts | 13 +++- .../signals/wrap_hits_factory.ts | 4 +- .../signals/wrap_sequences_factory.ts | 10 ++- .../timelines/common/ecs/ecs_fields/index.ts | 1 + .../public/components/t_grid/body/helpers.tsx | 1 + .../timeline/factory/events/all/constants.ts | 1 + .../security_and_spaces/tests/create_ml.ts | 1 + .../tests/create_threat_matching.ts | 1 + .../tests/generating_signals.ts | 26 +++++-- 38 files changed, 373 insertions(+), 96 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatter.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatters.ts diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts index 10ebae84365f56..f5cbc65effd855 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts @@ -54,7 +54,7 @@ describe('Alert details with unmapped fields', () => { it('Displays the unmapped field on the table', () => { const expectedUnmmappedField = { - row: 88, + row: 90, field: 'unmapped', text: 'This is the unmapped field', }; diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts index 7d833b134ddd7f..a6043123ce0a8b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts @@ -14,11 +14,9 @@ import { getNewOverrideRule, } from '../../objects/rule'; import { - ALERT_RULE_METHOD, ALERT_RULE_NAME, ALERT_RULE_RISK_SCORE, ALERT_RULE_SEVERITY, - ALERT_RULE_VERSION, NUMBER_OF_ALERTS, } from '../../screens/alerts'; @@ -223,8 +221,6 @@ describe('Custom detection rules creation', () => { cy.get(NUMBER_OF_ALERTS).should(($count) => expect(+$count.text()).to.be.gte(1)); cy.get(ALERT_RULE_NAME).first().should('have.text', this.rule.name); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'query'); cy.get(ALERT_RULE_SEVERITY).first().should('have.text', this.rule.severity.toLowerCase()); cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', this.rule.riskScore); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts index 677a9b55464948..e06026ce12c7c9 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts @@ -9,11 +9,9 @@ import { formatMitreAttackDescription } from '../../helpers/rules'; import { getEqlRule, getEqlSequenceRule, getIndexPatterns } from '../../objects/rule'; import { - ALERT_RULE_METHOD, ALERT_RULE_NAME, ALERT_RULE_RISK_SCORE, ALERT_RULE_SEVERITY, - ALERT_RULE_VERSION, NUMBER_OF_ALERTS, } from '../../screens/alerts'; import { @@ -169,8 +167,6 @@ describe('Detection rules, EQL', () => { cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); cy.get(ALERT_RULE_NAME).first().should('have.text', this.rule.name); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'eql'); cy.get(ALERT_RULE_SEVERITY).first().should('have.text', this.rule.severity.toLowerCase()); cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', this.rule.riskScore); }); @@ -221,8 +217,6 @@ describe('Detection rules, sequence EQL', () => { cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfSequenceAlerts); cy.get(ALERT_RULE_NAME).first().should('have.text', this.rule.name); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'eql'); cy.get(ALERT_RULE_SEVERITY).first().should('have.text', this.rule.severity.toLowerCase()); cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', this.rule.riskScore); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts index 07b40df53e2d5b..ff000c105a1b44 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts @@ -9,11 +9,9 @@ import { formatMitreAttackDescription } from '../../helpers/rules'; import { getIndexPatterns, getNewThreatIndicatorRule } from '../../objects/rule'; import { - ALERT_RULE_METHOD, ALERT_RULE_NAME, ALERT_RULE_RISK_SCORE, ALERT_RULE_SEVERITY, - ALERT_RULE_VERSION, NUMBER_OF_ALERTS, } from '../../screens/alerts'; import { @@ -482,8 +480,6 @@ describe('indicator match', () => { cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); cy.get(ALERT_RULE_NAME).first().should('have.text', getNewThreatIndicatorRule().name); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threat_match'); cy.get(ALERT_RULE_SEVERITY) .first() .should('have.text', getNewThreatIndicatorRule().severity.toLowerCase()); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts index 24a56dd563e174..24c98aaee8f971 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts @@ -16,10 +16,8 @@ import { import { NUMBER_OF_ALERTS, ALERT_RULE_NAME, - ALERT_RULE_METHOD, ALERT_RULE_RISK_SCORE, ALERT_RULE_SEVERITY, - ALERT_RULE_VERSION, } from '../../screens/alerts'; import { @@ -196,8 +194,6 @@ describe('Detection rules, override', () => { cy.get(NUMBER_OF_ALERTS).should(($count) => expect(+$count.text()).to.be.gte(1)); cy.get(ALERT_RULE_NAME).first().should('have.text', 'auditbeat'); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'query'); cy.get(ALERT_RULE_SEVERITY).first().should('have.text', 'critical'); sortRiskScore(); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts index dba12fb4ab95c2..665df894359526 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts @@ -14,11 +14,9 @@ import { } from '../../objects/rule'; import { - ALERT_RULE_METHOD, ALERT_RULE_NAME, ALERT_RULE_RISK_SCORE, ALERT_RULE_SEVERITY, - ALERT_RULE_VERSION, NUMBER_OF_ALERTS, } from '../../screens/alerts'; @@ -179,8 +177,6 @@ describe('Detection rules, threshold', () => { cy.get(NUMBER_OF_ALERTS).should(($count) => expect(+$count.text()).to.be.lt(100)); cy.get(ALERT_RULE_NAME).first().should('have.text', rule.name); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threshold'); cy.get(ALERT_RULE_SEVERITY).first().should('have.text', rule.severity.toLowerCase()); cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', rule.riskScore); }); diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts index d6d3d829d3be56..89de83ab6e5cfe 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts @@ -35,18 +35,6 @@ export const columns: Array< initialWidth: DEFAULT_COLUMN_MIN_WIDTH, linkField: 'signal.rule.id', }, - { - columnHeaderType: defaultColumnHeaderType, - displayAsText: i18n.ALERTS_HEADERS_VERSION, - id: 'signal.rule.version', - initialWidth: 95, - }, - { - columnHeaderType: defaultColumnHeaderType, - displayAsText: i18n.ALERTS_HEADERS_METHOD, - id: 'signal.rule.type', - initialWidth: 100, - }, { columnHeaderType: defaultColumnHeaderType, displayAsText: i18n.ALERTS_HEADERS_SEVERITY, @@ -57,31 +45,29 @@ export const columns: Array< columnHeaderType: defaultColumnHeaderType, displayAsText: i18n.ALERTS_HEADERS_RISK_SCORE, id: 'signal.rule.risk_score', - initialWidth: 115, + initialWidth: 100, }, { columnHeaderType: defaultColumnHeaderType, - id: 'event.module', - linkField: 'rule.reference', + displayAsText: i18n.ALERTS_HEADERS_REASON, + id: 'signal.reason', + initialWidth: 450, }, { - aggregatable: true, - category: 'event', columnHeaderType: defaultColumnHeaderType, - id: 'event.action', - type: 'string', + id: 'host.name', }, { columnHeaderType: defaultColumnHeaderType, - id: 'event.category', + id: 'user.name', }, { columnHeaderType: defaultColumnHeaderType, - id: 'host.name', + id: 'process.name', }, { columnHeaderType: defaultColumnHeaderType, - id: 'user.name', + id: 'file.name', }, { columnHeaderType: defaultColumnHeaderType, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap index 9fd3e20f79b430..f07bed9fa556a4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap @@ -1639,6 +1639,10 @@ Object { "path": "signal.original_event.provider", "type": "alias", }, + "kibana.alert.original_event.reason": Object { + "path": "signal.original_event.reason", + "type": "alias", + }, "kibana.alert.original_event.risk_score": Object { "path": "signal.original_event.risk_score", "type": "alias", @@ -1671,6 +1675,10 @@ Object { "path": "signal.original_time", "type": "alias", }, + "kibana.alert.reason": Object { + "path": "signal.reason", + "type": "alias", + }, "kibana.alert.risk_score": Object { "path": "signal.rule.risk_score", "type": "alias", @@ -3249,6 +3257,9 @@ Object { "provider": Object { "type": "keyword", }, + "reason": Object { + "type": "keyword", + }, "risk_score": Object { "type": "float", }, @@ -3318,6 +3329,9 @@ Object { }, }, }, + "reason": Object { + "type": "keyword", + }, "rule": Object { "properties": Object { "author": Object { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_aad_mapping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_aad_mapping.json index 066fdbc87f9066..68c184b66c562d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_aad_mapping.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_aad_mapping.json @@ -17,6 +17,7 @@ "signal.original_event.module": "kibana.alert.original_event.module", "signal.original_event.outcome": "kibana.alert.original_event.outcome", "signal.original_event.provider": "kibana.alert.original_event.provider", + "signal.original_event.reason": "kibana.alert.original_event.reason", "signal.original_event.risk_score": "kibana.alert.original_event.risk_score", "signal.original_event.risk_score_norm": "kibana.alert.original_event.risk_score_norm", "signal.original_event.sequence": "kibana.alert.original_event.sequence", @@ -25,6 +26,7 @@ "signal.original_event.timezone": "kibana.alert.original_event.timezone", "signal.original_event.type": "kibana.alert.original_event.type", "signal.original_time": "kibana.alert.original_time", + "signal.reason": "kibana.alert.reason", "signal.rule.author": "kibana.alert.rule.author", "signal.rule.building_block_type": "kibana.alert.rule.building_block_type", "signal.rule.created_at": "kibana.alert.rule.created_at", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json index e20aa0ef16df43..7bc20fd540b9bc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json @@ -43,6 +43,9 @@ } } }, + "reason": { + "type": "keyword" + }, "rule": { "type": "object", "properties": { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json index d6a06848592cc7..4f754ecd2d33a7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json @@ -360,6 +360,9 @@ "provider": { "type": "keyword" }, + "reason": { + "type": "keyword" + }, "risk_score": { "type": "float" }, @@ -421,6 +424,9 @@ }, "depth": { "type": "integer" + }, + "reason": { + "type": "keyword" } } } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts index 4c59063d39e604..09f35e279a2449 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts @@ -6,6 +6,7 @@ */ import { + ALERT_REASON, ALERT_RULE_CONSUMER, ALERT_RULE_NAMESPACE, ALERT_STATUS, @@ -50,8 +51,9 @@ describe('buildAlert', () => { const doc = sampleDocNoSortIdWithTimestamp('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); delete doc._source.event; const rule = getRulesSchemaMock(); + const reason = 'alert reasonable reason'; const alert = { - ...buildAlert([doc], rule, SPACE_ID), + ...buildAlert([doc], rule, SPACE_ID, reason), ...additionalAlertFields(doc), }; const timestamp = alert['@timestamp']; @@ -68,6 +70,7 @@ describe('buildAlert', () => { }, ], [ALERT_ORIGINAL_TIME]: '2020-04-20T21:27:45.000Z', + [ALERT_REASON]: 'alert reasonable reason', [ALERT_STATUS]: 'open', [ALERT_WORKFLOW_STATUS]: 'open', ...flattenWithPrefix(ALERT_RULE_NAMESPACE, { @@ -119,8 +122,9 @@ describe('buildAlert', () => { module: 'system', }; const rule = getRulesSchemaMock(); + const reason = 'alert reasonable reason'; const alert = { - ...buildAlert([doc], rule, SPACE_ID), + ...buildAlert([doc], rule, SPACE_ID, reason), ...additionalAlertFields(doc), }; const timestamp = alert['@timestamp']; @@ -143,6 +147,7 @@ describe('buildAlert', () => { kind: 'event', module: 'system', }, + [ALERT_REASON]: 'alert reasonable reason', [ALERT_STATUS]: 'open', [ALERT_WORKFLOW_STATUS]: 'open', ...flattenWithPrefix(ALERT_RULE_NAMESPACE, { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts index ec667fa50934b6..eea85ba26faf88 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts @@ -6,6 +6,7 @@ */ import { + ALERT_REASON, ALERT_RULE_CONSUMER, ALERT_RULE_NAMESPACE, ALERT_STATUS, @@ -92,7 +93,8 @@ export const removeClashes = (doc: SimpleHit) => { export const buildAlert = ( docs: SimpleHit[], rule: RulesSchema, - spaceId: string | null | undefined + spaceId: string | null | undefined, + reason: string ): RACAlert => { const removedClashes = docs.map(removeClashes); const parents = removedClashes.map(buildParent); @@ -110,6 +112,7 @@ export const buildAlert = ( [ALERT_STATUS]: 'open', [ALERT_WORKFLOW_STATUS]: 'open', [ALERT_DEPTH]: depth, + [ALERT_REASON]: reason, ...flattenWithPrefix(ALERT_RULE_NAMESPACE, rule), } as unknown) as RACAlert; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts index ca5857e0ee3958..a67337d3b779d2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts @@ -9,6 +9,7 @@ import { SavedObject } from 'src/core/types'; import { BaseHit } from '../../../../../../common/detection_engine/types'; import type { ConfigType } from '../../../../../config'; import { buildRuleWithOverrides, buildRuleWithoutOverrides } from '../../../signals/build_rule'; +import { BuildReasonMessage } from '../../../signals/reason_formatters'; import { getMergeStrategy } from '../../../signals/source_fields_merging/strategies'; import { AlertAttributes, SignalSource, SignalSourceHit } from '../../../signals/types'; import { RACAlert } from '../../types'; @@ -35,19 +36,23 @@ export const buildBulkBody = ( ruleSO: SavedObject, doc: SignalSourceHit, mergeStrategy: ConfigType['alertMergeStrategy'], - applyOverrides: boolean + applyOverrides: boolean, + buildReasonMessage: BuildReasonMessage ): RACAlert => { const mergedDoc = getMergeStrategy(mergeStrategy)({ doc }); const rule = applyOverrides ? buildRuleWithOverrides(ruleSO, mergedDoc._source ?? {}) : buildRuleWithoutOverrides(ruleSO); const filteredSource = filterSource(mergedDoc); + const timestamp = new Date().toISOString(); + + const reason = buildReasonMessage({ mergedDoc, rule, timestamp }); if (isSourceDoc(mergedDoc)) { return { ...filteredSource, - ...buildAlert([mergedDoc], rule, spaceId), + ...buildAlert([mergedDoc], rule, spaceId, reason), ...additionalAlertFields(mergedDoc), - '@timestamp': new Date().toISOString(), + '@timestamp': timestamp, }; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts index 0b00b2f656379c..62946c52b7f40a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts @@ -24,7 +24,7 @@ export const wrapHitsFactory = ({ ruleSO: SearchAfterAndBulkCreateParams['ruleSO']; mergeStrategy: ConfigType['alertMergeStrategy']; spaceId: string | null | undefined; -}): WrapHits => (events) => { +}): WrapHits => (events, buildReasonMessage) => { try { const wrappedDocs: WrappedRACAlert[] = events.flatMap((doc) => [ { @@ -35,7 +35,14 @@ export const wrapHitsFactory = ({ String(doc._version), ruleSO.attributes.params.ruleId ?? '' ), - _source: buildBulkBody(spaceId, ruleSO, doc as SignalSourceHit, mergeStrategy, true), + _source: buildBulkBody( + spaceId, + ruleSO, + doc as SignalSourceHit, + mergeStrategy, + true, + buildReasonMessage + ), }, ]); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/field_maps/alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/field_maps/alerts.ts index 7ab998fe16074c..1c4b7f03fd73ff 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/field_maps/alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/field_maps/alerts.ts @@ -193,6 +193,11 @@ export const alertsFieldMap: FieldMap = { array: false, required: true, }, + 'kibana.alert.reason': { + type: 'keyword', + array: false, + required: false, + }, 'kibana.alert.threat': { type: 'object', array: false, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts index 117dcdf0c18da3..206f3ae59d2467 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts @@ -37,11 +37,13 @@ describe('buildBulkBody', () => { test('bulk body builds well-defined body', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); delete doc._source.source; const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -77,6 +79,7 @@ describe('buildBulkBody', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'reasonable reason', status: 'open', rule: expectedRule(), depth: 1, @@ -91,6 +94,7 @@ describe('buildBulkBody', () => { test('bulk body builds well-defined body with threshold results', () => { const ruleSO = sampleRuleSO(getThresholdRuleParams()); const baseDoc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); const doc: SignalSourceHit & { _source: Required['_source'] } = { ...baseDoc, _source: { @@ -109,7 +113,8 @@ describe('buildBulkBody', () => { const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -145,6 +150,7 @@ describe('buildBulkBody', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'reasonable reason', status: 'open', rule: { ...expectedRule(), @@ -181,6 +187,7 @@ describe('buildBulkBody', () => { test('bulk body builds original_event if it exists on the event to begin with', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); delete doc._source.source; doc._source.event = { action: 'socket_opened', @@ -191,7 +198,8 @@ describe('buildBulkBody', () => { const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -227,6 +235,7 @@ describe('buildBulkBody', () => { depth: 0, }, ], + reason: 'reasonable reason', ancestors: [ { id: sampleIdGuid, @@ -250,6 +259,7 @@ describe('buildBulkBody', () => { test('bulk body builds original_event if it exists on the event to begin with but no kind information', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); delete doc._source.source; doc._source.event = { action: 'socket_opened', @@ -259,7 +269,8 @@ describe('buildBulkBody', () => { const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -303,6 +314,7 @@ describe('buildBulkBody', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'reasonable reason', status: 'open', rule: expectedRule(), depth: 1, @@ -317,6 +329,7 @@ describe('buildBulkBody', () => { test('bulk body builds original_event if it exists on the event to begin with with only kind information', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); delete doc._source.source; doc._source.event = { kind: 'event', @@ -324,7 +337,8 @@ describe('buildBulkBody', () => { const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -363,6 +377,7 @@ describe('buildBulkBody', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'reasonable reason', status: 'open', rule: expectedRule(), depth: 1, @@ -377,6 +392,7 @@ describe('buildBulkBody', () => { test('bulk body builds "original_signal" if it exists already as a numeric', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const sampleDoc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); delete sampleDoc._source.source; const doc = ({ ...sampleDoc, @@ -388,7 +404,8 @@ describe('buildBulkBody', () => { const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); const expected: Omit & { someKey: string } = { someKey: 'someValue', @@ -423,6 +440,7 @@ describe('buildBulkBody', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'reasonable reason', status: 'open', rule: expectedRule(), depth: 1, @@ -437,6 +455,7 @@ describe('buildBulkBody', () => { test('bulk body builds "original_signal" if it exists already as an object', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const sampleDoc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); delete sampleDoc._source.source; const doc = ({ ...sampleDoc, @@ -448,7 +467,8 @@ describe('buildBulkBody', () => { const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); const expected: Omit & { someKey: string } = { someKey: 'someValue', @@ -483,6 +503,7 @@ describe('buildBulkBody', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'reasonable reason', status: 'open', rule: expectedRule(), depth: 1, @@ -504,7 +525,12 @@ describe('buildSignalFromSequence', () => { block2._source.new_key = 'new_key_value'; const blocks = [block1, block2]; const ruleSO = sampleRuleSO(getQueryRuleParams()); - const signal: SignalHitOptionalTimestamp = buildSignalFromSequence(blocks, ruleSO); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); + const signal: SignalHitOptionalTimestamp = buildSignalFromSequence( + blocks, + ruleSO, + buildReasonMessage + ); // Timestamp will potentially always be different so remove it for the test delete signal['@timestamp']; const expected: Omit & { new_key: string } = { @@ -573,6 +599,7 @@ describe('buildSignalFromSequence', () => { }, ], status: 'open', + reason: 'reasonable reason', rule: expectedRule(), depth: 2, group: { @@ -589,7 +616,12 @@ describe('buildSignalFromSequence', () => { block2._source['@timestamp'] = '2021-05-20T22:28:46+0000'; block2._source.someKey = 'someOtherValue'; const ruleSO = sampleRuleSO(getQueryRuleParams()); - const signal: SignalHitOptionalTimestamp = buildSignalFromSequence([block1, block2], ruleSO); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); + const signal: SignalHitOptionalTimestamp = buildSignalFromSequence( + [block1, block2], + ruleSO, + buildReasonMessage + ); // Timestamp will potentially always be different so remove it for the test delete signal['@timestamp']; const expected: Omit = { @@ -657,6 +689,7 @@ describe('buildSignalFromSequence', () => { }, ], status: 'open', + reason: 'reasonable reason', rule: expectedRule(), depth: 2, group: { @@ -673,11 +706,13 @@ describe('buildSignalFromEvent', () => { const ancestor = sampleDocWithAncestors().hits.hits[0]; delete ancestor._source.source; const ruleSO = sampleRuleSO(getQueryRuleParams()); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); const signal: SignalHitOptionalTimestamp = buildSignalFromEvent( ancestor, ruleSO, true, - 'missingFields' + 'missingFields', + buildReasonMessage ); // Timestamp will potentially always be different so remove it for the test @@ -724,6 +759,7 @@ describe('buildSignalFromEvent', () => { }, ], status: 'open', + reason: 'reasonable reason', rule: expectedRule(), depth: 2, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts index 54a41be5cbadeb..626dcb2fe83ff3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts @@ -22,6 +22,7 @@ import { buildEventTypeSignal } from './build_event_type_signal'; import { EqlSequence } from '../../../../common/detection_engine/types'; import { generateSignalId, wrapBuildingBlocks, wrapSignal } from './utils'; import type { ConfigType } from '../../../config'; +import { BuildReasonMessage } from './reason_formatters'; /** * Formats the search_after result for insertion into the signals index. We first create a @@ -35,12 +36,15 @@ import type { ConfigType } from '../../../config'; export const buildBulkBody = ( ruleSO: SavedObject, doc: SignalSourceHit, - mergeStrategy: ConfigType['alertMergeStrategy'] + mergeStrategy: ConfigType['alertMergeStrategy'], + buildReasonMessage: BuildReasonMessage ): SignalHit => { const mergedDoc = getMergeStrategy(mergeStrategy)({ doc }); const rule = buildRuleWithOverrides(ruleSO, mergedDoc._source ?? {}); + const timestamp = new Date().toISOString(); + const reason = buildReasonMessage({ mergedDoc, rule, timestamp }); const signal: Signal = { - ...buildSignal([mergedDoc], rule), + ...buildSignal([mergedDoc], rule, reason), ...additionalSignalFields(mergedDoc), }; const event = buildEventTypeSignal(mergedDoc); @@ -52,7 +56,7 @@ export const buildBulkBody = ( }; const signalHit: SignalHit = { ...filteredSource, - '@timestamp': new Date().toISOString(), + '@timestamp': timestamp, event, signal, }; @@ -71,11 +75,12 @@ export const buildSignalGroupFromSequence = ( sequence: EqlSequence, ruleSO: SavedObject, outputIndex: string, - mergeStrategy: ConfigType['alertMergeStrategy'] + mergeStrategy: ConfigType['alertMergeStrategy'], + buildReasonMessage: BuildReasonMessage ): WrappedSignalHit[] => { const wrappedBuildingBlocks = wrapBuildingBlocks( sequence.events.map((event) => { - const signal = buildSignalFromEvent(event, ruleSO, false, mergeStrategy); + const signal = buildSignalFromEvent(event, ruleSO, false, mergeStrategy, buildReasonMessage); signal.signal.rule.building_block_type = 'default'; return signal; }), @@ -94,7 +99,7 @@ export const buildSignalGroupFromSequence = ( // we can build the signal that links the building blocks together // and also insert the group id (which is also the "shell" signal _id) in each building block const sequenceSignal = wrapSignal( - buildSignalFromSequence(wrappedBuildingBlocks, ruleSO), + buildSignalFromSequence(wrappedBuildingBlocks, ruleSO, buildReasonMessage), outputIndex ); wrappedBuildingBlocks.forEach((block, idx) => { @@ -111,14 +116,18 @@ export const buildSignalGroupFromSequence = ( export const buildSignalFromSequence = ( events: WrappedSignalHit[], - ruleSO: SavedObject + ruleSO: SavedObject, + buildReasonMessage: BuildReasonMessage ): SignalHit => { const rule = buildRuleWithoutOverrides(ruleSO); - const signal: Signal = buildSignal(events, rule); + const timestamp = new Date().toISOString(); + + const reason = buildReasonMessage({ rule, timestamp }); + const signal: Signal = buildSignal(events, rule, reason); const mergedEvents = objectArrayIntersection(events.map((event) => event._source)); return { ...mergedEvents, - '@timestamp': new Date().toISOString(), + '@timestamp': timestamp, event: { kind: 'signal', }, @@ -137,14 +146,17 @@ export const buildSignalFromEvent = ( event: BaseSignalHit, ruleSO: SavedObject, applyOverrides: boolean, - mergeStrategy: ConfigType['alertMergeStrategy'] + mergeStrategy: ConfigType['alertMergeStrategy'], + buildReasonMessage: BuildReasonMessage ): SignalHit => { const mergedEvent = getMergeStrategy(mergeStrategy)({ doc: event }); const rule = applyOverrides ? buildRuleWithOverrides(ruleSO, mergedEvent._source ?? {}) : buildRuleWithoutOverrides(ruleSO); + const timestamp = new Date().toISOString(); + const reason = buildReasonMessage({ mergedDoc: mergedEvent, rule, timestamp }); const signal: Signal = { - ...buildSignal([mergedEvent], rule), + ...buildSignal([mergedEvent], rule, reason), ...additionalSignalFields(mergedEvent), }; const eventFields = buildEventTypeSignal(mergedEvent); @@ -155,7 +167,7 @@ export const buildSignalFromEvent = ( // TODO: better naming for SignalHit - it's really a new signal to be inserted const signalHit: SignalHit = { ...filteredSource, - '@timestamp': new Date().toISOString(), + '@timestamp': timestamp, event: eventFields, signal, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts index 8c0790761a5e0a..90b9cce9e057d9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts @@ -31,8 +31,10 @@ describe('buildSignal', () => { const doc = sampleDocNoSortId('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); delete doc._source.event; const rule = getRulesSchemaMock(); + const reason = 'signal reasonable reason'; + const signal = { - ...buildSignal([doc], rule), + ...buildSignal([doc], rule, reason), ...additionalSignalFields(doc), }; const expected: Signal = { @@ -62,6 +64,7 @@ describe('buildSignal', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'signal reasonable reason', status: 'open', rule: { author: [], @@ -112,8 +115,9 @@ describe('buildSignal', () => { module: 'system', }; const rule = getRulesSchemaMock(); + const reason = 'signal reasonable reason'; const signal = { - ...buildSignal([doc], rule), + ...buildSignal([doc], rule, reason), ...additionalSignalFields(doc), }; const expected: Signal = { @@ -143,6 +147,7 @@ describe('buildSignal', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'signal reasonable reason', original_event: { action: 'socket_opened', dataset: 'socket', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts index a415c83e857c21..962869cc4d61a3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts @@ -77,7 +77,7 @@ export const removeClashes = (doc: BaseSignalHit): BaseSignalHit => { * @param docs The parent signals/events of the new signal to be built. * @param rule The rule that is generating the new signal. */ -export const buildSignal = (docs: BaseSignalHit[], rule: RulesSchema): Signal => { +export const buildSignal = (docs: BaseSignalHit[], rule: RulesSchema, reason: string): Signal => { const _meta = { version: SIGNALS_TEMPLATE_VERSION, }; @@ -94,6 +94,7 @@ export const buildSignal = (docs: BaseSignalHit[], rule: RulesSchema): Signal => ancestors, status: 'open', rule, + reason, depth, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts index ebb4462817eabc..be6f4cb8feae56 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts @@ -19,6 +19,7 @@ import { AnomalyResults, Anomaly } from '../../machine_learning'; import { BuildRuleMessage } from './rule_messages'; import { AlertAttributes, BulkCreate, WrapHits } from './types'; import { MachineLearningRuleParams } from '../schemas/rule_schemas'; +import { buildReasonMessageForMlAlert } from './reason_formatters'; interface BulkCreateMlSignalsParams { someResult: AnomalyResults; @@ -89,6 +90,6 @@ export const bulkCreateMlSignals = async ( const anomalyResults = params.someResult; const ecsResults = transformAnomalyResultsToEcs(anomalyResults); - const wrappedDocs = params.wrapHits(ecsResults.hits.hits); + const wrappedDocs = params.wrapHits(ecsResults.hits.hits, buildReasonMessageForMlAlert); return params.bulkCreate(wrappedDocs); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts index 8d19510c634772..9a2805610ca8b7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts @@ -35,6 +35,7 @@ import { } from '../types'; import { createSearchAfterReturnType, makeFloatString } from '../utils'; import { ExperimentalFeatures } from '../../../../../common/experimental_features'; +import { buildReasonMessageForEqlAlert } from '../reason_formatters'; export const eqlExecutor = async ({ rule, @@ -119,9 +120,9 @@ export const eqlExecutor = async ({ result.searchAfterTimes = [eqlSearchDuration]; let newSignals: SimpleHit[] | undefined; if (response.hits.sequences !== undefined) { - newSignals = wrapSequences(response.hits.sequences); + newSignals = wrapSequences(response.hits.sequences, buildReasonMessageForEqlAlert); } else if (response.hits.events !== undefined) { - newSignals = wrapHits(response.hits.events); + newSignals = wrapHits(response.hits.events, buildReasonMessageForEqlAlert); } else { throw new Error( 'eql query response should have either `sequences` or `events` but had neither' diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts index f27680315d1947..f281475fe59ebb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts @@ -22,6 +22,7 @@ import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; import { QueryRuleParams, SavedQueryRuleParams } from '../../schemas/rule_schemas'; import { ExperimentalFeatures } from '../../../../../common/experimental_features'; +import { buildReasonMessageForQueryAlert } from '../reason_formatters'; export const queryExecutor = async ({ rule, @@ -84,6 +85,7 @@ export const queryExecutor = async ({ signalsIndex: ruleParams.outputIndex, filter: esFilter, pageSize: searchAfterSize, + buildReasonMessage: buildReasonMessageForQueryAlert, buildRuleMessage, bulkCreate, wrapHits, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatter.test.ts new file mode 100644 index 00000000000000..e7f4fb41c763b0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatter.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildCommonReasonMessage } from './reason_formatters'; +import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; +import { SignalSourceHit } from './types'; + +describe('reason_formatter', () => { + let rule: RulesSchema; + let mergedDoc: SignalSourceHit; + let timestamp: string; + beforeAll(() => { + rule = { + name: 'What is in a name', + risk_score: 9000, + severity: 'medium', + } as RulesSchema; // Cast here as all fields aren't required + mergedDoc = { + _index: 'some-index', + _id: 'some-id', + fields: { + 'host.name': ['party host'], + 'user.name': ['ferris bueller'], + '@timestamp': '2021-08-11T02:28:59.101Z', + }, + }; + timestamp = '2021-08-11T02:28:59.401Z'; + }); + + describe('buildCommonReasonMessage', () => { + describe('when rule, mergedDoc, and timestamp are provided', () => { + it('should return the full reason message', () => { + expect(buildCommonReasonMessage({ rule, mergedDoc, timestamp })).toEqual( + 'Alert What is in a name created at 2021-08-11T02:28:59.401Z with a medium severity and risk score of 9000 by ferris bueller on party host.' + ); + }); + }); + describe('when rule, mergedDoc, and timestamp are provided and host.name is missing', () => { + it('should return the reason message without the host name', () => { + const updatedMergedDoc = { + ...mergedDoc, + fields: { + ...mergedDoc.fields, + 'host.name': ['-'], + }, + }; + expect(buildCommonReasonMessage({ rule, mergedDoc: updatedMergedDoc, timestamp })).toEqual( + 'Alert What is in a name created at 2021-08-11T02:28:59.401Z with a medium severity and risk score of 9000 by ferris bueller.' + ); + }); + }); + describe('when rule, mergedDoc, and timestamp are provided and user.name is missing', () => { + it('should return the reason message without the user name', () => { + const updatedMergedDoc = { + ...mergedDoc, + fields: { + ...mergedDoc.fields, + 'user.name': ['-'], + }, + }; + expect(buildCommonReasonMessage({ rule, mergedDoc: updatedMergedDoc, timestamp })).toEqual( + 'Alert What is in a name created at 2021-08-11T02:28:59.401Z with a medium severity and risk score of 9000 on party host.' + ); + }); + }); + describe('when only rule and timestamp are provided', () => { + it('should return the reason message without host name or user name', () => { + expect(buildCommonReasonMessage({ rule, timestamp })).toEqual( + 'Alert What is in a name created at 2021-08-11T02:28:59.401Z with a medium severity and risk score of 9000.' + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatters.ts new file mode 100644 index 00000000000000..0586462a2a581c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatters.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; +import { SignalSourceHit } from './types'; + +export interface BuildReasonMessageArgs { + rule: RulesSchema; + mergedDoc?: SignalSourceHit; + timestamp: string; +} + +export type BuildReasonMessage = (args: BuildReasonMessageArgs) => string; + +/** + * Currently all security solution rule types share a common reason message string. This function composes that string + * In the future there may be different configurations based on the different rule types, so the plumbing has been put in place + * to more easily allow for this in the future. + * @export buildCommonReasonMessage - is only exported for testing purposes, and only used internally here. + */ +export const buildCommonReasonMessage = ({ + rule, + mergedDoc, + timestamp, +}: BuildReasonMessageArgs) => { + if (!rule) { + // This should never happen, but in case, better to not show a malformed string + return ''; + } + let hostName; + let userName; + if (mergedDoc?.fields) { + hostName = mergedDoc.fields['host.name'] != null ? mergedDoc.fields['host.name'] : hostName; + userName = mergedDoc.fields['user.name'] != null ? mergedDoc.fields['user.name'] : userName; + } + + const isFieldEmpty = (field: string | string[] | undefined | null) => + !field || !field.length || (field.length === 1 && field[0] === '-'); + + return i18n.translate('xpack.securitySolution.detectionEngine.signals.alertReasonDescription', { + defaultMessage: + 'Alert {alertName} created at {timestamp} with a {alertSeverity} severity and risk score of {alertRiskScore}{userName, select, null {} other {{whitespace}by {userName}} }{hostName, select, null {} other {{whitespace}on {hostName}} }.', + values: { + alertName: rule.name, + alertSeverity: rule.severity, + alertRiskScore: rule.risk_score, + hostName: isFieldEmpty(hostName) ? 'null' : hostName, + timestamp, + userName: isFieldEmpty(userName) ? 'null' : userName, + whitespace: ' ', // there isn't support for the unicode /u0020 for whitespace, and leading spaces are deleted, so to prevent double-whitespace explicitly passing the space in. + }, + }); +}; + +export const buildReasonMessageForEqlAlert = (args: BuildReasonMessageArgs) => + buildCommonReasonMessage({ ...args }); + +export const buildReasonMessageForMlAlert = (args: BuildReasonMessageArgs) => + buildCommonReasonMessage({ ...args }); + +export const buildReasonMessageForQueryAlert = (args: BuildReasonMessageArgs) => + buildCommonReasonMessage({ ...args }); + +export const buildReasonMessageForThreatMatchAlert = (args: BuildReasonMessageArgs) => + buildCommonReasonMessage({ ...args }); + +export const buildReasonMessageForThresholdAlert = (args: BuildReasonMessageArgs) => + buildCommonReasonMessage({ ...args }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 711db931e9072f..8bf0c986b9c250 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -32,11 +32,13 @@ import { bulkCreateFactory } from './bulk_create_factory'; import { wrapHitsFactory } from './wrap_hits_factory'; import { mockBuildRuleMessage } from './__mocks__/build_rule_message.mock'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; +import { BuildReasonMessage } from './reason_formatters'; const buildRuleMessage = mockBuildRuleMessage; describe('searchAfterAndBulkCreate', () => { let mockService: AlertServicesMock; + let buildReasonMessage: BuildReasonMessage; let bulkCreate: BulkCreate; let wrapHits: WrapHits; let inputIndexPattern: string[] = []; @@ -48,6 +50,7 @@ describe('searchAfterAndBulkCreate', () => { let tuple: RuleRangeTuple; beforeEach(() => { jest.clearAllMocks(); + buildReasonMessage = jest.fn().mockResolvedValue('some alert reason message'); listClient = listMock.getListClient(); listClient.searchListItemByValues = jest.fn().mockResolvedValue([]); inputIndexPattern = ['auditbeat-*']; @@ -191,6 +194,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -295,6 +299,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -373,6 +378,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -432,6 +438,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -511,6 +518,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -566,6 +574,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -638,6 +647,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -712,6 +722,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -763,6 +774,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -810,6 +822,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -871,6 +884,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -997,6 +1011,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -1093,6 +1108,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index 7b5b61577cf32d..8037a9a2015104 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -34,6 +34,7 @@ export const searchAfterAndBulkCreate = async ({ filter, pageSize, buildRuleMessage, + buildReasonMessage, enrichment = identity, bulkCreate, wrapHits, @@ -146,7 +147,7 @@ export const searchAfterAndBulkCreate = async ({ ); } const enrichedEvents = await enrichment(filteredEvents); - const wrappedDocs = wrapHits(enrichedEvents.hits.hits); + const wrappedDocs = wrapHits(enrichedEvents.hits.hits, buildReasonMessage); const { bulkCreateDuration: bulkDuration, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index fb9881b519a16d..312d75f7a10cc8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -9,6 +9,7 @@ import { buildThreatMappingFilter } from './build_threat_mapping_filter'; import { getFilter } from '../get_filter'; import { searchAfterAndBulkCreate } from '../search_after_bulk_create'; +import { buildReasonMessageForThreatMatchAlert } from '../reason_formatters'; import { CreateThreatSignalOptions } from './types'; import { SearchAfterAndBulkCreateReturnType } from '../types'; @@ -83,6 +84,7 @@ export const createThreatSignal = async ({ filter: esFilter, pageSize: searchAfterSize, buildRuleMessage, + buildReasonMessage: buildReasonMessageForThreatMatchAlert, enrichment: threatEnrichment, bulkCreate, wrapHits, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts index f56ed3a5e9eb46..afb0353c4ba031 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts @@ -24,6 +24,7 @@ import { getThresholdAggregationParts, getThresholdTermsHash, } from '../utils'; +import { buildReasonMessageForThresholdAlert } from '../reason_formatters'; import type { MultiAggBucket, SignalSource, @@ -248,5 +249,7 @@ export const bulkCreateThresholdSignals = async ( params.thresholdSignalHistory ); - return params.bulkCreate(params.wrapHits(ecsResults.hits.hits)); + return params.bulkCreate( + params.wrapHits(ecsResults.hits.hits, buildReasonMessageForThresholdAlert) + ); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index 4da411d0c70a12..89233cf2c82427 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -35,6 +35,7 @@ import { RuleParams } from '../schemas/rule_schemas'; import { GenericBulkCreateResponse } from './bulk_create_factory'; import { EcsFieldMap } from '../../../../../rule_registry/common/assets/field_maps/ecs_field_map'; import { TypeOfFieldMap } from '../../../../../rule_registry/common/field_map'; +import { BuildReasonMessage } from './reason_formatters'; // used for gap detection code // eslint-disable-next-line @typescript-eslint/naming-convention @@ -238,6 +239,7 @@ export interface Signal { }; original_time?: string; original_event?: SearchTypes; + reason?: string; status: Status; threshold_result?: ThresholdResult; original_signal?: SearchTypes; @@ -286,9 +288,15 @@ export type BulkCreate = (docs: Array>) => Promise; -export type WrapHits = (hits: estypes.SearchHit[]) => SimpleHit[]; +export type WrapHits = ( + hits: Array>, + buildReasonMessage: BuildReasonMessage +) => SimpleHit[]; -export type WrapSequences = (sequences: Array>) => SimpleHit[]; +export type WrapSequences = ( + sequences: Array>, + buildReasonMessage: BuildReasonMessage +) => SimpleHit[]; export interface SearchAfterAndBulkCreateParams { tuple: { @@ -308,6 +316,7 @@ export interface SearchAfterAndBulkCreateParams { pageSize: number; filter: unknown; buildRuleMessage: BuildRuleMessage; + buildReasonMessage: BuildReasonMessage; enrichment?: SignalsEnrichment; bulkCreate: BulkCreate; wrapHits: WrapHits; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_hits_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_hits_factory.ts index 5cef740e178958..19bdd58140a335 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_hits_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_hits_factory.ts @@ -24,7 +24,7 @@ export const wrapHitsFactory = ({ ruleSO: SearchAfterAndBulkCreateParams['ruleSO']; signalsIndex: string; mergeStrategy: ConfigType['alertMergeStrategy']; -}): WrapHits => (events) => { +}): WrapHits => (events, buildReasonMessage) => { const wrappedDocs: WrappedSignalHit[] = events.flatMap((doc) => [ { _index: signalsIndex, @@ -34,7 +34,7 @@ export const wrapHitsFactory = ({ String(doc._version), ruleSO.attributes.params.ruleId ?? '' ), - _source: buildBulkBody(ruleSO, doc as SignalSourceHit, mergeStrategy), + _source: buildBulkBody(ruleSO, doc as SignalSourceHit, mergeStrategy, buildReasonMessage), }, ]); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_sequences_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_sequences_factory.ts index f0b9e640476929..0ca4b9688f9712 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_sequences_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_sequences_factory.ts @@ -17,11 +17,17 @@ export const wrapSequencesFactory = ({ ruleSO: SearchAfterAndBulkCreateParams['ruleSO']; signalsIndex: string; mergeStrategy: ConfigType['alertMergeStrategy']; -}): WrapSequences => (sequences) => +}): WrapSequences => (sequences, buildReasonMessage) => sequences.reduce( (acc: WrappedSignalHit[], sequence) => [ ...acc, - ...buildSignalGroupFromSequence(sequence, ruleSO, signalsIndex, mergeStrategy), + ...buildSignalGroupFromSequence( + sequence, + ruleSO, + signalsIndex, + mergeStrategy, + buildReasonMessage + ), ], [] ); diff --git a/x-pack/plugins/timelines/common/ecs/ecs_fields/index.ts b/x-pack/plugins/timelines/common/ecs/ecs_fields/index.ts index 292822019fc9ca..239e295a1f8b13 100644 --- a/x-pack/plugins/timelines/common/ecs/ecs_fields/index.ts +++ b/x-pack/plugins/timelines/common/ecs/ecs_fields/index.ts @@ -292,6 +292,7 @@ export const systemFieldsMap: Readonly> = { export const signalFieldsMap: Readonly> = { 'signal.original_time': 'signal.original_time', + 'signal.reason': 'signal.reason', 'signal.rule.id': 'signal.rule.id', 'signal.rule.saved_id': 'signal.rule.saved_id', 'signal.rule.timeline_id': 'signal.rule.timeline_id', diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx index 790414314ecddb..3dea3e71445a19 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx @@ -147,6 +147,7 @@ export const allowSorting = ({ 'signal.parent.index', 'signal.parent.rule', 'signal.parent.type', + 'signal.reason', 'signal.rule.created_by', 'signal.rule.description', 'signal.rule.enabled', diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/constants.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/constants.ts index aae68dbcf86d1e..9b45a5bebfc218 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/constants.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/constants.ts @@ -45,6 +45,7 @@ export const TIMELINE_EVENTS_FIELDS = [ 'signal.status', 'signal.group.id', 'signal.original_time', + 'signal.reason', 'signal.rule.filters', 'signal.rule.from', 'signal.rule.language', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts index a03bd07c86020a..cd209da25e883a 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts @@ -193,6 +193,7 @@ export default ({ getService }: FtrProviderContext) => { index: '.ml-anomalies-custom-linux_anomalous_network_activity_ecs', depth: 0, }, + reason: `Alert Test ML rule created at ${signal._source['@timestamp']} with a critical severity and risk score of 50 by root on mothra.`, original_time: '2020-11-16T22:58:08.000Z', }, all_field_values: [ diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index c341761160633b..399eafc475a89c 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -275,6 +275,7 @@ export default ({ getService }: FtrProviderContext) => { depth: 0, }, ], + reason: `Alert Query with a rule id created at ${fullSignal['@timestamp']} with a high severity and risk score of 55 by root on zeek-sensor-amsterdam.`, rule: fullSignal.signal.rule, status: 'open', }, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts index 66c94a7317b721..1c1e2b9966b7f6 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts @@ -112,7 +112,8 @@ export default ({ getService }: FtrProviderContext) => { const signalsOpen = await getSignalsByIds(supertest, [id]); const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + // remove reason to avoid failures due to @timestamp mismatches in the reason string + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ @@ -165,7 +166,8 @@ export default ({ getService }: FtrProviderContext) => { const signalsOpen = await getSignalsByIds(supertest, [id]); const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + // remove reason to avoid failures due to @timestamp mismatches in the reason string + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ { @@ -228,7 +230,7 @@ export default ({ getService }: FtrProviderContext) => { const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ { @@ -360,6 +362,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, signal: { + reason: `Alert Signal Testing Query created at ${fullSignal['@timestamp']} with a high severity and risk score of 1 on suricata-zeek-sensor-toronto.`, rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, status: 'open', @@ -494,6 +497,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, signal: { + reason: `Alert Signal Testing Query created at ${fullSignal['@timestamp']} with a high severity and risk score of 1 on suricata-zeek-sensor-toronto.`, rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, status: 'open', @@ -658,6 +662,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, signal: { + reason: `Alert Signal Testing Query created at ${fullSignal['@timestamp']} with a high severity and risk score of 1 by root on zeek-sensor-amsterdam.`, rule: fullSignal.signal.rule, group: fullSignal.signal.group, original_time: fullSignal.signal.original_time, @@ -748,6 +753,7 @@ export default ({ getService }: FtrProviderContext) => { status: 'open', depth: 2, group: source.signal.group, + reason: `Alert Signal Testing Query created at ${source['@timestamp']} with a high severity and risk score of 1.`, rule: source.signal.rule, ancestors: [ { @@ -866,6 +872,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], status: 'open', + reason: `Alert Signal Testing Query created at ${fullSignal['@timestamp']} with a high severity and risk score of 1.`, rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, depth: 1, @@ -1003,6 +1010,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], status: 'open', + reason: `Alert Signal Testing Query created at ${fullSignal['@timestamp']} with a high severity and risk score of 1.`, rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, depth: 1, @@ -1086,6 +1094,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], status: 'open', + reason: `Alert Signal Testing Query created at ${fullSignal['@timestamp']} with a high severity and risk score of 1.`, rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, depth: 1, @@ -1171,7 +1180,8 @@ export default ({ getService }: FtrProviderContext) => { const signalsOpen = await getSignalsByIds(supertest, [id]); const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + // remove reason to avoid failures due to @timestamp mismatches in the reason string + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ { @@ -1228,7 +1238,7 @@ export default ({ getService }: FtrProviderContext) => { const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ @@ -1325,7 +1335,8 @@ export default ({ getService }: FtrProviderContext) => { const signalsOpen = await getSignalsByIds(supertest, [id]); const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + // remove reason to avoid failures due to @timestamp mismatches in the reason string + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ { @@ -1387,7 +1398,7 @@ export default ({ getService }: FtrProviderContext) => { const signalsOpen = await getSignalsByRuleIds(supertest, ['signal-on-signal']); const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ @@ -1675,6 +1686,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], status: 'open', + reason: `Alert boot created at ${fullSignal['@timestamp']} with a high severity and risk score of 1 on zeek-sensor-amsterdam.`, rule: { ...fullSignal.signal.rule, name: 'boot', From 73e7db5ead8b1aed47de005f579cecaa4c360c0f Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 11 Aug 2021 15:32:28 -0700 Subject: [PATCH 14/20] [scripts/type_check] don't fail if --project is a composite project (#108249) Co-authored-by: spalger --- src/dev/typescript/run_type_check_cli.ts | 31 ++++++++++++++++++------ 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/dev/typescript/run_type_check_cli.ts b/src/dev/typescript/run_type_check_cli.ts index 1bf31a6c5bac0d..6a286313228572 100644 --- a/src/dev/typescript/run_type_check_cli.ts +++ b/src/dev/typescript/run_type_check_cli.ts @@ -37,19 +37,34 @@ export async function runTypeCheckCli() { : undefined; const projects = PROJECTS.filter((p) => { - return ( - !p.disableTypeCheck && - (!projectFilter || p.tsConfigPath === projectFilter) && - !p.isCompositeProject() - ); + return !p.disableTypeCheck && (!projectFilter || p.tsConfigPath === projectFilter); }); if (!projects.length) { - throw createFailError(`Unable to find project at ${flags.project}`); + if (projectFilter) { + throw createFailError(`Unable to find project at ${flags.project}`); + } else { + throw createFailError(`Unable to find projects to type-check`); + } + } + + const nonCompositeProjects = projects.filter((p) => !p.isCompositeProject()); + if (!nonCompositeProjects.length) { + if (projectFilter) { + log.success( + `${flags.project} is a composite project so its types are validated by scripts/build_ts_refs` + ); + } else { + log.success( + `All projects are composite so their types are validated by scripts/build_ts_refs` + ); + } + + return; } const concurrency = Math.min(4, Math.round((Os.cpus() || []).length / 2) || 1) || 1; - log.info('running type check in', projects.length, 'non-composite projects'); + log.info('running type check in', nonCompositeProjects.length, 'non-composite projects'); const tscArgs = [ ...['--emitDeclarationOnly', 'false'], @@ -61,7 +76,7 @@ export async function runTypeCheckCli() { ]; const failureCount = await lastValueFrom( - Rx.from(projects).pipe( + Rx.from(nonCompositeProjects).pipe( mergeMap(async (p) => { const relativePath = Path.relative(process.cwd(), p.tsConfigPath); From 33cd67a4b0566bd5838b158f548ffd246c13a5b2 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 11 Aug 2021 18:07:38 -0500 Subject: [PATCH 15/20] [Workplace Search] Add custom branding controls to Settings (#108235) * Add image upload route * Add base64 converter Loosely based on similar App Search util https://github.com/elastic/kibana/blob/master/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.ts I opted to use this built-in class and strip out what the server does not need. Will have to manually add the prefix back in the template the way eweb does it: https://github.com/elastic/ent-search/blob/master/app/javascript/eweb/components/shared/search_results/ServiceTypeResultIcon.jsx#L7 * Swap out flash messages for success toasts Will be doing this app-wide in a future PR to match App Search * Make propperties optional After the redesign, it was decided that both icons could be uploaded individually. * Add constants * Add BrandingSection component * Add logic for image upload and display * Add BrandingSection to settings view * Fix failing test * Fix some typos in tests --- .../applications/shared/constants/actions.ts | 5 + .../workplace_search/utils/index.ts | 1 + .../read_uploaded_file_as_base64.test.ts | 21 +++ .../utils/read_uploaded_file_as_base64.ts | 26 +++ .../components/branding_section.test.tsx | 86 ++++++++++ .../settings/components/branding_section.tsx | 152 ++++++++++++++++++ .../settings/components/customize.test.tsx | 2 + .../views/settings/components/customize.tsx | 50 +++++- .../views/settings/constants.ts | 93 +++++++++++ .../views/settings/settings_logic.test.ts | 114 ++++++++++++- .../views/settings/settings_logic.ts | 95 ++++++++++- .../routes/workplace_search/settings.test.ts | 31 ++++ .../routes/workplace_search/settings.ts | 21 +++ 13 files changed, 687 insertions(+), 10 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/constants.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/actions.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/actions.ts index e6511947d25061..6579e911cc19b8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/constants/actions.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/actions.ts @@ -44,3 +44,8 @@ export const CLOSE_BUTTON_LABEL = i18n.translate( 'xpack.enterpriseSearch.actions.closeButtonLabel', { defaultMessage: 'Close' } ); + +export const RESET_DEFAULT_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.actions.resetDefaultButtonLabel', + { defaultMessage: 'Reset to default' } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts index e9ebc791622d9f..fb9846dbccde81 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts @@ -8,3 +8,4 @@ export { toSentenceSerial } from './to_sentence_serial'; export { getAsLocalDateTimeString } from './get_as_local_datetime_string'; export { mimeType } from './mime_types'; +export { readUploadedFileAsBase64 } from './read_uploaded_file_as_base64'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.test.ts new file mode 100644 index 00000000000000..9f612a7432ec5b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { readUploadedFileAsBase64 } from './'; + +describe('readUploadedFileAsBase64', () => { + it('reads a file and returns base64 string', async () => { + const file = new File(['a mock file'], 'mockFile.png', { type: 'img/png' }); + const text = await readUploadedFileAsBase64(file); + expect(text).toEqual('YSBtb2NrIGZpbGU='); + }); + + it('throws an error if the file cannot be read', async () => { + const badFile = ('causes an error' as unknown) as File; + await expect(readUploadedFileAsBase64(badFile)).rejects.toThrow(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.ts new file mode 100644 index 00000000000000..d9f6d177cf9cd6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const readUploadedFileAsBase64 = (fileInput: File): Promise => { + const reader = new FileReader(); + + return new Promise((resolve, reject) => { + reader.onload = () => { + // We need to split off the prefix from the DataUrl and only pass the base64 string + // before: 'data:image/png;base64,encodedData==' + // after: 'encodedData==' + const base64 = (reader.result as string).split(',')[1]; + resolve(base64); + }; + try { + reader.readAsDataURL(fileInput); + } catch { + reader.abort(); + reject(new Error()); + } + }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.test.tsx new file mode 100644 index 00000000000000..0f96b76130b4f1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow, mount } from 'enzyme'; + +import { EuiFilePicker, EuiConfirmModal } from '@elastic/eui'; +import { nextTick } from '@kbn/test/jest'; + +jest.mock('../../../utils', () => ({ + readUploadedFileAsBase64: jest.fn(({ img }) => img), +})); +import { readUploadedFileAsBase64 } from '../../../utils'; + +import { RESET_IMAGE_TITLE } from '../constants'; + +import { BrandingSection, defaultLogo } from './branding_section'; + +describe('BrandingSection', () => { + const stageImage = jest.fn(); + const saveImage = jest.fn(); + const resetImage = jest.fn(); + + const props = { + image: 'foo', + imageType: 'logo' as 'logo', + description: 'logo test', + helpText: 'this is a logo', + stageImage, + saveImage, + resetImage, + }; + + it('renders logo', () => { + const wrapper = mount(); + + expect(wrapper.find(EuiFilePicker)).toHaveLength(1); + }); + + it('renders icon copy', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="ResetImageButton"]').simulate('click'); + + expect(wrapper.find(EuiConfirmModal).prop('title')).toEqual(RESET_IMAGE_TITLE); + }); + + it('renders default Workplace Search logo', () => { + const wrapper = shallow(); + + expect(wrapper.find('img').prop('src')).toContain(defaultLogo); + }); + + describe('resetConfirmModal', () => { + it('calls method and hides modal when modal confirmed', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="ResetImageButton"]').simulate('click'); + wrapper.find(EuiConfirmModal).prop('onConfirm')!({} as any); + + expect(wrapper.find(EuiConfirmModal)).toHaveLength(0); + expect(resetImage).toHaveBeenCalled(); + }); + }); + + describe('handleUpload', () => { + it('handles empty files', () => { + const wrapper = shallow(); + wrapper.find(EuiFilePicker).prop('onChange')!([] as any); + + expect(stageImage).toHaveBeenCalledWith(null); + }); + + it('handles image', async () => { + const wrapper = shallow(); + wrapper.find(EuiFilePicker).prop('onChange')!(['foo'] as any); + + expect(readUploadedFileAsBase64).toHaveBeenCalledWith('foo'); + await nextTick(); + expect(stageImage).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.tsx new file mode 100644 index 00000000000000..776e72c4026cf0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.tsx @@ -0,0 +1,152 @@ +/* + * 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 { + EuiButton, + EuiConfirmModal, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFilePicker, + EuiText, + EuiSpacer, +} from '@elastic/eui'; + +import { + SAVE_BUTTON_LABEL, + CANCEL_BUTTON_LABEL, + RESET_DEFAULT_BUTTON_LABEL, +} from '../../../../shared/constants'; +import { readUploadedFileAsBase64 } from '../../../utils'; + +import { + LOGO_TEXT, + ICON_TEXT, + RESET_IMAGE_TITLE, + RESET_LOGO_DESCRIPTION, + RESET_ICON_DESCRIPTION, + RESET_IMAGE_CONFIRMATION_TEXT, + ORGANIZATION_LABEL, + BRAND_TEXT, +} from '../constants'; + +export const defaultLogo = + 'iVBORw0KGgoAAAANSUhEUgAAAMMAAAAeCAMAAACmAVppAAABp1BMVEUAAAAmLjf/xRPwTpglLjf/xhIlLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjcwMTslLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjf+xBMlLjclLjclLjclLjclLjf/xxBUOFP+wRclLjf+xxb/0w3wTpgkLkP+xRM6ME3wTphKPEnxU5PwT5f/yhDwTpj/xxD/yBJQLF/wTpjyWY7/zQw5I1z/0Aj3SKT/zg//zg38syyoOYfhTZL/0QT+xRP/Uqr/UqtBMFD+xBV6SllaOVY7J1VXM1v/yhH/1wYlLjf+xRPwTpgzN0HvTpc1OEH+xBMuNj7/UaX/UKEXMzQQMzH4TpvwS5swNkArNj4nNTv/UqflTZPdTJA6OEQiNDr/yQ7zT5q9SIB1P19nPlhMOkz/UqbUTIvSS4oFLTD1hLkfAAAAbXRSTlMADfLy4wwCKflGIPzzaF0k8BEFlMd/G9rNFAjosWJWNC8s1LZ4bey9q6SZclHewJxlQDkLoIqDfE09So4Y6MSniIaFy8G8h04Q/vb29ObitpyQiodmXlZUVDssJSQfHQj+7Ovi4caspKFzbGw11xUNcgAABZRJREFUWMPVmIeT0kAUh180IoQOJyAgvQt4dLD33nvvXX8ed/beu3+0bzcJtjiDjuMM38xluU12932b3U2ytGu+ZM8RGrFl0zzJqgU0GczoPHq0l3QWXH79+vYtyaQ4zJ8x2U+C0xtumcybPIeZw/zv8fO3Jtph2wmim7cn2mF29uIZoqO3J9lh5tnnjZxx4PbkOsw+e/H4wVXO2WTpoCgBIyUz/QnrPGopNhoTZWHaT2MTUAI/OczePTt3//Gd60Rb51k5OOyqKLLS56oS03at+zUEl8tCIuNaOKZBxQmgHKIx6bl6PzrM3pt9eX9ueGfuGNENKwc/0OTEAywjxo4q/YwfsHDwIT2eQgaYqgOxxTQea9H50eHhvfcP5obD4ZPdnLfKaj5kkeNjEKhxkoQ9Sj9iI8V0+GHwqBjvPuSQ8RKFwmjTeCzCItPBGElv798ZMo/vHCLaZ+WwFFk+huGE1/wnN6VmPZxGl63QSoUGSYdBOe6n9opWJxzp2UwHW66urs6RIFkJhyspYhZ3Mmq5QQZxTMvT5aV81ILhWrsp+4Mbqef5R7rsaa5WNSJ3US26pcN0qliL902HN3ffPRhKnm4k2mLlkIY9QF6sXga3aDBP/ghgB8pyELkAj3QYgLunBYTBTEV1B60G+CC9+5Bw6Joqy7tJJ4iplaO2fPJUlcyScaIqnAC8lIUgKxyKEFQNh4czH17pDk92RumklQPFMKAlyHtRInJxZW2++baBj2NXfCg0Qq0oQCFgKYkMV7PVLKCnOyxFRqOQCgf5nVgXjQYBogiCAY4MxiT2OuEMeuRkCKjYbOO2nArlENFIK6BJDqCe0riqWDOQ9CHHDugqoSKmDId7z18+HepsV2jrDiuHZRxdiSuDi7yIURTQiLilDNmcSMo5XUipQoEUOxycJKDqDooMrYQ8ublJplKyebkgs54zdZKyh0tp4nCLeoMeo2Qdbs4sEFNAn4+Nspt68iov7H/gkECJfIjSFAIJVGiAmhzUAJHemYrL7uRrxC/wdSQ0zTldDcZjwBJqs6OOG7VyPLsmgjVk4s2XAHuKowvzqXIYK0Ylpw0xDbCN5nRQz/iDseSHmhK9mENiPRJURUTOOenAccoRBKhe3UGeMx1SqpgcGXhoDf/p5MHKTsTUzfQdoSyH2tVPqWqekqJkJMb2DtT5fOo7B7nKLwTGn9NiABdFL7KICj8l4SPjXpoOdiwPIqw7LBYB6Q4aZdDWAtThSIKyb6nlt3kQp+8IrFtk0+vz0TSCZBDGMi5ZGjks1msmxf/NYey1VYrrsarAau5kn+zSCocSNRwAMfPbYlRhhb7UiKtDZIdNxjNNy1GIciQFZ0CB3c+Znm5KdwDkk38dIqQhJkfbIs0GEFMbOVBEPtk69hXfHMZ+xjFNQCUZNnpyNiPn4N9J8o8cFEqLsdtyOVFJBIHlQsrLUyg+6Ef4jIgh7EmEUReGsSWNtYCDJNNAyZ3PAgniEVfzNCqi1gjKzX5Gzge5GnCCYH89MKD1aP/oMHvv+Zz5rnHwd++tPlT0yY2kSLtgfFUZfNp0IDeQIhQWgVlkvGukVQC1Kbj5FqwGU/fLdYdxLSGDHgR2MecDcTCFPlEyBiBT5JLLESGB2wnAyTWtlatB2nSQo+nF8P7cq2tEC+b9ziGVWClv+3KHuY6s9YhgbI7lLZk4xJBpeNIBOGlhN7eQmEFfYT13x00rEyES57vdhlFfrrNkJY0ILel2+QEhSfbWehS57uU707Lk4mrSuMy9Oa+J1hOi41oczMhh5tmLuS9XLN69/wI/0KL/BzuYEh8/XfpH30ByVP0/2GFkceFffYvKL4n/gPWewPF/syeg/B8F672ZU+duTfD3tLlHtur1xDn8sld5Smz0TdZepcWe8cENk7Vn/BXafhbMBIo0xQAAAABJRU5ErkJggg=='; +const defaultIcon = + 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAA/1BMVEUAAADwTpj+xRT+xRTwTpjwTpj+xRTwTpj+wxT+xRT/yBMSHkn8yxL+wxX+xRT+xRT/1QwzN0E6OkH/xxPwTpjwTpjwTpj/xBQsMUPwTpj/UK3/yRMWHkvwTpj/zg7wTpj/0A3wTpjwTpgRIEf/0Qx/P2P/yBMuM0I1OEH+xRQuM0L+xRQuM0LntRr+xRT+xRT+xBQ1JlZjPVdaUDwtMEUbJkYbJEj+xRTwTpg0N0E2N0LuTZX/U6z/Uqf9UaFkPVYRMjD/UqnzTpgKMS0BMCn/UaL3T53gTJGwRn2jRHRdPFUtNj4qNjwmNToALyfKSojISoeJQWhtPlsFKTP/yxKq4k7GAAAAN3RSTlMA29vt7fPy6uPQdjYd/aSVBfHs49nPwq+nlIuEU084MichEAoK/vPXz6iempOSjn9kY1w0LBcVaxnnyQAAASFJREFUOMuVk3lbgkAQh6cIxQq0u6zM7vs+cHchRbE7O7//Z+nng60PDuDj+9/MvMCyM0O0YE4Ac35lkzTTp3M5A+QKCPK1HuY69bjY+3UjDERjNc1GVD9zNeNxIb+FeOfYZYJmEXHFzhBUGYnVdEHde1fILHFB1+uNG5zCYoKuh2L2jqhqJwnqwfsOpRQHyE0mCU3vqyOkEOIESYsLyv9svUoB5BRewYVm8NJCvcsymsGF9uP7m4iY2SYqMMF/aoh/8I1DLjz3hTWi4ogC/4Qz9JCj/6byP7IvCle925Fd4yj5qtGsoB7C2I83i7f7Fiew0wfm55qoZKWOXDu4zBo5UMbz50PGvop85uKUigMCXz0nJrDlja2OQcnrX3H0+v8BzVCfXpvPH1sAAAAASUVORK5CYII='; + +interface Props { + imageType: 'logo' | 'icon'; + description: string; + helpText: string; + image?: string | null; + stagedImage?: string | null; + stageImage(image: string | null): void; + saveImage(): void; + resetImage(): void; +} + +export const BrandingSection: React.FC = ({ + imageType, + description, + helpText, + image, + stagedImage, + stageImage, + saveImage, + resetImage, +}) => { + const [resetConfirmModalVisible, setVisible] = useState(false); + const [imageUploadKey, setKey] = useState(1); + const showDeleteModal = () => setVisible(true); + const closeDeleteModal = () => setVisible(false); + const isLogo = imageType === 'logo'; + const imageText = isLogo ? LOGO_TEXT : ICON_TEXT; + const defaultImage = isLogo ? defaultLogo : defaultIcon; + + const handleUpload = async (files: FileList | null) => { + if (!files || files.length < 1) { + return stageImage(null); + } + const file = files[0]; + const img = await readUploadedFileAsBase64(file); + stageImage(img); + }; + + const resetConfirmModal = ( + { + resetImage(); + closeDeleteModal(); + }} + cancelButtonText={CANCEL_BUTTON_LABEL} + confirmButtonText={RESET_DEFAULT_BUTTON_LABEL} + buttonColor="danger" + defaultFocusedButton="confirm" + > + <> +

{isLogo ? RESET_LOGO_DESCRIPTION : RESET_ICON_DESCRIPTION}

+

{RESET_IMAGE_CONFIRMATION_TEXT}

+ +
+ ); + + // EUI currently does not support clearing an upload input programatically, so we can render a new + // one each time the image is changed. + useEffect(() => { + setKey(imageUploadKey + 1); + }, [image]); + + return ( + <> + + {description} +
+ } + > + <> + + {`${BRAND_TEXT} + + + + + + + + + {SAVE_BUTTON_LABEL} + + + + {image && ( + + {RESET_DEFAULT_BUTTON_LABEL} + + )} + + + + {resetConfirmModalVisible && resetConfirmModal} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx index 15d0db4c415d00..9b17ec560ba515 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx @@ -17,6 +17,7 @@ import { EuiFieldText } from '@elastic/eui'; import { ContentSection } from '../../../components/shared/content_section'; +import { BrandingSection } from './branding_section'; import { Customize } from './customize'; describe('Customize', () => { @@ -32,6 +33,7 @@ describe('Customize', () => { const wrapper = shallow(); expect(wrapper.find(ContentSection)).toHaveLength(1); + expect(wrapper.find(BrandingSection)).toHaveLength(2); }); it('handles input change', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx index 98662585ce3300..be4be08f54ebd8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx @@ -9,7 +9,14 @@ import React, { FormEvent } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiButton, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; +import { + EuiButton, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, +} from '@elastic/eui'; import { WorkplaceSearchPageTemplate } from '../../../components/layout'; import { ContentSection } from '../../../components/shared/content_section'; @@ -20,11 +27,25 @@ import { CUSTOMIZE_NAME_LABEL, CUSTOMIZE_NAME_BUTTON, } from '../../../constants'; +import { LOGO_DESCRIPTION, LOGO_HELP_TEXT, ICON_DESCRIPTION, ICON_HELP_TEXT } from '../constants'; import { SettingsLogic } from '../settings_logic'; +import { BrandingSection } from './branding_section'; + export const Customize: React.FC = () => { - const { onOrgNameInputChange, updateOrgName } = useActions(SettingsLogic); - const { orgNameInputValue } = useValues(SettingsLogic); + const { + onOrgNameInputChange, + updateOrgName, + setStagedIcon, + setStagedLogo, + updateOrgLogo, + updateOrgIcon, + resetOrgLogo, + resetOrgIcon, + } = useActions(SettingsLogic); + const { dataLoading, orgNameInputValue, icon, stagedIcon, logo, stagedLogo } = useValues( + SettingsLogic + ); const handleSubmit = (e: FormEvent) => { e.preventDefault(); @@ -38,6 +59,7 @@ export const Customize: React.FC = () => { pageTitle: CUSTOMIZE_HEADER_TITLE, description: CUSTOMIZE_HEADER_DESCRIPTION, }} + isLoading={dataLoading} >
@@ -63,6 +85,28 @@ export const Customize: React.FC = () => {
+ + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/constants.ts new file mode 100644 index 00000000000000..1bcd038947117d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/constants.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const LOGO_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.logoText', + { + defaultMessage: 'logo', + } +); + +export const ICON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.iconText', + { + defaultMessage: 'icon', + } +); + +export const RESET_IMAGE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.resetImageTitle', + { + defaultMessage: 'Reset to default branding', + } +); + +export const RESET_LOGO_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.resetLogoDescription', + { + defaultMessage: "You're about to reset the logo to the default Workplace Search branding.", + } +); + +export const RESET_ICON_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.resetIconDescription', + { + defaultMessage: "You're about to reset the icon to the default Workplace Search branding.", + } +); + +export const RESET_IMAGE_CONFIRMATION_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.resetImageConfirmationText', + { + defaultMessage: 'Are you sure you want to do this?', + } +); + +export const ORGANIZATION_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.organizationLabel', + { + defaultMessage: 'Organization', + } +); + +export const BRAND_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.brandText', + { + defaultMessage: 'Brand', + } +); + +export const LOGO_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.logoDescription', + { + defaultMessage: 'Used as the main visual branding element across prebuilt search applications', + } +); + +export const LOGO_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.logoHelpText', + { + defaultMessage: 'Maximum file size is 2MB. Only PNG files are supported.', + } +); + +export const ICON_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.iconDescription', + { + defaultMessage: 'Used as the branding element for smaller screen sizes and browser icons', + } +); + +export const ICON_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.iconHelpText', + { + defaultMessage: + 'Maximum file size is 2MB and recommended aspect ratio is 1:1. Only PNG files are supported.', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts index 0aef84ccf20e2b..005f2f016d5610 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts @@ -25,7 +25,7 @@ describe('SettingsLogic', () => { const { clearFlashMessages, flashAPIErrors, - setSuccessMessage, + flashSuccessToast, setQueuedSuccessMessage, } = mockFlashMessageHelpers; const { mount } = new LogicMounter(SettingsLogic); @@ -35,8 +35,12 @@ describe('SettingsLogic', () => { connectors: [], orgNameInputValue: '', oauthApplication: null, + icon: null, + stagedIcon: null, + logo: null, + stagedLogo: null, }; - const serverProps = { organizationName: ORG_NAME, oauthApplication }; + const serverProps = { organizationName: ORG_NAME, oauthApplication, logo: null, icon: null }; beforeEach(() => { jest.clearAllMocks(); @@ -79,6 +83,34 @@ describe('SettingsLogic', () => { expect(SettingsLogic.values.oauthApplication).toEqual(oauthApplication); }); + it('setIcon', () => { + SettingsLogic.actions.setStagedIcon('stagedIcon'); + SettingsLogic.actions.setIcon('icon'); + + expect(SettingsLogic.values.icon).toEqual('icon'); + expect(SettingsLogic.values.stagedIcon).toEqual(null); + }); + + it('setStagedIcon', () => { + SettingsLogic.actions.setStagedIcon('stagedIcon'); + + expect(SettingsLogic.values.stagedIcon).toEqual('stagedIcon'); + }); + + it('setLogo', () => { + SettingsLogic.actions.setStagedLogo('stagedLogo'); + SettingsLogic.actions.setLogo('logo'); + + expect(SettingsLogic.values.logo).toEqual('logo'); + expect(SettingsLogic.values.stagedLogo).toEqual(null); + }); + + it('setStagedLogo', () => { + SettingsLogic.actions.setStagedLogo('stagedLogo'); + + expect(SettingsLogic.values.stagedLogo).toEqual('stagedLogo'); + }); + it('setUpdatedOauthApplication', () => { SettingsLogic.actions.setUpdatedOauthApplication({ oauthApplication }); @@ -143,7 +175,7 @@ describe('SettingsLogic', () => { body: JSON.stringify({ name: NAME }), }); await nextTick(); - expect(setSuccessMessage).toHaveBeenCalledWith(ORG_UPDATED_MESSAGE); + expect(flashSuccessToast).toHaveBeenCalledWith(ORG_UPDATED_MESSAGE); expect(setUpdatedNameSpy).toHaveBeenCalledWith({ organizationName: NAME }); }); @@ -156,6 +188,80 @@ describe('SettingsLogic', () => { }); }); + describe('updateOrgIcon', () => { + it('calls API and sets values', async () => { + const ICON = 'icon'; + SettingsLogic.actions.setStagedIcon(ICON); + const setIconSpy = jest.spyOn(SettingsLogic.actions, 'setIcon'); + http.put.mockReturnValue(Promise.resolve({ icon: ICON })); + + SettingsLogic.actions.updateOrgIcon(); + + expect(http.put).toHaveBeenCalledWith('/api/workplace_search/org/settings/upload_images', { + body: JSON.stringify({ icon: ICON }), + }); + await nextTick(); + expect(flashSuccessToast).toHaveBeenCalledWith(ORG_UPDATED_MESSAGE); + expect(setIconSpy).toHaveBeenCalledWith(ICON); + }); + + it('handles error', async () => { + http.put.mockReturnValue(Promise.reject('this is an error')); + SettingsLogic.actions.updateOrgIcon(); + + await nextTick(); + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + describe('updateOrgLogo', () => { + it('calls API and sets values', async () => { + const LOGO = 'logo'; + SettingsLogic.actions.setStagedLogo(LOGO); + const setLogoSpy = jest.spyOn(SettingsLogic.actions, 'setLogo'); + http.put.mockReturnValue(Promise.resolve({ logo: LOGO })); + + SettingsLogic.actions.updateOrgLogo(); + + expect(http.put).toHaveBeenCalledWith('/api/workplace_search/org/settings/upload_images', { + body: JSON.stringify({ logo: LOGO }), + }); + await nextTick(); + expect(flashSuccessToast).toHaveBeenCalledWith(ORG_UPDATED_MESSAGE); + expect(setLogoSpy).toHaveBeenCalledWith(LOGO); + }); + + it('handles error', async () => { + http.put.mockReturnValue(Promise.reject('this is an error')); + SettingsLogic.actions.updateOrgLogo(); + + await nextTick(); + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + it('resetOrgLogo', () => { + const updateOrgLogoSpy = jest.spyOn(SettingsLogic.actions, 'updateOrgLogo'); + SettingsLogic.actions.setStagedLogo('stagedLogo'); + SettingsLogic.actions.setLogo('logo'); + SettingsLogic.actions.resetOrgLogo(); + + expect(SettingsLogic.values.logo).toEqual(null); + expect(SettingsLogic.values.stagedLogo).toEqual(null); + expect(updateOrgLogoSpy).toHaveBeenCalled(); + }); + + it('resetOrgIcon', () => { + const updateOrgIconSpy = jest.spyOn(SettingsLogic.actions, 'updateOrgIcon'); + SettingsLogic.actions.setStagedIcon('stagedIcon'); + SettingsLogic.actions.setIcon('icon'); + SettingsLogic.actions.resetOrgIcon(); + + expect(SettingsLogic.values.icon).toEqual(null); + expect(SettingsLogic.values.stagedIcon).toEqual(null); + expect(updateOrgIconSpy).toHaveBeenCalled(); + }); + describe('updateOauthApplication', () => { it('calls API and sets values', async () => { const { name, redirectUri, confidential } = oauthApplication; @@ -179,7 +285,7 @@ describe('SettingsLogic', () => { ); await nextTick(); expect(setUpdatedOauthApplicationSpy).toHaveBeenCalledWith({ oauthApplication }); - expect(setSuccessMessage).toHaveBeenCalledWith(OAUTH_APP_UPDATED_MESSAGE); + expect(flashSuccessToast).toHaveBeenCalledWith(OAUTH_APP_UPDATED_MESSAGE); }); it('handles error', async () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts index e07adbde159392..65a2cdf8c3f30b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts @@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n'; import { clearFlashMessages, setQueuedSuccessMessage, - setSuccessMessage, + flashSuccessToast, flashAPIErrors, } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; @@ -34,6 +34,8 @@ interface IOauthApplication { export interface SettingsServerProps { organizationName: string; oauthApplication: IOauthApplication; + logo: string | null; + icon: string | null; } interface SettingsActions { @@ -41,6 +43,10 @@ interface SettingsActions { onOrgNameInputChange(orgNameInputValue: string): string; setUpdatedName({ organizationName }: { organizationName: string }): string; setServerProps(props: SettingsServerProps): SettingsServerProps; + setIcon(icon: string | null): string | null; + setStagedIcon(stagedIcon: string | null): string | null; + setLogo(logo: string | null): string | null; + setStagedLogo(stagedLogo: string | null): string | null; setOauthApplication(oauthApplication: IOauthApplication): IOauthApplication; setUpdatedOauthApplication({ oauthApplication, @@ -52,6 +58,10 @@ interface SettingsActions { initializeConnectors(): void; updateOauthApplication(): void; updateOrgName(): void; + updateOrgLogo(): void; + updateOrgIcon(): void; + resetOrgLogo(): void; + resetOrgIcon(): void; deleteSourceConfig( serviceType: string, name: string @@ -66,14 +76,24 @@ interface SettingsValues { connectors: Connector[]; orgNameInputValue: string; oauthApplication: IOauthApplication | null; + logo: string | null; + icon: string | null; + stagedLogo: string | null; + stagedIcon: string | null; } +const imageRoute = '/api/workplace_search/org/settings/upload_images'; + export const SettingsLogic = kea>({ actions: { onInitializeConnectors: (connectors: Connector[]) => connectors, onOrgNameInputChange: (orgNameInputValue: string) => orgNameInputValue, setUpdatedName: ({ organizationName }) => organizationName, setServerProps: (props: SettingsServerProps) => props, + setIcon: (icon) => icon, + setStagedIcon: (stagedIcon) => stagedIcon, + setLogo: (logo) => logo, + setStagedLogo: (stagedLogo) => stagedLogo, setOauthApplication: (oauthApplication: IOauthApplication) => oauthApplication, setUpdatedOauthApplication: ({ oauthApplication }: { oauthApplication: IOauthApplication }) => oauthApplication, @@ -81,6 +101,10 @@ export const SettingsLogic = kea> initializeSettings: () => true, initializeConnectors: () => true, updateOrgName: () => true, + updateOrgLogo: () => true, + updateOrgIcon: () => true, + resetOrgLogo: () => true, + resetOrgIcon: () => true, updateOauthApplication: () => true, deleteSourceConfig: (serviceType: string, name: string) => ({ serviceType, @@ -113,10 +137,43 @@ export const SettingsLogic = kea> dataLoading: [ true, { + setServerProps: () => false, onInitializeConnectors: () => false, resetSettingsState: () => true, }, ], + logo: [ + null, + { + setServerProps: (_, { logo }) => logo, + setLogo: (_, logo) => logo, + resetOrgLogo: () => null, + }, + ], + stagedLogo: [ + null, + { + setStagedLogo: (_, stagedLogo) => stagedLogo, + resetOrgLogo: () => null, + setLogo: () => null, + }, + ], + icon: [ + null, + { + setServerProps: (_, { icon }) => icon, + setIcon: (_, icon) => icon, + resetOrgIcon: () => null, + }, + ], + stagedIcon: [ + null, + { + setStagedIcon: (_, stagedIcon) => stagedIcon, + resetOrgIcon: () => null, + setIcon: () => null, + }, + ], }, listeners: ({ actions, values }) => ({ initializeSettings: async () => { @@ -150,12 +207,38 @@ export const SettingsLogic = kea> try { const response = await http.put(route, { body }); actions.setUpdatedName(response); - setSuccessMessage(ORG_UPDATED_MESSAGE); + flashSuccessToast(ORG_UPDATED_MESSAGE); AppLogic.actions.setOrgName(name); } catch (e) { flashAPIErrors(e); } }, + updateOrgLogo: async () => { + const { http } = HttpLogic.values; + const { stagedLogo: logo } = values; + const body = JSON.stringify({ logo }); + + try { + const response = await http.put(imageRoute, { body }); + actions.setLogo(response.logo); + flashSuccessToast(ORG_UPDATED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, + updateOrgIcon: async () => { + const { http } = HttpLogic.values; + const { stagedIcon: icon } = values; + const body = JSON.stringify({ icon }); + + try { + const response = await http.put(imageRoute, { body }); + actions.setIcon(response.icon); + flashSuccessToast(ORG_UPDATED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, updateOauthApplication: async () => { const { http } = HttpLogic.values; const route = '/api/workplace_search/org/settings/oauth_application'; @@ -170,7 +253,7 @@ export const SettingsLogic = kea> try { const response = await http.put(route, { body }); actions.setUpdatedOauthApplication(response); - setSuccessMessage(OAUTH_APP_UPDATED_MESSAGE); + flashSuccessToast(OAUTH_APP_UPDATED_MESSAGE); } catch (e) { flashAPIErrors(e); } @@ -195,5 +278,11 @@ export const SettingsLogic = kea> resetSettingsState: () => { clearFlashMessages(); }, + resetOrgLogo: () => { + actions.updateOrgLogo(); + }, + resetOrgIcon: () => { + actions.updateOrgIcon(); + }, }), }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.test.ts index 00a5b6c75df0a5..858bd71c50c447 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.test.ts @@ -10,6 +10,7 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks_ import { registerOrgSettingsRoute, registerOrgSettingsCustomizeRoute, + registerOrgSettingsUploadImagesRoute, registerOrgSettingsOauthApplicationRoute, } from './settings'; @@ -67,6 +68,36 @@ describe('settings routes', () => { }); }); + describe('PUT /api/workplace_search/org/settings/upload_images', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/workplace_search/org/settings/upload_images', + }); + + registerOrgSettingsUploadImagesRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/settings/upload_images', + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { body: { logo: 'foo', icon: null } }; + mockRouter.shouldValidate(request); + }); + }); + }); + describe('PUT /api/workplace_search/org/settings/oauth_application', () => { let mockRouter: MockRouter; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.ts index bd8b5388625c62..aa8651f74bec53 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.ts @@ -43,6 +43,26 @@ export function registerOrgSettingsCustomizeRoute({ ); } +export function registerOrgSettingsUploadImagesRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.put( + { + path: '/api/workplace_search/org/settings/upload_images', + validate: { + body: schema.object({ + logo: schema.maybe(schema.nullable(schema.string())), + icon: schema.maybe(schema.nullable(schema.string())), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/settings/upload_images', + }) + ); +} + export function registerOrgSettingsOauthApplicationRoute({ router, enterpriseSearchRequestHandler, @@ -69,5 +89,6 @@ export function registerOrgSettingsOauthApplicationRoute({ export const registerSettingsRoutes = (dependencies: RouteDependencies) => { registerOrgSettingsRoute(dependencies); registerOrgSettingsCustomizeRoute(dependencies); + registerOrgSettingsUploadImagesRoute(dependencies); registerOrgSettingsOauthApplicationRoute(dependencies); }; From 31b8a8229ca8110ba8a464a714588ec4532fbaca Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 11 Aug 2021 17:37:33 -0700 Subject: [PATCH 16/20] skip flaky suite (#107911) --- .../tests/exception_operators_data_types/text.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts index dbffeacb03b77e..48832cef27cd9a 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts @@ -33,7 +33,8 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); - describe('Rule exception operators for data type text', () => { + // FLAKY: https://github.com/elastic/kibana/issues/107911 + describe.skip('Rule exception operators for data type text', () => { beforeEach(async () => { await createSignalsIndex(supertest); await createListsIndex(supertest); From 7860c2aac3962b66730bc09a86a501e5196493b3 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 12 Aug 2021 03:09:50 +0100 Subject: [PATCH 17/20] chore(NA): moving @kbn/crypto to babel transpiler (#108189) * chore(NA): moving @kbn/crypto to babel transpiler * chore(NA): update configs --- packages/kbn-crypto/.babelrc | 3 +++ packages/kbn-crypto/BUILD.bazel | 22 +++++++++++++--------- packages/kbn-crypto/package.json | 4 ++-- packages/kbn-crypto/tsconfig.json | 3 ++- 4 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 packages/kbn-crypto/.babelrc diff --git a/packages/kbn-crypto/.babelrc b/packages/kbn-crypto/.babelrc new file mode 100644 index 00000000000000..7da72d17791281 --- /dev/null +++ b/packages/kbn-crypto/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["@kbn/babel-preset/node_preset"] +} diff --git a/packages/kbn-crypto/BUILD.bazel b/packages/kbn-crypto/BUILD.bazel index 36b61d0fb046ba..0f35aab4610788 100644 --- a/packages/kbn-crypto/BUILD.bazel +++ b/packages/kbn-crypto/BUILD.bazel @@ -1,6 +1,7 @@ load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") +load("//src/dev/bazel:index.bzl", "jsts_transpiler") PKG_BASE_NAME = "kbn-crypto" PKG_REQUIRE_NAME = "@kbn/crypto" @@ -26,22 +27,24 @@ NPM_MODULE_EXTRA_FILES = [ "README.md" ] -SRC_DEPS = [ +RUNTIME_DEPS = [ "//packages/kbn-dev-utils", "@npm//node-forge", ] TYPES_DEPS = [ + "//packages/kbn-dev-utils", "@npm//@types/flot", "@npm//@types/jest", "@npm//@types/node", "@npm//@types/node-forge", - "@npm//@types/testing-library__jest-dom", - "@npm//resize-observer-polyfill", - "@npm//@emotion/react", ] -DEPS = SRC_DEPS + TYPES_DEPS +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) ts_config( name = "tsconfig", @@ -53,13 +56,14 @@ ts_config( ) ts_project( - name = "tsc", + name = "tsc_types", args = ['--pretty'], srcs = SRCS, - deps = DEPS, + deps = TYPES_DEPS, declaration = True, declaration_map = True, - out_dir = "target", + emit_declaration_only = True, + out_dir = "target_types", source_map = True, root_dir = "src", tsconfig = ":tsconfig", @@ -68,7 +72,7 @@ ts_project( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES, - deps = DEPS + [":tsc"], + deps = RUNTIME_DEPS + [":target_node", ":tsc_types"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) diff --git a/packages/kbn-crypto/package.json b/packages/kbn-crypto/package.json index bbeb57e5b7cca6..8fa6cd3c232fad 100644 --- a/packages/kbn-crypto/package.json +++ b/packages/kbn-crypto/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", - "main": "./target/index.js", - "types": "./target/index.d.ts" + "main": "./target_node/index.js", + "types": "./target_types/index.d.ts" } diff --git a/packages/kbn-crypto/tsconfig.json b/packages/kbn-crypto/tsconfig.json index af1a7c75c8e99c..0863fc3f530def 100644 --- a/packages/kbn-crypto/tsconfig.json +++ b/packages/kbn-crypto/tsconfig.json @@ -1,9 +1,10 @@ { "extends": "../../tsconfig.bazel.json", "compilerOptions": { - "outDir": "./target/types", "declaration": true, "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./target_types", "rootDir": "src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-crypto/src", From a2347b2d7794d473289c58942ca16f3e8b394666 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Wed, 11 Aug 2021 21:45:01 -0700 Subject: [PATCH 18/20] Add scoring support to KQL (#103727) * Add ability to generate KQL filters in the "must" clause Also defaults search source to generate filters in the must clause if _score is one of the sort fields * Update docs * Review feedback * Fix tests * update tests * Fix merge error Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../src/es_query/build_es_query.ts | 9 +- .../kbn-es-query/src/es_query/from_kuery.ts | 5 +- .../src/kuery/functions/and.test.ts | 18 +++ .../kbn-es-query/src/kuery/functions/and.ts | 8 +- packages/kbn-es-query/src/kuery/index.ts | 2 +- packages/kbn-es-query/src/kuery/types.ts | 6 + .../search_source/search_source.test.ts | 103 ++++++++++++++---- .../search/search_source/search_source.ts | 10 +- .../table_header/score_sort_warning.tsx | 19 ++++ .../table_header/table_header_column.tsx | 6 + 10 files changed, 155 insertions(+), 31 deletions(-) create mode 100644 src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/score_sort_warning.tsx diff --git a/packages/kbn-es-query/src/es_query/build_es_query.ts b/packages/kbn-es-query/src/es_query/build_es_query.ts index 955af1e4c185f3..c01b11f580ba6d 100644 --- a/packages/kbn-es-query/src/es_query/build_es_query.ts +++ b/packages/kbn-es-query/src/es_query/build_es_query.ts @@ -12,17 +12,17 @@ import { buildQueryFromFilters } from './from_filters'; import { buildQueryFromLucene } from './from_lucene'; import { Filter, Query } from '../filters'; import { IndexPatternBase } from './types'; +import { KueryQueryOptions } from '../kuery'; /** * Configurations to be used while constructing an ES query. * @public */ -export interface EsQueryConfig { +export type EsQueryConfig = KueryQueryOptions & { allowLeadingWildcards: boolean; queryStringOptions: Record; ignoreFilterIfFieldNotInIndex: boolean; - dateFormatTZ?: string; -} +}; function removeMatchAll(filters: T[]) { return filters.filter( @@ -59,7 +59,8 @@ export function buildEsQuery( indexPattern, queriesByLanguage.kuery, config.allowLeadingWildcards, - config.dateFormatTZ + config.dateFormatTZ, + config.filtersInMustClause ); const luceneQuery = buildQueryFromLucene( queriesByLanguage.lucene, diff --git a/packages/kbn-es-query/src/es_query/from_kuery.ts b/packages/kbn-es-query/src/es_query/from_kuery.ts index 87382585181f8c..bf66057e49327b 100644 --- a/packages/kbn-es-query/src/es_query/from_kuery.ts +++ b/packages/kbn-es-query/src/es_query/from_kuery.ts @@ -15,13 +15,14 @@ export function buildQueryFromKuery( indexPattern: IndexPatternBase | undefined, queries: Query[] = [], allowLeadingWildcards: boolean = false, - dateFormatTZ?: string + dateFormatTZ?: string, + filtersInMustClause: boolean = false ) { const queryASTs = queries.map((query) => { return fromKueryExpression(query.query, { allowLeadingWildcards }); }); - return buildQuery(indexPattern, queryASTs, { dateFormatTZ }); + return buildQuery(indexPattern, queryASTs, { dateFormatTZ, filtersInMustClause }); } function buildQuery( diff --git a/packages/kbn-es-query/src/kuery/functions/and.test.ts b/packages/kbn-es-query/src/kuery/functions/and.test.ts index 1e6797485c9648..239342bdc0a1ca 100644 --- a/packages/kbn-es-query/src/kuery/functions/and.test.ts +++ b/packages/kbn-es-query/src/kuery/functions/and.test.ts @@ -55,6 +55,24 @@ describe('kuery functions', () => { ) ); }); + + test("should wrap subqueries in an ES bool query's must clause for scoring if enabled", () => { + const node = nodeTypes.function.buildNode('and', [childNode1, childNode2]); + const result = and.toElasticsearchQuery(node, indexPattern, { + filtersInMustClause: true, + }); + + expect(result).toHaveProperty('bool'); + expect(Object.keys(result).length).toBe(1); + expect(result.bool).toHaveProperty('must'); + expect(Object.keys(result.bool).length).toBe(1); + + expect(result.bool.must).toEqual( + [childNode1, childNode2].map((childNode) => + ast.toElasticsearchQuery(childNode, indexPattern) + ) + ); + }); }); }); }); diff --git a/packages/kbn-es-query/src/kuery/functions/and.ts b/packages/kbn-es-query/src/kuery/functions/and.ts index 239dd67e73d107..98788ac07b715f 100644 --- a/packages/kbn-es-query/src/kuery/functions/and.ts +++ b/packages/kbn-es-query/src/kuery/functions/and.ts @@ -7,7 +7,7 @@ */ import * as ast from '../ast'; -import { IndexPatternBase, KueryNode } from '../..'; +import { IndexPatternBase, KueryNode, KueryQueryOptions } from '../..'; export function buildNodeParams(children: KueryNode[]) { return { @@ -18,14 +18,16 @@ export function buildNodeParams(children: KueryNode[]) { export function toElasticsearchQuery( node: KueryNode, indexPattern?: IndexPatternBase, - config: Record = {}, + config: KueryQueryOptions = {}, context: Record = {} ) { + const { filtersInMustClause } = config; const children = node.arguments || []; + const key = filtersInMustClause ? 'must' : 'filter'; return { bool: { - filter: children.map((child: KueryNode) => { + [key]: children.map((child: KueryNode) => { return ast.toElasticsearchQuery(child, indexPattern, config, context); }), }, diff --git a/packages/kbn-es-query/src/kuery/index.ts b/packages/kbn-es-query/src/kuery/index.ts index 7796785f853943..dd1e39307b27e8 100644 --- a/packages/kbn-es-query/src/kuery/index.ts +++ b/packages/kbn-es-query/src/kuery/index.ts @@ -9,4 +9,4 @@ export { KQLSyntaxError } from './kuery_syntax_error'; export { nodeTypes, nodeBuilder } from './node_types'; export { fromKueryExpression, toElasticsearchQuery } from './ast'; -export { DslQuery, KueryNode } from './types'; +export { DslQuery, KueryNode, KueryQueryOptions } from './types'; diff --git a/packages/kbn-es-query/src/kuery/types.ts b/packages/kbn-es-query/src/kuery/types.ts index f188eab61c5460..59c48f21425bc3 100644 --- a/packages/kbn-es-query/src/kuery/types.ts +++ b/packages/kbn-es-query/src/kuery/types.ts @@ -32,3 +32,9 @@ export interface KueryParseOptions { } export { nodeTypes } from './node_types'; + +/** @public */ +export interface KueryQueryOptions { + filtersInMustClause?: boolean; + dateFormatTZ?: string; +} diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 90f5ff331b9718..4e62b49938ade0 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -359,6 +359,69 @@ describe('SearchSource', () => { expect(request.fields).toEqual(['*']); expect(request._source).toEqual(false); }); + + test('includes queries in the "filter" clause by default', async () => { + searchSource.setField('query', { + query: 'agent.keyword : "Mozilla" ', + language: 'kuery', + }); + const request = searchSource.getSearchRequestBody(); + expect(request.query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "agent.keyword": "Mozilla", + }, + }, + ], + }, + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + } + `); + }); + + test('includes queries in the "must" clause if sorting by _score', async () => { + searchSource.setField('query', { + query: 'agent.keyword : "Mozilla" ', + language: 'kuery', + }); + searchSource.setField('sort', [{ _score: SortDirection.asc }]); + const request = searchSource.getSearchRequestBody(); + expect(request.query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "filter": Array [], + "must": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "agent.keyword": "Mozilla", + }, + }, + ], + }, + }, + ], + "must_not": Array [], + "should": Array [], + }, + } + `); + }); }); describe('source filters handling', () => { @@ -943,27 +1006,27 @@ describe('SearchSource', () => { expect(next).toBeCalledTimes(2); expect(complete).toBeCalledTimes(1); expect(next.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "isPartial": true, - "isRunning": true, - "rawResponse": Object { - "test": 1, - }, - }, - ] - `); + Array [ + Object { + "isPartial": true, + "isRunning": true, + "rawResponse": Object { + "test": 1, + }, + }, + ] + `); expect(next.mock.calls[1]).toMatchInlineSnapshot(` - Array [ - Object { - "isPartial": false, - "isRunning": false, - "rawResponse": Object { - "test": 2, - }, - }, - ] - `); + Array [ + Object { + "isPartial": false, + "isRunning": false, + "rawResponse": Object { + "test": 2, + }, + }, + ] + `); }); test('shareReplays result', async () => { diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index c72976e3412a6c..f2b801ebac29fe 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -79,6 +79,7 @@ import { IIndexPattern, IndexPattern, IndexPatternField } from '../../index_patt import { AggConfigs, ES_SEARCH_STRATEGY, + EsQuerySortValue, IEsSearchResponse, ISearchGeneric, ISearchOptions, @@ -833,7 +834,14 @@ export class SearchSource { body.fields = filteredDocvalueFields; } - const esQueryConfigs = getEsQueryConfig({ get: getConfig }); + // If sorting by _score, build queries in the "must" clause instead of "filter" clause to enable scoring + const filtersInMustClause = (body.sort ?? []).some((sort: EsQuerySortValue[]) => + sort.hasOwnProperty('_score') + ); + const esQueryConfigs = { + ...getEsQueryConfig({ get: getConfig }), + filtersInMustClause, + }; body.query = buildEsQuery(index, query, filters, esQueryConfigs); if (highlightAll && body.query) { diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/score_sort_warning.tsx b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/score_sort_warning.tsx new file mode 100644 index 00000000000000..f2b086b84a2603 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/score_sort_warning.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EuiIconTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export function DocViewTableScoreSortWarning() { + const tooltipContent = i18n.translate('discover.docViews.table.scoreSortWarningTooltip', { + defaultMessage: 'In order to retrieve values for _score, you must sort by it.', + }); + + return ; +} diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/table_header_column.tsx b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/table_header_column.tsx index e4cbac052ca67a..fc7b41c43049b7 100644 --- a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/table_header_column.tsx +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/table_header_column.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { SortOrder } from './helpers'; +import { DocViewTableScoreSortWarning } from './score_sort_warning'; interface Props { colLeftIdx: number; // idx of the column to the left, -1 if moving is not possible @@ -64,6 +65,10 @@ export function TableHeaderColumn({ const curColSort = sortOrder.find((pair) => pair[0] === name); const curColSortDir = (curColSort && curColSort[1]) || ''; + // If this is the _score column, and _score is not one of the columns inside the sort, show a + // warning that the _score will not be retrieved from Elasticsearch + const showScoreSortWarning = name === '_score' && !curColSort; + const handleChangeSortOrder = () => { if (!onChangeSortOrder) return; @@ -177,6 +182,7 @@ export function TableHeaderColumn({ return ( + {showScoreSortWarning && } {displayName} {buttons .filter((button) => button.active) From dc2a1e1ceaca46e1e23d9fbd51ccabee2976bb38 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Thu, 12 Aug 2021 08:29:31 +0200 Subject: [PATCH 19/20] [Lens] Introduce new layer types (#107791) --- .../public/toolbar_button/toolbar_button.scss | 4 + .../embedded_lens_example/public/app.tsx | 1 + .../field_data_row/action_menu/lens_utils.ts | 5 + x-pack/plugins/lens/common/constants.ts | 3 + .../expressions/metric_chart/metric_chart.ts | 2 +- .../common/expressions/metric_chart/types.ts | 3 + .../common/expressions/pie_chart/types.ts | 3 +- .../expressions/xy_chart/layer_config.ts | 4 + x-pack/plugins/lens/common/types.ts | 2 + .../components/dimension_editor.test.tsx | 2 + .../visualization.test.tsx | 79 +++++++- .../datatable_visualization/visualization.tsx | 21 ++ .../editor_frame/config_panel/add_layer.tsx | 128 ++++++++++++ .../buttons/empty_dimension_button.tsx | 6 +- .../config_panel/config_panel.scss | 4 - .../config_panel/config_panel.tsx | 111 ++++++----- .../config_panel/layer_actions.test.ts | 2 + .../config_panel/layer_actions.ts | 5 +- .../config_panel/layer_panel.scss | 44 +++-- .../config_panel/layer_panel.test.tsx | 37 ++-- .../editor_frame/config_panel/layer_panel.tsx | 137 +++++++------ .../config_panel/layer_settings.tsx | 78 +++----- .../config_panel/remove_layer_button.tsx | 21 +- .../editor_frame/frame_layout.scss | 2 +- .../editor_frame/suggestion_helpers.ts | 101 ++++++---- .../workspace_panel/chart_switch.tsx | 23 +-- .../heatmap_visualization/suggestions.test.ts | 12 ++ .../heatmap_visualization/suggestions.ts | 2 + .../public/heatmap_visualization/types.ts | 3 +- .../visualization.test.ts | 30 +++ .../heatmap_visualization/visualization.tsx | 21 +- .../indexpattern_datasource/datapanel.scss | 11 ++ .../dimension_panel/dimension_editor.tsx | 4 +- .../dimension_panel/dimension_panel.test.tsx | 2 + .../droppable/droppable.test.ts | 1 + .../definitions/calculations/counter_rate.tsx | 20 +- .../calculations/cumulative_sum.tsx | 19 +- .../definitions/calculations/differences.tsx | 19 +- .../calculations/moving_average.tsx | 19 +- .../definitions/calculations/utils.test.ts | 11 +- .../definitions/calculations/utils.ts | 14 ++ .../operations/definitions/index.ts | 8 +- .../definitions/last_value.test.tsx | 11 +- .../operations/layer_helpers.ts | 2 + .../metric_visualization/expression.test.tsx | 3 + .../metric_suggestions.test.ts | 1 + .../metric_suggestions.ts | 4 +- .../visualization.test.ts | 27 ++- .../metric_visualization/visualization.tsx | 19 ++ x-pack/plugins/lens/public/mocks.tsx | 6 +- .../pie_visualization/suggestions.test.ts | 11 +- .../public/pie_visualization/suggestions.ts | 5 + .../pie_visualization/visualization.test.ts | 30 +++ .../pie_visualization/visualization.tsx | 17 ++ x-pack/plugins/lens/public/types.ts | 55 ++++-- .../__snapshots__/to_expression.test.ts.snap | 3 + .../axes_configuration.test.ts | 2 + .../axis_settings_popover.test.tsx | 2 + .../xy_visualization/color_assignment.test.ts | 3 + .../xy_visualization/expression.test.tsx | 14 ++ .../public/xy_visualization/expression.tsx | 30 +-- .../get_legend_action.test.tsx | 2 + .../xy_visualization/to_expression.test.ts | 11 ++ .../public/xy_visualization/to_expression.ts | 4 +- .../visual_options_popover.test.tsx | 3 + .../xy_visualization/visualization.test.ts | 108 +++++++++- .../public/xy_visualization/visualization.tsx | 84 +++++--- .../xy_visualization/xy_config_panel.test.tsx | 4 + .../xy_visualization/xy_config_panel.tsx | 91 ++++++++- .../xy_visualization/xy_suggestions.test.ts | 17 +- .../public/xy_visualization/xy_suggestions.ts | 2 + .../embeddable/lens_embeddable_factory.ts | 16 +- .../server/migrations/common_migrations.ts | 20 ++ .../saved_object_migrations.test.ts | 185 ++++++++++++++++++ .../migrations/saved_object_migrations.ts | 20 +- .../plugins/lens/server/migrations/types.ts | 40 ++++ .../configurations/lens_attributes.test.ts | 2 + .../configurations/lens_attributes.ts | 1 + .../test_data/sample_attribute.ts | 1 + .../test_data/sample_attribute_cwv.ts | 1 + .../test_data/sample_attribute_kpi.ts | 1 + .../scheduled_query_group_queries_table.tsx | 1 + .../translations/translations/ja-JP.json | 8 - .../translations/translations/zh-CN.json | 8 - .../api_integration/apis/maps/migrations.js | 2 +- 85 files changed, 1505 insertions(+), 396 deletions(-) create mode 100644 x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx diff --git a/src/plugins/kibana_react/public/toolbar_button/toolbar_button.scss b/src/plugins/kibana_react/public/toolbar_button/toolbar_button.scss index 8a4545672de3c0..0b5152bd99bbfe 100644 --- a/src/plugins/kibana_react/public/toolbar_button/toolbar_button.scss +++ b/src/plugins/kibana_react/public/toolbar_button/toolbar_button.scss @@ -58,6 +58,10 @@ font-weight: $euiFontWeightBold; } +.kbnToolbarButton--normal { + font-weight: $euiFontWeightRegular; +} + .kbnToolbarButton--s { box-shadow: none !important; // sass-lint:disable-line no-important font-size: $euiFontSizeS; diff --git a/x-pack/examples/embedded_lens_example/public/app.tsx b/x-pack/examples/embedded_lens_example/public/app.tsx index bf43e200b902d3..58c932c3ca164a 100644 --- a/x-pack/examples/embedded_lens_example/public/app.tsx +++ b/x-pack/examples/embedded_lens_example/public/app.tsx @@ -67,6 +67,7 @@ function getLensAttributes( { accessors: ['col2'], layerId: 'layer1', + layerType: 'data', seriesType: 'bar_stacked', xAccessor: 'col1', yConfig: [{ forAccessor: 'col2', color }], diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts index 4d90defc668a4b..0b0c0e8fe3f09c 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts @@ -55,6 +55,7 @@ export function getNumberSettings(item: FieldVisConfig, defaultIndexPattern: Ind const layer: XYLayerConfig = { accessors: ['col2'], layerId: 'layer1', + layerType: 'data', seriesType: 'bar', xAccessor: 'col1', }; @@ -86,6 +87,7 @@ export function getNumberSettings(item: FieldVisConfig, defaultIndexPattern: Ind const layer: XYLayerConfig = { accessors: ['col2'], layerId: 'layer1', + layerType: 'data', seriesType: 'line', xAccessor: 'col1', }; @@ -115,6 +117,7 @@ export function getDateSettings(item: FieldVisConfig) { const layer: XYLayerConfig = { accessors: ['col2'], layerId: 'layer1', + layerType: 'data', seriesType: 'line', xAccessor: 'col1', }; @@ -147,6 +150,7 @@ export function getKeywordSettings(item: FieldVisConfig) { const layer: XYLayerConfig = { accessors: ['col2'], layerId: 'layer1', + layerType: 'data', seriesType: 'bar', xAccessor: 'col1', }; @@ -179,6 +183,7 @@ export function getBooleanSettings(item: FieldVisConfig) { const layer: XYLayerConfig = { accessors: ['col2'], layerId: 'layer1', + layerType: 'data', seriesType: 'bar', xAccessor: 'col1', }; diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 26edf66130ee3a..bba3ac7e8a9ca7 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -7,6 +7,7 @@ import rison from 'rison-node'; import type { TimeRange } from '../../../../src/plugins/data/common/query'; +import { LayerType } from './types'; export const PLUGIN_ID = 'lens'; export const APP_ID = 'lens'; @@ -16,6 +17,8 @@ export const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; export const BASE_API_URL = '/api/lens'; export const LENS_EDIT_BY_VALUE = 'edit_by_value'; +export const layerTypes: Record = { DATA: 'data', THRESHOLD: 'threshold' }; + export function getBasePath() { return `#/`; } diff --git a/x-pack/plugins/lens/common/expressions/metric_chart/metric_chart.ts b/x-pack/plugins/lens/common/expressions/metric_chart/metric_chart.ts index 53ed7c8da32eb6..6c05502bb2b036 100644 --- a/x-pack/plugins/lens/common/expressions/metric_chart/metric_chart.ts +++ b/x-pack/plugins/lens/common/expressions/metric_chart/metric_chart.ts @@ -23,7 +23,7 @@ export interface MetricRender { export const metricChart: ExpressionFunctionDefinition< 'lens_metric_chart', LensMultiTable, - Omit, + Omit, MetricRender > = { name: 'lens_metric_chart', diff --git a/x-pack/plugins/lens/common/expressions/metric_chart/types.ts b/x-pack/plugins/lens/common/expressions/metric_chart/types.ts index c182b19f3ced5b..65a72632a5491d 100644 --- a/x-pack/plugins/lens/common/expressions/metric_chart/types.ts +++ b/x-pack/plugins/lens/common/expressions/metric_chart/types.ts @@ -5,9 +5,12 @@ * 2.0. */ +import { LayerType } from '../../types'; + export interface MetricState { layerId: string; accessor?: string; + layerType: LayerType; } export interface MetricConfig extends MetricState { diff --git a/x-pack/plugins/lens/common/expressions/pie_chart/types.ts b/x-pack/plugins/lens/common/expressions/pie_chart/types.ts index e3772723229507..213651134d98ab 100644 --- a/x-pack/plugins/lens/common/expressions/pie_chart/types.ts +++ b/x-pack/plugins/lens/common/expressions/pie_chart/types.ts @@ -6,7 +6,7 @@ */ import type { PaletteOutput } from '../../../../../../src/plugins/charts/common'; -import type { LensMultiTable } from '../../types'; +import type { LensMultiTable, LayerType } from '../../types'; export interface SharedPieLayerState { groups: string[]; @@ -21,6 +21,7 @@ export interface SharedPieLayerState { export type PieLayerState = SharedPieLayerState & { layerId: string; + layerType: LayerType; }; export interface PieVisualizationState { diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config.ts index f3baf242425f5d..ff3d50a13a06d9 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config.ts @@ -7,6 +7,8 @@ import type { PaletteOutput } from '../../../../../../src/plugins/charts/common'; import type { ExpressionFunctionDefinition } from '../../../../../../src/plugins/expressions/common'; +import type { LayerType } from '../../types'; +import { layerTypes } from '../../constants'; import { axisConfig, YConfig } from './axis_config'; import type { SeriesType } from './series_type'; @@ -19,6 +21,7 @@ export interface XYLayerConfig { seriesType: SeriesType; splitAccessor?: string; palette?: PaletteOutput; + layerType: LayerType; } export interface ValidLayer extends XYLayerConfig { @@ -57,6 +60,7 @@ export const layerConfig: ExpressionFunctionDefinition< types: ['string'], help: '', }, + layerType: { types: ['string'], options: Object.values(layerTypes), help: '' }, seriesType: { types: ['string'], options: [ diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index 06fa31b87ce640..f5f10887dee1d6 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -57,3 +57,5 @@ export interface CustomPaletteParams { } export type RequiredPaletteParamTypes = Required; + +export type LayerType = 'data' | 'threshold'; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx index ea8914e9078c4d..ba4ca284fe26ee 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx @@ -16,6 +16,7 @@ import { chartPluginMock } from 'src/plugins/charts/public/mocks'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { act } from 'react-dom/test-utils'; import { PalettePanelContainer } from '../../shared_components'; +import { layerTypes } from '../../../common'; describe('data table dimension editor', () => { let frame: FramePublicAPI; @@ -28,6 +29,7 @@ describe('data table dimension editor', () => { function testState(): DatatableVisualizationState { return { layerId: 'first', + layerType: layerTypes.DATA, columns: [ { columnId: 'foo', diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 1e4b1cfa6069d2..64d5a6f8f25a67 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -17,6 +17,7 @@ import { VisualizationDimensionGroupConfig, } from '../types'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { layerTypes } from '../../common'; function mockFrame(): FramePublicAPI { return { @@ -34,6 +35,7 @@ describe('Datatable Visualization', () => { it('should initialize from the empty state', () => { expect(datatableVisualization.initialize(() => 'aaa', undefined)).toEqual({ layerId: 'aaa', + layerType: layerTypes.DATA, columns: [], }); }); @@ -41,6 +43,7 @@ describe('Datatable Visualization', () => { it('should initialize from a persisted state', () => { const expectedState: DatatableVisualizationState = { layerId: 'foo', + layerType: layerTypes.DATA, columns: [{ columnId: 'saved' }], }; expect(datatableVisualization.initialize(() => 'foo', expectedState)).toEqual(expectedState); @@ -51,6 +54,7 @@ describe('Datatable Visualization', () => { it('return the layer ids', () => { const state: DatatableVisualizationState = { layerId: 'baz', + layerType: layerTypes.DATA, columns: [{ columnId: 'a' }, { columnId: 'b' }, { columnId: 'c' }], }; expect(datatableVisualization.getLayerIds(state)).toEqual(['baz']); @@ -61,15 +65,35 @@ describe('Datatable Visualization', () => { it('should reset the layer', () => { const state: DatatableVisualizationState = { layerId: 'baz', + layerType: layerTypes.DATA, columns: [{ columnId: 'a' }, { columnId: 'b' }, { columnId: 'c' }], }; expect(datatableVisualization.clearLayer(state, 'baz')).toMatchObject({ layerId: 'baz', + layerType: layerTypes.DATA, columns: [], }); }); }); + describe('#getSupportedLayers', () => { + it('should return a single layer type', () => { + expect(datatableVisualization.getSupportedLayers()).toHaveLength(1); + }); + }); + + describe('#getLayerType', () => { + it('should return the type only if the layer is in the state', () => { + const state: DatatableVisualizationState = { + layerId: 'baz', + layerType: layerTypes.DATA, + columns: [{ columnId: 'a' }, { columnId: 'b' }, { columnId: 'c' }], + }; + expect(datatableVisualization.getLayerType('baz', state)).toEqual(layerTypes.DATA); + expect(datatableVisualization.getLayerType('foo', state)).toBeUndefined(); + }); + }); + describe('#getSuggestions', () => { function numCol(columnId: string): TableSuggestionColumn { return { @@ -97,6 +121,7 @@ describe('Datatable Visualization', () => { const suggestions = datatableVisualization.getSuggestions({ state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [{ columnId: 'col1' }], }, table: { @@ -115,6 +140,7 @@ describe('Datatable Visualization', () => { const suggestions = datatableVisualization.getSuggestions({ state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [ { columnId: 'col1', width: 123 }, { columnId: 'col2', hidden: true }, @@ -149,6 +175,7 @@ describe('Datatable Visualization', () => { const suggestions = datatableVisualization.getSuggestions({ state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [{ columnId: 'col1' }], }, table: { @@ -167,6 +194,7 @@ describe('Datatable Visualization', () => { const suggestions = datatableVisualization.getSuggestions({ state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [{ columnId: 'col1' }], }, table: { @@ -185,6 +213,7 @@ describe('Datatable Visualization', () => { const suggestions = datatableVisualization.getSuggestions({ state: { layerId: 'older', + layerType: layerTypes.DATA, columns: [{ columnId: 'col1' }], }, table: { @@ -225,6 +254,7 @@ describe('Datatable Visualization', () => { layerId: 'first', state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [], }, frame, @@ -240,6 +270,7 @@ describe('Datatable Visualization', () => { layerId: 'first', state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [], }, frame, @@ -279,6 +310,7 @@ describe('Datatable Visualization', () => { layerId: 'first', state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [], }, frame, @@ -313,6 +345,7 @@ describe('Datatable Visualization', () => { layerId: 'a', state: { layerId: 'a', + layerType: layerTypes.DATA, columns: [{ columnId: 'b' }, { columnId: 'c' }], }, frame, @@ -327,28 +360,37 @@ describe('Datatable Visualization', () => { datatableVisualization.removeDimension({ prevState: { layerId: 'layer1', + layerType: layerTypes.DATA, columns: [{ columnId: 'b' }, { columnId: 'c' }], }, layerId: 'layer1', columnId: 'b', + frame: mockFrame(), }) ).toEqual({ layerId: 'layer1', + layerType: layerTypes.DATA, columns: [{ columnId: 'c' }], }); }); it('should handle correctly the sorting state on removing dimension', () => { - const state = { layerId: 'layer1', columns: [{ columnId: 'b' }, { columnId: 'c' }] }; + const state = { + layerId: 'layer1', + layerType: layerTypes.DATA, + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }; expect( datatableVisualization.removeDimension({ prevState: { ...state, sorting: { columnId: 'b', direction: 'asc' } }, layerId: 'layer1', columnId: 'b', + frame: mockFrame(), }) ).toEqual({ sorting: undefined, layerId: 'layer1', + layerType: layerTypes.DATA, columns: [{ columnId: 'c' }], }); @@ -357,10 +399,12 @@ describe('Datatable Visualization', () => { prevState: { ...state, sorting: { columnId: 'c', direction: 'asc' } }, layerId: 'layer1', columnId: 'b', + frame: mockFrame(), }) ).toEqual({ sorting: { columnId: 'c', direction: 'asc' }, layerId: 'layer1', + layerType: layerTypes.DATA, columns: [{ columnId: 'c' }], }); }); @@ -370,13 +414,19 @@ describe('Datatable Visualization', () => { it('allows columns to be added', () => { expect( datatableVisualization.setDimension({ - prevState: { layerId: 'layer1', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, + prevState: { + layerId: 'layer1', + layerType: layerTypes.DATA, + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }, layerId: 'layer1', columnId: 'd', groupId: '', + frame: mockFrame(), }) ).toEqual({ layerId: 'layer1', + layerType: layerTypes.DATA, columns: [{ columnId: 'b' }, { columnId: 'c' }, { columnId: 'd', isTransposed: false }], }); }); @@ -384,13 +434,19 @@ describe('Datatable Visualization', () => { it('does not set a duplicate dimension', () => { expect( datatableVisualization.setDimension({ - prevState: { layerId: 'layer1', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, + prevState: { + layerId: 'layer1', + layerType: layerTypes.DATA, + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }, layerId: 'layer1', columnId: 'b', groupId: '', + frame: mockFrame(), }) ).toEqual({ layerId: 'layer1', + layerType: layerTypes.DATA, columns: [{ columnId: 'b', isTransposed: false }, { columnId: 'c' }], }); }); @@ -409,7 +465,11 @@ describe('Datatable Visualization', () => { }); const expression = datatableVisualization.toExpression( - { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, + { + layerId: 'a', + layerType: layerTypes.DATA, + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }, frame.datasourceLayers ) as Ast; @@ -460,7 +520,11 @@ describe('Datatable Visualization', () => { }); const expression = datatableVisualization.toExpression( - { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, + { + layerId: 'a', + layerType: layerTypes.DATA, + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }, frame.datasourceLayers ); @@ -482,6 +546,7 @@ describe('Datatable Visualization', () => { const error = datatableVisualization.getErrorMessages({ layerId: 'a', + layerType: layerTypes.DATA, columns: [{ columnId: 'b' }, { columnId: 'c' }], }); @@ -501,6 +566,7 @@ describe('Datatable Visualization', () => { const error = datatableVisualization.getErrorMessages({ layerId: 'a', + layerType: layerTypes.DATA, columns: [{ columnId: 'b' }, { columnId: 'c' }], }); @@ -512,6 +578,7 @@ describe('Datatable Visualization', () => { it('should add a sort column to the state', () => { const currentState: DatatableVisualizationState = { layerId: 'foo', + layerType: layerTypes.DATA, columns: [{ columnId: 'saved' }], }; expect( @@ -531,6 +598,7 @@ describe('Datatable Visualization', () => { it('should add a custom width to a column in the state', () => { const currentState: DatatableVisualizationState = { layerId: 'foo', + layerType: layerTypes.DATA, columns: [{ columnId: 'saved' }], }; expect( @@ -547,6 +615,7 @@ describe('Datatable Visualization', () => { it('should clear custom width value for the column from the state', () => { const currentState: DatatableVisualizationState = { layerId: 'foo', + layerType: layerTypes.DATA, columns: [{ columnId: 'saved', width: 5000 }], }; expect( diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 691fce0ed70d28..807d32a245834a 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -21,12 +21,14 @@ import { LensIconChartDatatable } from '../assets/chart_datatable'; import { TableDimensionEditor } from './components/dimension_editor'; import { CUSTOM_PALETTE } from '../shared_components/coloring/constants'; import { getStopsForFixedMode } from '../shared_components'; +import { LayerType, layerTypes } from '../../common'; import { getDefaultSummaryLabel } from '../../common/expressions'; import type { ColumnState, SortingState } from '../../common/expressions'; export interface DatatableVisualizationState { columns: ColumnState[]; layerId: string; + layerType: LayerType; sorting?: SortingState; } @@ -82,6 +84,7 @@ export const getDatatableVisualization = ({ state || { columns: [], layerId: addNewLayer(), + layerType: layerTypes.DATA, } ); }, @@ -141,6 +144,7 @@ export const getDatatableVisualization = ({ state: { ...(state || {}), layerId: table.layerId, + layerType: layerTypes.DATA, columns: table.columns.map((col, columnIndex) => ({ ...(oldColumnSettings[col.columnId] || {}), isTransposed: usesTransposing && columnIndex < lastTransposedColumnIndex, @@ -296,6 +300,23 @@ export const getDatatableVisualization = ({ ); }, + getSupportedLayers() { + return [ + { + type: layerTypes.DATA, + label: i18n.translate('xpack.lens.datatable.addLayer', { + defaultMessage: 'Add visualization layer', + }), + }, + ]; + }, + + getLayerType(layerId, state) { + if (state?.layerId === layerId) { + return state.layerType; + } + }, + toExpression(state, datasourceLayers, { title, description } = {}): Ast | null { const { sortedColumns, datasource } = getDataSourceAndSortedColumns(state, datasourceLayers, state.layerId) || {}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx new file mode 100644 index 00000000000000..0259acc4dcca15 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiToolTip, + EuiButton, + EuiPopover, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiIcon, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { LayerType, layerTypes } from '../../../../common'; +import type { FramePublicAPI, Visualization } from '../../../types'; + +interface AddLayerButtonProps { + visualization: Visualization; + visualizationState: unknown; + onAddLayerClick: (layerType: LayerType) => void; + layersMeta: Pick; +} + +export function getLayerType(visualization: Visualization, state: unknown, layerId: string) { + return visualization.getLayerType(layerId, state) || layerTypes.DATA; +} + +export function AddLayerButton({ + visualization, + visualizationState, + onAddLayerClick, + layersMeta, +}: AddLayerButtonProps) { + const [showLayersChoice, toggleLayersChoice] = useState(false); + + const hasMultipleLayers = Boolean(visualization.appendLayer && visualizationState); + if (!hasMultipleLayers) { + return null; + } + const supportedLayers = visualization.getSupportedLayers?.(visualizationState, layersMeta); + if (supportedLayers?.length === 1) { + return ( + + onAddLayerClick(supportedLayers[0].type)} + iconType="layers" + > + {i18n.translate('xpack.lens.configPanel.addLayerButton', { + defaultMessage: 'Add layer', + })} + + + ); + } + return ( + toggleLayersChoice(!showLayersChoice)} + iconType="layers" + > + {i18n.translate('xpack.lens.configPanel.addLayerButton', { + defaultMessage: 'Add layer', + })} + + } + isOpen={showLayersChoice} + closePopover={() => toggleLayersChoice(false)} + panelPaddingSize="none" + > + { + return ( + { + onAddLayerClick(type); + toggleLayersChoice(false); + }} + icon={icon && } + disabled={disabled} + toolTipContent={tooltipContent} + > + {label} + + ); + })} + /> + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx index cb72b986430d61..122f888e009d6f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx @@ -108,13 +108,13 @@ export function EmptyDimensionButton({ diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss index 6629b440758318..0d51108fb2dcb1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss @@ -1,7 +1,3 @@ -.lnsConfigPanel__addLayerBtnWrapper { - padding-bottom: $euiSize; -} - .lnsConfigPanel__addLayerBtn { @include kbnThemeStyle('v7') { // sass-lint:disable-block no-important diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index 804f73b5d5fecc..f7fe2beefa963f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -8,8 +8,7 @@ import './config_panel.scss'; import React, { useMemo, memo } from 'react'; -import { EuiFlexItem, EuiToolTip, EuiButton, EuiForm } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiForm } from '@elastic/eui'; import { mapValues } from 'lodash'; import { Visualization } from '../../../types'; import { LayerPanel } from './layer_panel'; @@ -26,7 +25,9 @@ import { setToggleFullscreen, useLensSelector, selectVisualization, + VisualizationState, } from '../../../state_management'; +import { AddLayerButton } from './add_layer'; export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { const visualization = useLensSelector(selectVisualization); @@ -39,6 +40,18 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config ) : null; }); +function getRemoveOperation( + activeVisualization: Visualization, + visualizationState: VisualizationState['state'], + layerId: string, + layerCount: number +) { + if (activeVisualization.getRemoveOperation) { + return activeVisualization.getRemoveOperation(visualizationState, layerId); + } + // fallback to generic count check + return layerCount === 1 ? 'clear' : 'remove'; +} export function LayerPanels( props: ConfigPanelWrapperProps & { activeVisualization: Visualization; @@ -104,6 +117,10 @@ export function LayerPanels( typeof newDatasourceState === 'function' ? newDatasourceState(prevState.datasourceStates[datasourceId].state) : newDatasourceState; + const updatedVisualizationState = + typeof newVisualizationState === 'function' + ? newVisualizationState(prevState.visualization.state) + : newVisualizationState; return { ...prevState, datasourceStates: { @@ -115,7 +132,7 @@ export function LayerPanels( }, visualization: { ...prevState.visualization, - state: newVisualizationState, + state: updatedVisualizationState, }, stagedPreview: undefined, }; @@ -152,15 +169,26 @@ export function LayerPanels( updateDatasource={updateDatasource} updateDatasourceAsync={updateDatasourceAsync} updateAll={updateAll} - isOnlyLayer={layerIds.length === 1} + isOnlyLayer={ + getRemoveOperation( + activeVisualization, + visualization.state, + layerId, + layerIds.length + ) === 'clear' + } onRemoveLayer={() => { dispatchLens( updateState({ subType: 'REMOVE_OR_CLEAR_LAYER', updater: (state) => { - const isOnlyLayer = activeVisualization - .getLayerIds(state.visualization.state) - .every((id) => id === layerId); + const isOnlyLayer = + getRemoveOperation( + activeVisualization, + state.visualization.state, + layerId, + layerIds.length + ) === 'clear'; return { ...state, @@ -195,51 +223,30 @@ export function LayerPanels( /> ) : null )} - {activeVisualization.appendLayer && visualization.state && ( - - - { - const id = generateId(); - dispatchLens( - updateState({ - subType: 'ADD_LAYER', - updater: (state) => - appendLayer({ - activeVisualization, - generateId: () => id, - trackUiEvent, - activeDatasource: datasourceMap[activeDatasourceId!], - state, - }), - }) - ); - setNextFocusedLayerId(id); - }} - iconType="plusInCircleFilled" - /> - - - )} + { + const id = generateId(); + dispatchLens( + updateState({ + subType: 'ADD_LAYER', + updater: (state) => + appendLayer({ + activeVisualization, + generateId: () => id, + trackUiEvent, + activeDatasource: datasourceMap[activeDatasourceId!], + state, + layerType, + }), + }) + ); + + setNextFocusedLayerId(id); + }} + /> ); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts index ad15be170e6314..967e6e47c55f0b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { layerTypes } from '../../../../common'; import { initialState } from '../../../state_management/lens_slice'; import { removeLayer, appendLayer } from './layer_actions'; @@ -119,6 +120,7 @@ describe('appendLayer', () => { generateId: () => 'foo', state, trackUiEvent, + layerType: layerTypes.DATA, }); expect(newState.visualization.state).toEqual(['layer1', 'layer2', 'foo']); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts index 328a868cfb893a..c0f0847e8ff5c3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts @@ -6,6 +6,7 @@ */ import { mapValues } from 'lodash'; +import type { LayerType } from '../../../../common'; import { LensAppState } from '../../../state_management'; import { Datasource, Visualization } from '../../../types'; @@ -24,6 +25,7 @@ interface AppendLayerOptions { generateId: () => string; activeDatasource: Pick; activeVisualization: Pick; + layerType: LayerType; } export function removeLayer(opts: RemoveLayerOptions): LensAppState { @@ -62,6 +64,7 @@ export function appendLayer({ state, generateId, activeDatasource, + layerType, }: AppendLayerOptions): LensAppState { trackUiEvent('layer_added'); @@ -85,7 +88,7 @@ export function appendLayer({ }, visualization: { ...state.visualization, - state: activeVisualization.appendLayer(state.visualization.state, layerId), + state: activeVisualization.appendLayer(state.visualization.state, layerId, layerType), }, stagedPreview: undefined, }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss index fd37a7bada02f1..7a1cbb8237f503 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss @@ -1,7 +1,7 @@ @import '../../../mixins'; .lnsLayerPanel { - margin-bottom: $euiSizeS; + margin-bottom: $euiSize; // disable focus ring for mouse clicks, leave it for keyboard users &:focus:not(:focus-visible) { @@ -9,26 +9,41 @@ } } -.lnsLayerPanel__sourceFlexItem { - max-width: calc(100% - #{$euiSize * 3.625}); +.lnsLayerPanel__layerHeader { + padding: $euiSize; + border-bottom: $euiBorderThin; +} + +// fixes truncation for too long chart switcher labels +.lnsLayerPanel__layerSettingsWrapper { + min-width: 0; } -.lnsLayerPanel__settingsFlexItem:empty + .lnsLayerPanel__sourceFlexItem { - max-width: calc(100% - #{$euiSizeS}); +.lnsLayerPanel__settingsStaticHeader { + padding-left: $euiSizeXS; } -.lnsLayerPanel__settingsFlexItem:empty { - margin: 0; +.lnsLayerPanel__settingsStaticHeaderIcon { + margin-right: $euiSizeS; + vertical-align: inherit; +} + +.lnsLayerPanel__settingsStaticHeaderTitle { + display: inline; } .lnsLayerPanel__row { background: $euiColorLightestShade; - padding: $euiSizeS 0; - border-radius: $euiBorderRadius; + padding: $euiSize; - // Add margin to the top of the next same panel + // Add border to the top of the next same panel & + & { - margin-top: $euiSize; + border-top: $euiBorderThin; + margin-top: 0; + } + &:last-child { + border-bottom-right-radius: $euiBorderRadius; + border-bottom-left-radius: $euiBorderRadius; } } @@ -45,10 +60,6 @@ padding: 0; } -.lnsLayerPanel__groupLabel { - padding: 0 $euiSizeS; -} - .lnsLayerPanel__error { padding: 0 $euiSizeS; } @@ -76,7 +87,7 @@ } .lnsLayerPanel__dimensionContainer { - margin: 0 $euiSizeS $euiSizeS; + margin: 0 0 $euiSizeS; position: relative; &:last-child { @@ -93,6 +104,7 @@ padding: $euiSizeS; min-height: $euiSizeXXL - 2; word-break: break-word; + font-weight: $euiFontWeightRegular; } .lnsLayerPanel__triggerTextLabel { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index 12f27b5bfba10e..13b7b8cfecf568 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { EuiFormRow } from '@elastic/eui'; -import { Visualization } from '../../../types'; +import { FramePublicAPI, Visualization } from '../../../types'; import { LayerPanel } from './layer_panel'; import { ChildDragDropProvider, DragDrop } from '../../../drag_drop'; import { coreMock } from '../../../../../../../src/core/public/mocks'; @@ -56,9 +56,10 @@ describe('LayerPanel', () => { let mockVisualization2: jest.Mocked; let mockDatasource: DatasourceMock; + let frame: FramePublicAPI; function getDefaultProps() { - const frame = createMockFramePublicAPI(); + frame = createMockFramePublicAPI(); frame.datasourceLayers = { first: mockDatasource.publicAPIMock, }; @@ -119,27 +120,27 @@ describe('LayerPanel', () => { describe('layer reset and remove', () => { it('should show the reset button when single layer', async () => { const { instance } = await mountWithProvider(); - expect(instance.find('[data-test-subj="lnsLayerRemove"]').first().text()).toContain( - 'Reset layer' - ); + expect( + instance.find('[data-test-subj="lnsLayerRemove"]').first().props()['aria-label'] + ).toContain('Reset layer'); }); it('should show the delete button when multiple layers', async () => { const { instance } = await mountWithProvider( ); - expect(instance.find('[data-test-subj="lnsLayerRemove"]').first().text()).toContain( - 'Delete layer' - ); + expect( + instance.find('[data-test-subj="lnsLayerRemove"]').first().props()['aria-label'] + ).toContain('Delete layer'); }); it('should show to reset visualization for visualizations only allowing a single layer', async () => { const layerPanelAttributes = getDefaultProps(); delete layerPanelAttributes.activeVisualization.removeLayer; const { instance } = await mountWithProvider(); - expect(instance.find('[data-test-subj="lnsLayerRemove"]').first().text()).toContain( - 'Reset visualization' - ); + expect( + instance.find('[data-test-subj="lnsLayerRemove"]').first().props()['aria-label'] + ).toContain('Reset visualization'); }); it('should call the clear callback', async () => { @@ -901,12 +902,14 @@ describe('LayerPanel', () => { droppedItem: draggingOperation, }) ); - expect(mockVis.setDimension).toHaveBeenCalledWith({ - columnId: 'c', - groupId: 'b', - layerId: 'first', - prevState: 'state', - }); + expect(mockVis.setDimension).toHaveBeenCalledWith( + expect.objectContaining({ + columnId: 'c', + groupId: 'b', + layerId: 'first', + prevState: 'state', + }) + ); expect(mockVis.removeDimension).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'a', 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 d0a6830aa178a3..c729885fef8a94 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 @@ -124,7 +124,7 @@ export function LayerPanel( dateRange, }; - const { groups } = useMemo( + const { groups, supportStaticValue } = useMemo( () => activeVisualization.getConfiguration(layerVisualizationConfigProps), // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -194,6 +194,7 @@ export function LayerPanel( layerId: targetLayerId, prevState: props.visualizationState, previousColumn: typeof droppedItem.column === 'string' ? droppedItem.column : undefined, + frame: framePublicAPI, }); if (typeof dropResult === 'object') { @@ -203,6 +204,7 @@ export function LayerPanel( columnId: dropResult.deleted, layerId: targetLayerId, prevState: newVisState, + frame: framePublicAPI, }) ); } else { @@ -211,6 +213,7 @@ export function LayerPanel( } }; }, [ + framePublicAPI, groups, layerDatasourceOnDrop, props.visualizationState, @@ -242,6 +245,7 @@ export function LayerPanel( layerId, columnId: activeId, prevState: visualizationState, + frame: framePublicAPI, }) ); } @@ -254,6 +258,7 @@ export function LayerPanel( groupId: activeGroup.groupId, columnId: activeId, prevState: visualizationState, + frame: framePublicAPI, }) ); setActiveDimension({ ...activeDimension, isNew: false }); @@ -272,6 +277,7 @@ export function LayerPanel( updateAll, updateDatasourceAsync, visualizationState, + framePublicAPI, ] ); @@ -283,60 +289,72 @@ export function LayerPanel( className="lnsLayerPanel" style={{ visibility: isDimensionPanelOpen ? 'hidden' : 'visible' }} > - - - - - - + +
+ + + + + + + + + {layerDatasource && ( - - { - const newState = - typeof updater === 'function' ? updater(layerDatasourceState) : updater; - // Look for removed columns - const nextPublicAPI = layerDatasource.getPublicAPI({ - state: newState, + { + const newState = + typeof updater === 'function' ? updater(layerDatasourceState) : updater; + // Look for removed columns + const nextPublicAPI = layerDatasource.getPublicAPI({ + state: newState, + layerId, + }); + const nextTable = new Set( + nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) + ); + const removed = datasourcePublicAPI + .getTableSpec() + .map(({ columnId }) => columnId) + .filter((columnId) => !nextTable.has(columnId)); + let nextVisState = props.visualizationState; + removed.forEach((columnId) => { + nextVisState = activeVisualization.removeDimension({ layerId, + columnId, + prevState: nextVisState, + frame: framePublicAPI, }); - const nextTable = new Set( - nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) - ); - const removed = datasourcePublicAPI - .getTableSpec() - .map(({ columnId }) => columnId) - .filter((columnId) => !nextTable.has(columnId)); - let nextVisState = props.visualizationState; - removed.forEach((columnId) => { - nextVisState = activeVisualization.removeDimension({ - layerId, - columnId, - prevState: nextVisState, - }); - }); + }); - props.updateAll(datasourceId, newState, nextVisState); - }, - }} - /> - + props.updateAll(datasourceId, newState, nextVisState); + }, + }} + /> )} - - - +
{groups.map((group, groupIndex) => { const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; @@ -349,7 +367,7 @@ export function LayerPanel( } fullWidth label={ -
+
{group.groupLabel} {group.groupTooltip && ( <> @@ -429,6 +447,7 @@ export function LayerPanel( layerId, columnId: id, prevState: props.visualizationState, + frame: framePublicAPI, }) ); removeButtonRef(id); @@ -462,7 +481,7 @@ export function LayerPanel( setActiveDimension({ activeGroup: group, activeId: id, - isNew: true, + isNew: !supportStaticValue, }); }} onDrop={onDrop} @@ -472,19 +491,6 @@ export function LayerPanel( ); })} - - - - - - - - @@ -532,6 +538,7 @@ export function LayerPanel( toggleFullscreen, isFullscreen, setState: updateDataLayerState, + layerType: activeVisualization.getLayerType(layerId, visualizationState), }} /> )} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx index 2d421965a633ab..467b1ecfe1b5b0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx @@ -5,12 +5,10 @@ * 2.0. */ -import React, { useState } from 'react'; -import { EuiPopover, EuiToolTip } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle } from '@elastic/eui'; import { NativeRenderer } from '../../../native_renderer'; import { Visualization, VisualizationLayerWidgetProps } from '../../../types'; -import { ToolbarButton } from '../../../../../../../src/plugins/kibana_react/public'; export function LayerSettings({ layerId, @@ -21,56 +19,34 @@ export function LayerSettings({ activeVisualization: Visualization; layerConfigProps: VisualizationLayerWidgetProps; }) { - const [isOpen, setIsOpen] = useState(false); + const description = activeVisualization.getDescription(layerConfigProps.state); - if (!activeVisualization.renderLayerContextMenu) { - return null; - } - - const a11yText = (chartType?: string) => { - if (chartType) { - return i18n.translate('xpack.lens.editLayerSettingsChartType', { - defaultMessage: 'Edit layer settings, {chartType}', - values: { - chartType, - }, - }); + if (!activeVisualization.renderLayerHeader) { + if (!description) { + return null; } - return i18n.translate('xpack.lens.editLayerSettings', { - defaultMessage: 'Edit layer settings', - }); - }; + return ( + + {description.icon && ( + + {' '} + + )} + + +
{description.label}
+
+
+
+ ); + } - const contextMenuIcon = activeVisualization.getLayerContextMenuIcon?.(layerConfigProps); return ( - - setIsOpen(!isOpen)} - data-test-subj="lns_layer_settings" - /> - - } - isOpen={isOpen} - closePopover={() => setIsOpen(false)} - anchorPosition="downLeft" - > - - + ); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx index cca8cc88c6ab1b..fbc498b729d2a8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiButtonIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Visualization } from '../../../types'; @@ -22,40 +22,31 @@ export function RemoveLayerButton({ activeVisualization: Visualization; }) { let ariaLabel; - let componentText; if (!activeVisualization.removeLayer) { ariaLabel = i18n.translate('xpack.lens.resetVisualizationAriaLabel', { defaultMessage: 'Reset visualization', }); - componentText = i18n.translate('xpack.lens.resetVisualization', { - defaultMessage: 'Reset visualization', - }); } else if (isOnlyLayer) { ariaLabel = i18n.translate('xpack.lens.resetLayerAriaLabel', { defaultMessage: 'Reset layer {index}', values: { index: layerIndex + 1 }, }); - componentText = i18n.translate('xpack.lens.resetLayer', { - defaultMessage: 'Reset layer', - }); } else { ariaLabel = i18n.translate('xpack.lens.deleteLayerAriaLabel', { defaultMessage: `Delete layer {index}`, values: { index: layerIndex + 1 }, }); - componentText = i18n.translate('xpack.lens.deleteLayer', { - defaultMessage: `Delete layer`, - }); } return ( - { // If we don't blur the remove / clear button, it remains focused // which is a strange UX in this case. e.target.blur doesn't work @@ -69,8 +60,6 @@ export function RemoveLayerButton({ onRemoveLayer(); }} - > - {componentText} - + /> ); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss index 2d86b37669ed01..91793d1f6cb718 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss @@ -107,7 +107,7 @@ a tilemap in an iframe: https://github.com/elastic/kibana/issues/16457 */ .lnsConfigPanel { @include euiScrollBar; - padding: $euiSize $euiSizeXS $euiSize $euiSize; + padding: $euiSize $euiSizeXS $euiSizeXL $euiSize; overflow-x: hidden; overflow-y: scroll; padding-left: $euiFormMaxWidth + $euiSize; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index 65cd5ae35c6fec..2f3fe3795a8815 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { flatten } from 'lodash'; import { Ast } from '@kbn/interpreter/common'; import { IconType } from '@elastic/eui/src/components/icon/icon'; import { Datatable } from 'src/plugins/expressions'; @@ -22,6 +21,8 @@ import { VisualizationMap, } from '../../types'; import { DragDropIdentifier } from '../../drag_drop'; +import { LayerType, layerTypes } from '../../../common'; +import { getLayerType } from './config_panel/add_layer'; import { LensDispatch, selectSuggestion, @@ -80,58 +81,88 @@ export function getSuggestions({ ([datasourceId]) => datasourceStates[datasourceId] && !datasourceStates[datasourceId].isLoading ); + const layerTypesMap = datasources.reduce((memo, [datasourceId, datasource]) => { + const datasourceState = datasourceStates[datasourceId].state; + if (!activeVisualizationId || !datasourceState || !visualizationMap[activeVisualizationId]) { + return memo; + } + const layers = datasource.getLayers(datasourceState); + for (const layerId of layers) { + const type = getLayerType( + visualizationMap[activeVisualizationId], + visualizationState, + layerId + ); + memo[layerId] = type; + } + return memo; + }, {} as Record); + + const isLayerSupportedByVisualization = (layerId: string, supportedTypes: LayerType[]) => + supportedTypes.includes(layerTypesMap[layerId] ?? layerTypes.DATA); + // Collect all table suggestions from available datasources - const datasourceTableSuggestions = flatten( - datasources.map(([datasourceId, datasource]) => { - const datasourceState = datasourceStates[datasourceId].state; - let dataSourceSuggestions; - if (visualizeTriggerFieldContext) { - dataSourceSuggestions = datasource.getDatasourceSuggestionsForVisualizeField( - datasourceState, - visualizeTriggerFieldContext.indexPatternId, - visualizeTriggerFieldContext.fieldName - ); - } else if (field) { - dataSourceSuggestions = datasource.getDatasourceSuggestionsForField(datasourceState, field); - } else { - dataSourceSuggestions = datasource.getDatasourceSuggestionsFromCurrentState( - datasourceState, - activeData - ); - } - return dataSourceSuggestions.map((suggestion) => ({ ...suggestion, datasourceId })); - }) - ); + const datasourceTableSuggestions = datasources.flatMap(([datasourceId, datasource]) => { + const datasourceState = datasourceStates[datasourceId].state; + let dataSourceSuggestions; + if (visualizeTriggerFieldContext) { + dataSourceSuggestions = datasource.getDatasourceSuggestionsForVisualizeField( + datasourceState, + visualizeTriggerFieldContext.indexPatternId, + visualizeTriggerFieldContext.fieldName + ); + } else if (field) { + dataSourceSuggestions = datasource.getDatasourceSuggestionsForField(datasourceState, field); + } else { + dataSourceSuggestions = datasource.getDatasourceSuggestionsFromCurrentState( + datasourceState, + activeData + ); + } + return dataSourceSuggestions.map((suggestion) => ({ ...suggestion, datasourceId })); + }); // Pass all table suggestions to all visualization extensions to get visualization suggestions // and rank them by score - return flatten( - Object.entries(visualizationMap).map(([visualizationId, visualization]) => - flatten( - datasourceTableSuggestions.map((datasourceSuggestion) => { + return Object.entries(visualizationMap) + .flatMap(([visualizationId, visualization]) => { + const supportedLayerTypes = visualization.getSupportedLayers().map(({ type }) => type); + return datasourceTableSuggestions + .filter((datasourceSuggestion) => { + const filteredCount = datasourceSuggestion.keptLayerIds.filter((layerId) => + isLayerSupportedByVisualization(layerId, supportedLayerTypes) + ).length; + // make it pass either suggestions with some ids left after filtering + // or suggestion with already 0 ids before the filtering (testing purposes) + return filteredCount || filteredCount === datasourceSuggestion.keptLayerIds.length; + }) + .flatMap((datasourceSuggestion) => { const table = datasourceSuggestion.table; const currentVisualizationState = visualizationId === activeVisualizationId ? visualizationState : undefined; const palette = mainPalette || - (activeVisualizationId && - visualizationMap[activeVisualizationId] && - visualizationMap[activeVisualizationId].getMainPalette - ? visualizationMap[activeVisualizationId].getMainPalette!(visualizationState) + (activeVisualizationId && visualizationMap[activeVisualizationId]?.getMainPalette + ? visualizationMap[activeVisualizationId].getMainPalette?.(visualizationState) : undefined); + return getVisualizationSuggestions( visualization, table, visualizationId, - datasourceSuggestion, + { + ...datasourceSuggestion, + keptLayerIds: datasourceSuggestion.keptLayerIds.filter((layerId) => + isLayerSupportedByVisualization(layerId, supportedLayerTypes) + ), + }, currentVisualizationState, subVisualizationId, palette ); - }) - ) - ) - ).sort((a, b) => b.score - a.score); + }); + }) + .sort((a, b) => b.score - a.score); } export function getVisualizeFieldSuggestions({ diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index e2036e556a5514..010e4d73c47911 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -136,21 +136,22 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { ); } layerIds.forEach((layerId) => { - const layerDatasourceId = Object.entries(props.datasourceMap).find( - ([datasourceId, datasource]) => { + const [layerDatasourceId] = + Object.entries(props.datasourceMap).find(([datasourceId, datasource]) => { return ( datasourceStates[datasourceId] && datasource.getLayers(datasourceStates[datasourceId].state).includes(layerId) ); - } - )![0]; - dispatchLens( - updateLayer({ - layerId, - datasourceId: layerDatasourceId, - updater: props.datasourceMap[layerDatasourceId].removeLayer, - }) - ); + }) ?? []; + if (layerDatasourceId) { + dispatchLens( + updateLayer({ + layerId, + datasourceId: layerDatasourceId, + updater: props.datasourceMap[layerDatasourceId].removeLayer, + }) + ); + } }); } diff --git a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts index d7443ea8fe43df..e9f8acad7f82db 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts @@ -9,6 +9,7 @@ import { Position } from '@elastic/charts'; import { getSuggestions } from './suggestions'; import type { HeatmapVisualizationState } from './types'; import { HEATMAP_GRID_FUNCTION, LEGEND_FUNCTION } from './constants'; +import { layerTypes } from '../../common'; describe('heatmap suggestions', () => { describe('rejects suggestions', () => { @@ -24,6 +25,7 @@ describe('heatmap suggestions', () => { state: { shape: 'heatmap', layerId: 'first', + layerType: layerTypes.DATA, } as HeatmapVisualizationState, keptLayerIds: ['first'], }) @@ -78,6 +80,7 @@ describe('heatmap suggestions', () => { }, state: { layerId: 'first', + layerType: layerTypes.DATA, } as HeatmapVisualizationState, keptLayerIds: ['first'], }) @@ -96,6 +99,7 @@ describe('heatmap suggestions', () => { state: { shape: 'heatmap', layerId: 'first', + layerType: layerTypes.DATA, xAccessor: 'some-field', } as HeatmapVisualizationState, keptLayerIds: ['first'], @@ -116,6 +120,7 @@ describe('heatmap suggestions', () => { }, state: { layerId: 'first', + layerType: layerTypes.DATA, } as HeatmapVisualizationState, keptLayerIds: ['first'], }) @@ -123,6 +128,7 @@ describe('heatmap suggestions', () => { { state: { layerId: 'first', + layerType: layerTypes.DATA, shape: 'heatmap', gridConfig: { type: HEATMAP_GRID_FUNCTION, @@ -164,6 +170,7 @@ describe('heatmap suggestions', () => { }, state: { layerId: 'first', + layerType: layerTypes.DATA, } as HeatmapVisualizationState, keptLayerIds: ['first'], }) @@ -171,6 +178,7 @@ describe('heatmap suggestions', () => { { state: { layerId: 'first', + layerType: layerTypes.DATA, shape: 'heatmap', xAccessor: 'test-column', gridConfig: { @@ -225,6 +233,7 @@ describe('heatmap suggestions', () => { }, state: { layerId: 'first', + layerType: layerTypes.DATA, } as HeatmapVisualizationState, keptLayerIds: ['first'], }) @@ -232,6 +241,7 @@ describe('heatmap suggestions', () => { { state: { layerId: 'first', + layerType: layerTypes.DATA, shape: 'heatmap', xAccessor: 'date-column', valueAccessor: 'metric-column', @@ -295,6 +305,7 @@ describe('heatmap suggestions', () => { }, state: { layerId: 'first', + layerType: layerTypes.DATA, } as HeatmapVisualizationState, keptLayerIds: ['first'], }) @@ -302,6 +313,7 @@ describe('heatmap suggestions', () => { { state: { layerId: 'first', + layerType: layerTypes.DATA, shape: 'heatmap', xAccessor: 'date-column', yAccessor: 'group-column', diff --git a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts index 3f27d5e81b5070..ebe93419edce6d 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts @@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n'; import type { Visualization } from '../types'; import type { HeatmapVisualizationState } from './types'; import { CHART_SHAPES, HEATMAP_GRID_FUNCTION, LEGEND_FUNCTION } from './constants'; +import { layerTypes } from '../../common'; export const getSuggestions: Visualization['getSuggestions'] = ({ table, @@ -59,6 +60,7 @@ export const getSuggestions: Visualization['getSugges const newState: HeatmapVisualizationState = { shape: CHART_SHAPES.HEATMAP, layerId: table.layerId, + layerType: layerTypes.DATA, legend: { isVisible: state?.legend?.isVisible ?? true, position: state?.legend?.position ?? Position.Right, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/types.ts b/x-pack/plugins/lens/public/heatmap_visualization/types.ts index 0cf830bea609ac..5515d77d1a8aba 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/types.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/types.ts @@ -7,7 +7,7 @@ import type { PaletteOutput } from '../../../../../src/plugins/charts/common'; import type { LensBrushEvent, LensFilterEvent } from '../types'; -import type { LensMultiTable, FormatFactory, CustomPaletteParams } from '../../common'; +import type { LensMultiTable, FormatFactory, CustomPaletteParams, LayerType } from '../../common'; import type { HeatmapGridConfigResult, HeatmapLegendConfigResult } from '../../common/expressions'; import { CHART_SHAPES, LENS_HEATMAP_RENDERER } from './constants'; import type { ChartsPluginSetup, PaletteRegistry } from '../../../../../src/plugins/charts/public'; @@ -25,6 +25,7 @@ export interface SharedHeatmapLayerState { export type HeatmapLayerState = SharedHeatmapLayerState & { layerId: string; + layerType: LayerType; }; export type HeatmapVisualizationState = HeatmapLayerState & { diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts index 6cbe27fbf323fd..bceeeebb5e1403 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts @@ -22,10 +22,12 @@ import { Position } from '@elastic/charts'; import type { HeatmapVisualizationState } from './types'; import type { DatasourcePublicAPI, Operation } from '../types'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { layerTypes } from '../../common'; function exampleState(): HeatmapVisualizationState { return { layerId: 'test-layer', + layerType: layerTypes.DATA, legend: { isVisible: true, position: Position.Right, @@ -54,6 +56,7 @@ describe('heatmap', () => { test('returns a default state', () => { expect(getHeatmapVisualization({ paletteService }).initialize(() => 'l1')).toEqual({ layerId: 'l1', + layerType: layerTypes.DATA, title: 'Empty Heatmap chart', shape: CHART_SHAPES.HEATMAP, legend: { @@ -214,6 +217,7 @@ describe('heatmap', () => { layerId: 'first', columnId: 'new-x-accessor', groupId: 'x', + frame, }) ).toEqual({ ...prevState, @@ -236,6 +240,7 @@ describe('heatmap', () => { prevState, layerId: 'first', columnId: 'x-accessor', + frame, }) ).toEqual({ ...exampleState(), @@ -244,6 +249,31 @@ describe('heatmap', () => { }); }); + describe('#getSupportedLayers', () => { + it('should return a single layer type', () => { + expect( + getHeatmapVisualization({ + paletteService, + }).getSupportedLayers() + ).toHaveLength(1); + }); + }); + + describe('#getLayerType', () => { + it('should return the type only if the layer is in the state', () => { + const state: HeatmapVisualizationState = { + ...exampleState(), + xAccessor: 'x-accessor', + valueAccessor: 'value-accessor', + }; + const instance = getHeatmapVisualization({ + paletteService, + }); + expect(instance.getLayerType('test-layer', state)).toEqual(layerTypes.DATA); + expect(instance.getLayerType('foo', state)).toBeUndefined(); + }); + }); + describe('#toExpression', () => { let datasourceLayers: Record; diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index 716792805e1b56..5405cff6ed1db6 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -31,6 +31,7 @@ import { CUSTOM_PALETTE, getStopsForFixedMode } from '../shared_components'; import { HeatmapDimensionEditor } from './dimension_editor'; import { getSafePaletteParams } from './utils'; import type { CustomPaletteParams } from '../../common'; +import { layerTypes } from '../../common'; const groupLabelForBar = i18n.translate('xpack.lens.heatmapVisualization.heatmapGroupLabel', { defaultMessage: 'Heatmap', @@ -63,7 +64,7 @@ export const isCellValueSupported = (op: OperationMetadata) => { return !isBucketed(op) && (op.scale === 'ordinal' || op.scale === 'ratio') && isNumericMetric(op); }; -function getInitialState(): Omit { +function getInitialState(): Omit { return { shape: CHART_SHAPES.HEATMAP, legend: { @@ -138,6 +139,7 @@ export const getHeatmapVisualization = ({ return ( state || { layerId: addNewLayer(), + layerType: layerTypes.DATA, title: 'Empty Heatmap chart', ...getInitialState(), } @@ -263,6 +265,23 @@ export const getHeatmapVisualization = ({ ); }, + getSupportedLayers() { + return [ + { + type: layerTypes.DATA, + label: i18n.translate('xpack.lens.heatmap.addLayer', { + defaultMessage: 'Add visualization layer', + }), + }, + ]; + }, + + getLayerType(layerId, state) { + if (state?.layerId === layerId) { + return state.layerType; + } + }, + toExpression(state, datasourceLayers, attributes): Ast | null { const datasource = datasourceLayers[state.layerId]; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss index d3320714a65cd9..0303e6549d8dfb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss @@ -45,4 +45,15 @@ .lnsChangeIndexPatternPopover { width: 320px; +} + +.lnsChangeIndexPatternPopover__trigger { + padding: 0 $euiSize; +} + +.lnsLayerPanelChartSwitch_title { + font-weight: 600; + display: inline; + vertical-align: middle; + padding-left: 8px; } \ No newline at end of file diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 05100567c1b035..0faaa1f342eebf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -96,6 +96,7 @@ export function DimensionEditor(props: DimensionEditorProps) { dimensionGroups, toggleFullscreen, isFullscreen, + layerType, } = props; const services = { data: props.data, @@ -186,7 +187,8 @@ export function DimensionEditor(props: DimensionEditorProps) { definition.getDisabledStatus && definition.getDisabledStatus( state.indexPatterns[state.currentIndexPatternId], - state.layers[layerId] + state.layers[layerId], + layerType ), }; }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index b6d3a230d06f54..6d96b853ab2394 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -36,6 +36,7 @@ import { Filtering, setFilter } from './filtering'; import { TimeShift } from './time_shift'; import { DimensionEditor } from './dimension_editor'; import { AdvancedOptions } from './advanced_options'; +import { layerTypes } from '../../../common'; jest.mock('../loader'); jest.mock('../query_input', () => ({ @@ -184,6 +185,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dateRange: { fromDate: 'now-1d', toDate: 'now' }, columnId: 'col1', layerId: 'first', + layerType: layerTypes.DATA, uniqueLabel: 'stuff', filterOperations: () => true, storage: {} as IStorageWrapper, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts index 56d255ec02227c..d1082da2beb208 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -263,6 +263,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dateRange: { fromDate: 'now-1d', toDate: 'now' }, columnId: 'col1', layerId: 'first', + layerType: 'data', uniqueLabel: 'stuff', groupId: 'group1', filterOperations: () => true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx index e6cba7ac9dce08..8cc6139fedc0a0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx @@ -14,6 +14,7 @@ import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField, + checkForDataLayerType, } from './utils'; import { DEFAULT_TIME_SCALE } from '../../time_scale_utils'; import { OperationDefinition } from '..'; @@ -111,13 +112,18 @@ export const counterRateOperation: OperationDefinition< }) ); }, - getDisabledStatus(indexPattern, layer) { - return checkForDateHistogram( - layer, - i18n.translate('xpack.lens.indexPattern.counterRate', { - defaultMessage: 'Counter rate', - }) - )?.join(', '); + getDisabledStatus(indexPattern, layer, layerType) { + const opName = i18n.translate('xpack.lens.indexPattern.counterRate', { + defaultMessage: 'Counter rate', + }); + if (layerType) { + const dataLayerErrors = checkForDataLayerType(layerType, opName); + if (dataLayerErrors) { + return dataLayerErrors.join(', '); + } + } + + return checkForDateHistogram(layer, opName)?.join(', '); }, timeScalingMode: 'mandatory', filterable: true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx index 9c8437140f7939..a59491cfc8a6bc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx @@ -14,6 +14,7 @@ import { dateBasedOperationToExpression, hasDateField, buildLabelFunction, + checkForDataLayerType, } from './utils'; import { OperationDefinition } from '..'; import { getFormatFromPreviousColumn, getFilter } from '../helpers'; @@ -108,13 +109,17 @@ export const cumulativeSumOperation: OperationDefinition< }) ); }, - getDisabledStatus(indexPattern, layer) { - return checkForDateHistogram( - layer, - i18n.translate('xpack.lens.indexPattern.cumulativeSum', { - defaultMessage: 'Cumulative sum', - }) - )?.join(', '); + getDisabledStatus(indexPattern, layer, layerType) { + const opName = i18n.translate('xpack.lens.indexPattern.cumulativeSum', { + defaultMessage: 'Cumulative sum', + }); + if (layerType) { + const dataLayerErrors = checkForDataLayerType(layerType, opName); + if (dataLayerErrors) { + return dataLayerErrors.join(', '); + } + } + return checkForDateHistogram(layer, opName)?.join(', '); }, filterable: true, documentation: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx index 8890390378d216..730067e9c5577c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx @@ -14,6 +14,7 @@ import { getErrorsForDateReference, dateBasedOperationToExpression, hasDateField, + checkForDataLayerType, } from './utils'; import { adjustTimeScaleOnOtherColumnChange } from '../../time_scale_utils'; import { OperationDefinition } from '..'; @@ -99,13 +100,17 @@ export const derivativeOperation: OperationDefinition< }) ); }, - getDisabledStatus(indexPattern, layer) { - return checkForDateHistogram( - layer, - i18n.translate('xpack.lens.indexPattern.derivative', { - defaultMessage: 'Differences', - }) - )?.join(', '); + getDisabledStatus(indexPattern, layer, layerType) { + const opName = i18n.translate('xpack.lens.indexPattern.derivative', { + defaultMessage: 'Differences', + }); + if (layerType) { + const dataLayerErrors = checkForDataLayerType(layerType, opName); + if (dataLayerErrors) { + return dataLayerErrors.join(', '); + } + } + return checkForDateHistogram(layer, opName)?.join(', '); }, timeScalingMode: 'optional', filterable: true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx index 72e14cc2ea016b..7a26253c41f09a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -18,6 +18,7 @@ import { getErrorsForDateReference, dateBasedOperationToExpression, hasDateField, + checkForDataLayerType, } from './utils'; import { updateColumnParam } from '../../layer_helpers'; import { getFormatFromPreviousColumn, isValidNumber, getFilter } from '../helpers'; @@ -122,13 +123,17 @@ export const movingAverageOperation: OperationDefinition< ); }, getHelpMessage: () => , - getDisabledStatus(indexPattern, layer) { - return checkForDateHistogram( - layer, - i18n.translate('xpack.lens.indexPattern.movingAverage', { - defaultMessage: 'Moving average', - }) - )?.join(', '); + getDisabledStatus(indexPattern, layer, layerType) { + const opName = i18n.translate('xpack.lens.indexPattern.movingAverage', { + defaultMessage: 'Moving average', + }); + if (layerType) { + const dataLayerErrors = checkForDataLayerType(layerType, opName); + if (dataLayerErrors) { + return dataLayerErrors.join(', '); + } + } + return checkForDateHistogram(layer, opName)?.join(', '); }, timeScalingMode: 'optional', filterable: true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.test.ts index 7a6f96d705b0c0..d68fd8b9555f9e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.test.ts @@ -5,9 +5,10 @@ * 2.0. */ -import { checkReferences } from './utils'; +import { checkReferences, checkForDataLayerType } from './utils'; import { operationDefinitionMap } from '..'; import { createMockedFullReference } from '../../mocks'; +import { layerTypes } from '../../../../../common'; // Mock prevents issue with circular loading jest.mock('..'); @@ -18,6 +19,14 @@ describe('utils', () => { operationDefinitionMap.testReference = createMockedFullReference(); }); + describe('checkForDataLayerType', () => { + it('should return an error if the layer is of the wrong type', () => { + expect(checkForDataLayerType(layerTypes.THRESHOLD, 'Operation')).toEqual([ + 'Operation is disabled for this type of layer.', + ]); + }); + }); + describe('checkReferences', () => { it('should show an error if the reference is missing', () => { expect( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts index 34b33d35d41399..29865ac8d60b8f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import type { ExpressionFunctionAST } from '@kbn/interpreter/common'; import memoizeOne from 'memoize-one'; +import { LayerType, layerTypes } from '../../../../../common'; import type { TimeScaleUnit } from '../../../../../common/expressions'; import type { IndexPattern, IndexPatternLayer } from '../../../types'; import { adjustTimeScaleLabelSuffix } from '../../time_scale_utils'; @@ -24,6 +25,19 @@ export const buildLabelFunction = (ofName: (name?: string) => string) => ( return adjustTimeScaleLabelSuffix(rawLabel, undefined, timeScale, undefined, timeShift); }; +export function checkForDataLayerType(layerType: LayerType, name: string) { + if (layerType === layerTypes.THRESHOLD) { + return [ + i18n.translate('xpack.lens.indexPattern.calculations.layerDataType', { + defaultMessage: '{name} is disabled for this type of layer.', + values: { + name, + }, + }), + ]; + } +} + /** * Checks whether the current layer includes a date histogram and returns an error otherwise */ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index a8ab6ef943b645..569045f39877e7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -53,7 +53,7 @@ import { lastValueOperation, LastValueIndexPatternColumn } from './last_value'; import { FrameDatasourceAPI, OperationMetadata } from '../../../types'; import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types'; import { IndexPattern, IndexPatternField, IndexPatternLayer } from '../../types'; -import { DateRange } from '../../../../common'; +import { DateRange, LayerType } from '../../../../common'; import { ExpressionAstFunction } from '../../../../../../../src/plugins/expressions/public'; import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; import { RangeIndexPatternColumn, rangeOperation } from './ranges'; @@ -259,7 +259,11 @@ interface BaseOperationDefinitionProps { * but disable it from usage, this function returns the string describing * the status. Otherwise it returns undefined */ - getDisabledStatus?: (indexPattern: IndexPattern, layer: IndexPatternLayer) => string | undefined; + getDisabledStatus?: ( + indexPattern: IndexPattern, + layer: IndexPatternLayer, + layerType?: LayerType + ) => string | undefined; /** * Validate that the operation has the right preconditions in the state. For example: * diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx index 2db4d5e4b77421..77af42ab418883 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx @@ -322,15 +322,15 @@ describe('last_value', () => { it('should return disabledStatus if indexPattern does contain date field', () => { const indexPattern = createMockedIndexPattern(); - expect(lastValueOperation.getDisabledStatus!(indexPattern, layer)).toEqual(undefined); + expect(lastValueOperation.getDisabledStatus!(indexPattern, layer, 'data')).toEqual(undefined); const indexPatternWithoutTimeFieldName = { ...indexPattern, timeFieldName: undefined, }; - expect(lastValueOperation.getDisabledStatus!(indexPatternWithoutTimeFieldName, layer)).toEqual( - undefined - ); + expect( + lastValueOperation.getDisabledStatus!(indexPatternWithoutTimeFieldName, layer, 'data') + ).toEqual(undefined); const indexPatternWithoutTimefields = { ...indexPatternWithoutTimeFieldName, @@ -339,7 +339,8 @@ describe('last_value', () => { const disabledStatus = lastValueOperation.getDisabledStatus!( indexPatternWithoutTimefields, - layer + layer, + 'data' ); expect(disabledStatus).toEqual( 'This function requires the presence of a date field in your index' diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 232843171016ae..11c8206fee0213 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -51,6 +51,7 @@ interface ColumnChange { targetGroup?: string; shouldResetLabel?: boolean; incompleteParams?: ColumnAdvancedParams; + initialParams?: { params: Record }; // TODO: bind this to the op parameter } interface ColumnCopy { @@ -398,6 +399,7 @@ export function replaceColumn({ if (previousDefinition.input === 'managedReference') { // If the transition is incomplete, leave the managed state until it's finished. tempLayer = deleteColumn({ layer: tempLayer, columnId, indexPattern }); + const hypotheticalLayer = insertNewColumn({ layer: tempLayer, columnId, diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx index 10575f37dba6e0..36ae3904f073c2 100644 --- a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx @@ -11,6 +11,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; import type { IFieldFormat } from '../../../../../src/plugins/field_formats/common'; +import { layerTypes } from '../../common'; import type { LensMultiTable } from '../../common'; function sampleArgs() { @@ -37,6 +38,7 @@ function sampleArgs() { const args: MetricConfig = { accessor: 'c', layerId: 'l1', + layerType: layerTypes.DATA, title: 'My fanci metric chart', description: 'Fancy chart description', metricTitle: 'My fanci metric chart', @@ -46,6 +48,7 @@ function sampleArgs() { const noAttributesArgs: MetricConfig = { accessor: 'c', layerId: 'l1', + layerType: layerTypes.DATA, title: '', description: '', metricTitle: 'My fanci metric chart', diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts index 7a3119d81d65c4..82e9a901d5041d 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts @@ -106,6 +106,7 @@ describe('metric_suggestions', () => { "state": Object { "accessor": "bytes", "layerId": "l1", + "layerType": "data", }, "title": "Avg bytes", } diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts index d07dccb7701962..de79f5f0a4cbc0 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts @@ -6,7 +6,8 @@ */ import { SuggestionRequest, VisualizationSuggestion, TableSuggestion } from '../types'; -import type { MetricState } from '../../common/expressions'; +import { MetricState } from '../../common/expressions'; +import { layerTypes } from '../../common'; import { LensIconChartMetric } from '../assets/chart_metric'; /** @@ -49,6 +50,7 @@ function getSuggestion(table: TableSuggestion): VisualizationSuggestion { Object { "accessor": undefined, "layerId": "test-id1", + "layerType": "data", } `); }); @@ -62,6 +65,7 @@ describe('metric_visualization', () => { expect(metricVisualization.clearLayer(exampleState(), 'l1')).toEqual({ accessor: undefined, layerId: 'l1', + layerType: layerTypes.DATA, }); }); }); @@ -73,6 +77,7 @@ describe('metric_visualization', () => { state: { accessor: undefined, layerId: 'l1', + layerType: layerTypes.DATA, }, layerId: 'l1', frame: mockFrame(), @@ -92,6 +97,7 @@ describe('metric_visualization', () => { state: { accessor: 'a', layerId: 'l1', + layerType: layerTypes.DATA, }, layerId: 'l1', frame: mockFrame(), @@ -113,14 +119,17 @@ describe('metric_visualization', () => { prevState: { accessor: undefined, layerId: 'l1', + layerType: layerTypes.DATA, }, layerId: 'l1', groupId: '', columnId: 'newDimension', + frame: mockFrame(), }) ).toEqual({ accessor: 'newDimension', layerId: 'l1', + layerType: layerTypes.DATA, }); }); }); @@ -132,17 +141,33 @@ describe('metric_visualization', () => { prevState: { accessor: 'a', layerId: 'l1', + layerType: layerTypes.DATA, }, layerId: 'l1', columnId: 'a', + frame: mockFrame(), }) ).toEqual({ accessor: undefined, layerId: 'l1', + layerType: layerTypes.DATA, }); }); }); + describe('#getSupportedLayers', () => { + it('should return a single layer type', () => { + expect(metricVisualization.getSupportedLayers()).toHaveLength(1); + }); + }); + + describe('#getLayerType', () => { + it('should return the type only if the layer is in the state', () => { + expect(metricVisualization.getLayerType('l1', exampleState())).toEqual(layerTypes.DATA); + expect(metricVisualization.getLayerType('foo', exampleState())).toBeUndefined(); + }); + }); + describe('#toExpression', () => { it('should map to a valid AST', () => { const datasource: DatasourcePublicAPI = { diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index d312030b5a490d..72aa3550e30dd1 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -11,6 +11,7 @@ import { getSuggestions } from './metric_suggestions'; import { LensIconChartMetric } from '../assets/chart_metric'; import { Visualization, OperationMetadata, DatasourcePublicAPI } from '../types'; import type { MetricState } from '../../common/expressions'; +import { layerTypes } from '../../common'; const toExpression = ( state: MetricState, @@ -90,6 +91,7 @@ export const metricVisualization: Visualization = { state || { layerId: addNewLayer(), accessor: undefined, + layerType: layerTypes.DATA, } ); }, @@ -109,6 +111,23 @@ export const metricVisualization: Visualization = { }; }, + getSupportedLayers() { + return [ + { + type: layerTypes.DATA, + label: i18n.translate('xpack.lens.metric.addLayer', { + defaultMessage: 'Add visualization layer', + }), + }, + ]; + }, + + getLayerType(layerId, state) { + if (state?.layerId === layerId) { + return state.layerType; + } + }, + toExpression, toPreviewExpression: (state, datasourceLayers) => toExpression(state, datasourceLayers, { mode: 'reduced' }), diff --git a/x-pack/plugins/lens/public/mocks.tsx b/x-pack/plugins/lens/public/mocks.tsx index 611b50b413b716..03f03e2f3826cd 100644 --- a/x-pack/plugins/lens/public/mocks.tsx +++ b/x-pack/plugins/lens/public/mocks.tsx @@ -20,8 +20,8 @@ import { DeepPartial } from '@reduxjs/toolkit'; import { LensPublicStart } from '.'; import { visualizationTypes } from './xy_visualization/types'; import { navigationPluginMock } from '../../../../src/plugins/navigation/public/mocks'; -import type { LensAppServices } from './app_plugin/types'; -import { DOC_TYPE } from '../common'; +import { LensAppServices } from './app_plugin/types'; +import { DOC_TYPE, layerTypes } from '../common'; import { DataPublicPluginStart, esFilters, UI_SETTINGS } from '../../../../src/plugins/data/public'; import { dashboardPluginMock } from '../../../../src/plugins/dashboard/public/mocks'; import type { @@ -63,6 +63,8 @@ export function createMockVisualization(): jest.Mocked { clearLayer: jest.fn((state, _layerId) => state), removeLayer: jest.fn(), getLayerIds: jest.fn((_state) => ['layer1']), + getSupportedLayers: jest.fn(() => [{ type: layerTypes.DATA, label: 'Data Layer' }]), + getLayerType: jest.fn((_state, _layerId) => layerTypes.DATA), visualizationTypes: [ { icon: 'empty', diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts index 36470fa3d74cf8..affc74d8b70cd9 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -8,7 +8,8 @@ import { PaletteOutput } from 'src/plugins/charts/public'; import { DataType, SuggestionRequest } from '../types'; import { suggestions } from './suggestions'; -import type { PieVisualizationState } from '../../common/expressions'; +import { PieVisualizationState } from '../../common/expressions'; +import { layerTypes } from '../../common'; describe('suggestions', () => { describe('pie', () => { @@ -56,6 +57,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: [], metric: 'a', numberDisplay: 'hidden', @@ -484,6 +486,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: ['a'], metric: 'b', @@ -505,6 +508,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: ['a'], metric: 'b', @@ -536,6 +540,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: [], metric: 'a', @@ -585,6 +590,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: ['a', 'b'], metric: 'e', numberDisplay: 'value', @@ -633,6 +639,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: ['a', 'b'], metric: 'e', numberDisplay: 'percent', @@ -669,6 +676,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: ['a'], metric: 'b', @@ -689,6 +697,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: ['a'], metric: 'b', diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts index 22be8e3357bbbb..9078e18588a2f2 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -8,6 +8,7 @@ import { partition } from 'lodash'; import { i18n } from '@kbn/i18n'; import type { SuggestionRequest, VisualizationSuggestion } from '../types'; +import { layerTypes } from '../../common'; import type { PieVisualizationState } from '../../common/expressions'; import { CHART_NAMES, MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS } from './constants'; @@ -75,6 +76,7 @@ export function suggestions({ layerId: table.layerId, groups: groups.map((col) => col.columnId), metric: metricColumnId, + layerType: layerTypes.DATA, } : { layerId: table.layerId, @@ -84,6 +86,7 @@ export function suggestions({ categoryDisplay: 'default', legendDisplay: 'default', nestedLegend: false, + layerType: layerTypes.DATA, }, ], }, @@ -134,6 +137,7 @@ export function suggestions({ state.layers[0].categoryDisplay === 'inside' ? 'default' : state.layers[0].categoryDisplay, + layerType: layerTypes.DATA, } : { layerId: table.layerId, @@ -143,6 +147,7 @@ export function suggestions({ categoryDisplay: 'default', legendDisplay: 'default', nestedLegend: false, + layerType: layerTypes.DATA, }, ], }, diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts index 07a4161e7d239b..cdbd4802976279 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts @@ -7,7 +7,10 @@ import { getPieVisualization } from './visualization'; import type { PieVisualizationState } from '../../common/expressions'; +import { layerTypes } from '../../common'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; +import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; +import { FramePublicAPI } from '../types'; jest.mock('../id_generator'); @@ -23,6 +26,7 @@ function getExampleState(): PieVisualizationState { layers: [ { layerId: LAYER_ID, + layerType: layerTypes.DATA, groups: [], metric: undefined, numberDisplay: 'percent', @@ -34,6 +38,16 @@ function getExampleState(): PieVisualizationState { }; } +function mockFrame(): FramePublicAPI { + return { + ...createMockFramePublicAPI(), + datasourceLayers: { + l1: createMockDatasource('l1').publicAPIMock, + l42: createMockDatasource('l42').publicAPIMock, + }, + }; +} + // Just a basic bootstrap here to kickstart the tests describe('pie_visualization', () => { describe('#getErrorMessages', () => { @@ -43,6 +57,20 @@ describe('pie_visualization', () => { expect(error).not.toBeDefined(); }); }); + + describe('#getSupportedLayers', () => { + it('should return a single layer type', () => { + expect(pieVisualization.getSupportedLayers()).toHaveLength(1); + }); + }); + + describe('#getLayerType', () => { + it('should return the type only if the layer is in the state', () => { + expect(pieVisualization.getLayerType(LAYER_ID, getExampleState())).toEqual(layerTypes.DATA); + expect(pieVisualization.getLayerType('foo', getExampleState())).toBeUndefined(); + }); + }); + describe('#setDimension', () => { it('returns expected state', () => { const prevState: PieVisualizationState = { @@ -50,6 +78,7 @@ describe('pie_visualization', () => { { groups: ['a'], layerId: LAYER_ID, + layerType: layerTypes.DATA, numberDisplay: 'percent', categoryDisplay: 'default', legendDisplay: 'default', @@ -64,6 +93,7 @@ describe('pie_visualization', () => { columnId: 'x', layerId: LAYER_ID, groupId: 'a', + frame: mockFrame(), }); expect(setDimensionResult).toEqual( diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index 5d75d82220d1fe..ea89ef0bfb8549 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -13,6 +13,7 @@ import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { Visualization, OperationMetadata, AccessorConfig } from '../types'; import { toExpression, toPreviewExpression } from './to_expression'; import type { PieLayerState, PieVisualizationState } from '../../common/expressions'; +import { layerTypes } from '../../common'; import { suggestions } from './suggestions'; import { CHART_NAMES, MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS } from './constants'; import { DimensionEditor, PieToolbar } from './toolbar'; @@ -26,6 +27,7 @@ function newLayerState(layerId: string): PieLayerState { categoryDisplay: 'default', legendDisplay: 'default', nestedLegend: false, + layerType: layerTypes.DATA, }; } @@ -231,6 +233,21 @@ export const getPieVisualization = ({ ); }, + getSupportedLayers() { + return [ + { + type: layerTypes.DATA, + label: i18n.translate('xpack.lens.pie.addLayer', { + defaultMessage: 'Add visualization layer', + }), + }, + ]; + }, + + getLayerType(layerId, state) { + return state?.layers.find(({ layerId: id }) => id === layerId)?.layerType; + }, + toExpression: (state, layers, attributes) => toExpression(state, layers, paletteService, attributes), toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService), diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index db17154e3bbd2f..bf576cb65c6887 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -18,7 +18,7 @@ import { Datatable, } from '../../../../src/plugins/expressions/public'; import { DraggingIdentifier, DragDropIdentifier, DragContextState } from './drag_drop'; -import { DateRange } from '../common'; +import type { DateRange, LayerType } from '../common'; import { Query, Filter } from '../../../../src/plugins/data/public'; import { VisualizeFieldContext } from '../../../../src/plugins/ui_actions/public'; import { RangeSelectContext, ValueClickContext } from '../../../../src/plugins/embeddable/public'; @@ -175,6 +175,17 @@ export interface Datasource { clearLayer: (state: T, layerId: string) => T; getLayers: (state: T) => string[]; removeColumn: (props: { prevState: T; layerId: string; columnId: string }) => T; + initializeDimension?: ( + state: T, + layerId: string, + value: { + columnId: string; + label: string; + dataType: string; + staticValue?: unknown; + groupId: string; + } + ) => T; renderDataPanel: ( domElement: Element, @@ -320,6 +331,7 @@ export type DatasourceDimensionEditorProps = DatasourceDimensionPro dimensionGroups: VisualizationDimensionGroupConfig[]; toggleFullscreen: () => void; isFullscreen: boolean; + layerType: LayerType | undefined; }; export type DatasourceDimensionTriggerProps = DatasourceDimensionProps; @@ -449,6 +461,7 @@ interface VisualizationDimensionChangeProps { layerId: string; columnId: string; prevState: T; + frame: Pick; } /** @@ -601,20 +614,42 @@ export interface Visualization { /** Optional, if the visualization supports multiple layers */ removeLayer?: (state: T, layerId: string) => T; /** Track added layers in internal state */ - appendLayer?: (state: T, layerId: string) => T; + appendLayer?: (state: T, layerId: string, type: LayerType) => T; + + /** Retrieve a list of supported layer types with initialization data */ + getSupportedLayers: ( + state?: T, + frame?: Pick + ) => Array<{ + type: LayerType; + label: string; + icon?: IconType; + disabled?: boolean; + tooltipContent?: string; + initialDimensions?: Array<{ + groupId: string; + columnId: string; + dataType: string; + label: string; + staticValue: unknown; + }>; + }>; + getLayerType: (layerId: string, state?: T) => LayerType | undefined; + /* returns the type of removal operation to perform for the specific layer in the current state */ + getRemoveOperation?: (state: T, layerId: string) => 'remove' | 'clear'; /** * For consistency across different visualizations, the dimension configuration UI is standardized */ getConfiguration: ( props: VisualizationConfigProps - ) => { groups: VisualizationDimensionGroupConfig[] }; + ) => { groups: VisualizationDimensionGroupConfig[]; supportStaticValue?: boolean }; /** - * Popover contents that open when the user clicks the contextMenuIcon. This can be used - * for extra configurability, such as for styling the legend or axis + * Header rendered as layer title This can be used for both static and dynamic content lioke + * for extra configurability, such as for switch chart type */ - renderLayerContextMenu?: ( + renderLayerHeader?: ( domElement: Element, props: VisualizationLayerWidgetProps ) => ((cleanupElement: Element) => void) | void; @@ -626,14 +661,6 @@ export interface Visualization { domElement: Element, props: VisualizationToolbarProps ) => ((cleanupElement: Element) => void) | void; - /** - * Visualizations can provide a custom icon which will open a layer-specific popover - * If no icon is provided, gear icon is default - */ - getLayerContextMenuIcon?: (opts: { - state: T; - layerId: string; - }) => { icon: IconType | 'gear'; label: string } | undefined; /** * The frame is telling the visualization to update or set a dimension based on user interaction diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index 3bd0e9354c1580..9846e92b07bf8c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -107,6 +107,9 @@ Object { "layerId": Array [ "first", ], + "layerType": Array [ + "data", + ], "seriesType": Array [ "area", ], diff --git a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts index 873827700d6e8b..355374165c7886 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts @@ -6,6 +6,7 @@ */ import { LayerArgs } from '../../common/expressions'; +import { layerTypes } from '../../common'; import { Datatable } from '../../../../../src/plugins/expressions/public'; import { getAxesConfiguration } from './axes_configuration'; @@ -220,6 +221,7 @@ describe('axes_configuration', () => { const sampleLayer: LayerArgs = { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'c', accessors: ['yAccessorId'], diff --git a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx index 7609c534711d06..aa287795c81817 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { shallowWithIntl as shallow } from '@kbn/test/jest'; import { AxisSettingsPopover, AxisSettingsPopoverProps } from './axis_settings_popover'; import { ToolbarPopover } from '../shared_components'; +import { layerTypes } from '../../common'; describe('Axes Settings', () => { let props: AxisSettingsPopoverProps; @@ -17,6 +18,7 @@ describe('Axes Settings', () => { layers: [ { seriesType: 'bar', + layerType: layerTypes.DATA, layerId: 'first', splitAccessor: 'baz', xAccessor: 'foo', diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts index 390eded97d705b..4157eabfad82da 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts @@ -8,6 +8,7 @@ import { getColorAssignments } from './color_assignment'; import type { FormatFactory, LensMultiTable } from '../../common'; import type { LayerArgs } from '../../common/expressions'; +import { layerTypes } from '../../common'; describe('color_assignment', () => { const layers: LayerArgs[] = [ @@ -18,6 +19,7 @@ describe('color_assignment', () => { seriesType: 'bar', palette: { type: 'palette', name: 'palette1' }, layerId: '1', + layerType: layerTypes.DATA, splitAccessor: 'split1', accessors: ['y1', 'y2'], }, @@ -28,6 +30,7 @@ describe('color_assignment', () => { seriesType: 'bar', palette: { type: 'palette', name: 'palette2' }, layerId: '2', + layerType: layerTypes.DATA, splitAccessor: 'split2', accessors: ['y3', 'y4'], }, diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 94ed5037000422..a41ad59ebee930 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -24,6 +24,7 @@ import { import { PaletteOutput } from 'src/plugins/charts/public'; import { calculateMinInterval, XYChart, XYChartRenderProps, xyChart } from './expression'; import type { LensMultiTable } from '../../common'; +import { layerTypes } from '../../common'; import { layerConfig, legendConfig, @@ -208,6 +209,7 @@ const dateHistogramData: LensMultiTable = { const dateHistogramLayer: LayerArgs = { layerId: 'timeLayer', + layerType: layerTypes.DATA, hide: false, xAccessor: 'xAccessorId', yScaleType: 'linear', @@ -249,6 +251,7 @@ const createSampleDatatableWithRows = (rows: DatatableRow[]): Datatable => ({ const sampleLayer: LayerArgs = { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'c', accessors: ['a', 'b'], @@ -345,6 +348,7 @@ describe('xy_expression', () => { test('layerConfig produces the correct arguments', () => { const args: LayerArgs = { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'c', accessors: ['a', 'b'], @@ -506,6 +510,7 @@ describe('xy_expression', () => { describe('date range', () => { const timeSampleLayer: LayerArgs = { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'c', accessors: ['a', 'b'], @@ -984,6 +989,7 @@ describe('xy_expression', () => { const numberLayer: LayerArgs = { layerId: 'numberLayer', + layerType: layerTypes.DATA, hide: false, xAccessor: 'xAccessorId', yScaleType: 'linear', @@ -1089,6 +1095,7 @@ describe('xy_expression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, isHistogram: true, seriesType: 'bar_stacked', xAccessor: 'b', @@ -1177,6 +1184,7 @@ describe('xy_expression', () => { const numberLayer: LayerArgs = { layerId: 'numberLayer', + layerType: layerTypes.DATA, hide: false, xAccessor: 'xAccessorId', yScaleType: 'linear', @@ -1295,6 +1303,7 @@ describe('xy_expression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'd', accessors: ['a', 'b'], @@ -2140,6 +2149,7 @@ describe('xy_expression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'a', accessors: ['c'], @@ -2152,6 +2162,7 @@ describe('xy_expression', () => { }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'a', accessors: ['c'], @@ -2228,6 +2239,7 @@ describe('xy_expression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'a', accessors: ['c'], @@ -2302,6 +2314,7 @@ describe('xy_expression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'a', accessors: ['c'], @@ -2535,6 +2548,7 @@ describe('xy_expression', () => { }; const timeSampleLayer: LayerArgs = { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'c', accessors: ['a', 'b'], diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 9ef87fe4f48d4a..83ef8af4bec9ce 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -40,7 +40,8 @@ import { i18n } from '@kbn/i18n'; import { RenderMode } from 'src/plugins/expressions'; import type { ILensInterpreterRenderHandlers, LensFilterEvent, LensBrushEvent } from '../types'; import type { LensMultiTable, FormatFactory } from '../../common'; -import { LayerArgs, SeriesType, XYChartProps } from '../../common/expressions'; +import { layerTypes } from '../../common'; +import type { LayerArgs, SeriesType, XYChartProps } from '../../common/expressions'; import { visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart, getSeriesColor } from './state_helpers'; @@ -316,7 +317,7 @@ export function XYChart({ const isHistogramViz = filteredLayers.every((l) => l.isHistogram); const { baseDomain: rawXDomain, extendedDomain: xDomain } = getXDomain( - layers, + filteredLayers, data, minInterval, Boolean(isTimeViz), @@ -842,17 +843,20 @@ export function XYChart({ } function getFilteredLayers(layers: LayerArgs[], data: LensMultiTable) { - return layers.filter(({ layerId, xAccessor, accessors, splitAccessor }) => { - return !( - !accessors.length || - !data.tables[layerId] || - data.tables[layerId].rows.length === 0 || - (xAccessor && - data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || - // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty - (!xAccessor && - splitAccessor && - data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) + return layers.filter(({ layerId, xAccessor, accessors, splitAccessor, layerType }) => { + return ( + layerType === layerTypes.DATA && + !( + !accessors.length || + !data.tables[layerId] || + data.tables[layerId].rows.length === 0 || + (xAccessor && + data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || + // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty + (!xAccessor && + splitAccessor && + data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) + ) ); }); } diff --git a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx index e3489ae7808af0..700aaf91ad5cbc 100644 --- a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx @@ -11,12 +11,14 @@ import { EuiPopover } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test/jest'; import { ComponentType, ReactWrapper } from 'enzyme'; import type { LensMultiTable } from '../../common'; +import { layerTypes } from '../../common'; import type { LayerArgs } from '../../common/expressions'; import { getLegendAction } from './get_legend_action'; import { LegendActionPopover } from '../shared_components'; const sampleLayer = { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'c', accessors: ['a', 'b'], diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index 621e2897a10597..5ce44db1c4db59 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -11,6 +11,7 @@ import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks' import { getXyVisualization } from './xy_visualization'; import { Operation } from '../types'; import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; +import { layerTypes } from '../../common'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; describe('#toExpression', () => { @@ -65,6 +66,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -87,6 +89,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -108,6 +111,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -135,6 +139,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: undefined, xAccessor: undefined, @@ -159,6 +164,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: undefined, xAccessor: 'a', @@ -180,6 +186,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -216,6 +223,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -243,6 +251,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -268,6 +277,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -295,6 +305,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index dfad8334ab76ae..3f396df4b99a97 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -11,7 +11,8 @@ import { PaletteRegistry } from 'src/plugins/charts/public'; import { State } from './types'; import { OperationMetadata, DatasourcePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; -import { ValidLayer, XYLayerConfig } from '../../common/expressions'; +import type { ValidLayer, XYLayerConfig } from '../../common/expressions'; +import { layerTypes } from '../../common'; export const getSortedAccessors = (datasource: DatasourcePublicAPI, layer: XYLayerConfig) => { const originalOrder = datasource @@ -325,6 +326,7 @@ export const buildExpression = ( })) : [], seriesType: [layer.seriesType], + layerType: [layer.layerType || layerTypes.DATA], accessors: layer.accessors, columnToLabel: [JSON.stringify(columnToLabel)], ...(layer.palette diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx index b4c8e8f40dde7e..cd6a20c37dd385 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx @@ -15,6 +15,7 @@ import { VisualOptionsPopover } from './visual_options_popover'; import { ToolbarPopover } from '../../shared_components'; import { MissingValuesOptions } from './missing_values_option'; import { FillOpacityOption } from './fill_opacity_option'; +import { layerTypes } from '../../../common'; describe('Visual options popover', () => { let frame: FramePublicAPI; @@ -27,6 +28,7 @@ describe('Visual options popover', () => { layers: [ { seriesType: 'bar', + layerType: layerTypes.DATA, layerId: 'first', splitAccessor: 'baz', xAccessor: 'foo', @@ -231,6 +233,7 @@ describe('Visual options popover', () => { { ...state.layers[0], seriesType: 'bar' }, { seriesType: 'bar', + layerType: layerTypes.DATA, layerId: 'second', splitAccessor: 'baz', xAccessor: 'foo', diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index ef97e2622ee82b..14a13fbb0f3bbe 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -10,6 +10,7 @@ import { Position } from '@elastic/charts'; import { Operation } from '../types'; import type { State } from './types'; import type { SeriesType, XYLayerConfig } from '../../common/expressions'; +import { layerTypes } from '../../common'; import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { LensIconChartBar } from '../assets/chart_bar'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; @@ -23,6 +24,7 @@ function exampleState(): State { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -145,6 +147,7 @@ describe('xy_visualization', () => { Object { "accessors": Array [], "layerId": "l1", + "layerType": "data", "position": "top", "seriesType": "bar_stacked", "showGridlines": false, @@ -174,6 +177,7 @@ describe('xy_visualization', () => { ...exampleState().layers, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'e', xAccessor: 'f', @@ -188,7 +192,7 @@ describe('xy_visualization', () => { describe('#appendLayer', () => { it('adds a layer', () => { - const layers = xyVisualization.appendLayer!(exampleState(), 'foo').layers; + const layers = xyVisualization.appendLayer!(exampleState(), 'foo', layerTypes.DATA).layers; expect(layers.length).toEqual(exampleState().layers.length + 1); expect(layers[layers.length - 1]).toMatchObject({ layerId: 'foo' }); }); @@ -211,15 +215,61 @@ describe('xy_visualization', () => { }); }); + describe('#getSupportedLayers', () => { + it('should return a single layer type', () => { + expect(xyVisualization.getSupportedLayers()).toHaveLength(1); + }); + + it('should return the icon for the visualization type', () => { + expect(xyVisualization.getSupportedLayers()[0].icon).not.toBeUndefined(); + }); + }); + + describe('#getLayerType', () => { + it('should return the type only if the layer is in the state', () => { + expect(xyVisualization.getLayerType('first', exampleState())).toEqual(layerTypes.DATA); + expect(xyVisualization.getLayerType('foo', exampleState())).toBeUndefined(); + }); + }); + describe('#setDimension', () => { + let mockDatasource: ReturnType; + let frame: ReturnType; + + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'd' }, + { columnId: 'a' }, + { columnId: 'b' }, + { columnId: 'c' }, + ]); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + }); + it('sets the x axis', () => { expect( xyVisualization.setDimension({ + frame, prevState: { ...exampleState(), layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -232,6 +282,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'newCol', accessors: [], @@ -241,11 +292,13 @@ describe('xy_visualization', () => { it('replaces the x axis', () => { expect( xyVisualization.setDimension({ + frame, prevState: { ...exampleState(), layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], @@ -258,6 +311,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'newCol', accessors: [], @@ -266,14 +320,43 @@ describe('xy_visualization', () => { }); describe('#removeDimension', () => { + let mockDatasource: ReturnType; + let frame: ReturnType; + + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'd' }, + { columnId: 'a' }, + { columnId: 'b' }, + { columnId: 'c' }, + ]); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + }); + it('removes the x axis', () => { expect( xyVisualization.removeDimension({ + frame, prevState: { ...exampleState(), layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], @@ -285,6 +368,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -609,6 +693,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], @@ -624,12 +709,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], @@ -645,12 +732,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['a'], }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: ['a'], @@ -667,6 +756,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -682,6 +772,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -689,6 +780,7 @@ describe('xy_visualization', () => { }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -705,12 +797,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: ['a'], @@ -731,12 +825,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['a'], }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -744,6 +840,7 @@ describe('xy_visualization', () => { }, { layerId: 'third', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -765,18 +862,21 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['a'], }, { layerId: 'third', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['a'], @@ -799,6 +899,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -846,6 +947,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -853,6 +955,7 @@ describe('xy_visualization', () => { }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'e', @@ -900,6 +1003,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -907,6 +1011,7 @@ describe('xy_visualization', () => { }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'e', @@ -970,6 +1075,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['b'], diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 799246ef26b806..0a4b18f554f316 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { getSuggestions } from './xy_suggestions'; -import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; +import { XyToolbar, DimensionEditor, LayerHeader } from './xy_config_panel'; import type { Visualization, OperationMetadata, @@ -23,7 +23,8 @@ import type { DatasourcePublicAPI, } from '../types'; import { State, visualizationTypes, XYState } from './types'; -import type { SeriesType, XYLayerConfig } from '../../common/expressions'; +import { SeriesType, XYLayerConfig } from '../../common/expressions'; +import { LayerType, layerTypes } from '../../common'; import { isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; import { LensIconChartBarStacked } from '../assets/chart_bar_stacked'; @@ -101,7 +102,12 @@ export const getXyVisualization = ({ }, getLayerIds(state) { - return state.layers.map((l) => l.layerId); + return getLayersByType(state).map((l) => l.layerId); + }, + + getRemoveOperation(state, layerId) { + const dataLayers = getLayersByType(state, layerTypes.DATA).map((l) => l.layerId); + return dataLayers.includes(layerId) && dataLayers.length === 1 ? 'clear' : 'remove'; }, removeLayer(state, layerId) { @@ -111,7 +117,7 @@ export const getXyVisualization = ({ }; }, - appendLayer(state, layerId) { + appendLayer(state, layerId, layerType) { const usedSeriesTypes = uniq(state.layers.map((layer) => layer.seriesType)); return { ...state, @@ -119,7 +125,8 @@ export const getXyVisualization = ({ ...state.layers, newLayerState( usedSeriesTypes.length === 1 ? usedSeriesTypes[0] : state.preferredSeriesType, - layerId + layerId, + layerType ), ], }; @@ -167,16 +174,35 @@ export const getXyVisualization = ({ position: Position.Top, seriesType: defaultSeriesType, showGridlines: false, + layerType: layerTypes.DATA, }, ], } ); }, + getLayerType(layerId, state) { + return state?.layers.find(({ layerId: id }) => id === layerId)?.layerType; + }, + + getSupportedLayers(state, frame) { + const layers = [ + { + type: layerTypes.DATA, + label: i18n.translate('xpack.lens.xyChart.addDataLayerLabel', { + defaultMessage: 'Add visualization layer', + }), + icon: LensIconChartMixedXy, + }, + ]; + + return layers; + }, + getConfiguration({ state, frame, layerId }) { const layer = state.layers.find((l) => l.layerId === layerId); if (!layer) { - return { groups: [] }; + return { groups: [], supportStaticValue: true }; } const datasource = frame.datasourceLayers[layer.layerId]; @@ -204,6 +230,14 @@ export const getXyVisualization = ({ } const isHorizontal = isHorizontalChart(state.layers); + const isDataLayer = !layer.layerType || layer.layerType === layerTypes.DATA; + + if (!isDataLayer) { + return { + groups: [], + }; + } + return { groups: [ { @@ -261,7 +295,6 @@ export const getXyVisualization = ({ return prevState; } const newLayer = { ...foundLayer }; - if (groupId === 'x') { newLayer.xAccessor = columnId; } @@ -278,7 +311,7 @@ export const getXyVisualization = ({ }; }, - removeDimension({ prevState, layerId, columnId }) { + removeDimension({ prevState, layerId, columnId, frame }) { const foundLayer = prevState.layers.find((l) => l.layerId === layerId); if (!foundLayer) { return prevState; @@ -298,25 +331,18 @@ export const getXyVisualization = ({ newLayer.yConfig = newLayer.yConfig.filter(({ forAccessor }) => forAccessor !== columnId); } - return { - ...prevState, - layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)), - }; - }, + const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); - getLayerContextMenuIcon({ state, layerId }) { - const layer = state.layers.find((l) => l.layerId === layerId); - const visualizationType = visualizationTypes.find((t) => t.id === layer?.seriesType); return { - icon: visualizationType?.icon || 'gear', - label: visualizationType?.label || '', + ...prevState, + layers: newLayers, }; }, - renderLayerContextMenu(domElement, props) { + renderLayerHeader(domElement, props) { render( - + , domElement ); @@ -370,8 +396,9 @@ export const getXyVisualization = ({ // filter out those layers with no accessors at all const filteredLayers = state.layers.filter( - ({ accessors, xAccessor, splitAccessor }: XYLayerConfig) => - accessors.length > 0 || xAccessor != null || splitAccessor != null + ({ accessors, xAccessor, splitAccessor, layerType }: XYLayerConfig) => + layerType === layerTypes.DATA && + (accessors.length > 0 || xAccessor != null || splitAccessor != null) ); for (const [dimension, criteria] of checks) { const result = validateLayersForDimension(dimension, filteredLayers, criteria); @@ -526,11 +553,16 @@ function getMessageIdsForDimension(dimension: string, layers: number[], isHorizo return { shortMessage: '', longMessage: '' }; } -function newLayerState(seriesType: SeriesType, layerId: string): XYLayerConfig { +function newLayerState( + seriesType: SeriesType, + layerId: string, + layerType: LayerType = layerTypes.DATA +): XYLayerConfig { return { layerId, seriesType, accessors: [], + layerType, }; } @@ -603,3 +635,9 @@ function checkScaleOperation( ); }; } + +function getLayersByType(state: State, byType?: string) { + return state.layers.filter(({ layerType = layerTypes.DATA }) => + byType ? layerType === byType : true + ); +} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index 9292a8d87bbc48..9ca9021382fdaf 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -16,6 +16,7 @@ import { Position } from '@elastic/charts'; import { createMockFramePublicAPI, createMockDatasource } from '../mocks'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; import { EuiColorPicker } from '@elastic/eui'; +import { layerTypes } from '../../common'; describe('XY Config panels', () => { let frame: FramePublicAPI; @@ -28,6 +29,7 @@ describe('XY Config panels', () => { layers: [ { seriesType: 'bar', + layerType: layerTypes.DATA, layerId: 'first', splitAccessor: 'baz', xAccessor: 'foo', @@ -319,6 +321,7 @@ describe('XY Config panels', () => { layers: [ { seriesType: 'bar', + layerType: layerTypes.DATA, layerId: 'first', splitAccessor: undefined, xAccessor: 'foo', @@ -358,6 +361,7 @@ describe('XY Config panels', () => { layers: [ { seriesType: 'bar', + layerType: layerTypes.DATA, layerId: 'first', splitAccessor: undefined, xAccessor: 'foo', diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index 129f2df895ef27..c386b22f241d0a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -20,6 +20,10 @@ import { EuiColorPickerProps, EuiToolTip, EuiIcon, + EuiPopover, + EuiSelectable, + EuiText, + EuiPopoverTitle, } from '@elastic/eui'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { @@ -30,7 +34,7 @@ import type { } from '../types'; import { State, visualizationTypes, XYState } from './types'; import type { FormatFactory } from '../../common'; -import type { +import { SeriesType, YAxisMode, AxesSettingsConfig, @@ -45,6 +49,7 @@ import { PalettePicker, TooltipWrapper } from '../shared_components'; import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; import { getScaleType, getSortedAccessors } from './to_expression'; import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover'; +import { ToolbarButton } from '../../../../../src/plugins/kibana_react/public'; type UnwrapArray = T extends Array ? P : T; type AxesSettingsConfigKeys = keyof AxesSettingsConfig; @@ -87,6 +92,90 @@ const legendOptions: Array<{ }, ]; +export function LayerHeader(props: VisualizationLayerWidgetProps) { + const [isPopoverOpen, setPopoverIsOpen] = useState(false); + const { state, layerId } = props; + const horizontalOnly = isHorizontalChart(state.layers); + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index]; + if (!layer) { + return null; + } + + const currentVisType = visualizationTypes.find(({ id }) => id === layer.seriesType)!; + + const createTrigger = function () { + return ( + setPopoverIsOpen(!isPopoverOpen)} + fullWidth + size="s" + > + <> + + + {currentVisType.fullLabel || currentVisType.label} + + + + ); + }; + + return ( + <> + setPopoverIsOpen(false)} + display="block" + panelPaddingSize="s" + ownFocus + > + + {i18n.translate('xpack.lens.layerPanel.layerVisualizationType', { + defaultMessage: 'Layer visualization type', + })} + +
+ + singleSelection="always" + options={visualizationTypes + .filter((t) => isHorizontalSeries(t.id as SeriesType) === horizontalOnly) + .map((t) => ({ + value: t.id, + key: t.id, + checked: t.id === currentVisType.id ? 'on' : undefined, + prepend: , + label: t.fullLabel || t.label, + 'data-test-subj': `lnsXY_seriesType-${t.id}`, + }))} + onChange={(newOptions) => { + const chosenType = newOptions.find(({ checked }) => checked === 'on'); + if (!chosenType) { + return; + } + const id = chosenType.value!; + trackUiEvent('xy_change_layer_display'); + props.setState(updateLayer(state, { ...layer, seriesType: id as SeriesType }, index)); + setPopoverIsOpen(false); + }} + > + {(list) => <>{list}} + +
+
+ + ); +} + export function LayerContextMenu(props: VisualizationLayerWidgetProps) { const { state, layerId } = props; const horizontalOnly = isHorizontalChart(state.layers); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts index 924b87647fcee5..36e69ab6cbf740 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts @@ -11,8 +11,9 @@ import { State, XYState, visualizationTypes } from './types'; import { generateId } from '../id_generator'; import { getXyVisualization } from './xy_visualization'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; +import { PaletteOutput } from 'src/plugins/charts/public'; +import { layerTypes } from '../../common'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; -import type { PaletteOutput } from 'src/plugins/charts/public'; jest.mock('../id_generator'); @@ -157,12 +158,14 @@ describe('xy_suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', accessors: ['bytes'], splitAccessor: undefined, }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'bar', accessors: ['bytes'], splitAccessor: undefined, @@ -270,6 +273,7 @@ describe('xy_suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', xAccessor: 'date', accessors: ['bytes'], @@ -311,6 +315,7 @@ describe('xy_suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', xAccessor: 'date', accessors: ['bytes'], @@ -318,6 +323,7 @@ describe('xy_suggestions', () => { }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'bar', xAccessor: undefined, accessors: [], @@ -547,6 +553,7 @@ describe('xy_suggestions', () => { { accessors: ['price', 'quantity'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'product', xAccessor: 'date', @@ -601,6 +608,7 @@ describe('xy_suggestions', () => { { accessors: [], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', splitAccessor: undefined, xAccessor: '', @@ -639,6 +647,7 @@ describe('xy_suggestions', () => { { accessors: ['price'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: undefined, xAccessor: 'date', @@ -681,6 +690,7 @@ describe('xy_suggestions', () => { { accessors: ['price', 'quantity'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'product', xAccessor: 'date', @@ -724,6 +734,7 @@ describe('xy_suggestions', () => { { accessors: ['price', 'quantity'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'dummyCol', xAccessor: 'product', @@ -757,6 +768,7 @@ describe('xy_suggestions', () => { { accessors: ['price'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'date', xAccessor: 'product', @@ -797,6 +809,7 @@ describe('xy_suggestions', () => { { accessors: ['price', 'quantity'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'dummyCol', xAccessor: 'product', @@ -841,6 +854,7 @@ describe('xy_suggestions', () => { { accessors: ['price'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'category', xAccessor: 'product', @@ -886,6 +900,7 @@ describe('xy_suggestions', () => { { accessors: ['price', 'quantity'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'dummyCol', xAccessor: 'product', diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index dfa06464043883..2e275c455a4d03 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -18,6 +18,7 @@ import { } from '../types'; import { State, XYState, visualizationTypes } from './types'; import type { SeriesType, XYLayerConfig } from '../../common/expressions'; +import { layerTypes } from '../../common'; import { getIconForSeries } from './state_helpers'; const columnSortOrder = { @@ -504,6 +505,7 @@ function buildSuggestion({ 'yConfig' in existingLayer && existingLayer.yConfig ? existingLayer.yConfig.filter(({ forAccessor }) => accessors.indexOf(forAccessor) !== -1) : undefined, + layerType: layerTypes.DATA, }; // Maintain consistent order for any layers that were saved diff --git a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts index 6f1ec38ea951ad..14a9713d8461ec 100644 --- a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts @@ -11,8 +11,14 @@ import { DOC_TYPE } from '../../common'; import { commonRemoveTimezoneDateHistogramParam, commonRenameOperationsForFormula, + commonUpdateVisLayerType, } from '../migrations/common_migrations'; -import { LensDocShape713, LensDocShapePre712 } from '../migrations/types'; +import { + LensDocShape713, + LensDocShape715, + LensDocShapePre712, + VisStatePre715, +} from '../migrations/types'; export const lensEmbeddableFactory = (): EmbeddableRegistryDefinition => { return { @@ -35,6 +41,14 @@ export const lensEmbeddableFactory = (): EmbeddableRegistryDefinition => { attributes: migratedLensState, } as unknown) as SerializableRecord; }, + '7.15.0': (state) => { + const lensState = (state as unknown) as { attributes: LensDocShape715 }; + const migratedLensState = commonUpdateVisLayerType(lensState.attributes); + return ({ + ...lensState, + attributes: migratedLensState, + } as unknown) as SerializableRecord; + }, }, }; }; diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.ts b/x-pack/plugins/lens/server/migrations/common_migrations.ts index db19de7fd9c07f..fda4300e03ea94 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -12,7 +12,11 @@ import { LensDocShapePost712, LensDocShape713, LensDocShape714, + LensDocShape715, + VisStatePost715, + VisStatePre715, } from './types'; +import { layerTypes } from '../../common'; export const commonRenameOperationsForFormula = ( attributes: LensDocShapePre712 @@ -78,3 +82,19 @@ export const commonRemoveTimezoneDateHistogramParam = ( ); return newAttributes as LensDocShapePost712; }; + +export const commonUpdateVisLayerType = ( + attributes: LensDocShape715 +): LensDocShape715 => { + const newAttributes = cloneDeep(attributes); + const visState = (newAttributes as LensDocShape715).state.visualization; + if ('layerId' in visState) { + visState.layerType = layerTypes.DATA; + } + if ('layers' in visState) { + for (const layer of visState.layers) { + layer.layerType = layerTypes.DATA; + } + } + return newAttributes as LensDocShape715; +}; diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index 9daae1d184ab63..afc6e6c6a590c6 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -5,12 +5,15 @@ * 2.0. */ +import { cloneDeep } from 'lodash'; import { migrations, LensDocShape } from './saved_object_migrations'; import { SavedObjectMigrationContext, SavedObjectMigrationFn, SavedObjectUnsanitizedDoc, } from 'src/core/server'; +import { LensDocShape715, VisStatePost715, VisStatePre715 } from './types'; +import { layerTypes } from '../../common'; describe('Lens migrations', () => { describe('7.7.0 missing dimensions in XY', () => { @@ -944,4 +947,186 @@ describe('Lens migrations', () => { expect((columns[2] as { params: {} }).params).toEqual({ timeZone: 'do not delete' }); }); }); + + describe('7.15.0 add layer type information', () => { + const context = ({ log: { warning: () => {} } } as unknown) as SavedObjectMigrationContext; + const example = ({ + type: 'lens', + id: 'mocked-saved-object-id', + attributes: { + savedObjectId: '1', + title: 'MyRenamedOps', + description: '', + visualizationType: null, + state: { + datasourceMetaData: { + filterableIndexPatterns: [], + }, + datasourceStates: { + indexpattern: { + currentIndexPatternId: 'logstash-*', + layers: { + '2': { + columns: { + '3': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto', timeZone: 'Europe/Berlin' }, + }, + '4': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5': { + label: '@timestamp', + dataType: 'date', + operationType: 'my_unexpected_operation', + isBucketed: true, + scale: 'interval', + params: { timeZone: 'do not delete' }, + }, + }, + columnOrder: ['3', '4', '5'], + }, + }, + }, + }, + visualization: {}, + query: { query: '', language: 'kuery' }, + filters: [], + }, + }, + } as unknown) as SavedObjectUnsanitizedDoc>; + + it('should add the layerType to a XY visualization', () => { + const xyExample = cloneDeep(example); + xyExample.attributes.visualizationType = 'lnsXY'; + (xyExample.attributes as LensDocShape715).state.visualization = ({ + title: 'Empty XY chart', + legend: { isVisible: true, position: 'right' }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '1', + accessors: [ + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + xAccessor: '2e57a41e-5a52-42d3-877f-bd211d903ef8', + }, + { + layerId: '2', + accessors: [ + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + xAccessor: '2e57a41e-5a52-42d3-877f-bd211d903ef8', + }, + ], + } as unknown) as VisStatePre715; + const result = migrations['7.15.0'](xyExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715).state.visualization; + if ('layers' in state) { + for (const layer of state.layers) { + expect(layer.layerType).toEqual(layerTypes.DATA); + } + } + }); + + it('should add layer info to a pie visualization', () => { + const pieExample = cloneDeep(example); + pieExample.attributes.visualizationType = 'lnsPie'; + (pieExample.attributes as LensDocShape715).state.visualization = ({ + shape: 'pie', + layers: [ + { + layerId: '1', + groups: [], + metric: undefined, + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + nestedLegend: false, + }, + ], + } as unknown) as VisStatePre715; + const result = migrations['7.15.0'](pieExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715).state.visualization; + if ('layers' in state) { + for (const layer of state.layers) { + expect(layer.layerType).toEqual(layerTypes.DATA); + } + } + }); + it('should add layer info to a metric visualization', () => { + const metricExample = cloneDeep(example); + metricExample.attributes.visualizationType = 'lnsMetric'; + (metricExample.attributes as LensDocShape715).state.visualization = ({ + layerId: '1', + accessor: undefined, + } as unknown) as VisStatePre715; + const result = migrations['7.15.0'](metricExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715).state.visualization; + expect('layerType' in state).toEqual(true); + if ('layerType' in state) { + expect(state.layerType).toEqual(layerTypes.DATA); + } + }); + it('should add layer info to a datatable visualization', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsDatatable'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + layerId: '1', + accessor: undefined, + } as unknown) as VisStatePre715; + const result = migrations['7.15.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715).state.visualization; + expect('layerType' in state).toEqual(true); + if ('layerType' in state) { + expect(state.layerType).toEqual(layerTypes.DATA); + } + }); + it('should add layer info to a heatmap visualization', () => { + const heatmapExample = cloneDeep(example); + heatmapExample.attributes.visualizationType = 'lnsHeatmap'; + (heatmapExample.attributes as LensDocShape715).state.visualization = ({ + layerId: '1', + accessor: undefined, + } as unknown) as VisStatePre715; + const result = migrations['7.15.0'](heatmapExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715).state.visualization; + expect('layerType' in state).toEqual(true); + if ('layerType' in state) { + expect(state.layerType).toEqual(layerTypes.DATA); + } + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index efcd6e2e6f3420..7d08e76841cf5f 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -15,10 +15,19 @@ import { } from 'src/core/server'; import { Query, Filter } from 'src/plugins/data/public'; import { PersistableFilter } from '../../common'; -import { LensDocShapePost712, LensDocShapePre712, LensDocShape713, LensDocShape714 } from './types'; +import { + LensDocShapePost712, + LensDocShapePre712, + LensDocShape713, + LensDocShape714, + LensDocShape715, + VisStatePost715, + VisStatePre715, +} from './types'; import { commonRenameOperationsForFormula, commonRemoveTimezoneDateHistogramParam, + commonUpdateVisLayerType, } from './common_migrations'; interface LensDocShapePre710 { @@ -413,6 +422,14 @@ const removeTimezoneDateHistogramParam: SavedObjectMigrationFn, + LensDocShape715 +> = (doc) => { + const newDoc = cloneDeep(doc); + return { ...newDoc, attributes: commonUpdateVisLayerType(newDoc.attributes) }; +}; + export const migrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -424,4 +441,5 @@ export const migrations: SavedObjectMigrationMap = { '7.13.0': renameOperationsForFormula, '7.13.1': renameOperationsForFormula, // duplicate this migration in case a broken by value panel is added to the library '7.14.0': removeTimezoneDateHistogramParam, + '7.15.0': addLayerTypeToVisualization, }; diff --git a/x-pack/plugins/lens/server/migrations/types.ts b/x-pack/plugins/lens/server/migrations/types.ts index 035e1a86b86f8f..09b460ff8b8cdc 100644 --- a/x-pack/plugins/lens/server/migrations/types.ts +++ b/x-pack/plugins/lens/server/migrations/types.ts @@ -6,6 +6,7 @@ */ import { Query, Filter } from 'src/plugins/data/public'; +import type { LayerType } from '../../common'; export type OperationTypePre712 = | 'avg' @@ -152,3 +153,42 @@ export type LensDocShape714 = Omit & { }; }; }; + +interface LayerPre715 { + layerId: string; +} + +export type VisStatePre715 = LayerPre715 | { layers: LayerPre715[] }; + +interface LayerPost715 extends LayerPre715 { + layerType: LayerType; +} + +export type VisStatePost715 = LayerPost715 | { layers: LayerPost715[] }; + +export interface LensDocShape715 { + visualizationType: string | null; + title: string; + expression: string | null; + state: { + datasourceMetaData: { + filterableIndexPatterns: Array<{ id: string; title: string }>; + }; + datasourceStates: { + // This is hardcoded as our only datasource + indexpattern: { + currentIndexPatternId: string; + layers: Record< + string, + { + columnOrder: string[]; + columns: Record>; + } + >; + }; + }; + visualization: VisualizationState; + query: Query; + filters: Filter[]; + }; +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts index ae70bbdcfa3b8c..bd8e158b2d4ab2 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -380,6 +380,7 @@ describe('Lens Attribute', () => { { accessors: ['y-axis-column-layer0'], layerId: 'layer0', + layerType: 'data', palette: undefined, seriesType: 'line', xAccessor: 'x-axis-column-layer0', @@ -418,6 +419,7 @@ describe('Lens Attribute', () => { { accessors: ['y-axis-column-layer0'], layerId: 'layer0', + layerType: 'data', palette: undefined, seriesType: 'line', splitAccessor: 'breakdown-column-layer0', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index dfb17ee470d355..6605a74630e112 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -621,6 +621,7 @@ export class LensAttributes { ...Object.keys(this.getChildYAxises(layerConfig)), ], layerId: `layer${index}`, + layerType: 'data', seriesType: layerConfig.seriesType || layerConfig.seriesConfig.defaultSeriesType, palette: layerConfig.seriesConfig.palette, yConfig: layerConfig.seriesConfig.yConfig || [ diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts index 569d68ad4ebffd..73a722642f69bb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts @@ -154,6 +154,7 @@ export const sampleAttribute = { { accessors: ['y-axis-column-layer0'], layerId: 'layer0', + layerType: 'data', seriesType: 'line', yConfig: [{ forAccessor: 'y-axis-column-layer0' }], xAccessor: 'x-axis-column-layer0', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts index 2087b85b818866..56ceba8fc52de6 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts @@ -113,6 +113,7 @@ export const sampleAttributeCoreWebVital = { { accessors: ['y-axis-column-layer0', 'y-axis-column-1', 'y-axis-column-2'], layerId: 'layer0', + layerType: 'data', seriesType: 'bar_horizontal_percentage_stacked', xAccessor: 'x-axis-column-layer0', yConfig: [ diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts index 7f066caf66bf1a..72933573c410bf 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts @@ -59,6 +59,7 @@ export const sampleAttributeKpi = { { accessors: ['y-axis-column-layer0'], layerId: 'layer0', + layerType: 'data', seriesType: 'line', yConfig: [{ forAccessor: 'y-axis-column-layer0' }], xAccessor: 'x-axis-column-layer0', diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx index 01acf2dc0d8263..6fcad4f11003f3 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx @@ -80,6 +80,7 @@ function getLensAttributes(actionId: string): TypedLensByValueInput['attributes' legendDisplay: 'default', nestedLegend: false, layerId: 'layer1', + layerType: 'data', metric: 'ed999e9d-204c-465b-897f-fe1a125b39ed', numberDisplay: 'percent', groups: ['8690befd-fd69-4246-af4a-dd485d2a3b38'], diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9fbfe10f66b20f..79e3c27d85f780 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13221,7 +13221,6 @@ "xpack.lens.configPanel.selectVisualization": "ビジュアライゼーションを選択してください", "xpack.lens.configure.configurePanelTitle": "{groupLabel}", "xpack.lens.configure.editConfig": "{label}構成の編集", - "xpack.lens.configure.emptyConfig": "フィールドを破棄、またはクリックして追加", "xpack.lens.configure.invalidConfigTooltip": "無効な構成です。", "xpack.lens.configure.invalidConfigTooltipClick": "詳細はクリックしてください。", "xpack.lens.customBucketContainer.dragToReorder": "ドラッグして並べ替え", @@ -13248,7 +13247,6 @@ "xpack.lens.datatypes.number": "数字", "xpack.lens.datatypes.record": "レコード", "xpack.lens.datatypes.string": "文字列", - "xpack.lens.deleteLayer": "レイヤーを削除", "xpack.lens.deleteLayerAriaLabel": "レイヤー {index} を削除", "xpack.lens.dimensionContainer.close": "閉じる", "xpack.lens.dimensionContainer.closeConfiguration": "構成を閉じる", @@ -13300,8 +13298,6 @@ "xpack.lens.dynamicColoring.customPalette.deleteButtonLabel": "削除", "xpack.lens.dynamicColoring.customPalette.sortReason": "新しい経由値{value}のため、色経由点が並べ替えられました", "xpack.lens.dynamicColoring.customPalette.stopAriaLabel": "{index}を停止", - "xpack.lens.editLayerSettings": "レイヤー設定を編集", - "xpack.lens.editLayerSettingsChartType": "レイヤー設定を編集、{chartType}", "xpack.lens.editorFrame.buildExpressionError": "グラフの準備中に予期しないエラーが発生しました", "xpack.lens.editorFrame.colorIndicatorLabel": "このディメンションの色:{hex}", "xpack.lens.editorFrame.dataFailure": "データの読み込み中にエラーが発生しました。", @@ -13620,7 +13616,6 @@ "xpack.lens.indexPattern.ranges.lessThanTooltip": "より小さい", "xpack.lens.indexPattern.records": "記録", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "サブ関数", - "xpack.lens.indexPattern.removeColumnAriaLabel": "フィールドを破棄するか、またはクリックして {groupLabel} に追加します", "xpack.lens.indexPattern.removeColumnLabel": "「{groupLabel}」から構成を削除", "xpack.lens.indexPattern.removeFieldLabel": "インデックスパターンを削除", "xpack.lens.indexPattern.sortField.invalid": "無効なフィールドです。インデックスパターンを確認するか、別のフィールドを選択してください。", @@ -13721,9 +13716,7 @@ "xpack.lens.pieChart.showPercentValuesLabel": "割合を表示", "xpack.lens.pieChart.showTreemapCategoriesLabel": "ラベルを表示", "xpack.lens.pieChart.valuesLabel": "ラベル", - "xpack.lens.resetLayer": "レイヤーをリセット", "xpack.lens.resetLayerAriaLabel": "レイヤー {index} をリセット", - "xpack.lens.resetVisualization": "ビジュアライゼーションをリセット", "xpack.lens.resetVisualizationAriaLabel": "ビジュアライゼーションをリセット", "xpack.lens.searchTitle": "Lens:ビジュアライゼーションを作成", "xpack.lens.section.configPanelLabel": "構成パネル", @@ -13796,7 +13789,6 @@ "xpack.lens.visTypeAlias.type": "レンズ", "xpack.lens.visualizeGeoFieldMessage": "Lensは{fieldType}フィールドを可視化できません", "xpack.lens.xyChart.addLayer": "レイヤーを追加", - "xpack.lens.xyChart.addLayerButton": "レイヤーを追加", "xpack.lens.xyChart.axisExtent.custom": "カスタム", "xpack.lens.xyChart.axisExtent.dataBounds": "データ境界", "xpack.lens.xyChart.axisExtent.disabledDataBoundsMessage": "折れ線グラフのみをデータ境界に合わせることができます", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index abedf54509bafb..99c45b9b2fe8d7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13561,7 +13561,6 @@ "xpack.lens.configPanel.selectVisualization": "选择可视化", "xpack.lens.configure.configurePanelTitle": "{groupLabel}", "xpack.lens.configure.editConfig": "编辑 {label} 配置", - "xpack.lens.configure.emptyConfig": "放置字段或单击添加", "xpack.lens.configure.invalidConfigTooltip": "配置无效。", "xpack.lens.configure.invalidConfigTooltipClick": "单击了解更多详情。", "xpack.lens.customBucketContainer.dragToReorder": "拖动以重新排序", @@ -13588,7 +13587,6 @@ "xpack.lens.datatypes.number": "数字", "xpack.lens.datatypes.record": "记录", "xpack.lens.datatypes.string": "字符串", - "xpack.lens.deleteLayer": "删除图层", "xpack.lens.deleteLayerAriaLabel": "删除图层 {index}", "xpack.lens.dimensionContainer.close": "关闭", "xpack.lens.dimensionContainer.closeConfiguration": "关闭配置", @@ -13643,8 +13641,6 @@ "xpack.lens.dynamicColoring.customPalette.deleteButtonLabel": "删除", "xpack.lens.dynamicColoring.customPalette.sortReason": "由于新停止值 {value},颜色停止已排序", "xpack.lens.dynamicColoring.customPalette.stopAriaLabel": "停止 {index}", - "xpack.lens.editLayerSettings": "编辑图层设置", - "xpack.lens.editLayerSettingsChartType": "编辑图层设置 {chartType}", "xpack.lens.editorFrame.buildExpressionError": "准备图表时发生意外错误", "xpack.lens.editorFrame.colorIndicatorLabel": "此维度的颜色:{hex}", "xpack.lens.editorFrame.configurationFailureMoreErrors": " +{errors} 个{errors, plural, other {错误}}", @@ -13970,7 +13966,6 @@ "xpack.lens.indexPattern.ranges.lessThanTooltip": "小于", "xpack.lens.indexPattern.records": "记录", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "子函数", - "xpack.lens.indexPattern.removeColumnAriaLabel": "丢弃字段,或单击以添加到 {groupLabel}", "xpack.lens.indexPattern.removeColumnLabel": "从“{groupLabel}”中删除配置", "xpack.lens.indexPattern.removeFieldLabel": "移除索引模式字段", "xpack.lens.indexPattern.sortField.invalid": "字段无效。检查索引模式或选取其他字段。", @@ -14072,9 +14067,7 @@ "xpack.lens.pieChart.showPercentValuesLabel": "显示百分比", "xpack.lens.pieChart.showTreemapCategoriesLabel": "显示标签", "xpack.lens.pieChart.valuesLabel": "标签", - "xpack.lens.resetLayer": "重置图层", "xpack.lens.resetLayerAriaLabel": "重置图层 {index}", - "xpack.lens.resetVisualization": "重置可视化", "xpack.lens.resetVisualizationAriaLabel": "重置可视化", "xpack.lens.searchTitle": "Lens:创建可视化", "xpack.lens.section.configPanelLabel": "配置面板", @@ -14147,7 +14140,6 @@ "xpack.lens.visTypeAlias.type": "Lens", "xpack.lens.visualizeGeoFieldMessage": "Lens 无法可视化 {fieldType} 字段", "xpack.lens.xyChart.addLayer": "添加图层", - "xpack.lens.xyChart.addLayerButton": "添加图层", "xpack.lens.xyChart.axisExtent.custom": "定制", "xpack.lens.xyChart.axisExtent.dataBounds": "数据边界", "xpack.lens.xyChart.axisExtent.disabledDataBoundsMessage": "仅折线图可适应数据边界", diff --git a/x-pack/test/api_integration/apis/maps/migrations.js b/x-pack/test/api_integration/apis/maps/migrations.js index 88e6f0c8425989..d121c79f6cfe1f 100644 --- a/x-pack/test/api_integration/apis/maps/migrations.js +++ b/x-pack/test/api_integration/apis/maps/migrations.js @@ -76,7 +76,7 @@ export default function ({ getService }) { } expect(panels.length).to.be(1); expect(panels[0].type).to.be('map'); - expect(panels[0].version).to.be('7.14.0'); + expect(panels[0].version).to.be('7.15.0'); }); }); }); From 997e9ec9b96b89900abbb684ff45bc8feea49473 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Thu, 12 Aug 2021 09:14:47 +0200 Subject: [PATCH 20/20] [Security Solution] User can make Exceptions for Behavior Protection alerts (#106853) --- .../security_solution/common/ecs/dll/index.ts | 15 + .../security_solution/common/ecs/index.ts | 2 + .../common/ecs/process/index.ts | 11 +- .../common/ecs/registry/index.ts | 5 + .../common/endpoint/generate_data.ts | 107 ++++++ .../common/endpoint/types/index.ts | 20 + .../exceptionable_endpoint_fields.json | 18 +- .../components/exceptions/helpers.test.tsx | 342 ++++++++++++++++++ .../common/components/exceptions/helpers.tsx | 139 +++++++ 9 files changed, 657 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/ecs/dll/index.ts diff --git a/x-pack/plugins/security_solution/common/ecs/dll/index.ts b/x-pack/plugins/security_solution/common/ecs/dll/index.ts new file mode 100644 index 00000000000000..0634d29c691cff --- /dev/null +++ b/x-pack/plugins/security_solution/common/ecs/dll/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CodeSignature } from '../file'; +import { ProcessPe } from '../process'; + +export interface DllEcs { + path?: string; + code_signature?: CodeSignature; + pe?: ProcessPe; +} diff --git a/x-pack/plugins/security_solution/common/ecs/index.ts b/x-pack/plugins/security_solution/common/ecs/index.ts index 610a2fd1f6e9ed..fbeb3231573671 100644 --- a/x-pack/plugins/security_solution/common/ecs/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/index.ts @@ -9,6 +9,7 @@ import { AgentEcs } from './agent'; import { AuditdEcs } from './auditd'; import { DestinationEcs } from './destination'; import { DnsEcs } from './dns'; +import { DllEcs } from './dll'; import { EndgameEcs } from './endgame'; import { EventEcs } from './event'; import { FileEcs } from './file'; @@ -68,4 +69,5 @@ export interface Ecs { // eslint-disable-next-line @typescript-eslint/naming-convention Memory_protection?: MemoryProtection; Target?: Target; + dll?: DllEcs; } diff --git a/x-pack/plugins/security_solution/common/ecs/process/index.ts b/x-pack/plugins/security_solution/common/ecs/process/index.ts index 0eb2400466e640..2a58c6d5b47d0d 100644 --- a/x-pack/plugins/security_solution/common/ecs/process/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/process/index.ts @@ -5,14 +5,16 @@ * 2.0. */ -import { Ext } from '../file'; +import { CodeSignature, Ext } from '../file'; export interface ProcessEcs { Ext?: Ext; + command_line?: string[]; entity_id?: string[]; exit_code?: number[]; hash?: ProcessHashData; parent?: ProcessParentData; + code_signature?: CodeSignature; pid?: number[]; name?: string[]; ppid?: number[]; @@ -32,6 +34,7 @@ export interface ProcessHashData { export interface ProcessParentData { name?: string[]; pid?: number[]; + executable?: string[]; } export interface Thread { @@ -39,3 +42,9 @@ export interface Thread { start?: string[]; Ext?: Ext; } +export interface ProcessPe { + original_file_name?: string; + company?: string; + description?: string; + file_version?: string; +} diff --git a/x-pack/plugins/security_solution/common/ecs/registry/index.ts b/x-pack/plugins/security_solution/common/ecs/registry/index.ts index c756fb139199e7..6ca6afc10098c7 100644 --- a/x-pack/plugins/security_solution/common/ecs/registry/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/registry/index.ts @@ -10,4 +10,9 @@ export interface RegistryEcs { key?: string[]; path?: string[]; value?: string[]; + data?: RegistryEcsData; +} + +export interface RegistryEcsData { + strings?: string[]; } diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 255ab8f0a598c9..b6d9e5f0f36959 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -392,6 +392,7 @@ enum AlertTypes { MALWARE = 'MALWARE', MEMORY_SIGNATURE = 'MEMORY_SIGNATURE', MEMORY_SHELLCODE = 'MEMORY_SHELLCODE', + BEHAVIOR = 'BEHAVIOR', } const alertsDefaultDataStream = { @@ -778,11 +779,117 @@ export class EndpointDocGenerator extends BaseDataGenerator { alertsDataStream, alertType, }); + case AlertTypes.BEHAVIOR: + return this.generateBehaviorAlert({ + ts, + entityID, + parentEntityID, + ancestry, + alertsDataStream, + }); default: return assertNever(alertType); } } + /** + * Creates a memory alert from the simulated host represented by this EndpointDocGenerator + * @param ts - Timestamp to put in the event + * @param entityID - entityID of the originating process + * @param parentEntityID - optional entityID of the parent process, if it exists + * @param ancestry - an array of ancestors for the generated alert + * @param alertsDataStream the values to populate the data_stream fields when generating alert documents + */ + public generateBehaviorAlert({ + ts = new Date().getTime(), + entityID = this.randomString(10), + parentEntityID, + ancestry = [], + alertsDataStream = alertsDefaultDataStream, + }: { + ts?: number; + entityID?: string; + parentEntityID?: string; + ancestry?: string[]; + alertsDataStream?: DataStream; + } = {}): AlertEvent { + const processName = this.randomProcessName(); + const newAlert: AlertEvent = { + ...this.commonInfo, + data_stream: alertsDataStream, + '@timestamp': ts, + ecs: { + version: '1.6.0', + }, + rule: { + id: this.randomUUID(), + }, + event: { + action: 'rule_detection', + kind: 'alert', + category: 'behavior', + code: 'behavior', + id: this.seededUUIDv4(), + dataset: 'endpoint.diagnostic.collection', + module: 'endpoint', + type: 'info', + sequence: this.sequence++, + }, + file: { + name: 'fake_behavior.exe', + path: 'C:/fake_behavior.exe', + }, + destination: { + port: 443, + ip: this.randomIP(), + }, + source: { + port: 59406, + ip: this.randomIP(), + }, + network: { + transport: 'tcp', + type: 'ipv4', + direction: 'outgoing', + }, + registry: { + path: + 'HKEY_USERS\\S-1-5-21-2460036010-3910878774-3458087990-1001\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run\\chrome', + value: processName, + data: { + strings: `C:/fake_behavior/${processName}`, + }, + }, + process: { + pid: 2, + name: processName, + entity_id: entityID, + executable: `C:/fake_behavior/${processName}`, + parent: parentEntityID + ? { + entity_id: parentEntityID, + pid: 1, + } + : undefined, + Ext: { + ancestry, + code_signature: [ + { + trusted: false, + subject_name: 'bad signer', + }, + ], + user: 'SYSTEM', + token: { + integrity_level_name: 'high', + elevation_level: 'full', + }, + }, + }, + dll: this.getAlertsDefaultDll(), + }; + return newAlert; + } /** * Returns the default DLLs used in alerts */ diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index d5a8caac1dffee..5f92965c0e6ed8 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -301,6 +301,21 @@ export type AlertEvent = Partial<{ feature: ECSField; self_injection: ECSField; }>; + destination: Partial<{ + port: ECSField; + ip: ECSField; + }>; + source: Partial<{ + port: ECSField; + ip: ECSField; + }>; + registry: Partial<{ + path: ECSField; + value: ECSField; + data: Partial<{ + strings: ECSField; + }>; + }>; Target: Partial<{ process: Partial<{ thread: Partial<{ @@ -359,6 +374,9 @@ export type AlertEvent = Partial<{ }>; }>; }>; + rule: Partial<{ + id: ECSField; + }>; file: Partial<{ owner: ECSField; name: ECSField; @@ -677,6 +695,8 @@ export type SafeEndpointEvent = Partial<{ }>; }>; network: Partial<{ + transport: ECSField; + type: ECSField; direction: ECSField; forwarded_ip: ECSField; }>; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_endpoint_fields.json b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_endpoint_fields.json index c37be60545ab2e..12ee0273f078ae 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_endpoint_fields.json +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_endpoint_fields.json @@ -56,6 +56,7 @@ "file.mode", "file.name", "file.owner", + "file.path", "file.pe.company", "file.pe.description", "file.pe.file_version", @@ -76,6 +77,7 @@ "host.os.platform", "host.os.version", "host.type", + "process.command_line", "process.Ext.services", "process.Ext.user", "process.Ext.code_signature", @@ -85,6 +87,7 @@ "process.hash.sha256", "process.hash.sha512", "process.name", + "process.parent.executable", "process.parent.hash.md5", "process.parent.hash.sha1", "process.parent.hash.sha256", @@ -97,11 +100,24 @@ "process.pe.product", "process.pgid", "rule.uuid", + "rule.id", + "source.ip", + "source.port", + "destination.ip", + "destination.port", + "registry.path", + "registry.value", + "registry.data.strings", "user.domain", "user.email", "user.hash", "user.id", "Ransomware.feature", "Memory_protection.feature", - "Memory_protection.self_injection" + "Memory_protection.self_injection", + "dll.path", + "dll.code_signature.subject_name", + "dll.pe.original_file_name", + "dns.question.name", + "dns.question.type" ] diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index 83006f09a14be0..9696604ddf2224 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -1255,4 +1255,346 @@ describe('Exception helpers', () => { ]); }); }); + describe('behavior protection exception items', () => { + test('it should return pre-populated behavior protection items', () => { + const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', { + _id: '123', + rule: { + id: '123', + }, + process: { + command_line: 'command_line', + executable: 'some file path', + parent: { + executable: 'parent file path', + }, + code_signature: { + subject_name: 'subject-name', + trusted: 'true', + }, + }, + event: { + code: 'behavior', + }, + file: { + path: 'fake-file-path', + name: 'fake-file-name', + }, + source: { + ip: '0.0.0.0', + }, + destination: { + ip: '0.0.0.0', + }, + registry: { + path: 'registry-path', + value: 'registry-value', + data: { + strings: 'registry-strings', + }, + }, + dll: { + path: 'dll-path', + code_signature: { + subject_name: 'dll-code-signature-subject-name', + trusted: 'false', + }, + pe: { + original_file_name: 'dll-pe-original-file-name', + }, + }, + dns: { + question: { + name: 'dns-question-name', + type: 'dns-question-type', + }, + }, + user: { + id: '0987', + }, + }); + + expect(defaultItems[0].entries).toEqual([ + { + id: '123', + field: 'rule.id', + operator: 'included' as const, + type: 'match' as const, + value: '123', + }, + { + id: '123', + field: 'process.executable.caseless', + operator: 'included' as const, + type: 'match' as const, + value: 'some file path', + }, + { + id: '123', + field: 'process.command_line', + operator: 'included' as const, + type: 'match' as const, + value: 'command_line', + }, + { + id: '123', + field: 'process.parent.executable', + operator: 'included' as const, + type: 'match' as const, + value: 'parent file path', + }, + { + id: '123', + field: 'process.code_signature.subject_name', + operator: 'included' as const, + type: 'match' as const, + value: 'subject-name', + }, + { + id: '123', + field: 'file.path', + operator: 'included' as const, + type: 'match' as const, + value: 'fake-file-path', + }, + { + id: '123', + field: 'file.name', + operator: 'included' as const, + type: 'match' as const, + value: 'fake-file-name', + }, + { + id: '123', + field: 'source.ip', + operator: 'included' as const, + type: 'match' as const, + value: '0.0.0.0', + }, + { + id: '123', + field: 'destination.ip', + operator: 'included' as const, + type: 'match' as const, + value: '0.0.0.0', + }, + { + id: '123', + field: 'registry.path', + operator: 'included' as const, + type: 'match' as const, + value: 'registry-path', + }, + { + id: '123', + field: 'registry.value', + operator: 'included' as const, + type: 'match' as const, + value: 'registry-value', + }, + { + id: '123', + field: 'registry.data.strings', + operator: 'included' as const, + type: 'match' as const, + value: 'registry-strings', + }, + { + id: '123', + field: 'dll.path', + operator: 'included' as const, + type: 'match' as const, + value: 'dll-path', + }, + { + id: '123', + field: 'dll.code_signature.subject_name', + operator: 'included' as const, + type: 'match' as const, + value: 'dll-code-signature-subject-name', + }, + { + id: '123', + field: 'dll.pe.original_file_name', + operator: 'included' as const, + type: 'match' as const, + value: 'dll-pe-original-file-name', + }, + { + id: '123', + field: 'dns.question.name', + operator: 'included' as const, + type: 'match' as const, + value: 'dns-question-name', + }, + { + id: '123', + field: 'dns.question.type', + operator: 'included' as const, + type: 'match' as const, + value: 'dns-question-type', + }, + { + id: '123', + field: 'user.id', + operator: 'included' as const, + type: 'match' as const, + value: '0987', + }, + ]); + }); + test('it should return pre-populated behavior protection fields and skip empty', () => { + const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', { + _id: '123', + rule: { + id: '123', + }, + process: { + // command_line: 'command_line', intentionally left commented + executable: 'some file path', + parent: { + executable: 'parent file path', + }, + code_signature: { + subject_name: 'subject-name', + trusted: 'true', + }, + }, + event: { + code: 'behavior', + }, + file: { + // path: 'fake-file-path', intentionally left commented + name: 'fake-file-name', + }, + source: { + ip: '0.0.0.0', + }, + destination: { + ip: '0.0.0.0', + }, + // intentionally left commented + // registry: { + // path: 'registry-path', + // value: 'registry-value', + // data: { + // strings: 'registry-strings', + // }, + // }, + dll: { + path: 'dll-path', + code_signature: { + subject_name: 'dll-code-signature-subject-name', + trusted: 'false', + }, + pe: { + original_file_name: 'dll-pe-original-file-name', + }, + }, + dns: { + question: { + name: 'dns-question-name', + type: 'dns-question-type', + }, + }, + user: { + id: '0987', + }, + }); + + expect(defaultItems[0].entries).toEqual([ + { + id: '123', + field: 'rule.id', + operator: 'included' as const, + type: 'match' as const, + value: '123', + }, + { + id: '123', + field: 'process.executable.caseless', + operator: 'included' as const, + type: 'match' as const, + value: 'some file path', + }, + { + id: '123', + field: 'process.parent.executable', + operator: 'included' as const, + type: 'match' as const, + value: 'parent file path', + }, + { + id: '123', + field: 'process.code_signature.subject_name', + operator: 'included' as const, + type: 'match' as const, + value: 'subject-name', + }, + { + id: '123', + field: 'file.name', + operator: 'included' as const, + type: 'match' as const, + value: 'fake-file-name', + }, + { + id: '123', + field: 'source.ip', + operator: 'included' as const, + type: 'match' as const, + value: '0.0.0.0', + }, + { + id: '123', + field: 'destination.ip', + operator: 'included' as const, + type: 'match' as const, + value: '0.0.0.0', + }, + { + id: '123', + field: 'dll.path', + operator: 'included' as const, + type: 'match' as const, + value: 'dll-path', + }, + { + id: '123', + field: 'dll.code_signature.subject_name', + operator: 'included' as const, + type: 'match' as const, + value: 'dll-code-signature-subject-name', + }, + { + id: '123', + field: 'dll.pe.original_file_name', + operator: 'included' as const, + type: 'match' as const, + value: 'dll-pe-original-file-name', + }, + { + id: '123', + field: 'dns.question.name', + operator: 'included' as const, + type: 'match' as const, + value: 'dns-question-name', + }, + { + id: '123', + field: 'dns.question.type', + operator: 'included' as const, + type: 'match' as const, + value: 'dns-question-type', + }, + { + id: '123', + field: 'user.id', + operator: 'included' as const, + type: 'match' as const, + value: '0987', + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 62250a0933ffb9..3d219b90a2fc84 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -655,6 +655,136 @@ export const getPrepopulatedMemoryShellcodeException = ({ }; }; +export const getPrepopulatedBehaviorException = ({ + listId, + ruleName, + eventCode, + listNamespace = 'agnostic', + alertEcsData, +}: { + listId: string; + listNamespace?: NamespaceType; + ruleName: string; + eventCode: string; + alertEcsData: Flattened; +}): ExceptionsBuilderExceptionItem => { + const { process } = alertEcsData; + const entries = filterEmptyExceptionEntries([ + { + field: 'rule.id', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.rule?.id ?? '', + }, + { + field: 'process.executable.caseless', + operator: 'included' as const, + type: 'match' as const, + value: process?.executable ?? '', + }, + { + field: 'process.command_line', + operator: 'included' as const, + type: 'match' as const, + value: process?.command_line ?? '', + }, + { + field: 'process.parent.executable', + operator: 'included' as const, + type: 'match' as const, + value: process?.parent?.executable ?? '', + }, + { + field: 'process.code_signature.subject_name', + operator: 'included' as const, + type: 'match' as const, + value: process?.code_signature?.subject_name ?? '', + }, + { + field: 'file.path', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.file?.path ?? '', + }, + { + field: 'file.name', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.file?.name ?? '', + }, + { + field: 'source.ip', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.source?.ip ?? '', + }, + { + field: 'destination.ip', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.destination?.ip ?? '', + }, + { + field: 'registry.path', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.registry?.path ?? '', + }, + { + field: 'registry.value', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.registry?.value ?? '', + }, + { + field: 'registry.data.strings', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.registry?.data?.strings ?? '', + }, + { + field: 'dll.path', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.dll?.path ?? '', + }, + { + field: 'dll.code_signature.subject_name', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.dll?.code_signature?.subject_name ?? '', + }, + { + field: 'dll.pe.original_file_name', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.dll?.pe?.original_file_name ?? '', + }, + { + field: 'dns.question.name', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.dns?.question?.name ?? '', + }, + { + field: 'dns.question.type', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.dns?.question?.type ?? '', + }, + { + field: 'user.id', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.user?.id ?? '', + }, + ]); + return { + ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + entries: addIdToEntries(entries), + }; +}; + /** * Determines whether or not any entries within the given exceptionItems contain values not in the specified ECS mapping */ @@ -697,6 +827,15 @@ export const defaultEndpointExceptionItems = ( const eventCode = alertEvent?.code ?? ''; switch (eventCode) { + case 'behavior': + return [ + getPrepopulatedBehaviorException({ + listId, + ruleName, + eventCode, + alertEcsData, + }), + ]; case 'memory_signature': return [ getPrepopulatedMemorySignatureException({