From de29010c4309cce65c11dc6c8ad4a9e5ea832fbc Mon Sep 17 00:00:00 2001 From: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com> Date: Wed, 18 May 2022 15:13:44 +0200 Subject: [PATCH 001/150] Retain APIKey when disabling/enabling a rule (#131581) * Retain APIKey when disabling/enabling a rule --- docs/user/alerting/alerting-setup.asciidoc | 3 +- .../server/rules_client/rules_client.ts | 78 +- .../server/rules_client/tests/disable.test.ts | 67 +- .../server/rules_client/tests/enable.test.ts | 152 +- .../components/delete_modal_confirmation.tsx | 2 +- .../update_api_key_modal_confirmation.tsx | 95 + .../public/application/lib/rule_api/index.ts | 1 + .../lib/rule_api/update_api_key.test.ts | 26 + .../lib/rule_api/update_api_key.ts | 14 + .../components/rule_actions_popopver.scss | 3 + .../components/rule_actions_popover.test.tsx | 201 +++ .../components/rule_actions_popover.tsx | 107 ++ .../components/rule_details.test.tsx | 1535 +++++++++-------- .../rule_details/components/rule_details.tsx | 129 +- .../components/collapsed_item_actions.scss | 10 +- .../collapsed_item_actions.test.tsx | 5 + .../components/collapsed_item_actions.tsx | 17 + .../rules_list/components/rules_list.test.tsx | 501 +++--- .../rules_list/components/rules_list.tsx | 20 +- .../fixtures/plugins/alerts/server/routes.ts | 43 + .../common/lib/alert_utils.ts | 10 + .../group1/tests/alerting/disable.ts | 7 - .../group1/tests/alerting/index.ts | 1 + .../group1/tests/alerting/retain_api_key.ts | 111 ++ 24 files changed, 2015 insertions(+), 1123 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/update_api_key_modal_confirmation.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popopver.scss create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.tsx create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/retain_api_key.ts diff --git a/docs/user/alerting/alerting-setup.asciidoc b/docs/user/alerting/alerting-setup.asciidoc index 2b92e8caa7ef9..6643f8d0ec870 100644 --- a/docs/user/alerting/alerting-setup.asciidoc +++ b/docs/user/alerting/alerting-setup.asciidoc @@ -65,10 +65,9 @@ Rules and connectors are isolated to the {kib} space in which they were created. Rules are authorized using an <> associated with the last user to edit the rule. This API key captures a snapshot of the user's privileges at the time of edit and is subsequently used to run all background tasks associated with the rule, including condition checks like {es} queries and triggered actions. The following rule actions will re-generate the API key: * Creating a rule -* Enabling a disabled rule * Updating a rule [IMPORTANT] ============================================== -If a rule requires certain privileges, such as index privileges, to run, and a user without those privileges updates, disables, or re-enables the rule, the rule will no longer function. Conversely, if a user with greater or administrator privileges modifies the rule, it will begin running with increased privileges. +If a rule requires certain privileges, such as index privileges, to run, and a user without those privileges updates the rule, the rule will no longer function. Conversely, if a user with greater or administrator privileges modifies the rule, it will begin running with increased privileges. ============================================== diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 75398a6668755..ec01c2c15abf4 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -1828,7 +1828,7 @@ export class RulesClient { } private async enableWithOCC({ id }: { id: string }) { - let apiKeyToInvalidate: string | null = null; + let existingApiKey: string | null = null; let attributes: RawRule; let version: string | undefined; @@ -1837,14 +1837,11 @@ export class RulesClient { await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { namespace: this.namespace, }); - apiKeyToInvalidate = decryptedAlert.attributes.apiKey; + existingApiKey = decryptedAlert.attributes.apiKey; attributes = decryptedAlert.attributes; version = decryptedAlert.version; } catch (e) { - // We'll skip invalidating the API key since we failed to load the decrypted saved object - this.logger.error( - `enable(): Failed to load API key to invalidate on alert ${id}: ${e.message}` - ); + this.logger.error(`enable(): Failed to load API key of alert ${id}: ${e.message}`); // Still attempt to load the attributes and version using SOC const alert = await this.unsecuredSavedObjectsClient.get('alert', id); attributes = alert.attributes; @@ -1886,19 +1883,10 @@ export class RulesClient { if (attributes.enabled === false) { const username = await this.getUserName(); - let createdAPIKey = null; - try { - createdAPIKey = await this.createAPIKey( - this.generateAPIKeyName(attributes.alertTypeId, attributes.name) - ); - } catch (error) { - throw Boom.badRequest(`Error enabling rule: could not create API key - ${error.message}`); - } - const updateAttributes = this.updateMeta({ ...attributes, + ...(!existingApiKey && (await this.createNewAPIKeySet({ attributes, username }))), enabled: true, - ...this.apiKeyAsAlertAttributes(createdAPIKey, username), updatedBy: username, updatedAt: new Date().toISOString(), executionStatus: { @@ -1909,15 +1897,10 @@ export class RulesClient { warning: null, }, }); + try { await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version }); } catch (e) { - // Avoid unused API key - await bulkMarkApiKeysForInvalidation( - { apiKeys: updateAttributes.apiKey ? [updateAttributes.apiKey] : [] }, - this.logger, - this.unsecuredSavedObjectsClient - ); throw e; } const scheduledTask = await this.scheduleRule({ @@ -1930,16 +1913,28 @@ export class RulesClient { await this.unsecuredSavedObjectsClient.update('alert', id, { scheduledTaskId: scheduledTask.id, }); - if (apiKeyToInvalidate) { - await bulkMarkApiKeysForInvalidation( - { apiKeys: [apiKeyToInvalidate] }, - this.logger, - this.unsecuredSavedObjectsClient - ); - } } } + private async createNewAPIKeySet({ + attributes, + username, + }: { + attributes: RawRule; + username: string | null; + }): Promise> { + let createdAPIKey = null; + try { + createdAPIKey = await this.createAPIKey( + this.generateAPIKeyName(attributes.alertTypeId, attributes.name) + ); + } catch (error) { + throw Boom.badRequest(`Error creating API key for rule: ${error.message}`); + } + + return this.apiKeyAsAlertAttributes(createdAPIKey, username); + } + public async disable({ id }: { id: string }): Promise { return await retryIfConflicts( this.logger, @@ -1949,7 +1944,6 @@ export class RulesClient { } private async disableWithOCC({ id }: { id: string }) { - let apiKeyToInvalidate: string | null = null; let attributes: RawRule; let version: string | undefined; @@ -1958,14 +1952,10 @@ export class RulesClient { await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { namespace: this.namespace, }); - apiKeyToInvalidate = decryptedAlert.attributes.apiKey; attributes = decryptedAlert.attributes; version = decryptedAlert.version; } catch (e) { - // We'll skip invalidating the API key since we failed to load the decrypted saved object - this.logger.error( - `disable(): Failed to load API key to invalidate on alert ${id}: ${e.message}` - ); + this.logger.error(`disable(): Failed to load API key of alert ${id}: ${e.message}`); // Still attempt to load the attributes and version using SOC const alert = await this.unsecuredSavedObjectsClient.get('alert', id); attributes = alert.attributes; @@ -2058,26 +2048,14 @@ export class RulesClient { ...attributes, enabled: false, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, updatedBy: await this.getUserName(), updatedAt: new Date().toISOString(), }), { version } ); - - await Promise.all([ - attributes.scheduledTaskId - ? this.taskManager.removeIfExists(attributes.scheduledTaskId) - : null, - apiKeyToInvalidate - ? await bulkMarkApiKeysForInvalidation( - { apiKeys: [apiKeyToInvalidate] }, - this.logger, - this.unsecuredSavedObjectsClient - ) - : null, - ]); + if (attributes.scheduledTaskId) { + await this.taskManager.removeIfExists(attributes.scheduledTaskId); + } } } diff --git a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts index 02f2c66a491ad..a193733aff26f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts @@ -18,7 +18,6 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; -import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ bulkMarkApiKeysForInvalidation: jest.fn(), @@ -111,6 +110,7 @@ describe('disable()', () => { attributes: { ...existingAlert.attributes, apiKey: Buffer.from('123:abc').toString('base64'), + apiKeyOwner: 'elastic', }, version: '123', references: [], @@ -206,11 +206,11 @@ describe('disable()', () => { alertTypeId: 'myType', enabled: false, meta: { - versionApiKeyLastmodified: kibanaVersion, + versionApiKeyLastmodified: 'v7.10.0', }, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', actions: [ @@ -230,12 +230,6 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); }); test('disables the rule with calling event log to "recover" the alert instances from the task state', async () => { @@ -282,11 +276,11 @@ describe('disable()', () => { alertTypeId: 'myType', enabled: false, meta: { - versionApiKeyLastmodified: kibanaVersion, + versionApiKeyLastmodified: 'v7.10.0', }, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', actions: [ @@ -306,12 +300,6 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); expect(eventLogger.logEvent.mock.calls[0][0]).toStrictEqual({ @@ -369,11 +357,11 @@ describe('disable()', () => { alertTypeId: 'myType', enabled: false, meta: { - versionApiKeyLastmodified: kibanaVersion, + versionApiKeyLastmodified: 'v7.10.0', }, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', actions: [ @@ -393,12 +381,6 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); expect(eventLogger.logEvent).toHaveBeenCalledTimes(0); expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( @@ -408,7 +390,6 @@ describe('disable()', () => { test('falls back when getDecryptedAsInternalUser throws an error', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); - await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { @@ -422,12 +403,7 @@ describe('disable()', () => { schedule: { interval: '10s' }, alertTypeId: 'myType', enabled: false, - meta: { - versionApiKeyLastmodified: kibanaVersion, - }, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', actions: [ @@ -447,7 +423,6 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); test(`doesn't disable already disabled alerts`, async () => { @@ -463,14 +438,6 @@ describe('disable()', () => { await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); expect(taskManager.removeIfExists).not.toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); - }); - - test(`doesn't invalidate when no API key is used`, async () => { - encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce(existingAlert); - - await rulesClient.disable({ id: '1' }); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); test('swallows error when failing to load decrypted saved object', async () => { @@ -479,9 +446,8 @@ describe('disable()', () => { await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); expect(taskManager.removeIfExists).toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); expect(rulesClientParams.logger.error).toHaveBeenCalledWith( - 'disable(): Failed to load API key to invalidate on alert 1: Fail' + 'disable(): Failed to load API key of alert 1: Fail' ); }); @@ -493,17 +459,6 @@ describe('disable()', () => { ); }); - test('swallows error when invalidate API key throws', async () => { - unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); - await rulesClient.disable({ id: '1' }); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); - }); - test('throws when failing to remove task from task manager', async () => { taskManager.removeIfExists.mockRejectedValueOnce(new Error('Failed to remove task')); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts index d823e0aaafdb8..8923031ab6b87 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts @@ -17,7 +17,6 @@ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; -import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ bulkMarkApiKeysForInvalidation: jest.fn(), @@ -51,23 +50,22 @@ const rulesClientParams: jest.Mocked = { auditLogger, }; -beforeEach(() => { - getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); - (auditLogger.log as jest.Mock).mockClear(); -}); - setGlobalDate(); describe('enable()', () => { let rulesClient: RulesClient; - const existingAlert = { + + const existingRule = { id: '1', type: 'alert', attributes: { + name: 'name', consumer: 'myApp', schedule: { interval: '10s' }, alertTypeId: 'myType', enabled: false, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', actions: [ { group: 'default', @@ -84,23 +82,24 @@ describe('enable()', () => { references: [], }; + const existingRuleWithoutApiKey = { + ...existingRule, + attributes: { + ...existingRule.attributes, + apiKey: null, + apiKeyOwner: null, + }, + }; + beforeEach(() => { + getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); rulesClient = new RulesClient(rulesClientParams); - encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingAlert); - unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert); + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingRule); + unsecuredSavedObjectsClient.get.mockResolvedValue(existingRule); rulesClientParams.createAPIKey.mockResolvedValue({ apiKeysEnabled: false, }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - ...existingAlert, - attributes: { - ...existingAlert.attributes, - enabled: true, - apiKey: null, - apiKeyOwner: null, - updatedBy: 'elastic', - }, - }); taskManager.schedule.mockResolvedValue({ id: '1', scheduledAt: new Date(), @@ -187,27 +186,17 @@ describe('enable()', () => { }); test('enables a rule', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - ...existingAlert, - attributes: { - ...existingAlert.attributes, - enabled: true, - apiKey: null, - apiKeyOwner: null, - updatedBy: 'elastic', - }, - }); - await rulesClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { + name: 'name', schedule: { interval: '10s' }, alertTypeId: 'myType', consumer: 'myApp', @@ -217,8 +206,8 @@ describe('enable()', () => { }, updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', actions: [ { group: 'default', @@ -265,33 +254,65 @@ describe('enable()', () => { }); }); - test('invalidates API key if ever one existed prior to updating', async () => { - encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({ - ...existingAlert, - attributes: { - ...existingAlert.attributes, - apiKey: Buffer.from('123:abc').toString('base64'), - }, + test('enables a rule that does not have an apiKey', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingRuleWithoutApiKey); + rulesClientParams.createAPIKey.mockResolvedValueOnce({ + apiKeysEnabled: true, + result: { id: '123', name: '123', api_key: 'abc' }, }); - await rulesClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) + expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); + expect(rulesClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: myType/name'); + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( + 'alert', + '1', + { + name: 'name', + schedule: { interval: '10s' }, + alertTypeId: 'myType', + consumer: 'myApp', + enabled: true, + meta: { + versionApiKeyLastmodified: kibanaVersion, + }, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + executionStatus: { + status: 'pending', + lastDuration: 0, + lastExecutionDate: '2019-02-12T21:01:22.479Z', + error: null, + warning: null, + }, + }, + { + version: '123', + } ); }); test(`doesn't enable already enabled alerts`, async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce({ - ...existingAlert, + ...existingRuleWithoutApiKey, attributes: { - ...existingAlert.attributes, + ...existingRuleWithoutApiKey.attributes, enabled: true, }, }); @@ -314,6 +335,7 @@ describe('enable()', () => { 'alert', '1', { + name: 'name', schedule: { interval: '10s' }, alertTypeId: 'myType', consumer: 'myApp', @@ -351,14 +373,14 @@ describe('enable()', () => { }); test('throws an error if API key creation throws', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingRuleWithoutApiKey); + rulesClientParams.createAPIKey.mockImplementation(() => { throw new Error('no'); }); - expect( + await expect( async () => await rulesClient.enable({ id: '1' }) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Error enabling rule: could not create API key - no"` - ); + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Error creating API key for rule: no"`); }); test('falls back when failing to getDecryptedAsInternalUser', async () => { @@ -367,7 +389,7 @@ describe('enable()', () => { await rulesClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); expect(rulesClientParams.logger.error).toHaveBeenCalledWith( - 'enable(): Failed to load API key to invalidate on alert 1: Fail' + 'enable(): Failed to load API key of alert 1: Fail' ); }); @@ -396,13 +418,6 @@ describe('enable()', () => { `"Fail to update"` ); expect(rulesClientParams.getUserName).toHaveBeenCalled(); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); expect(taskManager.schedule).not.toHaveBeenCalled(); }); @@ -410,9 +425,9 @@ describe('enable()', () => { test('throws error when failing to update the second time', async () => { unsecuredSavedObjectsClient.update.mockReset(); unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ - ...existingAlert, + ...existingRuleWithoutApiKey, attributes: { - ...existingAlert.attributes, + ...existingRuleWithoutApiKey.attributes, enabled: true, }, }); @@ -424,7 +439,6 @@ describe('enable()', () => { `"Fail to update second time"` ); expect(rulesClientParams.getUserName).toHaveBeenCalled(); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(2); expect(taskManager.schedule).toHaveBeenCalled(); }); @@ -436,15 +450,14 @@ describe('enable()', () => { `"Fail to schedule"` ); expect(rulesClientParams.getUserName).toHaveBeenCalled(); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); }); test('enables a rule if conflict errors received when scheduling a task', async () => { unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - ...existingAlert, + ...existingRuleWithoutApiKey, attributes: { - ...existingAlert.attributes, + ...existingRuleWithoutApiKey.attributes, enabled: true, apiKey: null, apiKeyOwner: null, @@ -460,11 +473,12 @@ describe('enable()', () => { expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { + name: 'name', schedule: { interval: '10s' }, alertTypeId: 'myType', consumer: 'myApp', @@ -474,8 +488,8 @@ describe('enable()', () => { }, updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', actions: [ { group: 'default', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx index 7626cc49a5c4c..b8a11cc5892cd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx @@ -54,7 +54,7 @@ export const DeleteModalConfirmation = ({ 'xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.descriptionText', { defaultMessage: - "You can't recover {numIdsToDelete, plural, one {a deleted {singleTitle}} other {deleted {multipleTitle}}}.", + "You won't be able to recover {numIdsToDelete, plural, one {a deleted {singleTitle}} other {deleted {multipleTitle}}}.", values: { numIdsToDelete, singleTitle, multipleTitle }, } ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/update_api_key_modal_confirmation.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/update_api_key_modal_confirmation.tsx new file mode 100644 index 0000000000000..93845ae3b366c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/update_api_key_modal_confirmation.tsx @@ -0,0 +1,95 @@ +/* + * 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 { EuiConfirmModal } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useState } from 'react'; +import { HttpSetup } from '@kbn/core/public'; +import { useKibana } from '../../common/lib/kibana'; +export const UpdateApiKeyModalConfirmation = ({ + onCancel, + idsToUpdate, + apiUpdateApiKeyCall, + setIsLoadingState, + onUpdated, +}: { + onCancel: () => void; + idsToUpdate: string[]; + apiUpdateApiKeyCall: ({ id, http }: { id: string; http: HttpSetup }) => Promise; + setIsLoadingState: (isLoading: boolean) => void; + onUpdated: () => void; +}) => { + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const [updateModalFlyoutVisible, setUpdateModalVisibility] = useState(false); + + useEffect(() => { + setUpdateModalVisibility(idsToUpdate.length > 0); + }, [idsToUpdate]); + + return updateModalFlyoutVisible ? ( + { + setUpdateModalVisibility(false); + onCancel(); + }} + onConfirm={async () => { + setUpdateModalVisibility(false); + setIsLoadingState(true); + try { + await Promise.all(idsToUpdate.map((id) => apiUpdateApiKeyCall({ id, http }))); + toasts.addSuccess( + i18n.translate('xpack.triggersActionsUI.updateApiKeyConfirmModal.successMessage', { + defaultMessage: + 'API {idsToUpdate, plural, one {key} other {keys}} {idsToUpdate, plural, one {has} other {have}} been updated', + values: { idsToUpdate: idsToUpdate.length }, + }) + ); + } catch (e) { + toasts.addError(e, { + title: i18n.translate( + 'xpack.triggersActionsUI.updateApiKeyConfirmModal.failureMessage', + { + defaultMessage: + 'Failed to update the API {idsToUpdate, plural, one {key} other {keys}}', + values: { idsToUpdate: idsToUpdate.length }, + } + ), + }); + } + setIsLoadingState(false); + onUpdated(); + }} + cancelButtonText={i18n.translate( + 'xpack.triggersActionsUI.updateApiKeyConfirmModal.cancelButton', + { + defaultMessage: 'Cancel', + } + )} + confirmButtonText={i18n.translate( + 'xpack.triggersActionsUI.updateApiKeyConfirmModal.confirmButton', + { + defaultMessage: 'Update', + } + )} + > + {i18n.translate('xpack.triggersActionsUI.updateApiKeyConfirmModal.description', { + defaultMessage: + 'You will not be able to recover the old API {idsToUpdate, plural, one {key} other {keys}}', + values: { idsToUpdate: idsToUpdate.length }, + })} + + ) : null; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts index c9834dd140ea4..d0e7728498c5b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts @@ -27,3 +27,4 @@ export { updateRule } from './update'; export { resolveRule } from './resolve_rule'; export { snoozeRule } from './snooze'; export { unsnoozeRule } from './unsnooze'; +export { updateAPIKey } from './update_api_key'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.test.ts new file mode 100644 index 0000000000000..15d1e6a58596e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.test.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. + */ + +import { httpServiceMock } from '@kbn/core/public/mocks'; +import { updateAPIKey } from './update_api_key'; + +const http = httpServiceMock.createStartContract(); +beforeEach(() => jest.resetAllMocks()); + +describe('updateAPIKey', () => { + test('should call _update_api_key rule API', async () => { + const result = await updateAPIKey({ http, id: '1/' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/internal/alerting/rule/1%2F/_update_api_key", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.ts new file mode 100644 index 0000000000000..bc10217d441a2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.ts @@ -0,0 +1,14 @@ +/* + * 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 { HttpSetup } from '@kbn/core/public'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; + +export async function updateAPIKey({ id, http }: { id: string; http: HttpSetup }): Promise { + return http.post( + `${INTERNAL_BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/_update_api_key` + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popopver.scss b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popopver.scss new file mode 100644 index 0000000000000..f776a67fabf89 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popopver.scss @@ -0,0 +1,3 @@ +.ruleActionsPopover__deleteButton { + color: $euiColorDangerText; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.test.tsx new file mode 100644 index 0000000000000..bec45767bfee2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.test.tsx @@ -0,0 +1,201 @@ +/* + * 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 { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { RuleActionsPopover } from './rule_actions_popover'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { Rule } from '../../../..'; + +describe('rule_actions_popover', () => { + const onDeleteMock = jest.fn(); + const onApiKeyUpdateMock = jest.fn(); + const onEnableDisableMock = jest.fn(); + + function mockRule(overloads: Partial = {}): Rule { + return { + id: '12345', + enabled: true, + name: `rule-12345`, + tags: [], + ruleTypeId: '.noop', + consumer: 'consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + notifyWhen: null, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + ...overloads, + }; + } + + it('renders all the buttons', () => { + const rule = mockRule(); + render( + + + + ); + + const actionButton = screen.getByTestId('ruleActionsButton'); + expect(actionButton).toBeInTheDocument(); + fireEvent.click(actionButton); + expect(screen.getByText('Update API key')).toBeInTheDocument(); + expect(screen.getByText('Delete rule')).toBeInTheDocument(); + expect(screen.getByText('Disable')).toBeInTheDocument(); + }); + + it('calls onDelete', async () => { + const rule = mockRule(); + render( + + + + ); + + const actionButton = screen.getByTestId('ruleActionsButton'); + expect(actionButton).toBeInTheDocument(); + fireEvent.click(actionButton); + + const deleteButton = screen.getByText('Delete rule'); + expect(deleteButton).toBeInTheDocument(); + fireEvent.click(deleteButton); + + expect(onDeleteMock).toHaveBeenCalledWith('12345'); + await waitFor(() => { + expect(screen.queryByText('Delete rule')).not.toBeInTheDocument(); + }); + }); + + it('disables the rule', async () => { + const rule = mockRule(); + render( + + + + ); + + const actionButton = screen.getByTestId('ruleActionsButton'); + expect(actionButton).toBeInTheDocument(); + fireEvent.click(actionButton); + + const disableButton = screen.getByText('Disable'); + expect(disableButton).toBeInTheDocument(); + fireEvent.click(disableButton); + + expect(onEnableDisableMock).toHaveBeenCalledWith(false); + await waitFor(() => { + expect(screen.queryByText('Disable')).not.toBeInTheDocument(); + }); + }); + it('enables the rule', async () => { + const rule = mockRule({ enabled: false }); + render( + + + + ); + + const actionButton = screen.getByTestId('ruleActionsButton'); + expect(actionButton).toBeInTheDocument(); + fireEvent.click(actionButton); + + const enableButton = screen.getByText('Enable'); + expect(enableButton).toBeInTheDocument(); + fireEvent.click(enableButton); + + expect(onEnableDisableMock).toHaveBeenCalledWith(true); + await waitFor(() => { + expect(screen.queryByText('Disable')).not.toBeInTheDocument(); + }); + }); + + it('calls onApiKeyUpdate', async () => { + const rule = mockRule(); + render( + + + + ); + + const actionButton = screen.getByTestId('ruleActionsButton'); + expect(actionButton).toBeInTheDocument(); + fireEvent.click(actionButton); + + const deleteButton = screen.getByText('Update API key'); + expect(deleteButton).toBeInTheDocument(); + fireEvent.click(deleteButton); + + expect(onApiKeyUpdateMock).toHaveBeenCalledWith('12345'); + await waitFor(() => { + expect(screen.queryByText('Update API key')).not.toBeInTheDocument(); + }); + }); + + it('disables buttons when the user does not have enough permission', async () => { + const rule = mockRule(); + render( + + + + ); + + const actionButton = screen.getByTestId('ruleActionsButton'); + expect(actionButton).toBeInTheDocument(); + fireEvent.click(actionButton); + + expect(screen.getByText('Delete rule').closest('button')).toBeDisabled(); + expect(screen.getByText('Update API key').closest('button')).toBeDisabled(); + expect(screen.getByText('Disable').closest('button')).toBeDisabled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.tsx new file mode 100644 index 0000000000000..0862bf3bf3c1c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.tsx @@ -0,0 +1,107 @@ +/* + * 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 React, { useState } from 'react'; +import { EuiButtonEmpty, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import './rule_actions_popopver.scss'; +import { Rule } from '../../../..'; + +export interface RuleActionsPopoverProps { + rule: Rule; + canSaveRule: boolean; + onDelete: (ruleId: string) => void; + onApiKeyUpdate: (ruleId: string) => void; + onEnableDisable: (enable: boolean) => void; +} + +export const RuleActionsPopover: React.FunctionComponent = ({ + rule, + canSaveRule, + onDelete, + onApiKeyUpdate, + onEnableDisable, +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + setIsPopoverOpen(!isPopoverOpen)} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.popoverButtonTitle', + { defaultMessage: 'Actions' } + )} + /> + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + ownFocus + panelPaddingSize="none" + > + { + setIsPopoverOpen(false); + onEnableDisable(!rule.enabled); + }, + name: !rule.enabled + ? i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.enableRuleButtonLabel', + { defaultMessage: 'Enable' } + ) + : i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.disableRuleButtonLabel', + { defaultMessage: 'Disable' } + ), + }, + { + disabled: !canSaveRule, + 'data-test-subj': 'updateAPIKeyButton', + onClick: () => { + setIsPopoverOpen(false); + onApiKeyUpdate(rule.id); + }, + name: i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.updateAPIKeyButtonLabel', + { defaultMessage: 'Update API key' } + ), + }, + { + disabled: !canSaveRule, + className: 'ruleActionsPopover__deleteButton', + 'data-test-subj': 'deleteRuleButton', + onClick: () => { + setIsPopoverOpen(false); + onDelete(rule.id); + }, + name: i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.deleteRuleButtonLabel', + { defaultMessage: 'Delete rule' } + ), + }, + ], + }, + ]} + className="ruleActionsPopover" + data-test-subj="ruleActionsPopover" + data-testid="ruleActionsPopover" + /> + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx index 7857eb172eedb..59f4a1bc70e03 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx @@ -44,6 +44,12 @@ jest.mock('../../../lib/action_connector_api', () => ({ loadAllActions: jest.fn().mockResolvedValue([]), })); +jest.mock('../../../lib/rule_api', () => ({ + updateAPIKey: jest.fn(), + deleteRules: jest.fn(), +})); +const { updateAPIKey, deleteRules } = jest.requireMock('../../../lib/rule_api'); + jest.mock('../../../lib/capabilities', () => ({ hasAllPrivilege: jest.fn(() => true), hasSaveRulesCapability: jest.fn(() => true), @@ -83,219 +89,225 @@ const ruleType: RuleType = { }; describe('rule_details', () => { - it('renders the rule name as a title', () => { - const rule = mockRule(); - expect( - shallow( - - ).find('EuiPageHeader') - ).toBeTruthy(); - }); + describe('page', () => { + it('renders the rule name as a title', () => { + const rule = mockRule(); + expect( + shallow( + + ).find('EuiPageHeader') + ).toBeTruthy(); + }); - it('renders the rule type badge', () => { - const rule = mockRule(); - expect( - shallow( - - ).find({ruleType.name}) - ).toBeTruthy(); - }); + it('renders the rule type badge', () => { + const rule = mockRule(); + expect( + shallow( + + ).find({ruleType.name}) + ).toBeTruthy(); + }); - it('renders the API key owner badge when user can manage API keys', () => { - const rule = mockRule(); - expect( - shallow( - - ).find({rule.apiKeyOwner}) - ).toBeTruthy(); - }); + it('renders the API key owner badge when user can manage API keys', () => { + const rule = mockRule(); + expect( + shallow( + + ).find({rule.apiKeyOwner}) + ).toBeTruthy(); + }); - it(`doesn't render the API key owner badge when user can't manage API keys`, () => { - const { hasManageApiKeysCapability } = jest.requireMock('../../../lib/capabilities'); - hasManageApiKeysCapability.mockReturnValueOnce(false); - const rule = mockRule(); - expect( - shallow() - .find({rule.apiKeyOwner}) - .exists() - ).toBeFalsy(); - }); + it(`doesn't render the API key owner badge when user can't manage API keys`, () => { + const { hasManageApiKeysCapability } = jest.requireMock('../../../lib/capabilities'); + hasManageApiKeysCapability.mockReturnValueOnce(false); + const rule = mockRule(); + expect( + shallow() + .find({rule.apiKeyOwner}) + .exists() + ).toBeFalsy(); + }); - it('renders the rule error banner with error message, when rule has a license error', () => { - const rule = mockRule({ - enabled: true, - executionStatus: { - status: 'error', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: { - reason: RuleExecutionStatusErrorReasons.License, - message: 'test', + it('renders the rule error banner with error message, when rule has a license error', () => { + const rule = mockRule({ + enabled: true, + executionStatus: { + status: 'error', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.License, + message: 'test', + }, }, - }, + }); + const wrapper = shallow( + + ); + expect( + wrapper.find('[data-test-subj="ruleErrorBanner"]').first().text() + ).toMatchInlineSnapshot(`" Cannot run rule, test "`); }); - const wrapper = shallow( - - ); - expect(wrapper.find('[data-test-subj="ruleErrorBanner"]').first().text()).toMatchInlineSnapshot( - `" Cannot run rule, test "` - ); - }); - it('renders the rule warning banner with warning message, when rule status is a warning', () => { - const rule = mockRule({ - enabled: true, - executionStatus: { - status: 'warning', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - warning: { - reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, - message: 'warning message', + it('renders the rule warning banner with warning message, when rule status is a warning', () => { + const rule = mockRule({ + enabled: true, + executionStatus: { + status: 'warning', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'warning message', + }, }, - }, + }); + const wrapper = shallow( + + ); + expect( + wrapper.find('[data-test-subj="ruleWarningBanner"]').first().text() + ).toMatchInlineSnapshot(`" Action limit exceeded warning message"`); }); - const wrapper = shallow( - - ); - expect( - wrapper.find('[data-test-subj="ruleWarningBanner"]').first().text() - ).toMatchInlineSnapshot(`" Action limit exceeded warning message"`); - }); - it('displays a toast message when interval is less than configured minimum', async () => { - const rule = mockRule({ - schedule: { - interval: '1s', - }, - }); - const wrapper = mountWithIntl( - - ); + it('displays a toast message when interval is less than configured minimum', async () => { + const rule = mockRule({ + schedule: { + interval: '1s', + }, + }); + const wrapper = mountWithIntl( + + ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await act(async () => { + await nextTick(); + wrapper.update(); + }); - expect(useKibanaMock().services.notifications.toasts.addInfo).toHaveBeenCalled(); - }); + expect(useKibanaMock().services.notifications.toasts.addInfo).toHaveBeenCalled(); + }); - describe('actions', () => { - it('renders an rule action', () => { - const rule = mockRule({ - actions: [ + describe('actions', () => { + it('renders an rule action', () => { + const rule = mockRule({ + actions: [ + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + + const actionTypes: ActionType[] = [ { - group: 'default', - id: uuid.v4(), - params: {}, - actionTypeId: '.server-log', + id: '.server-log', + name: 'Server log', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', }, - ], + ]; + + expect( + mountWithIntl( + + ).containsMatchingElement( + + {actionTypes[0].name} + + ) + ).toBeTruthy(); }); - const actionTypes: ActionType[] = [ - { - id: '.server-log', - name: 'Server log', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - ]; + it('renders a counter for multiple rule action', () => { + const rule = mockRule({ + actions: [ + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.email', + }, + ], + }); + const actionTypes: ActionType[] = [ + { + id: '.server-log', + name: 'Server log', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + { + id: '.email', + name: 'Send email', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + ]; - expect( - mountWithIntl( + const details = mountWithIntl( - ).containsMatchingElement( - - {actionTypes[0].name} - - ) - ).toBeTruthy(); + ); + + expect( + details.containsMatchingElement( + + {actionTypes[0].name} + + ) + ).toBeTruthy(); + + expect( + details.containsMatchingElement( + + {actionTypes[1].name} + + ) + ).toBeTruthy(); + }); }); - it('renders a counter for multiple rule action', () => { - const rule = mockRule({ - actions: [ - { - group: 'default', - id: uuid.v4(), - params: {}, - actionTypeId: '.server-log', - }, - { - group: 'default', - id: uuid.v4(), - params: {}, - actionTypeId: '.email', - }, - ], + describe('links', () => { + it('links to the app that created the rule', () => { + const rule = mockRule(); + expect( + shallow( + + ).find('ViewInApp') + ).toBeTruthy(); }); - const actionTypes: ActionType[] = [ - { - id: '.server-log', - name: 'Server log', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - { - id: '.email', - name: 'Send email', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - ]; - - const details = mountWithIntl( - - ); - expect( - details.containsMatchingElement( - - {actionTypes[0].name} - - ) - ).toBeTruthy(); - - expect( - details.containsMatchingElement( - - {actionTypes[1].name} - - ) - ).toBeTruthy(); - }); - }); - - describe('links', () => { - it('links to the app that created the rule', () => { - const rule = mockRule(); - expect( - shallow( + it('links to the Edit flyout', () => { + const rule = mockRule(); + const pageHeaderProps = shallow( - ).find('ViewInApp') - ).toBeTruthy(); - }); - - it('links to the Edit flyout', () => { - const rule = mockRule(); - const pageHeaderProps = shallow( - - ) - .find('EuiPageHeader') - .props() as EuiPageHeaderProps; - const rightSideItems = pageHeaderProps.rightSideItems; - expect(!!rightSideItems && rightSideItems[2]!).toMatchInlineSnapshot(` + ) + .find('EuiPageHeader') + .props() as EuiPageHeaderProps; + const rightSideItems = pageHeaderProps.rightSideItems; + expect(!!rightSideItems && rightSideItems[1]!).toMatchInlineSnapshot(` { `); + }); }); }); -}); -describe('disable/enable functionality', () => { - it('should show that the rule is enabled', () => { - const rule = mockRule({ - enabled: true, + describe('disable/enable functionality', () => { + it('should show that the rule is enabled', () => { + const rule = mockRule({ + enabled: true, + }); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper.find('[data-test-subj="statusDropdown"]').first(); + + expect(actionsElem.text()).toEqual('Enabled'); }); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper.find('[data-test-subj="statusDropdown"]').first(); - expect(actionsElem.text()).toEqual('Enabled'); - }); + it('should show that the rule is disabled', async () => { + const rule = mockRule({ + enabled: false, + }); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper.find('[data-test-subj="statusDropdown"]').first(); - it('should show that the rule is disabled', async () => { - const rule = mockRule({ - enabled: false, + expect(actionsElem.text()).toEqual('Disabled'); }); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper.find('[data-test-subj="statusDropdown"]').first(); - expect(actionsElem.text()).toEqual('Disabled'); - }); + it('should disable the rule when picking disable in the dropdown', async () => { + const rule = mockRule({ + enabled: true, + }); + const disableRule = jest.fn(); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + actionsElem.simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); - it('should disable the rule when picking disable in the dropdown', async () => { - const rule = mockRule({ - enabled: true, - }); - const disableRule = jest.fn(); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper - .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') - .first(); - actionsElem.simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await act(async () => { + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(1).simulate('click'); + await nextTick(); + }); - await act(async () => { - const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); - const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); - actionsMenuItemElem.at(1).simulate('click'); - await nextTick(); + expect(disableRule).toHaveBeenCalledTimes(1); }); - expect(disableRule).toHaveBeenCalledTimes(1); - }); + it('if rule is already disable should do nothing when picking disable in the dropdown', async () => { + const rule = mockRule({ + enabled: false, + }); + const disableRule = jest.fn(); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + actionsElem.simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); - it('if rule is already disable should do nothing when picking disable in the dropdown', async () => { - const rule = mockRule({ - enabled: false, - }); - const disableRule = jest.fn(); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper - .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') - .first(); - actionsElem.simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await act(async () => { + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(1).simulate('click'); + await nextTick(); + }); - await act(async () => { - const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); - const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); - actionsMenuItemElem.at(1).simulate('click'); - await nextTick(); + expect(disableRule).toHaveBeenCalledTimes(0); }); - expect(disableRule).toHaveBeenCalledTimes(0); - }); + it('should enable the rule when picking enable in the dropdown', async () => { + const rule = mockRule({ + enabled: false, + }); + const enableRule = jest.fn(); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + actionsElem.simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); - it('should enable the rule when picking enable in the dropdown', async () => { - const rule = mockRule({ - enabled: false, - }); - const enableRule = jest.fn(); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper - .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') - .first(); - actionsElem.simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await act(async () => { + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(0).simulate('click'); + await nextTick(); + }); - await act(async () => { - const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); - const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); - actionsMenuItemElem.at(0).simulate('click'); - await nextTick(); + expect(enableRule).toHaveBeenCalledTimes(1); }); - expect(enableRule).toHaveBeenCalledTimes(1); - }); + it('if rule is already enable should do nothing when picking enable in the dropdown', async () => { + const rule = mockRule({ + enabled: true, + }); + const enableRule = jest.fn(); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + actionsElem.simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); - it('if rule is already enable should do nothing when picking enable in the dropdown', async () => { - const rule = mockRule({ - enabled: true, - }); - const enableRule = jest.fn(); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper - .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') - .first(); - actionsElem.simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await act(async () => { + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(0).simulate('click'); + await nextTick(); + }); - await act(async () => { - const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); - const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); - actionsMenuItemElem.at(0).simulate('click'); - await nextTick(); + expect(enableRule).toHaveBeenCalledTimes(0); }); - expect(enableRule).toHaveBeenCalledTimes(0); - }); + it('should show the loading spinner when the rule enabled switch was clicked and the server responded with some delay', async () => { + const rule = mockRule({ + enabled: true, + }); - it('should show the loading spinner when the rule enabled switch was clicked and the server responded with some delay', async () => { - const rule = mockRule({ - enabled: true, - }); + const disableRule = jest.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 6000)); + }); + const enableRule = jest.fn(); + const wrapper = mountWithIntl( + + ); - const disableRule = jest.fn(async () => { - await new Promise((resolve) => setTimeout(resolve, 6000)); - }); - const enableRule = jest.fn(); - const wrapper = mountWithIntl( - - ); - - const actionsElem = wrapper - .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') - .first(); - actionsElem.simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + actionsElem.simulate('click'); - await act(async () => { - const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); - const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); - actionsMenuItemElem.at(1).simulate('click'); - }); + await act(async () => { + await nextTick(); + wrapper.update(); + }); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await act(async () => { + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(1).simulate('click'); + }); - await act(async () => { - expect(disableRule).toHaveBeenCalled(); - expect( - wrapper.find('[data-test-subj="statusDropdown"] .euiBadge__childButton .euiLoadingSpinner') - .length - ).toBeGreaterThan(0); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + await act(async () => { + expect(disableRule).toHaveBeenCalled(); + expect( + wrapper.find( + '[data-test-subj="statusDropdown"] .euiBadge__childButton .euiLoadingSpinner' + ).length + ).toBeGreaterThan(0); + }); }); }); -}); -describe('snooze functionality', () => { - it('should render "Snooze Indefinitely" when rule is enabled and mute all', () => { - const rule = mockRule({ - enabled: true, - muteAll: true, + describe('snooze functionality', () => { + it('should render "Snooze Indefinitely" when rule is enabled and mute all', () => { + const rule = mockRule({ + enabled: true, + muteAll: true, + }); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + expect(actionsElem.text()).toEqual('Snoozed'); + expect(wrapper.find('[data-test-subj="remainingSnoozeTime"]').first().text()).toEqual( + 'Indefinitely' + ); }); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper - .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') - .first(); - expect(actionsElem.text()).toEqual('Snoozed'); - expect(wrapper.find('[data-test-subj="remainingSnoozeTime"]').first().text()).toEqual( - 'Indefinitely' - ); }); -}); -describe('edit button', () => { - const actionTypes: ActionType[] = [ - { - id: '.server-log', - name: 'Server log', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - ]; - ruleTypeRegistry.has.mockReturnValue(true); - const ruleTypeR: RuleTypeModel = { - id: 'my-rule-type', - iconClass: 'test', - description: 'Rule when testing', - documentationUrl: 'https://localhost.local/docs', - validate: () => { - return { errors: {} }; - }, - ruleParamsExpression: jest.fn(), - requiresAppContext: false, - }; - ruleTypeRegistry.get.mockReturnValue(ruleTypeR); - useKibanaMock().services.ruleTypeRegistry = ruleTypeRegistry; - - it('should render an edit button when rule and actions are editable', () => { - const rule = mockRule({ - enabled: true, - muteAll: false, - actions: [ - { - group: 'default', - id: uuid.v4(), - params: {}, - actionTypeId: '.server-log', - }, - ], - }); - const pageHeaderProps = shallow( - - ) - .find('EuiPageHeader') - .props() as EuiPageHeaderProps; - const rightSideItems = pageHeaderProps.rightSideItems; - expect(!!rightSideItems && rightSideItems[2]!).toMatchInlineSnapshot(` + describe('edit button', () => { + const actionTypes: ActionType[] = [ + { + id: '.server-log', + name: 'Server log', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + ]; + ruleTypeRegistry.has.mockReturnValue(true); + const ruleTypeR: RuleTypeModel = { + id: 'my-rule-type', + iconClass: 'test', + description: 'Rule when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + ruleParamsExpression: jest.fn(), + requiresAppContext: false, + }; + ruleTypeRegistry.get.mockReturnValue(ruleTypeR); + useKibanaMock().services.ruleTypeRegistry = ruleTypeRegistry; + + it('should render an edit button when rule and actions are editable', () => { + const rule = mockRule({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + const pageHeaderProps = shallow( + + ) + .find('EuiPageHeader') + .props() as EuiPageHeaderProps; + const rightSideItems = pageHeaderProps.rightSideItems; + expect(!!rightSideItems && rightSideItems[1]!).toMatchInlineSnapshot(` { `); - }); + }); - it('should not render an edit button when rule editable but actions arent', () => { - const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); - hasExecuteActionsCapability.mockReturnValueOnce(false); - const rule = mockRule({ - enabled: true, - muteAll: false, - actions: [ - { - group: 'default', - id: uuid.v4(), - params: {}, - actionTypeId: '.server-log', - }, - ], + it('should not render an edit button when rule editable but actions arent', () => { + const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); + hasExecuteActionsCapability.mockReturnValueOnce(false); + const rule = mockRule({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + expect( + shallow( + + ) + .find(EuiButtonEmpty) + .find('[name="edit"]') + .first() + .exists() + ).toBeFalsy(); }); - expect( - shallow( + + it('should render an edit button when rule editable but actions arent when there are no actions on the rule', async () => { + const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); + hasExecuteActionsCapability.mockReturnValueOnce(false); + const rule = mockRule({ + enabled: true, + muteAll: false, + actions: [], + }); + const pageHeaderProps = shallow( ) - .find(EuiButtonEmpty) - .find('[name="edit"]') - .first() - .exists() - ).toBeFalsy(); - }); - - it('should render an edit button when rule editable but actions arent when there are no actions on the rule', async () => { - const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); - hasExecuteActionsCapability.mockReturnValueOnce(false); - const rule = mockRule({ - enabled: true, - muteAll: false, - actions: [], - }); - const pageHeaderProps = shallow( - - ) - .find('EuiPageHeader') - .props() as EuiPageHeaderProps; - const rightSideItems = pageHeaderProps.rightSideItems; - expect(!!rightSideItems && rightSideItems[2]!).toMatchInlineSnapshot(` + .find('EuiPageHeader') + .props() as EuiPageHeaderProps; + const rightSideItems = pageHeaderProps.rightSideItems; + expect(!!rightSideItems && rightSideItems[1]!).toMatchInlineSnapshot(` { `); + }); }); -}); -describe('broken connector indicator', () => { - const actionTypes: ActionType[] = [ - { - id: '.server-log', - name: 'Server log', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - ]; - ruleTypeRegistry.has.mockReturnValue(true); - const ruleTypeR: RuleTypeModel = { - id: 'my-rule-type', - iconClass: 'test', - description: 'Rule when testing', - documentationUrl: 'https://localhost.local/docs', - validate: () => { - return { errors: {} }; - }, - ruleParamsExpression: jest.fn(), - requiresAppContext: false, - }; - ruleTypeRegistry.get.mockReturnValue(ruleTypeR); - useKibanaMock().services.ruleTypeRegistry = ruleTypeRegistry; - const { loadAllActions } = jest.requireMock('../../../lib/action_connector_api'); - loadAllActions.mockResolvedValue([ - { - secrets: {}, - isMissingSecrets: false, - id: 'connector-id-1', - actionTypeId: '.server-log', - name: 'Test connector', - config: {}, - isPreconfigured: false, - isDeprecated: false, - }, - { - secrets: {}, - isMissingSecrets: false, - id: 'connector-id-2', - actionTypeId: '.server-log', - name: 'Test connector 2', - config: {}, - isPreconfigured: false, - isDeprecated: false, - }, - ]); - - it('should not render broken connector indicator or warning if all rule actions connectors exist', async () => { - const rule = mockRule({ - enabled: true, - muteAll: false, - actions: [ - { - group: 'default', - id: 'connector-id-1', - params: {}, - actionTypeId: '.server-log', - }, - { - group: 'default', - id: 'connector-id-2', - params: {}, - actionTypeId: '.server-log', - }, - ], + describe('broken connector indicator', () => { + const actionTypes: ActionType[] = [ + { + id: '.server-log', + name: 'Server log', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + ]; + ruleTypeRegistry.has.mockReturnValue(true); + const ruleTypeR: RuleTypeModel = { + id: 'my-rule-type', + iconClass: 'test', + description: 'Rule when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + ruleParamsExpression: jest.fn(), + requiresAppContext: false, + }; + ruleTypeRegistry.get.mockReturnValue(ruleTypeR); + useKibanaMock().services.ruleTypeRegistry = ruleTypeRegistry; + const { loadAllActions } = jest.requireMock('../../../lib/action_connector_api'); + loadAllActions.mockResolvedValue([ + { + secrets: {}, + isMissingSecrets: false, + id: 'connector-id-1', + actionTypeId: '.server-log', + name: 'Test connector', + config: {}, + isPreconfigured: false, + isDeprecated: false, + }, + { + secrets: {}, + isMissingSecrets: false, + id: 'connector-id-2', + actionTypeId: '.server-log', + name: 'Test connector 2', + config: {}, + isPreconfigured: false, + isDeprecated: false, + }, + ]); + + it('should not render broken connector indicator or warning if all rule actions connectors exist', async () => { + const rule = mockRule({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: 'connector-id-1', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-2', + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + const wrapper = mountWithIntl( + + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const brokenConnectorIndicator = wrapper + .find('[data-test-subj="actionWithBrokenConnector"]') + .first(); + const brokenConnectorWarningBanner = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') + .first(); + expect(brokenConnectorIndicator.exists()).toBeFalsy(); + expect(brokenConnectorWarningBanner.exists()).toBeFalsy(); }); - const wrapper = mountWithIntl( - - ); - await act(async () => { - await nextTick(); - wrapper.update(); + + it('should render broken connector indicator and warning if any rule actions connector does not exist', async () => { + const rule = mockRule({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: 'connector-id-1', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-2', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-doesnt-exist', + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + const wrapper = mountWithIntl( + + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const brokenConnectorIndicator = wrapper + .find('[data-test-subj="actionWithBrokenConnector"]') + .first(); + const brokenConnectorWarningBanner = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') + .first(); + const brokenConnectorWarningBannerAction = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBannerEdit"]') + .first(); + expect(brokenConnectorIndicator.exists()).toBeTruthy(); + expect(brokenConnectorWarningBanner.exists()).toBeTruthy(); + expect(brokenConnectorWarningBannerAction.exists()).toBeTruthy(); }); - const brokenConnectorIndicator = wrapper - .find('[data-test-subj="actionWithBrokenConnector"]') - .first(); - const brokenConnectorWarningBanner = wrapper - .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') - .first(); - expect(brokenConnectorIndicator.exists()).toBeFalsy(); - expect(brokenConnectorWarningBanner.exists()).toBeFalsy(); - }); - it('should render broken connector indicator and warning if any rule actions connector does not exist', async () => { - const rule = mockRule({ - enabled: true, - muteAll: false, - actions: [ - { - group: 'default', - id: 'connector-id-1', - params: {}, - actionTypeId: '.server-log', - }, - { - group: 'default', - id: 'connector-id-2', - params: {}, - actionTypeId: '.server-log', - }, - { - group: 'default', - id: 'connector-id-doesnt-exist', - params: {}, - actionTypeId: '.server-log', - }, - ], + it('should render broken connector indicator and warning with no edit button if any rule actions connector does not exist and user has no edit access', async () => { + const rule = mockRule({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: 'connector-id-1', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-2', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-doesnt-exist', + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); + hasExecuteActionsCapability.mockReturnValue(false); + const wrapper = mountWithIntl( + + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const brokenConnectorIndicator = wrapper + .find('[data-test-subj="actionWithBrokenConnector"]') + .first(); + const brokenConnectorWarningBanner = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') + .first(); + const brokenConnectorWarningBannerAction = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBannerEdit"]') + .first(); + expect(brokenConnectorIndicator.exists()).toBeTruthy(); + expect(brokenConnectorWarningBanner.exists()).toBeTruthy(); + expect(brokenConnectorWarningBannerAction.exists()).toBeFalsy(); }); - const wrapper = mountWithIntl( - - ); - await act(async () => { - await nextTick(); - wrapper.update(); + }); + + describe('refresh button', () => { + it('should call requestRefresh when clicked', async () => { + const rule = mockRule(); + const requestRefresh = jest.fn(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + const refreshButton = wrapper.find('[data-test-subj="refreshRulesButton"]').first(); + expect(refreshButton.exists()).toBeTruthy(); + + refreshButton.simulate('click'); + expect(requestRefresh).toHaveBeenCalledTimes(1); }); - const brokenConnectorIndicator = wrapper - .find('[data-test-subj="actionWithBrokenConnector"]') - .first(); - const brokenConnectorWarningBanner = wrapper - .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') - .first(); - const brokenConnectorWarningBannerAction = wrapper - .find('[data-test-subj="actionWithBrokenConnectorWarningBannerEdit"]') - .first(); - expect(brokenConnectorIndicator.exists()).toBeTruthy(); - expect(brokenConnectorWarningBanner.exists()).toBeTruthy(); - expect(brokenConnectorWarningBannerAction.exists()).toBeTruthy(); }); - it('should render broken connector indicator and warning with no edit button if any rule actions connector does not exist and user has no edit access', async () => { - const rule = mockRule({ - enabled: true, - muteAll: false, - actions: [ - { - group: 'default', - id: 'connector-id-1', - params: {}, - actionTypeId: '.server-log', - }, - { - group: 'default', - id: 'connector-id-2', - params: {}, - actionTypeId: '.server-log', - }, - { - group: 'default', - id: 'connector-id-doesnt-exist', - params: {}, - actionTypeId: '.server-log', - }, - ], + describe('update API key button', () => { + it('should call update api key when clicked', async () => { + const rule = mockRule(); + const requestRefresh = jest.fn(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const actionsButton = wrapper.find('[data-test-subj="ruleActionsButton"]').first(); + actionsButton.simulate('click'); + + const updateButton = wrapper.find('[data-test-subj="updateAPIKeyButton"]').first(); + expect(updateButton.exists()).toBeTruthy(); + + updateButton.simulate('click'); + + const confirm = wrapper.find('[data-test-subj="updateApiKeyIdsConfirmation"]').first(); + expect(confirm.exists()).toBeTruthy(); + + const confirmButton = wrapper.find('[data-test-subj="confirmModalConfirmButton"]').first(); + expect(confirmButton.exists()).toBeTruthy(); + + confirmButton.simulate('click'); + + expect(updateAPIKey).toHaveBeenCalledTimes(1); + expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: rule.id })); }); - const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); - hasExecuteActionsCapability.mockReturnValue(false); - const wrapper = mountWithIntl( - - ); - await act(async () => { - await nextTick(); - wrapper.update(); + }); + + describe('delete rule button', () => { + it('should delete the rule when clicked', async () => { + deleteRules.mockResolvedValueOnce({ successes: ['1'], errors: [] }); + const rule = mockRule(); + const requestRefresh = jest.fn(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const actionsButton = wrapper.find('[data-test-subj="ruleActionsButton"]').first(); + actionsButton.simulate('click'); + + const updateButton = wrapper.find('[data-test-subj="deleteRuleButton"]').first(); + expect(updateButton.exists()).toBeTruthy(); + + updateButton.simulate('click'); + + const confirm = wrapper.find('[data-test-subj="deleteIdsConfirmation"]').first(); + expect(confirm.exists()).toBeTruthy(); + + const confirmButton = wrapper.find('[data-test-subj="confirmModalConfirmButton"]').first(); + expect(confirmButton.exists()).toBeTruthy(); + + confirmButton.simulate('click'); + + expect(deleteRules).toHaveBeenCalledTimes(1); + expect(deleteRules).toHaveBeenCalledWith(expect.objectContaining({ ids: [rule.id] })); }); - const brokenConnectorIndicator = wrapper - .find('[data-test-subj="actionWithBrokenConnector"]') - .first(); - const brokenConnectorWarningBanner = wrapper - .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') - .first(); - const brokenConnectorWarningBannerAction = wrapper - .find('[data-test-subj="actionWithBrokenConnectorWarningBannerEdit"]') - .first(); - expect(brokenConnectorIndicator.exists()).toBeTruthy(); - expect(brokenConnectorWarningBanner.exists()).toBeTruthy(); - expect(brokenConnectorWarningBannerAction.exists()).toBeFalsy(); }); -}); -describe('refresh button', () => { - it('should call requestRefresh when clicked', async () => { - const rule = mockRule(); - const requestRefresh = jest.fn(); - const wrapper = mountWithIntl( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); + describe('enable/disable rule button', () => { + it('should disable the rule when clicked', async () => { + const rule = mockRule(); + const requestRefresh = jest.fn(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const actionsButton = wrapper.find('[data-test-subj="ruleActionsButton"]').first(); + actionsButton.simulate('click'); + + const disableButton = wrapper.find('[data-test-subj="disableButton"]').first(); + expect(disableButton.exists()).toBeTruthy(); + + disableButton.simulate('click'); + + expect(mockRuleApis.disableRule).toHaveBeenCalledTimes(1); + expect(mockRuleApis.disableRule).toHaveBeenCalledWith(rule); }); - const refreshButton = wrapper.find('[data-test-subj="refreshRulesButton"]').first(); - expect(refreshButton.exists()).toBeTruthy(); - refreshButton.simulate('click'); - expect(requestRefresh).toHaveBeenCalledTimes(1); + it('should enable the rule when clicked', async () => { + const rule = { ...mockRule(), enabled: false }; + const requestRefresh = jest.fn(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const actionsButton = wrapper.find('[data-test-subj="ruleActionsButton"]').first(); + actionsButton.simulate('click'); + + const enableButton = wrapper.find('[data-test-subj="disableButton"]').first(); + expect(enableButton.exists()).toBeTruthy(); + + enableButton.simulate('click'); + + expect(mockRuleApis.enableRule).toHaveBeenCalledTimes(1); + expect(mockRuleApis.enableRule).toHaveBeenCalledWith(rule); + }); }); -}); -function mockRule(overloads: Partial = {}): Rule { - return { - id: uuid.v4(), - enabled: true, - name: `rule-${uuid.v4()}`, - tags: [], - ruleTypeId: '.noop', - consumer: ALERTS_FEATURE_ID, - schedule: { interval: '1m' }, - actions: [], - params: {}, - createdBy: null, - updatedBy: null, - createdAt: new Date(), - updatedAt: new Date(), - apiKeyOwner: 'bob', - throttle: null, - notifyWhen: null, - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'unknown', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - }, - ...overloads, - }; -} + function mockRule(overloads: Partial = {}): Rule { + return { + id: uuid.v4(), + enabled: true, + name: `rule-${uuid.v4()}`, + tags: [], + ruleTypeId: '.noop', + consumer: ALERTS_FEATURE_ID, + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + notifyWhen: null, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + ...overloads, + }; + } +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx index 4554aa99c54c1..4faae3f2b26d5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx @@ -27,6 +27,10 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { RuleExecutionStatusErrorReasons, parseDuration } from '@kbn/alerting-plugin/common'; +import { UpdateApiKeyModalConfirmation } from '../../../components/update_api_key_modal_confirmation'; +import { updateAPIKey, deleteRules } from '../../../lib/rule_api'; +import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation'; +import { RuleActionsPopover } from './rule_actions_popover'; import { hasAllPrivilege, hasExecuteActionsCapability, @@ -49,7 +53,7 @@ import { import { RuleRouteWithApi } from './rule_route'; import { ViewInApp } from './view_in_app'; import { RuleEdit } from '../../rule_form'; -import { routeToRuleDetails } from '../../../constants'; +import { routeToRuleDetails, routeToRules } from '../../../constants'; import { rulesErrorReasonTranslationsMapping, rulesWarningReasonTranslationsMapping, @@ -94,6 +98,9 @@ export const RuleDetails: React.FunctionComponent = ({ dispatch({ command: { type: 'setRule' }, payload: { key: 'rule', value } }); }; + const [rulesToDelete, setRulesToDelete] = useState([]); + const [rulesToUpdateAPIKey, setRulesToUpdateAPIKey] = useState([]); + const [hasActionsWithBrokenConnector, setHasActionsWithBrokenConnector] = useState(false); @@ -207,6 +214,10 @@ export const RuleDetails: React.FunctionComponent = ({ history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); }; + const goToRulesList = () => { + history.push(routeToRules); + }; + const getRuleStatusErrorReasonText = () => { if (rule.executionStatus.error && rule.executionStatus.error.reason) { return rulesErrorReasonTranslationsMapping[rule.executionStatus.error.reason]; @@ -223,40 +234,71 @@ export const RuleDetails: React.FunctionComponent = ({ } }; - const rightPageHeaderButtons = hasEditButton - ? [ - <> - setEditFlyoutVisibility(true)} - name="edit" - disabled={!ruleType.enabledInLicense} - > - - - {editFlyoutVisible && ( - { - setInitialRule(rule); - setEditFlyoutVisibility(false); - }} - actionTypeRegistry={actionTypeRegistry} - ruleTypeRegistry={ruleTypeRegistry} - ruleType={ruleType} - onSave={setRule} - /> - )} - , - ] - : []; + const editButton = hasEditButton ? ( + <> + setEditFlyoutVisibility(true)} + name="edit" + disabled={!ruleType.enabledInLicense} + > + + + {editFlyoutVisible && ( + { + setInitialRule(rule); + setEditFlyoutVisibility(false); + }} + actionTypeRegistry={actionTypeRegistry} + ruleTypeRegistry={ruleTypeRegistry} + ruleType={ruleType} + onSave={setRule} + /> + )} + + ) : null; return ( <> + { + setRulesToDelete([]); + goToRulesList(); + }} + onErrors={async () => { + // Refresh the rule from the server, it may have been deleted + await requestRefresh(); + setRulesToDelete([]); + }} + onCancel={() => { + setRulesToDelete([]); + }} + apiDeleteCall={deleteRules} + idsToDelete={rulesToDelete} + singleTitle={i18n.translate('xpack.triggersActionsUI.sections.rulesList.singleTitle', { + defaultMessage: 'rule', + })} + multipleTitle="" + setIsLoadingState={() => {}} + /> + { + setRulesToUpdateAPIKey([]); + }} + idsToUpdate={rulesToUpdateAPIKey} + apiUpdateApiKeyCall={updateAPIKey} + setIsLoadingState={() => {}} + onUpdated={async () => { + setRulesToUpdateAPIKey([]); + requestRefresh(); + }} + /> = ({ } rightSideItems={[ - , + { + setRulesToDelete([ruleId]); + }} + onApiKeyUpdate={(ruleId) => { + setRulesToUpdateAPIKey([ruleId]); + }} + onEnableDisable={async (enable) => { + if (enable) { + await enableRule(rule); + } else { + await disableRule(rule); + } + requestRefresh(); + }} + />, + editButton, = ({ defaultMessage="Refresh" /> , - ...rightPageHeaderButtons, + , ]} /> @@ -422,7 +482,6 @@ export const RuleDetails: React.FunctionComponent = ({ ) : null} - {rule.enabled && rule.executionStatus.status === 'warning' ? ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.scss b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.scss index ffe000073aa75..fe009998ff1c4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.scss +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.scss @@ -1,9 +1,3 @@ -.actCollapsedItemActions { - .euiContextMenuItem:hover { - text-decoration: none; - } -} - -button[data-test-subj='deleteRule'] { - color: $euiColorDanger; +.collapsedItemActions__deleteButton { + color: $euiColorDangerText; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.test.tsx index 8ce6736aee8ad..7759c940b8865 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.test.tsx @@ -21,6 +21,7 @@ const disableRule = jest.fn(); const enableRule = jest.fn(); const unmuteRule = jest.fn(); const muteRule = jest.fn(); +const onUpdateAPIKey = jest.fn(); export const tick = (ms = 0) => new Promise((resolve) => { @@ -91,6 +92,7 @@ describe('CollapsedItemActions', () => { enableRule, unmuteRule, muteRule, + onUpdateAPIKey, }; }; @@ -118,6 +120,7 @@ describe('CollapsedItemActions', () => { expect(wrapper.find('[data-test-subj="disableButton"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="editRule"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="deleteRule"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="updateApiKey"]').exists()).toBeFalsy(); wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); await act(async () => { @@ -130,6 +133,7 @@ describe('CollapsedItemActions', () => { expect(wrapper.find('[data-test-subj="disableButton"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="editRule"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="deleteRule"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="updateApiKey"]').exists()).toBeTruthy(); expect( wrapper.find('[data-test-subj="selectActionButton"]').first().props().disabled @@ -143,6 +147,7 @@ describe('CollapsedItemActions', () => { expect(wrapper.find(`[data-test-subj="editRule"] button`).text()).toEqual('Edit rule'); expect(wrapper.find(`[data-test-subj="deleteRule"] button`).prop('disabled')).toBeFalsy(); expect(wrapper.find(`[data-test-subj="deleteRule"] button`).text()).toEqual('Delete rule'); + expect(wrapper.find(`[data-test-subj="updateApiKey"] button`).text()).toEqual('Update API key'); }); test('handles case when rule is unmuted and enabled and mute is clicked', async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.tsx index 4fcecc3410f17..7e65961ac80f0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.tsx @@ -23,6 +23,7 @@ export type ComponentOpts = { onRuleChanged: () => void; setRulesToDelete: React.Dispatch>; onEditRule: (item: RuleTableItem) => void; + onUpdateAPIKey: (id: string[]) => void; } & Pick; export const CollapsedItemActions: React.FunctionComponent = ({ @@ -34,6 +35,7 @@ export const CollapsedItemActions: React.FunctionComponent = ({ muteRule, setRulesToDelete, onEditRule, + onUpdateAPIKey, }: ComponentOpts) => { const { ruleTypeRegistry } = useKibana().services; @@ -53,6 +55,7 @@ export const CollapsedItemActions: React.FunctionComponent = ({ setIsPopoverOpen(!isPopoverOpen)} aria-label={i18n.translate( @@ -133,6 +136,19 @@ export const CollapsedItemActions: React.FunctionComponent = ({ }, { disabled: !item.isEditable, + 'data-test-subj': 'updateApiKey', + onClick: () => { + setIsPopoverOpen(!isPopoverOpen); + onUpdateAPIKey([item.id]); + }, + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.collapsedItemActions.updateApiKey', + { defaultMessage: 'Update API key' } + ), + }, + { + disabled: !item.isEditable, + className: 'collapsedItemActions__deleteButton', 'data-test-subj': 'deleteRule', onClick: () => { setIsPopoverOpen(!isPopoverOpen); @@ -161,6 +177,7 @@ export const CollapsedItemActions: React.FunctionComponent = ({ panels={panels} className="actCollapsedItemActions" data-test-subj="collapsedActionPanel" + data-testid="collapsedActionPanel" /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index 12e1b0f1e4a6e..7827033138fbb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -6,9 +6,9 @@ */ import * as React from 'react'; +import { fireEvent, act, render, screen } from '@testing-library/react'; import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { ReactWrapper } from 'enzyme'; -import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; import { ruleTypeRegistryMock } from '../../../rule_type_registry.mock'; import { RulesList, percentileFields } from './rules_list'; @@ -23,8 +23,10 @@ import { getFormattedDuration, getFormattedMilliseconds } from '../../../lib/mon import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; import { useKibana } from '../../../../common/lib/kibana'; -jest.mock('../../../../common/lib/kibana'); +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { IToasts } from '@kbn/core/public'; +jest.mock('../../../../common/lib/kibana'); jest.mock('../../../lib/action_connector_api', () => ({ loadActionTypes: jest.fn(), loadAllActions: jest.fn(), @@ -33,6 +35,7 @@ jest.mock('../../../lib/rule_api', () => ({ loadRules: jest.fn(), loadRuleTypes: jest.fn(), loadRuleAggregations: jest.fn(), + updateAPIKey: jest.fn(), loadRuleTags: jest.fn(), alertingFrameworkHealth: jest.fn(() => ({ isSufficientlySecure: true, @@ -67,12 +70,12 @@ jest.mock('../../../../common/get_experimental_features', () => ({ const ruleTags = ['a', 'b', 'c', 'd']; -const { loadRules, loadRuleTypes, loadRuleAggregations, loadRuleTags } = +const { loadRules, loadRuleTypes, loadRuleAggregations, updateAPIKey, loadRuleTags } = jest.requireMock('../../../lib/rule_api'); const { loadActionTypes, loadAllActions } = jest.requireMock('../../../lib/action_connector_api'); + const actionTypeRegistry = actionTypeRegistryMock.create(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); - const ruleType = { id: 'test_rule_type', description: 'test', @@ -101,11 +104,299 @@ const ruleTypeFromApi = { }; ruleTypeRegistry.list.mockReturnValue([ruleType]); actionTypeRegistry.list.mockReturnValue([]); + const useKibanaMock = useKibana as jest.Mocked; +const mockedRulesData = [ + { + id: '1', + name: 'test rule', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '1s' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 1000000, + }, + { + success: true, + duration: 200000, + }, + { + success: false, + duration: 300000, + }, + ], + calculated_metrics: { + success_ratio: 0.66, + p50: 200000, + p95: 300000, + p99: 300000, + }, + }, + }, + }, + { + id: '2', + name: 'test rule ok', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'ok', + lastDuration: 61000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 100000, + }, + { + success: true, + duration: 500000, + }, + ], + calculated_metrics: { + success_ratio: 1, + p50: 0, + p95: 100000, + p99: 500000, + }, + }, + }, + }, + { + id: '3', + name: 'test rule pending', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'pending', + lastDuration: 30234, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [{ success: false, duration: 100 }], + calculated_metrics: { + success_ratio: 0, + }, + }, + }, + }, + { + id: '4', + name: 'test rule error', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 122000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.Unknown, + message: 'test', + }, + }, + }, + { + id: '5', + name: 'test rule license error', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.License, + message: 'test', + }, + }, + }, + { + id: '6', + name: 'test rule warning', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'warning', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'test', + }, + }, + }, +]; + beforeEach(() => { (getIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(() => false); }); +describe('Update Api Key', () => { + const addSuccess = jest.fn(); + const addError = jest.fn(); + + beforeAll(() => { + loadRules.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: mockedRulesData, + }); + loadActionTypes.mockResolvedValue([]); + loadRuleTypes.mockResolvedValue([ruleTypeFromApi]); + loadAllActions.mockResolvedValue([]); + useKibanaMock().services.notifications.toasts = { + addSuccess, + addError, + } as unknown as IToasts; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Updates the Api Key successfully', async () => { + updateAPIKey.mockResolvedValueOnce(204); + render( + + + + ); + expect(await screen.findByText('test rule ok')).toBeInTheDocument(); + + fireEvent.click(screen.getAllByTestId('selectActionButton')[1]); + expect(screen.getByTestId('collapsedActionPanel')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Update API key')); + expect(screen.getByText('You will not be able to recover the old API key')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Cancel')); + expect( + screen.queryByText('You will not be able to recover the old API key') + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getAllByTestId('selectActionButton')[1]); + expect(screen.getByTestId('collapsedActionPanel')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Update API key')); + + await act(async () => { + fireEvent.click(screen.getByText('Update')); + }); + expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' })); + expect(loadRules).toHaveBeenCalledTimes(2); + expect(screen.queryByText("You can't recover the old API key")).not.toBeInTheDocument(); + expect(addSuccess).toHaveBeenCalledWith('API key has been updated'); + }); + + it('Update API key fails', async () => { + updateAPIKey.mockRejectedValueOnce(500); + render( + + + + ); + + expect(await screen.findByText('test rule ok')).toBeInTheDocument(); + + fireEvent.click(screen.getAllByTestId('selectActionButton')[1]); + expect(screen.getByTestId('collapsedActionPanel')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Update API key')); + expect(screen.getByText('You will not be able to recover the old API key')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByText('Update')); + }); + expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' })); + expect(loadRules).toHaveBeenCalledTimes(2); + expect( + screen.queryByText('You will not be able to recover the old API key') + ).not.toBeInTheDocument(); + expect(addError).toHaveBeenCalledWith(500, { title: 'Failed to update the API key' }); + }); +}); describe('rules_list component empty', () => { let wrapper: ReactWrapper; @@ -174,208 +465,6 @@ describe('rules_list component empty', () => { describe('rules_list component with items', () => { let wrapper: ReactWrapper; - const mockedRulesData = [ - { - id: '1', - name: 'test rule', - tags: ['tag1'], - enabled: true, - ruleTypeId: 'test_rule_type', - schedule: { interval: '1s' }, - actions: [], - params: { name: 'test rule type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'active', - lastDuration: 500, - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: null, - }, - monitoring: { - execution: { - history: [ - { - success: true, - duration: 1000000, - }, - { - success: true, - duration: 200000, - }, - { - success: false, - duration: 300000, - }, - ], - calculated_metrics: { - success_ratio: 0.66, - p50: 200000, - p95: 300000, - p99: 300000, - }, - }, - }, - }, - { - id: '2', - name: 'test rule ok', - tags: ['tag1'], - enabled: true, - ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, - actions: [], - params: { name: 'test rule type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'ok', - lastDuration: 61000, - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: null, - }, - monitoring: { - execution: { - history: [ - { - success: true, - duration: 100000, - }, - { - success: true, - duration: 500000, - }, - ], - calculated_metrics: { - success_ratio: 1, - p50: 0, - p95: 100000, - p99: 500000, - }, - }, - }, - }, - { - id: '3', - name: 'test rule pending', - tags: ['tag1'], - enabled: true, - ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, - actions: [], - params: { name: 'test rule type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'pending', - lastDuration: 30234, - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: null, - }, - monitoring: { - execution: { - history: [{ success: false, duration: 100 }], - calculated_metrics: { - success_ratio: 0, - }, - }, - }, - }, - { - id: '4', - name: 'test rule error', - tags: ['tag1'], - enabled: true, - ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, - actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], - params: { name: 'test rule type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'error', - lastDuration: 122000, - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: { - reason: RuleExecutionStatusErrorReasons.Unknown, - message: 'test', - }, - }, - }, - { - id: '5', - name: 'test rule license error', - tags: [], - enabled: true, - ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, - actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], - params: { name: 'test rule type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'error', - lastDuration: 500, - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: { - reason: RuleExecutionStatusErrorReasons.License, - message: 'test', - }, - }, - }, - { - id: '6', - name: 'test rule warning', - tags: [], - enabled: true, - ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, - actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], - params: { name: 'test rule type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'warning', - lastDuration: 500, - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - warning: { - reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, - message: 'test', - }, - }, - }, - ]; - async function setup(editable: boolean = true) { loadRules.mockResolvedValue({ page: 1, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index c93d76eafda9b..9c3f1415e6641 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -80,6 +80,7 @@ import { snoozeRule, unsnoozeRule, deleteRules, + updateAPIKey, } from '../../../lib/rule_api'; import { loadActionTypes } from '../../../lib/action_connector_api'; import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; @@ -103,6 +104,7 @@ import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; import { RuleTagFilter } from './rule_tag_filter'; import { RuleStatusFilter } from './rule_status_filter'; import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; +import { UpdateApiKeyModalConfirmation } from '../../../components/update_api_key_modal_confirmation'; const ENTER_KEY = 13; @@ -219,6 +221,7 @@ export const RulesList: React.FunctionComponent = () => { totalItemCount: 0, }); const [rulesToDelete, setRulesToDelete] = useState([]); + const [rulesToUpdateAPIKey, setRulesToUpdateAPIKey] = useState([]); const onRuleEdit = (ruleItem: RuleTableItem) => { setEditFlyoutVisibility(true); setCurrentRuleToEdit(ruleItem); @@ -903,6 +906,7 @@ export const RulesList: React.FunctionComponent = () => { onRuleChanged={() => loadRulesData()} setRulesToDelete={setRulesToDelete} onEditRule={() => onRuleEdit(item)} + onUpdateAPIKey={setRulesToUpdateAPIKey} /> @@ -1330,7 +1334,7 @@ export const RulesList: React.FunctionComponent = () => { await loadRulesData(); }} onErrors={async () => { - // Refresh the rules from the server, some rules may have beend deleted + // Refresh the rules from the server, some rules may have been deleted await loadRulesData(); setRulesToDelete([]); }} @@ -1349,6 +1353,20 @@ export const RulesList: React.FunctionComponent = () => { setRulesState({ ...rulesState, isLoading }); }} /> + { + setRulesToUpdateAPIKey([]); + }} + idsToUpdate={rulesToUpdateAPIKey} + apiUpdateApiKeyCall={updateAPIKey} + setIsLoadingState={(isLoading: boolean) => { + setRulesState({ ...rulesState, isLoading }); + }} + onUpdated={async () => { + setRulesToUpdateAPIKey([]); + await loadRulesData(); + }} + /> {getRulesList()} {ruleFlyoutVisible && ( diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts index 837ca4adf217b..4934e31b27f79 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts @@ -13,6 +13,7 @@ import { KibanaResponseFactory, IKibanaResponse, Logger, + SavedObject, } from '@kbn/core/server'; import { schema } from '@kbn/config-schema'; import { InvalidatePendingApiKey } from '@kbn/alerting-plugin/server/types'; @@ -364,4 +365,46 @@ export function defineRoutes( } } ); + + router.get( + { + path: '/api/alerts_fixture/rule/{id}/_get_api_key', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + const { id } = req.params; + const [, { encryptedSavedObjects, spaces }] = await core.getStartServices(); + + const spaceId = spaces ? spaces.spacesService.getSpaceId(req) : 'default'; + + let namespace: string | undefined; + if (spaces && spaceId) { + namespace = spaces.spacesService.spaceIdToNamespace(spaceId); + } + + try { + const { + attributes: { apiKey, apiKeyOwner }, + }: SavedObject = await encryptedSavedObjects + .getClient({ + includedHiddenTypes: ['alert'], + }) + .getDecryptedAsInternalUser('alert', id, { + namespace, + }); + + return res.ok({ body: { apiKey, apiKeyOwner } }); + } catch (err) { + return res.badRequest({ body: err }); + } + } + ); } diff --git a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts index 436a98d4cf3f8..2525e7fa50b7e 100644 --- a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts +++ b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts @@ -76,6 +76,16 @@ export class AlertUtils { return request; } + public getAPIKeyRequest(ruleId: string) { + const request = this.supertestWithoutAuth.get( + `${getUrlPrefix(this.space.id)}/api/alerts_fixture/rule/${ruleId}/_get_api_key` + ); + if (this.user) { + return request.auth(this.user.username, this.user.password); + } + return request; + } + public getDisableRequest(alertId: string) { const request = this.supertestWithoutAuth .post(`${getUrlPrefix(this.space.id)}/api/alerting/rule/${alertId}/_disable`) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/disable.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/disable.ts index 864de743ea343..842a00366945a 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/disable.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/disable.ts @@ -334,13 +334,6 @@ export default function createDisableAlertTests({ getService }: FtrProviderConte } catch (e) { expect(e.meta.statusCode).to.eql(404); } - // Ensure AAD isn't broken - await checkAAD({ - supertest, - spaceId: space.id, - type: 'alert', - id: createdAlert.id, - }); break; default: throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts index a5c81a849d8f8..53b7e8e3fb2c0 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts @@ -31,6 +31,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./get_alert_summary')); loadTestFile(require.resolve('./rule_types')); loadTestFile(require.resolve('./bulk_edit')); + loadTestFile(require.resolve('./retain_api_key')); }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/retain_api_key.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/retain_api_key.ts new file mode 100644 index 0000000000000..51b50ae3dc6b3 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/retain_api_key.ts @@ -0,0 +1,111 @@ +/* + * 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 expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../../scenarios'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { AlertUtils, getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function retainAPIKeyTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('retain api key', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + const alertUtils = new AlertUtils({ user, space, supertestWithoutAuth }); + + describe(scenario.id, () => { + it('should retain the api key when a rule is disabled and then enabled', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + objectRemover.add(space.id, createdAction.id, 'action', 'actions'); + const { + body: { apiKey, apiKeyOwner }, + } = await alertUtils.getAPIKeyRequest(createdRule.id); + + expect(apiKey).not.to.be(null); + expect(apiKey).not.to.be(undefined); + expect(apiKeyOwner).not.to.be(null); + expect(apiKeyOwner).not.to.be(undefined); + + await alertUtils.getDisableRequest(createdRule.id); + + const { + body: { apiKey: apiKeyAfterDisable, apiKeyOwner: apiKeyOwnerAfterDisable }, + } = await alertUtils.getAPIKeyRequest(createdRule.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(apiKey).to.be(apiKeyAfterDisable); + expect(apiKeyOwner).to.be(apiKeyOwnerAfterDisable); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + + await alertUtils.getEnableRequest(createdRule.id); + + const { + body: { apiKey: apiKeyAfterEnable, apiKeyOwner: apiKeyOwnerAfterEnable }, + } = await alertUtils.getAPIKeyRequest(createdRule.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(apiKey).to.be(apiKeyAfterEnable); + expect(apiKeyOwner).to.be(apiKeyOwnerAfterEnable); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} From 57aebc483d189d05fe2f8bbc144c7b2731f70c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Wed, 18 May 2022 15:16:13 +0200 Subject: [PATCH 002/150] [ILM] Refactor edit policy form to use `EuiTimeline` (#131732) * [ILM] Refactored edit policy form to use `EuiTimeline` instead of `EuiComment` * [ILM] Added `data-test-subj` back to the phase component * [ILM] Fixed vertical alignment of phases icons * [ILM] Fixed linter issues * [ILM] Added a comment to explain the custom css for the timeline * [ILM] Refactored phase component to re-use it for delete phase as well. Extracted phase title component * [ILM] Refactored phase component to re-use it for delete phase as well. Extracted phase title component * [ILM] Fixed a linter issue --- .../components/phase_icon/phase_icon.scss | 2 +- .../phases/delete_phase/delete_phase.scss | 11 -- .../phases/delete_phase/delete_phase.tsx | 82 +--------- .../components/phases/phase/phase.scss | 23 +-- .../components/phases/phase/phase.tsx | 151 +++++++----------- .../phases/phase/phase_error_indicator.tsx | 40 ----- .../components/phases/phase/phase_title.scss | 8 + .../components/phases/phase/phase_title.tsx | 119 ++++++++++++++ .../sections/edit_policy/edit_policy.scss | 7 + .../sections/edit_policy/edit_policy.tsx | 19 +-- 10 files changed, 207 insertions(+), 255 deletions(-) delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.scss delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_error_indicator.tsx create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_title.scss create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_title.tsx create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.scss diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_icon/phase_icon.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_icon/phase_icon.scss index 5bd6790dda572..361c5caf37b9a 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_icon/phase_icon.scss +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_icon/phase_icon.scss @@ -7,9 +7,9 @@ border-radius: 50%; background-color: $euiColorLightestShade; &--disabled { - margin-top: $euiSizeS; width: $euiSize; height: $euiSize; + margin: $euiSizeS 0; } &--delete { background-color: $euiColorLightShade; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.scss deleted file mode 100644 index 60a39c7f1e9a6..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.scss +++ /dev/null @@ -1,11 +0,0 @@ -.ilmDeletePhase { - .euiCommentEvent { - &__header { - padding: $euiSize; - background-color: $euiColorEmptyShade; - } - &__body { - padding: $euiSize; - } - } -} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx index 75c94961b9ff3..874b98361de40 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx @@ -6,85 +6,9 @@ */ import React, { FunctionComponent } from 'react'; -import { get } from 'lodash'; -import { - EuiFlexItem, - EuiFlexGroup, - EuiTitle, - EuiButtonEmpty, - EuiSpacer, - EuiText, - EuiComment, -} from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n-react'; - -import { useFormData } from '../../../../../../shared_imports'; -import { i18nTexts } from '../../../i18n_texts'; -import { usePhaseTimings, globalFields } from '../../../form'; -import { PhaseIcon } from '../../phase_icon'; -import { MinAgeField, SnapshotPoliciesField } from '../shared_fields'; -import { PhaseErrorIndicator } from '../phase/phase_error_indicator'; - -import './delete_phase.scss'; - -const formFieldPaths = { - enabled: globalFields.deleteEnabled.path, -}; +import { Phase } from '../phase'; +import { SnapshotPoliciesField } from '../shared_fields'; export const DeletePhase: FunctionComponent = () => { - const { setDeletePhaseEnabled } = usePhaseTimings(); - const [formData] = useFormData({ - watch: formFieldPaths.enabled, - }); - - const enabled = get(formData, formFieldPaths.enabled); - - if (!enabled) { - return null; - } - const phaseTitle = ( - - - -

{i18nTexts.editPolicy.titles.delete}

-
-
- - - setDeletePhaseEnabled(false)} - data-test-subj={'disableDeletePhaseButton'} - > - - - - - - - -
- ); - - return ( - <> - - } - className="ilmDeletePhase ilmPhase" - timelineIcon={} - > - - {i18nTexts.editPolicy.descriptions.delete} - - - - - - ); + return } />; }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.scss index 75d25c0bffa50..f9d38e499d6fa 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.scss +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.scss @@ -1,28 +1,7 @@ -.ilmPhase { - .euiCommentEvent { - &__header { - padding: $euiSize; - } - &__body { - padding: $euiSize; - } - } +.ilmSettingsAccordion { .ilmSettingsButton { color: $euiColorPrimary; padding-top: $euiSizeS; padding-bottom: $euiSizeS; } - .euiCommentTimeline { - padding-top: $euiSize; - &::before { - height: calc(100% + #{$euiSizeXXL}); - } - } - &--enabled { - .euiCommentEvent { - &__header { - background-color: $euiColorEmptyShade; - } - } - } } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx index d458c6ed9e3f9..f79b3a7a9cbc8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx @@ -10,30 +10,28 @@ import React, { FunctionComponent } from 'react'; import { EuiFlexGroup, EuiFlexItem, - EuiTitle, EuiText, - EuiComment, EuiAccordion, EuiSpacer, - EuiBadge, + EuiTimelineItem, + EuiSplitPanel, + EuiHorizontalRule, } from '@elastic/eui'; import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; -import { PhaseExceptDelete } from '../../../../../../../common/types'; -import { ToggleField, useFormData } from '../../../../../../shared_imports'; +import { Phase as PhaseType } from '../../../../../../../common/types'; +import { useFormData } from '../../../../../../shared_imports'; import { i18nTexts } from '../../../i18n_texts'; import { FormInternal } from '../../../types'; -import { UseField } from '../../../form'; -import { MinAgeField } from '../shared_fields'; import { PhaseIcon } from '../../phase_icon'; import { PhaseFooter } from '../../phase_footer'; -import { PhaseErrorIndicator } from './phase_error_indicator'; import './phase.scss'; +import { PhaseTitle } from './phase_title'; interface Props { - phase: PhaseExceptDelete; + phase: PhaseType; /** * Settings that should always be visible on the phase when it is enabled. */ @@ -47,96 +45,71 @@ export const Phase: FunctionComponent = ({ children, topLevelSettings, ph }); const isHotPhase = phase === 'hot'; + const isDeletePhase = phase === 'delete'; // hot phase is always enabled const enabled = get(formData, enabledPath) || isHotPhase; - const phaseTitle = ( - - {!isHotPhase && ( - - - - )} - - -

{i18nTexts.editPolicy.titles[phase]}

-
-
- {isHotPhase && ( - - - - - - )} - - - -
- ); - - // @ts-ignore - const minAge = !isHotPhase && enabled ? : null; + // delete phase is hidden when disabled + if (isDeletePhase && !enabled) { + return null; + } return ( - } - className={`ilmPhase ${enabled ? 'ilmPhase--enabled' : ''}`} + } + verticalAlign="top" data-test-subj={`${phase}-phase`} > - - {i18nTexts.editPolicy.descriptions[phase]} - + + + + + + + + {i18nTexts.editPolicy.descriptions[phase]} + - {enabled && ( - <> - {!!topLevelSettings ? ( + {enabled && ( <> - - {topLevelSettings} - - ) : ( - - )} + {!!topLevelSettings ? ( + <> + + {topLevelSettings} + + ) : ( + + )} - {children ? ( - - } - buttonClassName="ilmSettingsButton" - extraAction={} - > - - {children} - - ) : ( - - - - - + {children ? ( + + } + buttonClassName="ilmSettingsButton" + extraAction={!isDeletePhase && } + > + + {children} + + ) : ( + !isDeletePhase && ( + + + + + + ) + )} + )} - - )} - + + + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_error_indicator.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_error_indicator.tsx deleted file mode 100644 index 647f12669cf77..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_error_indicator.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import React, { FunctionComponent, memo } from 'react'; -import { EuiIconTip } from '@elastic/eui'; - -import { Phases } from '../../../../../../../common/types'; -import { useFormErrorsContext } from '../../../form'; - -interface Props { - phase: string & keyof Phases; -} - -const i18nTexts = { - toolTipContent: i18n.translate('xpack.indexLifecycleMgmt.phaseErrorIcon.tooltipDescription', { - defaultMessage: 'This phase contains errors.', - }), -}; - -/** - * This component hooks into the form state and updates whenever new form data is inputted. - */ -export const PhaseErrorIndicator: FunctionComponent = memo(({ phase }) => { - const { errors } = useFormErrorsContext(); - - if (Object.keys(errors[phase]).length) { - return ( -
- -
- ); - } - - return null; -}); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_title.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_title.scss new file mode 100644 index 0000000000000..8b33e672a7371 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_title.scss @@ -0,0 +1,8 @@ + +.ilmPhaseRequiredBadge { + max-width: 150px; +} + +.ilmPhaseTitle { + min-width: 100px; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_title.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_title.tsx new file mode 100644 index 0000000000000..5c86546951098 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_title.tsx @@ -0,0 +1,119 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { get } from 'lodash'; + +import { + EuiBadge, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiTitle, +} from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +import { Phase } from '../../../../../../../common/types'; +import { ToggleField, useFormData } from '../../../../../../shared_imports'; +import { i18nTexts } from '../../../i18n_texts'; +import { FormInternal } from '../../../types'; +import { UseField, useFormErrorsContext, usePhaseTimings } from '../../../form'; +import { MinAgeField } from '../shared_fields'; + +import './phase_title.scss'; + +interface Props { + phase: Phase; +} + +export const PhaseTitle: FunctionComponent = ({ phase }) => { + const enabledPath = `_meta.${phase}.enabled`; + const [formData] = useFormData({ + watch: [enabledPath], + }); + + const isHotPhase = phase === 'hot'; + const isDeletePhase = phase === 'delete'; + const { setDeletePhaseEnabled } = usePhaseTimings(); + // hot phase is always enabled + const enabled = get(formData, enabledPath) || isHotPhase; + + const { errors } = useFormErrorsContext(); + const hasErrors = Object.keys(errors[phase]).length > 0; + + return ( + + + + {!isHotPhase && !isDeletePhase && ( + + + + )} + + +

{i18nTexts.editPolicy.titles[phase]}

+
+
+ {isHotPhase && ( + + + + + + )} + {isDeletePhase && ( + + setDeletePhaseEnabled(false)} + data-test-subj={'disableDeletePhaseButton'} + > + + + + )} + {hasErrors && ( + + + } + /> + + )} +
+
+ {!isHotPhase && enabled && ( + + + + )} +
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.scss new file mode 100644 index 0000000000000..9263db386d012 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.scss @@ -0,0 +1,7 @@ +// offset the vertical line and the phase icons to align with the phase toggle +.ilmPhases [class*='euiTimelineItemIcon-top'] { + padding-top: $euiSize; +} +.ilmPhases [class*='euiTimelineItemIcon-top']::before { + top: $euiSizeL; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index e7042e4a26223..b63f0b595a540 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -12,6 +12,8 @@ import { get } from 'lodash'; import { useHistory } from 'react-router-dom'; +import './edit_policy.scss'; + import { EuiButton, EuiButtonEmpty, @@ -22,6 +24,7 @@ import { EuiSpacer, EuiSwitch, EuiPageHeader, + EuiTimeline, } from '@elastic/eui'; import { TextField, useForm, useFormData, useKibana } from '../../../shared_imports'; @@ -235,27 +238,17 @@ export const EditPolicy: React.FunctionComponent = () => { -
+ - - - {isAllowedByLicense && ( - <> - - - - )} + {isAllowedByLicense && } - {/* We can't add the here as it breaks the layout - and makes the connecting line go further that it needs to. - There is an issue in EUI to fix this (https://github.com/elastic/eui/issues/4492) */} -
+ From 51d2ce97ea7d7d8aeebbd70c8905b9ab09104e77 Mon Sep 17 00:00:00 2001 From: Or Ouziel Date: Wed, 18 May 2022 16:40:48 +0300 Subject: [PATCH 003/150] [Cloud Posture] add pagination to findings by resource (#130968) --- .../findings_by_resource_container.tsx | 23 +++- .../findings_by_resource_table.test.tsx | 23 ++-- .../findings_by_resource_table.tsx | 35 +++--- .../use_findings_by_resource.ts | 110 ++++++++++++------ 4 files changed, 123 insertions(+), 68 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_container.tsx index 3dfbd477d4236..587719df60a0e 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_container.tsx @@ -12,18 +12,20 @@ import { FindingsSearchBar } from '../layout/findings_search_bar'; import * as TEST_SUBJECTS from '../test_subjects'; import { useUrlQuery } from '../../../common/hooks/use_url_query'; import type { FindingsBaseURLQuery } from '../types'; -import { useFindingsByResource } from './use_findings_by_resource'; +import { FindingsByResourceQuery, useFindingsByResource } from './use_findings_by_resource'; import { FindingsByResourceTable } from './findings_by_resource_table'; -import { getBaseQuery } from '../utils'; +import { getBaseQuery, getPaginationQuery, getPaginationTableParams } from '../utils'; import { PageTitle, PageTitleText, PageWrapper } from '../layout/findings_layout'; import { FindingsGroupBySelector } from '../layout/findings_group_by_selector'; import { findingsNavigation } from '../../../common/navigation/constants'; import { useCspBreadcrumbs } from '../../../common/navigation/use_csp_breadcrumbs'; import { ResourceFindings } from './resource_findings/resource_findings_container'; -const getDefaultQuery = (): FindingsBaseURLQuery => ({ +const getDefaultQuery = (): FindingsBaseURLQuery & FindingsByResourceQuery => ({ query: { language: 'kuery', query: '' }, filters: [], + pageIndex: 0, + pageSize: 10, }); export const FindingsByResourceContainer = ({ dataView }: { dataView: DataView }) => ( @@ -43,9 +45,10 @@ export const FindingsByResourceContainer = ({ dataView }: { dataView: DataView } const LatestFindingsByResource = ({ dataView }: { dataView: DataView }) => { useCspBreadcrumbs([findingsNavigation.findings_by_resource]); const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery); - const findingsGroupByResource = useFindingsByResource( - getBaseQuery({ dataView, filters: urlQuery.filters, query: urlQuery.query }) - ); + const findingsGroupByResource = useFindingsByResource({ + ...getBaseQuery({ dataView, filters: urlQuery.filters, query: urlQuery.query }), + ...getPaginationQuery(urlQuery), + }); return (
@@ -72,6 +75,14 @@ const LatestFindingsByResource = ({ dataView }: { dataView: DataView }) => { data={findingsGroupByResource.data} error={findingsGroupByResource.error} loading={findingsGroupByResource.isLoading} + pagination={getPaginationTableParams({ + pageSize: urlQuery.pageSize, + pageIndex: urlQuery.pageIndex, + totalItemCount: findingsGroupByResource.data?.total || 0, + })} + setTableOptions={({ page }) => + setUrlQuery({ pageIndex: page.index, pageSize: page.size }) + } />
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx index f51be5f7a43e1..a6b8f3b863401 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx @@ -7,7 +7,12 @@ import React from 'react'; import { render, screen, within } from '@testing-library/react'; import * as TEST_SUBJECTS from '../test_subjects'; -import { FindingsByResourceTable, formatNumber, getResourceId } from './findings_by_resource_table'; +import { + FindingsByResourceTable, + formatNumber, + getResourceId, + type CspFindingsByResource, +} from './findings_by_resource_table'; import * as TEXT from '../translations'; import type { PropsOf } from '@elastic/eui'; import Chance from 'chance'; @@ -16,10 +21,9 @@ import { TestProvider } from '../../../test/test_provider'; const chance = new Chance(); -const getFakeFindingsByResource = () => ({ +const getFakeFindingsByResource = (): CspFindingsByResource => ({ resource_id: chance.guid(), - cluster_id: chance.guid(), - cis_section: chance.word(), + cis_sections: [chance.word(), chance.word()], failed_findings: { total: chance.integer(), normalized: chance.integer({ min: 0, max: 1 }), @@ -32,8 +36,10 @@ describe('', () => { it('renders the zero state when status success and data has a length of zero ', async () => { const props: TableProps = { loading: false, - data: { page: [] }, + data: { page: [], total: 0 }, error: null, + pagination: { pageIndex: 0, pageSize: 10, totalItemCount: 0 }, + setTableOptions: jest.fn(), }; render( @@ -50,8 +56,10 @@ describe('', () => { const props: TableProps = { loading: false, - data: { page: data }, + data: { page: data, total: data.length }, error: null, + pagination: { pageIndex: 0, pageSize: 10, totalItemCount: 0 }, + setTableOptions: jest.fn(), }; render( @@ -66,8 +74,7 @@ describe('', () => { ); expect(row).toBeInTheDocument(); expect(within(row).getByText(item.resource_id)).toBeInTheDocument(); - expect(within(row).getByText(item.cluster_id)).toBeInTheDocument(); - expect(within(row).getByText(item.cis_section)).toBeInTheDocument(); + expect(within(row).getByText(item.cis_sections.join(', '))).toBeInTheDocument(); expect(within(row).getByText(formatNumber(item.failed_findings.total))).toBeInTheDocument(); expect( within(row).getByText(new RegExp(numeral(item.failed_findings.normalized).format('0%'))) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx index ef7b3da67fbb4..2e96306ad3a69 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx @@ -6,12 +6,14 @@ */ import React from 'react'; import { - EuiTableFieldDataColumnType, EuiEmptyPrompt, EuiBasicTable, EuiTextColor, EuiFlexGroup, EuiFlexItem, + type EuiTableFieldDataColumnType, + type CriteriaWithPagination, + type Pagination, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import numeral from '@elastic/numeral'; @@ -25,17 +27,25 @@ import { findingsNavigation } from '../../../common/navigation/constants'; export const formatNumber = (value: number) => value < 1000 ? value : numeral(value).format('0.0a'); -type FindingsGroupByResourceProps = CspFindingsByResourceResult; -type CspFindingsByResource = NonNullable['page'][number]; +export type CspFindingsByResource = NonNullable< + CspFindingsByResourceResult['data'] +>['page'][number]; + +interface Props extends CspFindingsByResourceResult { + pagination: Pagination; + setTableOptions(options: CriteriaWithPagination): void; +} export const getResourceId = (resource: CspFindingsByResource) => - [resource.resource_id, resource.cluster_id, resource.cis_section].join('/'); + [resource.resource_id, ...resource.cis_sections].join('/'); const FindingsByResourceTableComponent = ({ error, data, loading, -}: FindingsGroupByResourceProps) => { + pagination, + setTableOptions, +}: Props) => { const getRowProps = (row: CspFindingsByResource) => ({ 'data-test-subj': TEST_SUBJECTS.getFindingsByResourceTableRowTestId(getResourceId(row)), }); @@ -50,6 +60,8 @@ const FindingsByResourceTableComponent = ({ items={data?.page || []} columns={columns} rowProps={getRowProps} + pagination={pagination} + onChange={setTableOptions} /> ); }; @@ -70,7 +82,7 @@ const columns: Array> = [ ), }, { - field: 'cis_section', + field: 'cis_sections', truncateText: true, name: ( > = [ defaultMessage="CIS Section" /> ), - }, - { - field: 'cluster_id', - truncateText: true, - name: ( - - ), + render: (sections: string[]) => sections.join(', '), }, { field: 'failed_findings', diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts index 6fec85531b196..880b2be868e6f 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts @@ -8,91 +8,125 @@ import { useQuery } from 'react-query'; import { lastValueFrom } from 'rxjs'; import { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { Pagination } from '@elastic/eui'; import { useKibana } from '../../../common/hooks/use_kibana'; import { showErrorToast } from '../latest_findings/use_latest_findings'; import type { FindingsBaseEsQuery, FindingsQueryResult } from '../types'; +// a large number to probably get all the buckets +const MAX_BUCKETS = 60 * 1000; + +interface UseResourceFindingsOptions extends FindingsBaseEsQuery { + from: NonNullable; + size: NonNullable; +} + +export interface FindingsByResourceQuery { + pageIndex: Pagination['pageIndex']; + pageSize: Pagination['pageSize']; +} + type FindingsAggRequest = IKibanaSearchRequest; type FindingsAggResponse = IKibanaSearchResponse< estypes.SearchResponse<{}, FindingsByResourceAggs> >; export type CspFindingsByResourceResult = FindingsQueryResult< - ReturnType['data'] | undefined, + ReturnType['data'], unknown >; -interface FindingsByResourceAggs extends estypes.AggregationsCompositeAggregate { - groupBy: { - buckets: FindingsAggBucket[]; - }; +interface FindingsByResourceAggs { + resource_total: estypes.AggregationsCardinalityAggregate; + resources: estypes.AggregationsMultiBucketAggregateBase; } -interface FindingsAggBucket { - doc_count: number; - failed_findings: { doc_count: number }; - key: { - resource_id: string; - cluster_id: string; - cis_section: string; - }; +interface FindingsAggBucket extends estypes.AggregationsStringRareTermsBucketKeys { + failed_findings: estypes.AggregationsMultiBucketBase; + cis_sections: estypes.AggregationsMultiBucketAggregateBase; } export const getFindingsByResourceAggQuery = ({ index, query, -}: FindingsBaseEsQuery): estypes.SearchRequest => ({ + from, + size, +}: UseResourceFindingsOptions): estypes.SearchRequest => ({ index, - size: 0, body: { query, + size: 0, aggs: { - groupBy: { - composite: { - size: 10 * 1000, - sources: [ - { resource_id: { terms: { field: 'resource_id.keyword' } } }, - { cluster_id: { terms: { field: 'cluster_id.keyword' } } }, - { cis_section: { terms: { field: 'rule.section.keyword' } } }, - ], - }, + resource_total: { cardinality: { field: 'resource.id.keyword' } }, + resources: { + terms: { field: 'resource.id.keyword', size: MAX_BUCKETS }, aggs: { + cis_sections: { + terms: { field: 'rule.section.keyword' }, + }, failed_findings: { filter: { term: { 'result.evaluation.keyword': 'failed' } }, }, + sort_failed_findings: { + bucket_sort: { + from, + size, + sort: [ + { + 'failed_findings>_count': { order: 'desc' }, + _count: { order: 'desc' }, + _key: { order: 'asc' }, + }, + ], + }, + }, }, }, }, }, }); -export const useFindingsByResource = ({ index, query }: FindingsBaseEsQuery) => { +export const useFindingsByResource = ({ index, query, from, size }: UseResourceFindingsOptions) => { const { data, notifications: { toasts }, } = useKibana().services; return useQuery( - ['csp_findings_resource', { index, query }], + ['csp_findings_resource', { index, query, size, from }], () => lastValueFrom( data.search.search({ - params: getFindingsByResourceAggQuery({ index, query }), + params: getFindingsByResourceAggQuery({ index, query, from, size }), }) - ), - { - select: ({ rawResponse }) => ({ - page: rawResponse.aggregations?.groupBy.buckets.map(createFindingsByResource) || [], + ).then(({ rawResponse: { aggregations } }) => { + if (!aggregations) throw new Error('expected aggregations to be defined'); + + if (!Array.isArray(aggregations.resources.buckets)) + throw new Error('expected resources buckets to be an array'); + + return { + page: aggregations.resources.buckets.map(createFindingsByResource), + total: aggregations.resource_total.value, + }; }), + { + keepPreviousData: true, onError: (err) => showErrorToast(toasts, err), } ); }; -const createFindingsByResource = (bucket: FindingsAggBucket) => ({ - ...bucket.key, - failed_findings: { - total: bucket.failed_findings.doc_count, - normalized: bucket.doc_count > 0 ? bucket.failed_findings.doc_count / bucket.doc_count : 0, - }, -}); +const createFindingsByResource = (bucket: FindingsAggBucket) => { + if (!Array.isArray(bucket.cis_sections.buckets)) + throw new Error('expected buckets to be an array'); + + return { + resource_id: bucket.key, + cis_sections: bucket.cis_sections.buckets.map((v) => v.key), + failed_findings: { + total: bucket.failed_findings.doc_count, + normalized: bucket.doc_count > 0 ? bucket.failed_findings.doc_count / bucket.doc_count : 0, + }, + }; +}; From 1d69e5e7619dbaf1a13555dc4a710d2ce8bbcad9 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 18 May 2022 08:41:53 -0500 Subject: [PATCH 004/150] add comment about "secret" committed to source --- .buildkite/scripts/common/env.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/.buildkite/scripts/common/env.sh b/.buildkite/scripts/common/env.sh index b8b9ef2ffb7de..344117b57c452 100755 --- a/.buildkite/scripts/common/env.sh +++ b/.buildkite/scripts/common/env.sh @@ -38,6 +38,7 @@ export TEST_BROWSER_HEADLESS=1 export ELASTIC_APM_ENVIRONMENT=ci export ELASTIC_APM_TRANSACTION_SAMPLE_RATE=0.1 export ELASTIC_APM_SERVER_URL=https://kibana-ci-apm.apm.us-central1.gcp.cloud.es.io +# Not really a secret, if APM supported public auth we would use it and APM requires that we use this name export ELASTIC_APM_SECRET_TOKEN=7YKhoXsO4MzjhXjx2c if is_pr; then From 45828f3de1f8c0bf69c52abf82bd77d5aaa7397e Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 18 May 2022 17:14:25 +0300 Subject: [PATCH 005/150] [Cases] Improve reporter column in the cases table (#132200) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../all_cases/all_cases_list.test.tsx | 23 ++++++++++++++++++- .../public/components/all_cases/columns.tsx | 16 ++++++------- .../integration/cases/creation.spec.ts | 2 +- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index 853a32eaabbaf..b8f77dac79920 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -205,7 +205,7 @@ describe('AllCasesListGeneric', () => { wrapper.find(`span[data-test-subj="case-table-column-tags-coke"]`).first().prop('title') ).toEqual(useGetCasesMockState.data.cases[0].tags[0]); expect(wrapper.find(`[data-test-subj="case-table-column-createdBy"]`).first().text()).toEqual( - useGetCasesMockState.data.cases[0].createdBy.username + 'LK' ); expect( wrapper @@ -225,6 +225,27 @@ describe('AllCasesListGeneric', () => { }); }); + it('should show a tooltip with the reporter username when hover over the reporter avatar', async () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, + }); + const result = render( + + + + ); + + userEvent.hover(result.queryAllByTestId('case-table-column-createdBy')[0]); + + await waitFor(() => { + expect(result.getByTestId('case-table-column-createdBy-tooltip')).toBeTruthy(); + expect(result.getByTestId('case-table-column-createdBy-tooltip').textContent).toEqual( + 'lknope' + ); + }); + }); + it('should show a tooltip with all tags when hovered', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index c895dfdc11f3f..05345fb05d009 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -53,10 +53,6 @@ const MediumShadeText = styled.p` color: ${({ theme }) => theme.eui.euiColorMediumShade}; `; -const Spacer = styled.span` - margin-left: ${({ theme }) => theme.eui.paddingSizes.s}; -`; - const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); @@ -182,16 +178,18 @@ export const useCasesColumns = ({ render: (createdBy: Case['createdBy']) => { if (createdBy != null) { return ( - <> + - - {createdBy.username ?? i18n.UNKNOWN} - - + ); } return getEmptyTagValue(); diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts index 31468d043a781..8207e2256c48b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts @@ -87,7 +87,7 @@ describe('Cases', () => { cy.get(ALL_CASES_REPORTERS_COUNT).should('have.text', 'Reporter1'); cy.get(ALL_CASES_TAGS_COUNT).should('have.text', 'Tags2'); cy.get(ALL_CASES_NAME).should('have.text', this.mycase.name); - cy.get(ALL_CASES_REPORTER).should('have.text', this.mycase.reporter); + cy.get(ALL_CASES_REPORTER).should('have.text', 'e'); (this.mycase as TestCase).tags.forEach((tag) => { cy.get(ALL_CASES_TAGS(tag)).should('have.text', tag); }); From 4ad07bd81fa925789c621c430072431170e99b8c Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 18 May 2022 08:16:22 -0600 Subject: [PATCH 006/150] [maps] source adapters refactor (#132287) * [maps] source adapters refactor * update jest test snapshots Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/actions/data_request_actions.ts | 4 ++ .../maps/public/actions/layer_actions.ts | 29 ++++++--- .../public/classes/joins/inner_join.test.js | 1 - .../maps/public/classes/joins/inner_join.ts | 17 ++--- .../layers/__fixtures__/mock_sync_context.ts | 7 +++ .../layers/heatmap_layer/heatmap_layer.ts | 6 -- .../maps/public/classes/layers/layer.tsx | 7 --- .../raster_tile_layer.test.ts | 2 +- .../blended_vector_layer.ts | 11 +--- .../geojson_source_data.tsx | 3 +- .../mvt_vector_layer/mvt_source_data.test.ts | 8 +-- .../mvt_vector_layer/mvt_source_data.ts | 2 +- .../layers/vector_layer/vector_layer.tsx | 15 ++--- .../ems_file_source/ems_file_source.tsx | 5 +- .../sources/ems_tms_source/ems_tms_source.tsx | 5 +- .../es_agg_source/es_agg_source.test.ts | 21 +++---- .../sources/es_agg_source/es_agg_source.ts | 5 +- .../es_geo_grid_source.test.ts | 62 ++++++++----------- .../es_geo_grid_source/es_geo_grid_source.tsx | 15 ++++- .../es_geo_line_source/es_geo_line_source.tsx | 9 ++- .../es_pew_pew_source/es_pew_pew_source.js | 9 ++- .../es_search_source/es_search_source.tsx | 24 ++++--- .../classes/sources/es_source/es_source.ts | 23 +++---- .../sources/es_term_source/es_term_source.ts | 8 ++- .../geojson_file_source.ts | 5 +- .../mvt_single_layer_vector_source.tsx | 8 +-- .../maps/public/classes/sources/source.ts | 13 +--- .../sources/table_source/table_source.ts | 5 +- .../term_join_source/term_join_source.ts | 4 +- .../sources/vector_source/vector_source.tsx | 7 ++- .../toc_entry_actions_popover.test.tsx.snap | 6 -- .../maps/public/selectors/map_selectors.ts | 26 ++------ 32 files changed, 171 insertions(+), 201 deletions(-) diff --git a/x-pack/plugins/maps/public/actions/data_request_actions.ts b/x-pack/plugins/maps/public/actions/data_request_actions.ts index d2cb416dcbe20..0a1c5b8d7fae4 100644 --- a/x-pack/plugins/maps/public/actions/data_request_actions.ts +++ b/x-pack/plugins/maps/public/actions/data_request_actions.ts @@ -13,6 +13,7 @@ import bbox from '@turf/bbox'; import uuid from 'uuid/v4'; import { multiPoint } from '@turf/helpers'; import { FeatureCollection } from 'geojson'; +import { Adapters } from '@kbn/inspector-plugin/common/adapters'; import { MapStoreState } from '../reducers/store'; import { KBN_IS_CENTROID_FEATURE, @@ -31,6 +32,7 @@ import { registerCancelCallback, unregisterCancelCallback, getEventHandlers, + getInspectorAdapters, ResultMeta, } from '../reducers/non_serializable_instances'; import { updateTooltipStateForLayer } from './tooltip_actions'; @@ -69,6 +71,7 @@ export type DataRequestContext = { forceRefreshDueToDrawing: boolean; // Boolean signaling data request triggered by a user updating layer features via drawing tools. When true, layer will re-load regardless of "source.applyForceRefresh" flag. isForceRefresh: boolean; // Boolean signaling data request triggered by auto-refresh timer or user clicking refresh button. When true, layer will re-load only when "source.applyForceRefresh" flag is set to true. isFeatureEditorOpenForLayer: boolean; // Boolean signaling that feature editor menu is open for a layer. When true, layer will ignore all global and layer filtering so drawn features are displayed and not filtered out. + inspectorAdapters: Adapters; }; export function clearDataRequests(layer: ILayer) { @@ -148,6 +151,7 @@ function getDataRequestContext( forceRefreshDueToDrawing, isForceRefresh, isFeatureEditorOpenForLayer: getEditState(getState())?.layerId === layerId, + inspectorAdapters: getInspectorAdapters(getState()), }; } diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index 5a1c37c11b80d..257b27e422e2f 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -8,6 +8,7 @@ import { AnyAction, Dispatch } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; import { Query } from '@kbn/data-plugin/public'; +import { Adapters } from '@kbn/inspector-plugin/common/adapters'; import { MapStoreState } from '../reducers/store'; import { createLayerInstance, @@ -66,6 +67,7 @@ import { IVectorStyle } from '../classes/styles/vector/vector_style'; import { notifyLicensedFeatureUsage } from '../licensed_features'; import { IESAggField } from '../classes/fields/agg'; import { IField } from '../classes/fields/field'; +import type { IESSource } from '../classes/sources/es_source'; import { getDrawMode } from '../selectors/ui_selectors'; export function trackCurrentLayerState(layerId: string) { @@ -451,9 +453,7 @@ function updateLayerType(layerId: string, newLayerType: string) { return; } dispatch(clearDataRequests(layer)); - if (layer.getSource().isESSource()) { - getInspectorAdapters(getState()).vectorTiles?.removeLayer(layerId); - } + clearInspectorAdapters(layer, getInspectorAdapters(getState())); dispatch({ type: UPDATE_LAYER_PROP, id: layerId, @@ -589,10 +589,7 @@ function removeLayerFromLayerList(layerId: string) { dispatch(cancelRequest(requestToken)); }); dispatch(updateTooltipStateForLayer(layerGettingRemoved)); - layerGettingRemoved.destroy(); - if (layerGettingRemoved.getSource().isESSource()) { - getInspectorAdapters(getState())?.vectorTiles.removeLayer(layerId); - } + clearInspectorAdapters(layerGettingRemoved, getInspectorAdapters(getState())); dispatch({ type: REMOVE_LAYER, id: layerId, @@ -724,3 +721,21 @@ export function updateMetaFromTiles(layerId: string, mbMetaFeatures: TileMetaFea await dispatch(updateStyleMeta(layerId)); }; } + +function clearInspectorAdapters(layer: ILayer, adapters: Adapters) { + if (!layer.getSource().isESSource()) { + return; + } + + if (adapters.vectorTiles) { + adapters.vectorTiles.removeLayer(layer.getId()); + } + + if (adapters.requests && 'getValidJoins' in layer) { + const vectorLayer = layer as IVectorLayer; + adapters.requests!.resetRequest((layer.getSource() as IESSource).getId()); + vectorLayer.getValidJoins().forEach((join) => { + adapters.requests!.resetRequest(join.getRightJoinSource().getId()); + }); + } +} diff --git a/x-pack/plugins/maps/public/classes/joins/inner_join.test.js b/x-pack/plugins/maps/public/classes/joins/inner_join.test.js index 67fbf94fd1787..4e273f95515e4 100644 --- a/x-pack/plugins/maps/public/classes/joins/inner_join.test.js +++ b/x-pack/plugins/maps/public/classes/joins/inner_join.test.js @@ -21,7 +21,6 @@ const rightSource = { }; const mockSource = { - getInspectorAdapters() {}, createField({ fieldName: name }) { return { getName() { diff --git a/x-pack/plugins/maps/public/classes/joins/inner_join.ts b/x-pack/plugins/maps/public/classes/joins/inner_join.ts index 4ccd5bd289e40..5276d5fcdae30 100644 --- a/x-pack/plugins/maps/public/classes/joins/inner_join.ts +++ b/x-pack/plugins/maps/public/classes/joins/inner_join.ts @@ -7,7 +7,6 @@ import { Query } from '@kbn/data-plugin/public'; import { Feature, GeoJsonProperties } from 'geojson'; -import { Adapters } from '@kbn/inspector-plugin/common/adapters'; import { ESTermSource } from '../sources/es_term_source'; import { getComputedFieldNamePrefix } from '../styles/vector/style_util'; import { @@ -28,8 +27,7 @@ import { ITermJoinSource } from '../sources/term_join_source'; import { TableSource } from '../sources/table_source'; function createJoinTermSource( - descriptor: Partial | undefined, - inspectorAdapters: Adapters | undefined + descriptor: Partial | undefined ): ITermJoinSource | undefined { if (!descriptor) { return; @@ -40,9 +38,9 @@ function createJoinTermSource( 'indexPatternId' in descriptor && 'term' in descriptor ) { - return new ESTermSource(descriptor as ESTermSourceDescriptor, inspectorAdapters); + return new ESTermSource(descriptor as ESTermSourceDescriptor); } else if (descriptor.type === SOURCE_TYPES.TABLE_SOURCE) { - return new TableSource(descriptor as TableSourceDescriptor, inspectorAdapters); + return new TableSource(descriptor as TableSourceDescriptor); } } @@ -53,19 +51,12 @@ export class InnerJoin { constructor(joinDescriptor: JoinDescriptor, leftSource: IVectorSource) { this._descriptor = joinDescriptor; - const inspectorAdapters = leftSource.getInspectorAdapters(); - this._rightSource = createJoinTermSource(this._descriptor.right, inspectorAdapters); + this._rightSource = createJoinTermSource(this._descriptor.right); this._leftField = joinDescriptor.leftField ? leftSource.createField({ fieldName: joinDescriptor.leftField }) : undefined; } - destroy() { - if (this._rightSource) { - this._rightSource.destroy(); - } - } - hasCompleteConfig() { return this._leftField && this._rightSource ? this._rightSource.hasCompleteConfig() : false; } diff --git a/x-pack/plugins/maps/public/classes/layers/__fixtures__/mock_sync_context.ts b/x-pack/plugins/maps/public/classes/layers/__fixtures__/mock_sync_context.ts index 4d1f23599f48d..ef47418736792 100644 --- a/x-pack/plugins/maps/public/classes/layers/__fixtures__/mock_sync_context.ts +++ b/x-pack/plugins/maps/public/classes/layers/__fixtures__/mock_sync_context.ts @@ -6,6 +6,7 @@ */ import sinon from 'sinon'; +import { Adapters } from '@kbn/inspector-plugin/common/adapters'; import { DataRequestContext } from '../../../actions'; import { DataRequestMeta, DataFilters } from '../../../../common/descriptor_types'; @@ -21,6 +22,7 @@ export class MockSyncContext implements DataRequestContext { forceRefreshDueToDrawing: boolean; isForceRefresh: boolean; isFeatureEditorOpenForLayer: boolean; + inspectorAdapters: Adapters; constructor({ dataFilters }: { dataFilters: Partial }) { const mapFilters: DataFilters = { @@ -46,5 +48,10 @@ export class MockSyncContext implements DataRequestContext { this.forceRefreshDueToDrawing = false; this.isForceRefresh = false; this.isFeatureEditorOpenForLayer = false; + this.inspectorAdapters = { + vectorTiles: { + addLayer: sinon.spy(), + }, + }; } } diff --git a/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts b/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts index 0906e39ed37fc..e796ecad332ca 100644 --- a/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts @@ -48,12 +48,6 @@ export class HeatmapLayer extends AbstractLayer { } } - destroy() { - if (this.getSource()) { - this.getSource().destroy(); - } - } - getLayerIcon(isTocIcon: boolean) { const { docCount } = getAggsMeta(this._getMetaFromTiles()); return docCount === 0 ? NO_RESULTS_ICON_AND_TOOLTIPCONTENT : super.getLayerIcon(isTocIcon); diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index 5cc53d44df8ef..29aa19103e511 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -98,7 +98,6 @@ export interface ILayer { ): ReactElement | null; getInFlightRequestTokens(): symbol[]; getPrevRequestToken(dataId: string): symbol | undefined; - destroy: () => void; isPreviewLayer: () => boolean; areLabelsOnTop: () => boolean; supportsLabelsOnTop: () => boolean; @@ -151,12 +150,6 @@ export class AbstractLayer implements ILayer { }; } - destroy() { - if (this._source) { - this._source.destroy(); - } - } - constructor({ layerDescriptor, source }: ILayerArguments) { this._descriptor = AbstractLayer.createDescriptor(layerDescriptor); this._source = source; diff --git a/x-pack/plugins/maps/public/classes/layers/raster_tile_layer/raster_tile_layer.test.ts b/x-pack/plugins/maps/public/classes/layers/raster_tile_layer/raster_tile_layer.test.ts index 66c5b8da0591c..963a12e9f7374 100644 --- a/x-pack/plugins/maps/public/classes/layers/raster_tile_layer/raster_tile_layer.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/raster_tile_layer/raster_tile_layer.test.ts @@ -21,7 +21,7 @@ const sourceDescriptor: XYZTMSSourceDescriptor = { class MockTileSource extends AbstractSource implements ITMSSource { readonly _descriptor: XYZTMSSourceDescriptor; constructor(descriptor: XYZTMSSourceDescriptor) { - super(descriptor, {}); + super(descriptor); this._descriptor = descriptor; } diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.ts index b1fddcca5d5f2..a4b06fe043ff2 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.ts @@ -77,7 +77,7 @@ function getClusterSource(documentSource: IESSource, documentStyle: IVectorStyle }), ]; clusterSourceDescriptor.id = documentSource.getId(); - return new ESGeoGridSource(clusterSourceDescriptor, documentSource.getInspectorAdapters()); + return new ESGeoGridSource(clusterSourceDescriptor); } function getClusterStyleDescriptor( @@ -224,15 +224,6 @@ export class BlendedVectorLayer extends GeoJsonVectorLayer implements IVectorLay this._isClustered = isClustered; } - destroy() { - if (this._documentSource) { - this._documentSource.destroy(); - } - if (this._clusterSource) { - this._clusterSource.destroy(); - } - } - async getDisplayName(source?: ISource) { const displayName = await super.getDisplayName(source); return this._isClustered diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/geojson_source_data.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/geojson_source_data.tsx index 1f484b7ecfc50..3550e93bb1595 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/geojson_source_data.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/geojson_source_data.tsx @@ -73,7 +73,8 @@ export async function syncGeojsonSourceData({ registerCancelCallback.bind(null, requestToken), () => { return isRequestStillActive(dataRequestId, requestToken); - } + }, + syncContext.inspectorAdapters ); const layerFeatureCollection = assignFeatureIds(sourceFeatureCollection); const supportedShapes = await source.getSupportedShapeTypes(); diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts index 735d38f0f3624..1f710879d9dd7 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts @@ -338,9 +338,6 @@ describe('syncMvtSourceData', () => { test('Should add layer to vector tile inspector when source is synced', async () => { const syncContext = new MockSyncContext({ dataFilters: {} }); - const mockVectorTileAdapter = { - addLayer: sinon.spy(), - }; await syncMvtSourceData({ layerId: 'layer1', @@ -361,12 +358,9 @@ describe('syncMvtSourceData', () => { isESSource: () => { return true; }, - getInspectorAdapters: () => { - return { vectorTiles: mockVectorTileAdapter }; - }, }, syncContext, }); - sinon.assert.calledOnce(mockVectorTileAdapter.addLayer); + sinon.assert.calledOnce(syncContext.inspectorAdapters.vectorTiles.addLayer); }); }); diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts index daceeac1f072e..76550090109a1 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts @@ -74,7 +74,7 @@ export async function syncMvtSourceData({ const tileUrl = await source.getTileUrl(requestMeta, refreshToken); if (source.isESSource()) { - source.getInspectorAdapters()?.vectorTiles.addLayer(layerId, layerName, tileUrl); + syncContext.inspectorAdapters.vectorTiles.addLayer(layerId, layerName, tileUrl); } const sourceData = { tileUrl, diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index aee4312713b7d..82ca62c7f33df 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -228,15 +228,6 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { return this._style; } - destroy() { - if (this.getSource()) { - this.getSource().destroy(); - } - this.getJoins().forEach((joinSource) => { - joinSource.destroy(); - }); - } - getJoins() { return this._joins.slice(); } @@ -421,6 +412,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { stopLoading, onLoadError, registerCancelCallback, + inspectorAdapters, }: { dataRequestId: string; dynamicStyleProps: Array>; @@ -462,6 +454,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { sourceQuery: nextMeta.sourceQuery, timeFilters: nextMeta.timeFilters, searchSessionId: dataFilters.searchSessionId, + inspectorAdapters, }); stopLoading(dataRequestId, requestToken, styleMeta, nextMeta); @@ -551,6 +544,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { dataFilters, isForceRefresh, isFeatureEditorOpenForLayer, + inspectorAdapters, }: { join: InnerJoin } & DataRequestContext): Promise { const joinSource = join.getRightJoinSource(); const sourceDataId = join.getSourceDataRequestId(); @@ -591,7 +585,8 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { joinRequestMeta, leftSourceName, join.getLeftField().getName(), - registerCancelCallback.bind(null, requestToken) + registerCancelCallback.bind(null, requestToken), + inspectorAdapters ); stopLoading(sourceDataId, requestToken, propertiesMap); return { diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx index 413f23d1aeef3..70d5a9c54cc92 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx @@ -8,7 +8,6 @@ import React, { ReactElement } from 'react'; import { i18n } from '@kbn/i18n'; import { Feature } from 'geojson'; -import { Adapters } from '@kbn/inspector-plugin/public'; import { FileLayer } from '@elastic/ems-client'; import { ImmutableSourceProperty, SourceEditorArgs } from '../source'; import { AbstractVectorSource, GeoJsonWithMeta, IVectorSource } from '../vector_source'; @@ -64,8 +63,8 @@ export class EMSFileSource extends AbstractVectorSource implements IEmsFileSourc private readonly _tooltipFields: IField[]; readonly _descriptor: EMSFileSourceDescriptor; - constructor(descriptor: Partial, inspectorAdapters?: Adapters) { - super(EMSFileSource.createDescriptor(descriptor), inspectorAdapters); + constructor(descriptor: Partial) { + super(EMSFileSource.createDescriptor(descriptor)); this._descriptor = EMSFileSource.createDescriptor(descriptor); this._tooltipFields = this._descriptor.tooltipProperties.map((propertyKey) => this.createField({ fieldName: propertyKey }) diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_tms_source.tsx b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_tms_source.tsx index 014a34566b59a..6874820d561f7 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_tms_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_tms_source.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; -import { Adapters } from '@kbn/inspector-plugin/public'; import { i18n } from '@kbn/i18n'; import { AbstractSource, SourceEditorArgs } from '../source'; import { ITMSSource } from '../tms_source'; @@ -56,9 +55,9 @@ export class EMSTMSSource extends AbstractSource implements ITMSSource { readonly _descriptor: EMSTMSSourceDescriptor; - constructor(descriptor: Partial, inspectorAdapters?: Adapters) { + constructor(descriptor: Partial) { const emsTmsDescriptor = EMSTMSSource.createDescriptor(descriptor); - super(emsTmsDescriptor, inspectorAdapters); + super(emsTmsDescriptor); this._descriptor = emsTmsDescriptor; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.test.ts index ca889e8e07499..b66a8058149ea 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.test.ts @@ -32,18 +32,15 @@ const metricExamples = [ class TestESAggSource extends AbstractESAggSource { constructor(metrics: AggDescriptor[]) { - super( - { - type: 'test', - id: 'foobar', - indexPatternId: 'foobarid', - metrics, - applyGlobalQuery: true, - applyGlobalTime: true, - applyForceRefresh: true, - }, - [] - ); + super({ + type: 'test', + id: 'foobar', + indexPatternId: 'foobarid', + metrics, + applyGlobalQuery: true, + applyGlobalTime: true, + applyForceRefresh: true, + }); } } diff --git a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts index 8def3347c5602..fce9293cf9f02 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts @@ -6,7 +6,6 @@ */ import { i18n } from '@kbn/i18n'; -import { Adapters } from '@kbn/inspector-plugin/public'; import { GeoJsonProperties } from 'geojson'; import { DataView } from '@kbn/data-plugin/common'; import { IESSource } from '../es_source'; @@ -43,8 +42,8 @@ export abstract class AbstractESAggSource extends AbstractESSource implements IE }; } - constructor(descriptor: AbstractESAggSourceDescriptor, inspectorAdapters?: Adapters) { - super(descriptor, inspectorAdapters); + constructor(descriptor: AbstractESAggSourceDescriptor) { + super(descriptor); this._metricFields = []; if (descriptor.metrics) { descriptor.metrics.forEach((aggDescriptor: AggDescriptor) => { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts index 7110473b11261..b08b95a58a495 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts @@ -51,18 +51,15 @@ describe('ESGeoGridSource', () => { }; }, }; - const geogridSource = new ESGeoGridSource( - { - id: 'foobar', - indexPatternId: 'fooIp', - geoField: geoFieldName, - metrics: [], - resolution: GRID_RESOLUTION.COARSE, - type: SOURCE_TYPES.ES_GEO_GRID, - requestType: RENDER_AS.POINT, - }, - {} - ); + const geogridSource = new ESGeoGridSource({ + id: 'foobar', + indexPatternId: 'fooIp', + geoField: geoFieldName, + metrics: [], + resolution: GRID_RESOLUTION.COARSE, + type: SOURCE_TYPES.ES_GEO_GRID, + requestType: RENDER_AS.POINT, + }); geogridSource._runEsQuery = async (args: unknown) => { return { took: 71, @@ -187,7 +184,8 @@ describe('ESGeoGridSource', () => { 'foobarLayer', vectorSourceRequestMeta, () => {}, - () => true + () => true, + {} ); expect(meta && meta.areResultsTrimmed).toEqual(false); @@ -279,25 +277,7 @@ describe('ESGeoGridSource', () => { }); it('Should not return valid precision for super-fine resolution', () => { - const superFineSource = new ESGeoGridSource( - { - id: 'foobar', - indexPatternId: 'fooIp', - geoField: geoFieldName, - metrics: [], - resolution: GRID_RESOLUTION.SUPER_FINE, - type: SOURCE_TYPES.ES_GEO_GRID, - requestType: RENDER_AS.HEATMAP, - }, - {} - ); - expect(superFineSource.getGeoGridPrecision(10)).toBe(NaN); - }); - }); - - describe('IMvtVectorSource', () => { - const mvtGeogridSource = new ESGeoGridSource( - { + const superFineSource = new ESGeoGridSource({ id: 'foobar', indexPatternId: 'fooIp', geoField: geoFieldName, @@ -305,9 +285,21 @@ describe('ESGeoGridSource', () => { resolution: GRID_RESOLUTION.SUPER_FINE, type: SOURCE_TYPES.ES_GEO_GRID, requestType: RENDER_AS.HEATMAP, - }, - {} - ); + }); + expect(superFineSource.getGeoGridPrecision(10)).toBe(NaN); + }); + }); + + describe('IMvtVectorSource', () => { + const mvtGeogridSource = new ESGeoGridSource({ + id: 'foobar', + indexPatternId: 'fooIp', + geoField: geoFieldName, + metrics: [], + resolution: GRID_RESOLUTION.SUPER_FINE, + type: SOURCE_TYPES.ES_GEO_GRID, + requestType: RENDER_AS.HEATMAP, + }); it('getTileSourceLayer', () => { expect(mvtGeogridSource.getTileSourceLayer()).toBe('aggs'); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx index bf69c04ff69bb..66a07804c0105 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx @@ -78,9 +78,9 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo readonly _descriptor: ESGeoGridSourceDescriptor; - constructor(descriptor: Partial, inspectorAdapters?: Adapters) { + constructor(descriptor: Partial) { const sourceDescriptor = ESGeoGridSource.createDescriptor(descriptor); - super(sourceDescriptor, inspectorAdapters); + super(sourceDescriptor); this._descriptor = sourceDescriptor; } @@ -227,6 +227,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo bucketsPerGrid, isRequestStillActive, bufferedExtent, + inspectorAdapters, }: { searchSource: ISearchSource; searchSessionId?: string; @@ -237,6 +238,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo bucketsPerGrid: number; isRequestStillActive: () => boolean; bufferedExtent: MapExtent; + inspectorAdapters: Adapters; }) { const gridsPerRequest: number = Math.floor(DEFAULT_MAX_BUCKETS_LIMIT / bucketsPerGrid); const aggs: any = { @@ -308,6 +310,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo ), searchSessionId, executionContext: makePublicExecutionContext('es_geo_grid_source:cluster_composite'), + requestsAdapter: inspectorAdapters.requests, }); features.push(...convertCompositeRespToGeoJson(esResponse, this._descriptor.requestType)); @@ -333,6 +336,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo registerCancelCallback, bufferedExtent, tooManyBuckets, + inspectorAdapters, }: { searchSource: ISearchSource; searchSessionId?: string; @@ -342,6 +346,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo registerCancelCallback: (callback: () => void) => void; bufferedExtent: MapExtent; tooManyBuckets: boolean; + inspectorAdapters: Adapters; }): Promise { const valueAggsDsl = tooManyBuckets ? this.getValueAggsDsl(indexPattern, (metric) => { @@ -379,6 +384,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo }), searchSessionId, executionContext: makePublicExecutionContext('es_geo_grid_source:cluster'), + requestsAdapter: inspectorAdapters.requests, }); return convertRegularRespToGeoJson(esResponse, this._descriptor.requestType); @@ -398,7 +404,8 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo layerName: string, searchFilters: VectorSourceRequestMeta, registerCancelCallback: (callback: () => void) => void, - isRequestStillActive: () => boolean + isRequestStillActive: () => boolean, + inspectorAdapters: Adapters ): Promise { if (!searchFilters.buffer) { throw new Error('Cannot get GeoJson without searchFilter.buffer'); @@ -435,6 +442,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo bucketsPerGrid, isRequestStillActive, bufferedExtent: searchFilters.buffer, + inspectorAdapters, }) : await this._nonCompositeAggRequest({ searchSource, @@ -445,6 +453,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo registerCancelCallback, bufferedExtent: searchFilters.buffer, tooManyBuckets, + inspectorAdapters, }); return { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx index 86c343af0d113..4bb23cfb7e55b 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx @@ -83,9 +83,9 @@ export class ESGeoLineSource extends AbstractESAggSource { readonly _descriptor: ESGeoLineSourceDescriptor; - constructor(descriptor: Partial, inspectorAdapters?: Adapters) { + constructor(descriptor: Partial) { const sourceDescriptor = ESGeoLineSource.createDescriptor(descriptor); - super(sourceDescriptor, inspectorAdapters); + super(sourceDescriptor); this._descriptor = sourceDescriptor; } @@ -173,7 +173,8 @@ export class ESGeoLineSource extends AbstractESAggSource { layerName: string, searchFilters: VectorSourceRequestMeta, registerCancelCallback: (callback: () => void) => void, - isRequestStillActive: () => boolean + isRequestStillActive: () => boolean, + inspectorAdapters: Adapters ): Promise { if (!getIsGoldPlus()) { throw new Error(REQUIRES_GOLD_LICENSE_MSG); @@ -226,6 +227,7 @@ export class ESGeoLineSource extends AbstractESAggSource { }), searchSessionId: searchFilters.searchSessionId, executionContext: makePublicExecutionContext('es_geo_line:entities'), + requestsAdapter: inspectorAdapters.requests, }); const entityBuckets: Array<{ key: string; doc_count: number }> = _.get( entityResp, @@ -298,6 +300,7 @@ export class ESGeoLineSource extends AbstractESAggSource { }), searchSessionId: searchFilters.searchSessionId, executionContext: makePublicExecutionContext('es_geo_line:tracks'), + requestsAdapter: inspectorAdapters.requests, }); const { featureCollection, numTrimmedTracks } = convertToGeoJson( tracksResp, diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js index 73a267036044e..a38c769205304 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js @@ -105,7 +105,13 @@ export class ESPewPewSource extends AbstractESAggSource { return Math.min(targetGeotileLevel, MAX_GEOTILE_LEVEL); } - async getGeoJsonWithMeta(layerName, searchFilters, registerCancelCallback) { + async getGeoJsonWithMeta( + layerName, + searchFilters, + registerCancelCallback, + isRequestStillActive, + inspectorAdapters + ) { const indexPattern = await this.getIndexPattern(); const searchSource = await this.makeSearchSource(searchFilters, 0); searchSource.setField('trackTotalHits', false); @@ -165,6 +171,7 @@ export class ESPewPewSource extends AbstractESAggSource { }), searchSessionId: searchFilters.searchSessionId, executionContext: makePublicExecutionContext('es_pew_pew_source:connections'), + requestsAdapter: inspectorAdapters.requests, }); const { featureCollection } = convertToLines(esResponse); diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index f021772e59756..52b9675cdbb39 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -131,9 +131,9 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource }; } - constructor(descriptor: Partial, inspectorAdapters?: Adapters) { + constructor(descriptor: Partial) { const sourceDescriptor = ESSearchSource.createDescriptor(descriptor); - super(sourceDescriptor, inspectorAdapters); + super(sourceDescriptor); this._descriptor = sourceDescriptor; this._tooltipFields = this._descriptor.tooltipProperties ? this._descriptor.tooltipProperties.map((property) => { @@ -267,7 +267,8 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource async _getTopHits( layerName: string, searchFilters: VectorSourceRequestMeta, - registerCancelCallback: (callback: () => void) => void + registerCancelCallback: (callback: () => void) => void, + inspectorAdapters: Adapters ) { const { topHitsSplitField: topHitsSplitFieldName, topHitsSize } = this._descriptor; @@ -350,6 +351,7 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource requestDescription: 'Elasticsearch document top hits request', searchSessionId: searchFilters.searchSessionId, executionContext: makePublicExecutionContext('es_search_source:top_hits'), + requestsAdapter: inspectorAdapters.requests, }); const allHits: any[] = []; @@ -383,7 +385,8 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource async _getSearchHits( layerName: string, searchFilters: VectorSourceRequestMeta, - registerCancelCallback: (callback: () => void) => void + registerCancelCallback: (callback: () => void) => void, + inspectorAdapters: Adapters ) { const indexPattern = await this.getIndexPattern(); @@ -432,6 +435,7 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource requestDescription: 'Elasticsearch document request', searchSessionId: searchFilters.searchSessionId, executionContext: makePublicExecutionContext('es_search_source:doc_search'), + requestsAdapter: inspectorAdapters.requests, }); const isTimeExtentForTimeslice = @@ -512,13 +516,19 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource layerName: string, searchFilters: VectorSourceRequestMeta, registerCancelCallback: (callback: () => void) => void, - isRequestStillActive: () => boolean + isRequestStillActive: () => boolean, + inspectorAdapters: Adapters ): Promise { const indexPattern = await this.getIndexPattern(); const { hits, meta } = this._isTopHits() - ? await this._getTopHits(layerName, searchFilters, registerCancelCallback) - : await this._getSearchHits(layerName, searchFilters, registerCancelCallback); + ? await this._getTopHits(layerName, searchFilters, registerCancelCallback, inspectorAdapters) + : await this._getSearchHits( + layerName, + searchFilters, + registerCancelCallback, + inspectorAdapters + ); const unusedMetaFields = indexPattern.metaFields.filter((metaField) => { return !['_id', '_index'].includes(metaField); diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 944bf0ee3e0b1..e524f546ecc68 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -7,13 +7,14 @@ import { i18n } from '@kbn/i18n'; import uuid from 'uuid/v4'; +import { Adapters } from '@kbn/inspector-plugin/common/adapters'; import { Filter } from '@kbn/es-query'; import { DataViewField, DataView, ISearchSource } from '@kbn/data-plugin/common'; import type { Query } from '@kbn/data-plugin/common'; import type { KibanaExecutionContext } from '@kbn/core/public'; +import { RequestAdapter } from '@kbn/inspector-plugin/common/adapters/request'; import { lastValueFrom } from 'rxjs'; import { TimeRange } from '@kbn/data-plugin/common'; -import { Adapters } from '@kbn/inspector-plugin/common/adapters'; import { AbstractVectorSource, BoundsRequestMeta } from '../vector_source'; import { getAutocompleteService, @@ -60,6 +61,7 @@ export interface IESSource extends IVectorSource { sourceQuery, timeFilters, searchSessionId, + inspectorAdapters, }: { layerName: string; style: IVectorStyle; @@ -68,6 +70,7 @@ export interface IESSource extends IVectorSource { sourceQuery?: Query; timeFilters: TimeRange; searchSessionId?: string; + inspectorAdapters: Adapters; }): Promise; } @@ -98,8 +101,8 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource }; } - constructor(descriptor: AbstractESSourceDescriptor, inspectorAdapters?: Adapters) { - super(AbstractESSource.createDescriptor(descriptor), inspectorAdapters); + constructor(descriptor: AbstractESSourceDescriptor) { + super(AbstractESSource.createDescriptor(descriptor)); this._descriptor = descriptor; } @@ -142,13 +145,6 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource return true; } - destroy() { - const inspectorAdapters = this.getInspectorAdapters(); - if (inspectorAdapters?.requests) { - inspectorAdapters.requests.resetRequest(this.getId()); - } - } - cloneDescriptor(): AbstractSourceDescriptor { const clonedDescriptor = copyPersistentState(this._descriptor); // id used as uuid to track requests in inspector @@ -164,6 +160,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource searchSessionId, searchSource, executionContext, + requestsAdapter, }: { registerCancelCallback: (callback: () => void) => void; requestDescription: string; @@ -172,6 +169,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource searchSessionId?: string; searchSource: ISearchSource; executionContext: KibanaExecutionContext; + requestsAdapter: RequestAdapter | undefined; }): Promise { const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); @@ -183,7 +181,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource sessionId: searchSessionId, legacyHitsTotal: false, inspector: { - adapter: this.getInspectorAdapters()?.requests, + adapter: requestsAdapter, id: requestId, title: requestName, description: requestDescription, @@ -437,6 +435,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource sourceQuery, timeFilters, searchSessionId, + inspectorAdapters, }: { layerName: string; style: IVectorStyle; @@ -445,6 +444,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource sourceQuery?: Query; timeFilters: TimeRange; searchSessionId?: string; + inspectorAdapters: Adapters; }): Promise { const promises = dynamicStyleProps.map((dynamicStyleProp) => { return dynamicStyleProp.getFieldMetaRequest(); @@ -492,6 +492,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource ), searchSessionId, executionContext: makePublicExecutionContext('es_source:style_meta'), + requestsAdapter: inspectorAdapters.requests, }); return resp.aggregations; diff --git a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts index 7acf37409df94..0f5f60ba28601 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts @@ -73,9 +73,9 @@ export class ESTermSource extends AbstractESAggSource implements ITermJoinSource private readonly _termField: ESDocField; readonly _descriptor: ESTermSourceDescriptor; - constructor(descriptor: ESTermSourceDescriptor, inspectorAdapters?: Adapters) { + constructor(descriptor: ESTermSourceDescriptor) { const sourceDescriptor = ESTermSource.createDescriptor(descriptor); - super(sourceDescriptor, inspectorAdapters); + super(sourceDescriptor); this._descriptor = sourceDescriptor; this._termField = new ESDocField({ fieldName: this._descriptor.term, @@ -121,7 +121,8 @@ export class ESTermSource extends AbstractESAggSource implements ITermJoinSource searchFilters: VectorJoinSourceRequestMeta, leftSourceName: string, leftFieldName: string, - registerCancelCallback: (callback: () => void) => void + registerCancelCallback: (callback: () => void) => void, + inspectorAdapters: Adapters ): Promise { if (!this.hasCompleteConfig()) { return new Map(); @@ -155,6 +156,7 @@ export class ESTermSource extends AbstractESAggSource implements ITermJoinSource }), searchSessionId: searchFilters.searchSessionId, executionContext: makePublicExecutionContext('es_term_source:terms'), + requestsAdapter: inspectorAdapters.requests, }); const countPropertyName = this.getAggKey(AGG_TYPE.COUNT); diff --git a/x-pack/plugins/maps/public/classes/sources/geojson_file_source/geojson_file_source.ts b/x-pack/plugins/maps/public/classes/sources/geojson_file_source/geojson_file_source.ts index 9cfddb2fae884..404749b0adaf5 100644 --- a/x-pack/plugins/maps/public/classes/sources/geojson_file_source/geojson_file_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/geojson_file_source/geojson_file_source.ts @@ -6,7 +6,6 @@ */ import { Feature, FeatureCollection } from 'geojson'; -import { Adapters } from '@kbn/inspector-plugin/common/adapters'; import { AbstractVectorSource, BoundsRequestMeta, GeoJsonWithMeta } from '../vector_source'; import { EMPTY_FEATURE_COLLECTION, FIELD_ORIGIN, SOURCE_TYPES } from '../../../../common/constants'; import { @@ -55,9 +54,9 @@ export class GeoJsonFileSource extends AbstractVectorSource { }; } - constructor(descriptor: Partial, inspectorAdapters?: Adapters) { + constructor(descriptor: Partial) { const normalizedDescriptor = GeoJsonFileSource.createDescriptor(descriptor); - super(normalizedDescriptor, inspectorAdapters); + super(normalizedDescriptor); } _getFields(): InlineFieldDescriptor[] { diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx index 47c69e89c3c3a..bbfa3d0bdd693 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx @@ -9,7 +9,6 @@ import { i18n } from '@kbn/i18n'; import uuid from 'uuid/v4'; import React from 'react'; import { GeoJsonProperties, Geometry, Position } from 'geojson'; -import { Adapters } from '@kbn/inspector-plugin/common/adapters'; import { AbstractSource, ImmutableSourceProperty, SourceEditorArgs } from '../source'; import { BoundsRequestMeta, GeoJsonWithMeta, IMvtVectorSource } from '../vector_source'; import { @@ -63,11 +62,8 @@ export class MVTSingleLayerVectorSource extends AbstractSource implements IMvtVe readonly _descriptor: TiledSingleLayerVectorSourceDescriptor; readonly _tooltipFields: MVTField[]; - constructor( - sourceDescriptor: TiledSingleLayerVectorSourceDescriptor, - inspectorAdapters?: Adapters - ) { - super(sourceDescriptor, inspectorAdapters); + constructor(sourceDescriptor: TiledSingleLayerVectorSourceDescriptor) { + super(sourceDescriptor); this._descriptor = MVTSingleLayerVectorSource.createDescriptor(sourceDescriptor); this._tooltipFields = this._descriptor.tooltipProperties diff --git a/x-pack/plugins/maps/public/classes/sources/source.ts b/x-pack/plugins/maps/public/classes/sources/source.ts index 0e5e7174707d1..029742b830eff 100644 --- a/x-pack/plugins/maps/public/classes/sources/source.ts +++ b/x-pack/plugins/maps/public/classes/sources/source.ts @@ -8,7 +8,6 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions */ import { ReactElement } from 'react'; -import { Adapters } from '@kbn/inspector-plugin/public'; import { GeoJsonProperties } from 'geojson'; import { copyPersistentState } from '../../reducers/copy_persistent_state'; import { IField } from '../fields/field'; @@ -41,9 +40,7 @@ export type ImmutableSourceProperty = { }; export interface ISource { - destroy(): void; getDisplayName(): Promise; - getInspectorAdapters(): Adapters | undefined; getType(): string; isFieldAware(): boolean; isFilterByMapBounds(): boolean; @@ -74,15 +71,11 @@ export interface ISource { export class AbstractSource implements ISource { readonly _descriptor: AbstractSourceDescriptor; - private readonly _inspectorAdapters?: Adapters; - constructor(descriptor: AbstractSourceDescriptor, inspectorAdapters?: Adapters) { + constructor(descriptor: AbstractSourceDescriptor) { this._descriptor = descriptor; - this._inspectorAdapters = inspectorAdapters; } - destroy(): void {} - cloneDescriptor(): AbstractSourceDescriptor { return copyPersistentState(this._descriptor); } @@ -99,10 +92,6 @@ export class AbstractSource implements ISource { return []; } - getInspectorAdapters(): Adapters | undefined { - return this._inspectorAdapters; - } - getType(): string { return this._descriptor.type; } diff --git a/x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts b/x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts index d2d6e12036f4c..211fc65e09167 100644 --- a/x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts @@ -8,7 +8,6 @@ import uuid from 'uuid'; import { GeoJsonProperties } from 'geojson'; import type { Query } from '@kbn/data-plugin/common'; -import { Adapters } from '@kbn/inspector-plugin/common/adapters'; import { FIELD_ORIGIN, SOURCE_TYPES, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; import { MapExtent, @@ -45,9 +44,9 @@ export class TableSource extends AbstractVectorSource implements ITermJoinSource readonly _descriptor: TableSourceDescriptor; - constructor(descriptor: Partial, inspectorAdapters?: Adapters) { + constructor(descriptor: Partial) { const sourceDescriptor = TableSource.createDescriptor(descriptor); - super(sourceDescriptor, inspectorAdapters); + super(sourceDescriptor); this._descriptor = sourceDescriptor; } diff --git a/x-pack/plugins/maps/public/classes/sources/term_join_source/term_join_source.ts b/x-pack/plugins/maps/public/classes/sources/term_join_source/term_join_source.ts index 5ac7ca822fc93..9228fe1de4496 100644 --- a/x-pack/plugins/maps/public/classes/sources/term_join_source/term_join_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/term_join_source/term_join_source.ts @@ -7,6 +7,7 @@ import { GeoJsonProperties } from 'geojson'; import { Query } from '@kbn/data-plugin/common/query'; +import { Adapters } from '@kbn/inspector-plugin/common/adapters'; import { IField } from '../../fields/field'; import { VectorJoinSourceRequestMeta } from '../../../../common/descriptor_types'; import { PropertiesMap } from '../../../../common/elasticsearch_util'; @@ -21,7 +22,8 @@ export interface ITermJoinSource extends ISource { searchFilters: VectorJoinSourceRequestMeta, leftSourceName: string, leftFieldName: string, - registerCancelCallback: (callback: () => void) => void + registerCancelCallback: (callback: () => void) => void, + inspectorAdapters: Adapters ): Promise; /* 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 43a2a00ca59e1..cec48127ba148 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 @@ -9,6 +9,7 @@ import type { Query } from '@kbn/data-plugin/common'; import { FeatureCollection, GeoJsonProperties, Geometry, Position } from 'geojson'; import { Filter } from '@kbn/es-query'; import { TimeRange } from '@kbn/data-plugin/public'; +import { Adapters } from '@kbn/inspector-plugin/common/adapters'; import { VECTOR_SHAPE_TYPE } from '../../../../common/constants'; import { ITooltipProperty, TooltipProperty } from '../../tooltips/tooltip_property'; import { AbstractSource, ISource } from '../source'; @@ -57,7 +58,8 @@ export interface IVectorSource extends ISource { layerName: string, searchFilters: VectorSourceRequestMeta, registerCancelCallback: (callback: () => void) => void, - isRequestStillActive: () => boolean + isRequestStillActive: () => boolean, + inspectorAdapters: Adapters ): Promise; getFields(): Promise; @@ -140,7 +142,8 @@ export class AbstractVectorSource extends AbstractSource implements IVectorSourc layerName: string, searchFilters: VectorSourceRequestMeta, registerCancelCallback: (callback: () => void) => void, - isRequestStillActive: () => boolean + isRequestStillActive: () => boolean, + inspectorAdapters: Adapters ): Promise { throw new Error('Should implement VectorSource#getGeoJson'); } diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap index 1fdae3b596e00..a619aff4da284 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap @@ -29,7 +29,6 @@ exports[`TOCEntryActionsPopover is rendered 1`] = ` "_descriptor": Object { "type": "mySourceType", }, - "_inspectorAdapters": undefined, }, } } @@ -144,7 +143,6 @@ exports[`TOCEntryActionsPopover should disable Edit features when edit mode acti "_descriptor": Object { "type": "mySourceType", }, - "_inspectorAdapters": undefined, }, } } @@ -259,7 +257,6 @@ exports[`TOCEntryActionsPopover should disable fit to data when supportsFitToBou "_descriptor": Object { "type": "mySourceType", }, - "_inspectorAdapters": undefined, }, } } @@ -374,7 +371,6 @@ exports[`TOCEntryActionsPopover should have "show layer" action when layer is no "_descriptor": Object { "type": "mySourceType", }, - "_inspectorAdapters": undefined, }, "isVisible": [Function], } @@ -490,7 +486,6 @@ exports[`TOCEntryActionsPopover should not show edit actions in read only mode 1 "_descriptor": Object { "type": "mySourceType", }, - "_inspectorAdapters": undefined, }, } } @@ -574,7 +569,6 @@ exports[`TOCEntryActionsPopover should show "show this layer only" action when t "_descriptor": Object { "type": "mySourceType", }, - "_inspectorAdapters": undefined, }, } } diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index 2c964f6ef0843..d2ab0324b3941 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -8,7 +8,6 @@ import { createSelector } from 'reselect'; import { FeatureCollection } from 'geojson'; import _ from 'lodash'; -import { Adapters } from '@kbn/inspector-plugin/public'; import type { Query } from '@kbn/data-plugin/common'; import { Filter } from '@kbn/es-query'; import { TimeRange } from '@kbn/data-plugin/public'; @@ -23,10 +22,7 @@ import { import { VectorStyle } from '../classes/styles/vector/vector_style'; import { HeatmapLayer } from '../classes/layers/heatmap_layer'; import { getTimeFilter } from '../kibana_services'; -import { - getChartsPaletteServiceGetColor, - getInspectorAdapters, -} from '../reducers/non_serializable_instances'; +import { getChartsPaletteServiceGetColor } from '../reducers/non_serializable_instances'; import { copyPersistentState, TRACKED_LAYER_DESCRIPTOR } from '../reducers/copy_persistent_state'; import { InnerJoin } from '../classes/joins/inner_join'; import { getSourceByType } from '../classes/sources/source_registry'; @@ -75,10 +71,9 @@ function createJoinInstances(vectorLayerDescriptor: VectorLayerDescriptor, sourc export function createLayerInstance( layerDescriptor: LayerDescriptor, customIcons: CustomIcon[], - inspectorAdapters?: Adapters, chartsPaletteServiceGetColor?: (value: string) => string | null ): ILayer { - const source: ISource = createSourceInstance(layerDescriptor.sourceDescriptor, inspectorAdapters); + const source: ISource = createSourceInstance(layerDescriptor.sourceDescriptor); switch (layerDescriptor.type) { case LAYER_TYPE.RASTER_TILE: @@ -123,10 +118,7 @@ export function createLayerInstance( } } -function createSourceInstance( - sourceDescriptor: AbstractSourceDescriptor | null, - inspectorAdapters?: Adapters -): ISource { +function createSourceInstance(sourceDescriptor: AbstractSourceDescriptor | null): ISource { if (sourceDescriptor === null) { throw new Error('Source-descriptor should be initialized'); } @@ -134,7 +126,7 @@ function createSourceInstance( if (!source) { throw new Error(`Unrecognized sourceType ${sourceDescriptor.type}`); } - return new source.ConstructorFunction(sourceDescriptor, inspectorAdapters); + return new source.ConstructorFunction(sourceDescriptor); } export const getMapSettings = ({ map }: MapStoreState): MapSettings => map.settings; @@ -321,17 +313,11 @@ export const getSpatialFiltersLayer = createSelector( export const getLayerList = createSelector( getLayerListRaw, - getInspectorAdapters, getChartsPaletteServiceGetColor, getCustomIcons, - (layerDescriptorList, inspectorAdapters, chartsPaletteServiceGetColor, customIcons) => { + (layerDescriptorList, chartsPaletteServiceGetColor, customIcons) => { return layerDescriptorList.map((layerDescriptor) => - createLayerInstance( - layerDescriptor, - customIcons, - inspectorAdapters, - chartsPaletteServiceGetColor - ) + createLayerInstance(layerDescriptor, customIcons, chartsPaletteServiceGetColor) ); } ); From 956703a71f8b020b173344bac766c0717dfb7b1f Mon Sep 17 00:00:00 2001 From: James Garside Date: Wed, 18 May 2022 15:20:57 +0100 Subject: [PATCH 007/150] Updated trimet.vehicleID from Integer to Keyword (#132425) * updated tutorial to use Filebeat and Datastreams rather than Logstash and a static index * Fixed pipeline issue when inCongestion is null the pipeline fails. Now if null its set as false * Fixed pipeline issue when inCongestion is null the pipeline fails. Now if null its set as false * Corrected minor mistakes in docs * Changed trimet.vehicleID from int to keyword * Update docs/maps/asset-tracking-tutorial.asciidoc Co-authored-by: Nick Peihl * Update docs/maps/asset-tracking-tutorial.asciidoc Co-authored-by: Nick Peihl * Update docs/maps/asset-tracking-tutorial.asciidoc Co-authored-by: Nick Peihl * Update docs/maps/asset-tracking-tutorial.asciidoc Co-authored-by: Nick Peihl Co-authored-by: Nick Peihl --- docs/maps/asset-tracking-tutorial.asciidoc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/maps/asset-tracking-tutorial.asciidoc b/docs/maps/asset-tracking-tutorial.asciidoc index 46248c5280b20..85629e0e611f6 100644 --- a/docs/maps/asset-tracking-tutorial.asciidoc +++ b/docs/maps/asset-tracking-tutorial.asciidoc @@ -136,10 +136,10 @@ PUT _index_template/tri_met_tracks "type": "text" }, "lastLocID": { - "type": "integer" + "type": "keyword" }, "nextLocID": { - "type": "integer" + "type": "keyword" }, "locationInScheduleDay": { "type": "integer" @@ -163,13 +163,13 @@ PUT _index_template/tri_met_tracks "type": "keyword" }, "tripID": { - "type": "integer" + "type": "keyword" }, "delay": { "type": "integer" }, "extraBlockID": { - "type": "integer" + "type": "keyword" }, "messageCode": { "type": "integer" @@ -188,7 +188,7 @@ PUT _index_template/tri_met_tracks "doc_values": true }, "vehicleID": { - "type": "integer" + "type": "keyword" }, "offRoute": { "type": "boolean" From 3409ea325fcd75622d08d1cb24f4fecc4a2db4bf Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Wed, 18 May 2022 09:21:13 -0500 Subject: [PATCH 008/150] [artifacts] Verify docker UBI context (#132346) * [artifacts] Verify docker UBI context * add step * fix filename --- .buildkite/pipelines/artifacts.yml | 10 ++++++++++ .buildkite/scripts/steps/artifacts/docker_context.sh | 2 ++ 2 files changed, 12 insertions(+) diff --git a/.buildkite/pipelines/artifacts.yml b/.buildkite/pipelines/artifacts.yml index 14bddc49059ac..606ec6c2e038f 100644 --- a/.buildkite/pipelines/artifacts.yml +++ b/.buildkite/pipelines/artifacts.yml @@ -62,6 +62,16 @@ steps: - exit_status: '*' limit: 1 + - command: KIBANA_DOCKER_CONTEXT=ubi .buildkite/scripts/steps/artifacts/docker_context.sh + label: 'Docker Context Verification' + agents: + queue: n2-2 + timeout_in_minutes: 30 + retry: + automatic: + - exit_status: '*' + limit: 1 + - command: .buildkite/scripts/steps/artifacts/cloud.sh label: 'Cloud Deployment' agents: diff --git a/.buildkite/scripts/steps/artifacts/docker_context.sh b/.buildkite/scripts/steps/artifacts/docker_context.sh index d01cbccfc76c1..8076ebd043545 100755 --- a/.buildkite/scripts/steps/artifacts/docker_context.sh +++ b/.buildkite/scripts/steps/artifacts/docker_context.sh @@ -19,6 +19,8 @@ if [[ "$KIBANA_DOCKER_CONTEXT" == "default" ]]; then DOCKER_CONTEXT_FILE="kibana-$FULL_VERSION-docker-build-context.tar.gz" elif [[ "$KIBANA_DOCKER_CONTEXT" == "cloud" ]]; then DOCKER_CONTEXT_FILE="kibana-cloud-$FULL_VERSION-docker-build-context.tar.gz" +elif [[ "$KIBANA_DOCKER_CONTEXT" == "ubi" ]]; then + DOCKER_CONTEXT_FILE="kibana-ubi8-$FULL_VERSION-docker-build-context.tar.gz" fi tar -xf "target/$DOCKER_CONTEXT_FILE" -C "$DOCKER_BUILD_FOLDER" From 31bb2c7fc5e94adda0bb158a42fbb78faec705f5 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Wed, 18 May 2022 16:27:10 +0200 Subject: [PATCH 009/150] expose `retry_on_conflict` for `SOR.update` (#131371) * expose `retry_on_conflict` for `SOR.update` * update generated doc * stop using preflight check for version check for other methods too. * remove unused ignore --- ...n-core-server.savedobjectsupdateoptions.md | 1 + .../service/lib/repository.test.ts | 61 +++++++++++-------- .../saved_objects/service/lib/repository.ts | 19 ++++-- .../service/saved_objects_client.ts | 10 ++- src/core/server/server.api.md | 1 + 5 files changed, 59 insertions(+), 33 deletions(-) diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateoptions.md index b81a59c745e7b..7044f3007c382 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateoptions.md @@ -18,6 +18,7 @@ export interface SavedObjectsUpdateOptions extends SavedOb | --- | --- | --- | | [references?](./kibana-plugin-core-server.savedobjectsupdateoptions.references.md) | SavedObjectReference\[\] | (Optional) A reference to another saved object. | | [refresh?](./kibana-plugin-core-server.savedobjectsupdateoptions.refresh.md) | MutatingOperationRefreshSetting | (Optional) The Elasticsearch Refresh setting for this operation | +| [retryOnConflict?](./kibana-plugin-core-server.savedobjectsupdateoptions.retryonconflict.md) | number | (Optional) The Elasticsearch retry_on_conflict setting for this operation. Defaults to 0 when version is provided, 3 otherwise. | | [upsert?](./kibana-plugin-core-server.savedobjectsupdateoptions.upsert.md) | Attributes | (Optional) If specified, will be used to perform an upsert if the document doesn't exist | | [version?](./kibana-plugin-core-server.savedobjectsupdateoptions.version.md) | string | (Optional) An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. | diff --git a/src/core/server/saved_objects/service/lib/repository.test.ts b/src/core/server/saved_objects/service/lib/repository.test.ts index 746ed0033a1d2..313ca2bd07e73 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.ts +++ b/src/core/server/saved_objects/service/lib/repository.test.ts @@ -1688,20 +1688,6 @@ describe('SavedObjectsRepository', () => { ); }); - it(`defaults to the version of the existing document for multi-namespace types`, async () => { - // only multi-namespace documents are obtained using a pre-flight mget request - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkUpdateSuccess(objects); - const overrides = { - if_seq_no: mockVersionProps._seq_no, - if_primary_term: mockVersionProps._primary_term, - }; - expectClientCallArgsAction(objects, { method: 'update', overrides }); - }); - it(`defaults to no version for types that are not multi-namespace`, async () => { const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; await bulkUpdateSuccess(objects); @@ -1759,12 +1745,6 @@ describe('SavedObjectsRepository', () => { it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { const getId = (type: string, id: string) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - const overrides = { - // bulkUpdate uses a preflight `get` request for multi-namespace saved objects, and specifies that version on `update` - // we aren't testing for this here, but we need to include Jest assertions so this test doesn't fail - if_primary_term: expect.any(Number), - if_seq_no: expect.any(Number), - }; const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }; const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; @@ -1772,7 +1752,7 @@ describe('SavedObjectsRepository', () => { expectClientCallArgsAction([_obj1], { method: 'update', getId }); client.bulk.mockClear(); await bulkUpdateSuccess([_obj2], { namespace }); - expectClientCallArgsAction([_obj2], { method: 'update', getId, overrides }); + expectClientCallArgsAction([_obj2], { method: 'update', getId }); jest.clearAllMocks(); // test again with object namespace string that supersedes the operation's namespace ID @@ -1780,7 +1760,7 @@ describe('SavedObjectsRepository', () => { expectClientCallArgsAction([_obj1], { method: 'update', getId }); client.bulk.mockClear(); await bulkUpdateSuccess([{ ..._obj2, namespace }]); - expectClientCallArgsAction([_obj2], { method: 'update', getId, overrides }); + expectClientCallArgsAction([_obj2], { method: 'update', getId }); }); }); @@ -2723,14 +2703,14 @@ describe('SavedObjectsRepository', () => { expect(client.delete).toHaveBeenCalledTimes(1); }); - it(`includes the version of the existing document when using a multi-namespace type`, async () => { + it(`does not includes the version of the existing document when using a multi-namespace type`, async () => { await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; expect(client.delete).toHaveBeenCalledWith( - expect.objectContaining(versionProperties), + expect.not.objectContaining(versionProperties), expect.anything() ); }); @@ -4605,14 +4585,14 @@ describe('SavedObjectsRepository', () => { ); }); - it(`defaults to the version of the existing document when type is multi-namespace`, async () => { + it(`does not default to the version of the existing document when type is multi-namespace`, async () => { await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { references }); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; expect(client.update).toHaveBeenCalledWith( - expect.objectContaining(versionProperties), + expect.not.objectContaining(versionProperties), expect.anything() ); }); @@ -4627,6 +4607,35 @@ describe('SavedObjectsRepository', () => { ); }); + it('default to a `retry_on_conflict` setting of `3` when `version` is not provided', async () => { + await updateSuccess(type, id, attributes, {}); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ retry_on_conflict: 3 }), + expect.anything() + ); + }); + + it('default to a `retry_on_conflict` setting of `0` when `version` is provided', async () => { + await updateSuccess(type, id, attributes, { + version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), + }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ retry_on_conflict: 0, if_seq_no: 100, if_primary_term: 200 }), + expect.anything() + ); + }); + + it('accepts a `retryOnConflict` option', async () => { + await updateSuccess(type, id, attributes, { + version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), + retryOnConflict: 42, + }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ retry_on_conflict: 42, if_seq_no: 100, if_primary_term: 200 }), + expect.anything() + ); + }); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await updateSuccess(type, id, attributes, { namespace }); expect(client.update).toHaveBeenCalledWith( diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index db57e74bae138..287c78d3b2618 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -151,6 +151,7 @@ export interface SavedObjectsDeleteByNamespaceOptions extends SavedObjectsBaseOp } export const DEFAULT_REFRESH_SETTING = 'wait_for'; +export const DEFAULT_RETRY_COUNT = 3; /** * See {@link SavedObjectsRepository} @@ -523,7 +524,7 @@ export class SavedObjectsRepository { } savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace, existingDocument); - versionProperties = getExpectedVersionProperties(version, existingDocument); + versionProperties = getExpectedVersionProperties(version); } else { if (this._registry.isSingleNamespace(object.type)) { savedObjectNamespace = initialNamespaces @@ -761,7 +762,7 @@ export class SavedObjectsRepository { { id: rawId, index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined, preflightResult?.rawDocSource), + ...getExpectedVersionProperties(undefined), refresh, }, { ignore: [404], meta: true } @@ -1312,7 +1313,13 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createBadRequestError('id cannot be empty'); // prevent potentially upserting a saved object with an empty ID } - const { version, references, upsert, refresh = DEFAULT_REFRESH_SETTING } = options; + const { + version, + references, + upsert, + refresh = DEFAULT_REFRESH_SETTING, + retryOnConflict = version ? 0 : DEFAULT_RETRY_COUNT, + } = options; const namespace = normalizeNamespace(options.namespace); let preflightResult: PreflightCheckNamespacesResult | undefined; @@ -1373,8 +1380,9 @@ export class SavedObjectsRepository { .update({ id: this._serializer.generateRawId(namespace, type, id), index: this.getIndexForType(type), - ...getExpectedVersionProperties(version, preflightResult?.rawDocSource), + ...getExpectedVersionProperties(version), refresh, + retry_on_conflict: retryOnConflict, body: { doc, ...(rawUpsert && { upsert: rawUpsert._source }), @@ -1608,8 +1616,7 @@ export class SavedObjectsRepository { // @ts-expect-error MultiGetHit is incorrectly missing _id, _source SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace), ]; - // @ts-expect-error MultiGetHit is incorrectly missing _id, _source - versionProperties = getExpectedVersionProperties(version, actualResult!); + versionProperties = getExpectedVersionProperties(version); } else { if (this._registry.isSingleNamespace(type)) { // if `objectNamespace` is undefined, fall back to `options.namespace` diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 7ebea2d8ff26e..ba40127958aab 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -221,7 +221,10 @@ export interface SavedObjectsCheckConflictsResponse { * @public */ export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { - /** An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. */ + /** + * An opaque version number which changes on each successful write operation. + * Can be used for implementing optimistic concurrency control. + */ version?: string; /** {@inheritdoc SavedObjectReference} */ references?: SavedObjectReference[]; @@ -229,6 +232,11 @@ export interface SavedObjectsUpdateOptions extends SavedOb refresh?: MutatingOperationRefreshSetting; /** If specified, will be used to perform an upsert if the document doesn't exist */ upsert?: Attributes; + /** + * The Elasticsearch `retry_on_conflict` setting for this operation. + * Defaults to `0` when `version` is provided, `3` otherwise. + */ + retryOnConflict?: number; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 4ff42f95b571a..3ff44c5b10fb1 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2939,6 +2939,7 @@ export interface SavedObjectsUpdateObjectsSpacesResponseObject { export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { references?: SavedObjectReference[]; refresh?: MutatingOperationRefreshSetting; + retryOnConflict?: number; upsert?: Attributes; version?: string; } From f85c39e5f66f5e770a611ff3eae2f4ecfab5d7b3 Mon Sep 17 00:00:00 2001 From: Bobby Filar <29960025+bfilar@users.noreply.github.com> Date: Wed, 18 May 2022 09:33:46 -0500 Subject: [PATCH 010/150] [ML] Adding v3 modules for Security_Linux and Security_Windows and Deprecating v1 + v2 (#131166) * consolidate Security ML Modules * removal of auditbeat host processes ecs module * removing siem_winlogbeat_auth after consolidating into windows_security * renamed to avoid job collisions * Update recognize_module.ts removed references to deprecated v1 modules which no longer exist * test fixes remove references to deprecated module and modify module names to match the latest v3 modules being committed. * Update recognize_module.ts think this is what the linter wants * deprecating winlogbeat and auditbeat modules * fixes test post-deprecation of modules * fixes typo in test * revert linting changes * revert linting changes pt2 * fixing test in setup_module.ts * ml module refactor * manifest, job, and datafeed cleanup based on PR feedback * commenting out security solution tests for ML Modules * modified ml module tests and job descriptions * Update datafeed_auth_high_count_logon_events_for_a_source_ip.json added test for existence of source.ip field per https://github.com/elastic/kibana/issues/131376 * Update datafeed_auth_high_count_logon_events_for_a_source_ip.json formatting * descriptions standardized descriptions between Linux and Windows jobs; removed the term "services" from the rare process jobs because it has a special meaning under Windows and is the target of a different job; added a sentence to the sudo job description, I think this was a stub description that never got fleshed out when it was developed. * tags added job tags * tags added Linux job tags * tags * linting remove a dup json element * Update v3_windows_anomalous_script.json add the Security: Windows prefix which was missing * Update v3_linux_anomalous_network_activity.json missing bracket * Update v3_windows_anomalous_script.json the prefix was in the wrong place Co-authored-by: Craig Chamberlain Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...uditbeat_hosts_process_event_rate_ecs.json | 12 - ..._auditbeat_hosts_process_explorer_ecs.json | 12 - ...ml_auditbeat_hosts_process_events_ecs.json | 19 -- ...sts_process_event_rate_by_process_ecs.json | 11 - ...beat_hosts_process_event_rate_vis_ecs.json | 11 - ...uditbeat_hosts_process_occurrence_ecs.json | 11 - .../auditbeat_process_hosts_ecs/logo.json | 3 - .../auditbeat_process_hosts_ecs/manifest.json | 76 ----- ...d_hosts_high_count_process_events_ecs.json | 19 -- ...afeed_hosts_rare_process_activity_ecs.json | 19 -- .../hosts_high_count_process_events_ecs.json | 38 --- .../ml/hosts_rare_process_activity_ecs.json | 39 --- .../modules/security_auth/manifest.json | 9 + ...gh_count_logon_events_for_a_source_ip.json | 23 +- .../datafeed_suspicious_login_activity.json} | 0 .../ml/suspicious_login_activity.json} | 0 .../modules/security_linux/logo.json | 2 +- .../modules/security_linux/manifest.json | 141 ++++++--- ...feed_v2_linux_anomalous_user_name_ecs.json | 71 ----- .../datafeed_v2_linux_rare_metadata_user.json | 66 ---- ..._v3_linux_anomalous_network_activity.json} | 51 ++-- ...inux_anomalous_network_port_activity.json} | 3 +- ..._v3_linux_anomalous_process_all_hosts.json | 101 +++++++ ...datafeed_v3_linux_anomalous_user_name.json | 71 +++++ ...inux_network_configuration_discovery.json} | 44 +-- ...v3_linux_network_connection_discovery.json | 92 ++++++ ...tafeed_v3_linux_rare_metadata_process.json | 66 ++++ .../datafeed_v3_linux_rare_metadata_user.json | 66 ++++ .../ml/datafeed_v3_linux_rare_sudo_user.json | 71 +++++ .../datafeed_v3_linux_rare_user_compiler.json | 92 ++++++ ...v3_linux_system_information_discovery.json | 132 ++++++++ ...ed_v3_linux_system_process_discovery.json} | 23 +- ...tafeed_v3_linux_system_user_discovery.json | 92 ++++++ ...atafeed_v3_rare_process_by_host_linux.json | 71 +++++ .../ml/v2_linux_rare_metadata_process.json | 36 --- .../ml/v2_linux_rare_metadata_user.json | 35 --- .../v3_linux_anomalous_network_activity.json | 63 ++++ ...inux_anomalous_network_port_activity.json} | 13 +- ...v3_linux_anomalous_process_all_hosts.json} | 24 +- ...json => v3_linux_anomalous_user_name.json} | 24 +- ...inux_network_configuration_discovery.json} | 17 +- ...3_linux_network_connection_discovery.json} | 17 +- .../ml/v3_linux_rare_metadata_process.json | 45 +++ .../ml/v3_linux_rare_metadata_user.json | 45 +++ .../ml/v3_linux_rare_sudo_user.json} | 17 +- .../ml/v3_linux_rare_user_compiler.json} | 17 +- ...3_linux_system_information_discovery.json} | 17 +- .../v3_linux_system_process_discovery.json} | 17 +- .../ml/v3_linux_system_user_discovery.json} | 15 +- ...son => v3_rare_process_by_host_linux.json} | 25 +- .../modules/security_windows/logo.json | 2 +- .../modules/security_windows/manifest.json | 125 +++++--- ...d_v2_rare_process_by_host_windows_ecs.json | 47 --- ...indows_anomalous_network_activity_ecs.json | 71 ----- ...v2_windows_anomalous_process_creation.json | 47 --- ...ed_v2_windows_anomalous_user_name_ecs.json | 47 --- ...feed_v2_windows_rare_metadata_process.json | 23 -- ...atafeed_v2_windows_rare_metadata_user.json | 23 -- ...afeed_v3_rare_process_by_host_windows.json | 47 +++ ...v3_windows_anomalous_network_activity.json | 71 +++++ ...ed_v3_windows_anomalous_path_activity.json | 47 +++ ...3_windows_anomalous_process_all_hosts.json | 47 +++ ...v3_windows_anomalous_process_creation.json | 47 +++ ...datafeed_v3_windows_anomalous_script.json} | 7 +- ...atafeed_v3_windows_anomalous_service.json} | 9 +- ...tafeed_v3_windows_anomalous_user_name.json | 47 +++ ...feed_v3_windows_rare_metadata_process.json | 23 ++ ...atafeed_v3_windows_rare_metadata_user.json | 23 ++ ...feed_v3_windows_rare_user_runas_event.json | 42 +++ ...indows_rare_user_type10_remote_login.json} | 18 +- ...2_windows_anomalous_path_activity_ecs.json | 54 ---- .../ml/v2_windows_rare_metadata_process.json | 38 --- .../ml/v2_windows_rare_metadata_user.json | 37 --- ...n => v3_rare_process_by_host_windows.json} | 26 +- ...3_windows_anomalous_network_activity.json} | 28 +- .../v3_windows_anomalous_path_activity.json | 65 ++++ ..._windows_anomalous_process_all_hosts.json} | 26 +- ...3_windows_anomalous_process_creation.json} | 26 +- .../ml/v3_windows_anomalous_script.json | 53 ++++ .../ml/v3_windows_anomalous_service.json} | 21 +- ...on => v3_windows_anomalous_user_name.json} | 26 +- .../ml/v3_windows_rare_metadata_process.json | 47 +++ .../ml/v3_windows_rare_metadata_user.json | 46 +++ .../ml/v3_windows_rare_user_runas_event.json} | 16 +- ...indows_rare_user_type10_remote_login.json} | 16 +- .../modules/siem_auditbeat/logo.json | 3 - .../modules/siem_auditbeat/manifest.json | 173 ----------- ..._linux_anomalous_network_activity_ecs.json | 27 -- ...x_anomalous_network_port_activity_ecs.json | 28 -- ...afeed_linux_anomalous_network_service.json | 27 -- ...ux_anomalous_network_url_activity_ecs.json | 28 -- ...linux_anomalous_process_all_hosts_ecs.json | 28 -- ...atafeed_linux_anomalous_user_name_ecs.json | 15 - ...linux_network_configuration_discovery.json | 26 -- ...ed_linux_network_connection_discovery.json | 23 -- ...ed_linux_rare_kernel_module_arguments.json | 22 -- .../datafeed_linux_rare_metadata_process.json | 12 - .../ml/datafeed_linux_rare_metadata_user.json | 12 - .../ml/datafeed_linux_rare_sudo_user.json | 15 - .../ml/datafeed_linux_rare_user_compiler.json | 22 -- ...ed_linux_system_information_discovery.json | 31 -- ...tafeed_linux_system_process_discovery.json | 21 -- .../datafeed_linux_system_user_discovery.json | 23 -- ...tafeed_rare_process_by_host_linux_ecs.json | 16 - .../linux_anomalous_network_activity_ecs.json | 53 ---- ...x_anomalous_network_port_activity_ecs.json | 53 ---- .../ml/linux_anomalous_network_service.json | 52 ---- ...ux_anomalous_network_url_activity_ecs.json | 40 --- ...linux_anomalous_process_all_hosts_ecs.json | 52 ---- .../ml/linux_anomalous_user_name_ecs.json | 52 ---- .../linux_rare_kernel_module_arguments.json | 45 --- .../ml/linux_rare_metadata_process.json | 52 ---- .../ml/linux_rare_metadata_user.json | 43 --- .../ml/rare_process_by_host_linux_ecs.json | 53 ---- .../modules/siem_auditbeat_auth/logo.json | 3 - .../modules/siem_auditbeat_auth/manifest.json | 30 -- .../modules/siem_winlogbeat/logo.json | 3 - .../modules/siem_winlogbeat/manifest.json | 119 -------- ...feed_rare_process_by_host_windows_ecs.json | 15 - ...indows_anomalous_network_activity_ecs.json | 27 -- ...d_windows_anomalous_path_activity_ecs.json | 15 - ...ndows_anomalous_process_all_hosts_ecs.json | 15 - ...ed_windows_anomalous_process_creation.json | 15 - .../ml/datafeed_windows_anomalous_script.json | 15 - .../datafeed_windows_anomalous_service.json | 15 - ...afeed_windows_anomalous_user_name_ecs.json | 15 - ...atafeed_windows_rare_metadata_process.json | 12 - .../datafeed_windows_rare_metadata_user.json | 12 - ...atafeed_windows_rare_user_runas_event.json | 15 - .../ml/rare_process_by_host_windows_ecs.json | 53 ---- ...indows_anomalous_network_activity_ecs.json | 53 ---- .../windows_anomalous_path_activity_ecs.json | 52 ---- ...ndows_anomalous_process_all_hosts_ecs.json | 52 ---- .../windows_anomalous_process_creation.json | 52 ---- .../ml/windows_anomalous_script.json | 45 --- .../ml/windows_anomalous_user_name_ecs.json | 52 ---- .../ml/windows_rare_metadata_process.json | 52 ---- .../ml/windows_rare_metadata_user.json | 43 --- .../modules/siem_winlogbeat_auth/logo.json | 3 - .../siem_winlogbeat_auth/manifest.json | 30 -- .../machine_learning_rule.spec.ts | 2 +- .../exceptions/add_exception.spec.ts | 2 +- .../hooks/use_security_jobs.test.ts | 8 +- .../hooks/use_security_jobs_helpers.test.tsx | 16 +- .../components/ml_popover/ml_modules.tsx | 8 +- .../apis/ml/modules/get_module.ts | 9 +- .../apis/ml/modules/recognize_module.ts | 23 +- .../apis/ml/modules/setup_module.ts | 284 ------------------ 148 files changed, 2220 insertions(+), 3314 deletions(-) delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/dashboard/ml_auditbeat_hosts_process_event_rate_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/dashboard/ml_auditbeat_hosts_process_explorer_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/search/ml_auditbeat_hosts_process_events_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_event_rate_by_process_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_event_rate_vis_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_occurrence_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/logo.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/manifest.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/datafeed_hosts_high_count_process_events_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/datafeed_hosts_rare_process_activity_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_high_count_process_events_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_rare_process_activity_ecs.json rename x-pack/plugins/ml/server/models/data_recognizer/modules/{siem_auditbeat_auth/ml/datafeed_suspicious_login_activity_ecs.json => security_auth/ml/datafeed_suspicious_login_activity.json} (100%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/{siem_auditbeat_auth/ml/suspicious_login_activity_ecs.json => security_auth/ml/suspicious_login_activity.json} (100%) delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_user_name_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_rare_metadata_user.json rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/{datafeed_v2_linux_rare_metadata_process.json => datafeed_v3_linux_anomalous_network_activity.json} (58%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/{datafeed_v2_linux_anomalous_network_port_activity_ecs.json => datafeed_v3_linux_anomalous_network_port_activity.json} (96%) create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_process_all_hosts.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_user_name.json rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/{datafeed_v2_linux_anomalous_process_all_hosts_ecs.json => datafeed_v3_linux_network_configuration_discovery.json} (76%) create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_network_connection_discovery.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_metadata_process.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_metadata_user.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_sudo_user.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_user_compiler.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_system_information_discovery.json rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/{datafeed_v2_rare_process_by_host_linux_ecs.json => datafeed_v3_linux_system_process_discovery.json} (83%) create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_system_user_discovery.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_rare_process_by_host_linux.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_process.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_user.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_anomalous_network_activity.json rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/{v2_linux_anomalous_network_port_activity_ecs.json => v3_linux_anomalous_network_port_activity.json} (81%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/{v2_rare_process_by_host_linux_ecs.json => v3_linux_anomalous_process_all_hosts.json} (76%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/{v2_linux_anomalous_user_name_ecs.json => v3_linux_anomalous_user_name.json} (74%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/{siem_auditbeat/ml/linux_network_connection_discovery.json => security_linux/ml/v3_linux_network_configuration_discovery.json} (72%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/{siem_auditbeat/ml/linux_network_configuration_discovery.json => security_linux/ml/v3_linux_network_connection_discovery.json} (72%) create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_metadata_process.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_metadata_user.json rename x-pack/plugins/ml/server/models/data_recognizer/modules/{siem_auditbeat/ml/linux_rare_sudo_user.json => security_linux/ml/v3_linux_rare_sudo_user.json} (81%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/{siem_auditbeat/ml/linux_rare_user_compiler.json => security_linux/ml/v3_linux_rare_user_compiler.json} (69%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/{siem_auditbeat/ml/linux_system_process_discovery.json => security_linux/ml/v3_linux_system_information_discovery.json} (77%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/{siem_auditbeat/ml/linux_system_user_discovery.json => security_linux/ml/v3_linux_system_process_discovery.json} (73%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/{siem_auditbeat/ml/linux_system_information_discovery.json => security_linux/ml/v3_linux_system_user_discovery.json} (73%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/{v2_linux_anomalous_process_all_hosts_ecs.json => v3_rare_process_by_host_linux.json} (73%) delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_rare_process_by_host_windows_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_network_activity_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_process_creation.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_user_name_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_process.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_user.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_rare_process_by_host_windows.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_network_activity.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_path_activity.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_process_all_hosts.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_process_creation.json rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/{datafeed_v2_windows_anomalous_path_activity_ecs.json => datafeed_v3_windows_anomalous_script.json} (85%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/{datafeed_v2_windows_anomalous_process_all_hosts_ecs.json => datafeed_v3_windows_anomalous_service.json} (85%) create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_user_name.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_metadata_process.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_metadata_user.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_user_runas_event.json rename x-pack/plugins/ml/server/models/data_recognizer/modules/{siem_winlogbeat_auth/ml/datafeed_windows_rare_user_type10_remote_login.json => security_windows/ml/datafeed_v3_windows_rare_user_type10_remote_login.json} (82%) delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_path_activity_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_process.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_user.json rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/{v2_rare_process_by_host_windows_ecs.json => v3_rare_process_by_host_windows.json} (74%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/{v2_windows_anomalous_network_activity_ecs.json => v3_windows_anomalous_network_activity.json} (75%) create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_path_activity.json rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/{v2_windows_anomalous_user_name_ecs.json => v3_windows_anomalous_process_all_hosts.json} (74%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/{v2_windows_anomalous_process_creation.json => v3_windows_anomalous_process_creation.json} (75%) create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_script.json rename x-pack/plugins/ml/server/models/data_recognizer/modules/{siem_winlogbeat/ml/windows_anomalous_service.json => security_windows/ml/v3_windows_anomalous_service.json} (58%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/{v2_windows_anomalous_process_all_hosts_ecs.json => v3_windows_anomalous_user_name.json} (74%) create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_metadata_process.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_metadata_user.json rename x-pack/plugins/ml/server/models/data_recognizer/modules/{siem_winlogbeat_auth/ml/windows_rare_user_type10_remote_login.json => security_windows/ml/v3_windows_rare_user_runas_event.json} (82%) rename x-pack/plugins/ml/server/models/data_recognizer/modules/{siem_winlogbeat/ml/windows_rare_user_runas_event.json => security_windows/ml/v3_windows_rare_user_type10_remote_login.json} (81%) delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/logo.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/manifest.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_activity_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_port_activity_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_service.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_url_activity_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_process_all_hosts_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_user_name_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_network_configuration_discovery.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_network_connection_discovery.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_kernel_module_arguments.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_metadata_process.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_metadata_user.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_sudo_user.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_user_compiler.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_system_information_discovery.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_system_process_discovery.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_system_user_discovery.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_rare_process_by_host_linux_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_activity_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_port_activity_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_service.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_url_activity_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_process_all_hosts_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_user_name_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_kernel_module_arguments.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_metadata_process.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_metadata_user.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/rare_process_by_host_linux_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/logo.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/manifest.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/logo.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/manifest.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_rare_process_by_host_windows_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_network_activity_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_path_activity_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_process_all_hosts_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_process_creation.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_script.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_service.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_user_name_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_rare_metadata_process.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_rare_metadata_user.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_rare_user_runas_event.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/rare_process_by_host_windows_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_network_activity_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_path_activity_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_process_all_hosts_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_process_creation.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_script.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_user_name_ecs.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_rare_metadata_process.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_rare_metadata_user.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/logo.json delete mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/manifest.json diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/dashboard/ml_auditbeat_hosts_process_event_rate_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/dashboard/ml_auditbeat_hosts_process_event_rate_ecs.json deleted file mode 100644 index 2220480207282..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/dashboard/ml_auditbeat_hosts_process_event_rate_ecs.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "title": "ML Auditbeat Hosts: Process Event Rate (ECS)", - "hits": 0, - "description": "Investigate unusual process event rates on a host", - "panelsJSON": "[{\"size_x\":6,\"size_y\":4,\"row\":1,\"col\":1,\"id\":\"ml_auditbeat_hosts_process_event_rate_vis_ecs\",\"panelIndex\":\"1\",\"type\":\"visualization\"},{\"size_x\":6,\"size_y\":4,\"row\":1,\"col\":7,\"id\":\"ml_auditbeat_hosts_process_event_rate_by_process_ecs\",\"panelIndex\":\"2\",\"type\":\"visualization\"},{\"size_x\":12,\"size_y\":8,\"row\":5,\"col\":1,\"panelIndex\":\"3\",\"type\":\"search\",\"id\":\"ml_auditbeat_hosts_process_events_ecs\"}]", - "optionsJSON": "{\"useMargins\":true}", - "version": 1, - "timeRestore": false, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/dashboard/ml_auditbeat_hosts_process_explorer_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/dashboard/ml_auditbeat_hosts_process_explorer_ecs.json deleted file mode 100644 index 79f3b0fbacef7..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/dashboard/ml_auditbeat_hosts_process_explorer_ecs.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "title": "ML Auditbeat Hosts: Process Explorer (ECS)", - "hits": 0, - "description": "Explore processes on a host", - "panelsJSON": "[{\"size_x\": 6,\"size_y\": 4,\"row\": 1,\"col\": 1,\"id\": \"ml_auditbeat_hosts_process_occurrence_ecs\",\"panelIndex\": \"1\",\"type\": \"visualization\"},{\"size_x\": 12,\"size_y\": 8,\"row\": 5,\"col\": 1,\"panelIndex\": \"2\",\"type\": \"search\",\"id\": \"ml_auditbeat_hosts_process_events_ecs\"},{\"size_x\": 6,\"size_y\": 4,\"row\": 1,\"col\": 7,\"panelIndex\": \"3\",\"type\": \"visualization\",\"id\": \"ml_auditbeat_hosts_process_event_rate_by_process_ecs\"}\n]", - "optionsJSON": "{\"useMargins\":true}", - "version": 1, - "timeRestore": false, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/search/ml_auditbeat_hosts_process_events_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/search/ml_auditbeat_hosts_process_events_ecs.json deleted file mode 100644 index c81b4fdf98c12..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/search/ml_auditbeat_hosts_process_events_ecs.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "title": "ML Auditbeat Hosts: Process Events (ECS)", - "description": "Auditbeat auditd process events on host machines", - "hits": 0, - "columns": [ - "host.name", - "auditd.data.syscall", - "process.executable", - "process.title" - ], - "sort": [ - "@timestamp", - "desc" - ], - "version": 1, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"index\":\"INDEX_PATTERN_ID\",\"highlightAll\":true,\"version\":true,\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"index\":\"INDEX_PATTERN_ID\",\"negate\":true,\"disabled\":false,\"alias\":null,\"type\":\"exists\",\"key\":\"container.runtime\",\"value\":\"exists\"},\"exists\":{\"field\":\"container.runtime\"},\"$state\":{\"store\":\"appState\"}},{\"meta\":{\"index\":\"INDEX_PATTERN_ID\",\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"event.module\",\"value\":\"auditd\",\"params\":{\"query\":\"auditd\"}},\"query\":{\"match\":{\"event.module\":{\"query\":\"auditd\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}},{\"meta\":{\"index\":\"INDEX_PATTERN_ID\",\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"exists\",\"key\":\"auditd.data.syscall\",\"value\":\"exists\"},\"exists\":{\"field\":\"auditd.data.syscall\"},\"$state\":{\"store\":\"appState\"}}]}" - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_event_rate_by_process_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_event_rate_by_process_ecs.json deleted file mode 100644 index 6a70669a3ee5b..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_event_rate_by_process_ecs.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "title": "ML Auditbeat Hosts: Process Event Rate by Process (ECS)", - "visState": "{\"type\": \"histogram\",\"params\": {\"type\": \"histogram\",\"grid\": {\"categoryLines\": false,\"style\": {\"color\": \"#eee\"}},\"categoryAxes\": [{\"id\": \"CategoryAxis-1\",\"type\": \"category\",\"position\": \"bottom\",\"show\": true,\"style\": {},\"scale\": {\"type\": \"linear\"},\"labels\": {\"show\": true,\"truncate\": 100},\"title\": {}}],\"valueAxes\": [{\"id\": \"ValueAxis-1\",\"name\": \"LeftAxis-1\",\"type\": \"value\",\"position\": \"left\",\"show\": true,\"style\": {},\"scale\": {\"type\": \"linear\",\"mode\": \"normal\"},\"labels\": {\"show\": true,\"rotate\": 0,\"filter\": false,\"truncate\": 100},\"title\": {\"text\": \"Count\"}}],\"seriesParams\": [{\"show\": \"true\",\"type\": \"histogram\",\"mode\": \"stacked\",\"data\": {\"label\": \"Count\",\"id\": \"1\"},\"valueAxis\": \"ValueAxis-1\",\"drawLinesBetweenPoints\": true,\"showCircles\": true}],\"addTooltip\": true,\"addLegend\": true,\"legendPosition\": \"right\",\"times\": [],\"addTimeMarker\": false},\"aggs\": [{\"id\": \"1\",\"enabled\": true,\"type\": \"count\",\"schema\": \"metric\",\"params\": {}},{\"id\": \"2\",\"enabled\": true,\"type\": \"date_histogram\",\"schema\": \"segment\",\"params\": {\"field\": \"@timestamp\",\"useNormalizedEsInterval\": true,\"interval\": \"auto\",\"time_zone\": \"UTC\",\"drop_partials\": false,\"customInterval\": \"2h\",\"min_doc_count\": 1,\"extended_bounds\": {}}},{\"id\": \"3\",\"enabled\": true,\"type\": \"terms\",\"schema\": \"group\",\"params\": {\"field\": \"process.executable\",\"size\": 10,\"order\": \"desc\",\"orderBy\": \"1\",\"otherBucket\": false,\"otherBucketLabel\": \"Other\",\"missingBucket\": false,\"missingBucketLabel\": \"Missing\"}}]}", - "uiStateJSON": "{}", - "description": "", - "savedSearchId": "ml_auditbeat_hosts_process_events_ecs", - "version": 1, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_event_rate_vis_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_event_rate_vis_ecs.json deleted file mode 100644 index 9c41099c6bbd6..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_event_rate_vis_ecs.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "title": "ML Auditbeat Hosts: Process Event Rate (ECS)", - "visState":"{\"type\": \"line\",\"params\": {\"type\": \"line\",\"grid\": {\"categoryLines\": false,\"style\": {\"color\": \"#eee\"}},\"categoryAxes\": [{\"id\": \"CategoryAxis-1\",\"type\": \"category\",\"position\": \"bottom\",\"show\": true,\"style\": {},\"scale\": {\"type\": \"linear\"},\"labels\": {\"show\": true,\"truncate\": 100},\"title\": {}}],\"valueAxes\": [{\"id\": \"ValueAxis-1\",\"name\": \"LeftAxis-1\",\"type\": \"value\",\"position\": \"left\",\"show\": true,\"style\": {},\"scale\": {\"type\": \"linear\",\"mode\": \"normal\"},\"labels\": {\"show\": true,\"rotate\": 0,\"filter\": false,\"truncate\": 100},\"title\": {\"text\": \"Count\"}}],\"seriesParams\": [{\"show\": \"true\",\"type\": \"line\",\"mode\": \"normal\",\"data\": {\"label\": \"Count\",\"id\": \"1\"},\"valueAxis\": \"ValueAxis-1\",\"drawLinesBetweenPoints\": true,\"showCircles\": true}],\"addTooltip\": true,\"addLegend\": true,\"legendPosition\": \"right\",\"times\": [],\"addTimeMarker\": false},\"aggs\": [{\"id\": \"1\",\"enabled\": true,\"type\": \"count\",\"schema\": \"metric\",\"params\": {}},{\"id\": \"2\",\"enabled\": true,\"type\": \"date_histogram\",\"schema\": \"segment\",\"params\": {\"field\": \"@timestamp\",\"useNormalizedEsInterval\": true,\"interval\": \"auto\",\"time_zone\": \"UTC\",\"drop_partials\": false,\"customInterval\": \"2h\",\"min_doc_count\": 1,\"extended_bounds\": {}}},{\"id\": \"3\",\"enabled\": true,\"type\": \"terms\",\"schema\": \"group\",\"params\": {\"field\": \"host.name\",\"size\": 10,\"order\": \"desc\",\"orderBy\": \"1\",\"otherBucket\": false,\"otherBucketLabel\": \"Other\",\"missingBucket\": false,\"missingBucketLabel\": \"Missing\"}}]}", - "uiStateJSON": "{}", - "description": "", - "savedSearchId": "ml_auditbeat_hosts_process_events_ecs", - "version": 1, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_occurrence_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_occurrence_ecs.json deleted file mode 100644 index 0d28081818ac7..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_occurrence_ecs.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "title": "ML Auditbeat Hosts: Process Occurrence - experimental (ECS)", - "visState": "{\"type\":\"vega\",\"params\":{\"spec\":\"{\\n $schema: https://vega.github.io/schema/vega-lite/v4.json\\n width: \\\"container\\\"\\n mark: {type: \\\"point\\\"}\\n data: {\\n url: {\\n index: \\\"INDEX_PATTERN_NAME\\\"\\n body: {\\n size: 10000\\n query: {\\n bool: {\\n must: [\\n %dashboard_context-must_clause%\\n {\\n exists: {field: \\\"process.executable\\\"}\\n }\\n {\\n function_score: {\\n random_score: {seed: 10, field: \\\"_seq_no\\\"}\\n }\\n }\\n {\\n range: {\\n @timestamp: {\\n %timefilter%: true\\n }\\n }\\n }\\n ]\\n must_not: [\\n \\\"%dashboard_context-must_not_clause%\\\"\\n ]\\n }\\n }\\n script_fields: {\\n process_exe: {\\n script: {source: \\\"params['_source']['process']['executable']\\\"}\\n }\\n }\\n _source: [\\\"@timestamp\\\", \\\"process_exe\\\"]\\n }\\n }\\n format: {property: \\\"hits.hits\\\"}\\n }\\n transform: [\\n {calculate: \\\"toDate(datum._source['@timestamp'])\\\", as: \\\"time\\\"}\\n ]\\n encoding: {\\n x: {\\n field: time\\n type: temporal\\n axis: {labels: true, ticks: true, title: false},\\n timeUnit: utcyearmonthdatehoursminutes\\n }\\n y: {\\n field: fields.process_exe\\n type: ordinal\\n sort: {op: \\\"count\\\", order: \\\"descending\\\"}\\n axis: {labels: true, title: \\\"occurrence of process.executable\\\", ticks: false}\\n }\\n }\\n config: {\\n style: {\\n point: {filled: true}\\n }\\n }\\n}\"},\"aggs\":[]}", - "uiStateJSON": "{}", - "description": "", - "savedSearchId": "ml_auditbeat_hosts_process_events_ecs", - "version": 1, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{}" - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/logo.json deleted file mode 100644 index 5438a5241bdda..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/logo.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "icon": "auditbeatApp" -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/manifest.json deleted file mode 100644 index 96d0eb2a43866..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/manifest.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "id": "auditbeat_process_hosts_ecs", - "title": "Auditbeat host processes", - "description": "Detect unusual processes on hosts from auditd data (ECS).", - "type": "Auditbeat data", - "logoFile": "logo.json", - "defaultIndexPattern": "auditbeat-*", - "query": { - "bool": { - "filter": [ - { "term": { "event.module": "auditd" } } - ], - "must": { - "exists": { "field": "auditd.data.syscall" } - }, - "must_not": [ - { "exists": { "field": "container.runtime" } }, - { "terms": { "_tier": [ "data_frozen", "data_cold" ] } } - ] - } - }, - "jobs": [ - { - "id": "hosts_high_count_process_events_ecs", - "file": "hosts_high_count_process_events_ecs.json" - }, - { - "id": "hosts_rare_process_activity_ecs", - "file": "hosts_rare_process_activity_ecs.json" - } - ], - "datafeeds": [ - { - "id": "datafeed-hosts_high_count_process_events_ecs", - "file": "datafeed_hosts_high_count_process_events_ecs.json", - "job_id": "hosts_high_count_process_events_ecs" - }, - { - "id": "datafeed-hosts_rare_process_activity_ecs", - "file": "datafeed_hosts_rare_process_activity_ecs.json", - "job_id": "hosts_rare_process_activity_ecs" - } - ], - "kibana": { - "dashboard": [ - { - "id": "ml_auditbeat_hosts_process_event_rate_ecs", - "file": "ml_auditbeat_hosts_process_event_rate_ecs.json" - }, - { - "id": "ml_auditbeat_hosts_process_explorer_ecs", - "file": "ml_auditbeat_hosts_process_explorer_ecs.json" - } - ], - "search": [ - { - "id": "ml_auditbeat_hosts_process_events_ecs", - "file": "ml_auditbeat_hosts_process_events_ecs.json" - } - ], - "visualization": [ - { - "id": "ml_auditbeat_hosts_process_event_rate_by_process_ecs", - "file": "ml_auditbeat_hosts_process_event_rate_by_process_ecs.json" - }, - { - "id": "ml_auditbeat_hosts_process_event_rate_vis_ecs", - "file": "ml_auditbeat_hosts_process_event_rate_vis_ecs.json" - }, - { - "id": "ml_auditbeat_hosts_process_occurrence_ecs", - "file": "ml_auditbeat_hosts_process_occurrence_ecs.json" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/datafeed_hosts_high_count_process_events_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/datafeed_hosts_high_count_process_events_ecs.json deleted file mode 100644 index 9c04257fb8904..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/datafeed_hosts_high_count_process_events_ecs.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "query": { - "bool": { - "filter": [ - { "term": { "event.module": "auditd" } } - ], - "must": { - "exists": { "field": "auditd.data.syscall" } - }, - "must_not": { - "exists": { "field": "container.runtime" } - } - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/datafeed_hosts_rare_process_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/datafeed_hosts_rare_process_activity_ecs.json deleted file mode 100644 index 9c04257fb8904..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/datafeed_hosts_rare_process_activity_ecs.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "query": { - "bool": { - "filter": [ - { "term": { "event.module": "auditd" } } - ], - "must": { - "exists": { "field": "auditd.data.syscall" } - }, - "must_not": { - "exists": { "field": "container.runtime" } - } - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_high_count_process_events_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_high_count_process_events_ecs.json deleted file mode 100644 index 192842309dd92..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_high_count_process_events_ecs.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Auditbeat Hosts: Detect unusual increases in process execution rates (ECS)", - "groups": ["auditd"], - "analysis_config": { - "bucket_span": "1h", - "detectors": [ - { - "detector_description": "High process rate on hosts", - "function": "high_non_zero_count", - "partition_field_name": "host.name" - } - ], - "influencers": ["host.name", "process.executable"] - }, - "analysis_limits": { - "model_memory_limit": "256mb" - }, - "data_description": { - "time_field": "@timestamp", - "time_format": "epoch_ms" - }, - "custom_settings": { - "created_by": "ml-module-auditbeat-process-hosts", - "custom_urls": [ - { - "url_name": "Process rate", - "time_range": "1h", - "url_value": "dashboards#/view/ml_auditbeat_hosts_process_event_rate_ecs?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:event.module,negate:!f,params:(query:auditd),type:phrase,value:auditd),query:(match:(event.module:(query:auditd,type:phrase)))),('$state':(store:appState),exists:(field:container.runtime),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:container.runtime,negate:!t,type:exists,value:exists)),('$state':(store:appState),exists:(field:auditd.data.syscall),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:auditd.data.syscall,negate:!f,type:exists,value:exists))),query:(language:kuery,query:\u0027host.name:\u0022$host.name$\u0022\u0027))" - }, - { - "url_name": "Raw data", - "time_range": "1h", - "url_value": "discover#/?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(index:\u0027INDEX_PATTERN_ID\u0027,query:(language:kuery,query:\u0027host.name:\u0022$host.name$\u0022\u0027))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_rare_process_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_rare_process_activity_ecs.json deleted file mode 100644 index 9448537b387c2..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_rare_process_activity_ecs.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Auditbeat Hosts: Detect rare process executions on hosts (ECS)", - "groups": ["auditd"], - "analysis_config": { - "bucket_span": "1h", - "detectors": [ - { - "detector_description": "Rare process execution on hosts", - "function": "rare", - "by_field_name": "process.executable", - "partition_field_name": "host.name" - } - ], - "influencers": ["host.name", "process.executable"] - }, - "analysis_limits": { - "model_memory_limit": "256mb" - }, - "data_description": { - "time_field": "@timestamp", - "time_format": "epoch_ms" - }, - "custom_settings": { - "created_by": "ml-module-auditbeat-process-hosts", - "custom_urls": [ - { - "url_name": "Process explorer", - "time_range": "1h", - "url_value": "dashboards#/view/ml_auditbeat_hosts_process_explorer_ecs?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:event.module,negate:!f,params:(query:auditd),type:phrase,value:auditd),query:(match:(event.module:(query:auditd,type:phrase)))),('$state':(store:appState),exists:(field:container.runtime),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:container.runtime,negate:!t,type:exists,value:exists)),('$state':(store:appState),exists:(field:auditd.data.syscall),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:auditd.data.syscall,negate:!f,type:exists,value:exists))),query:(language:kuery,query:\u0027host.name:\u0022$host.name$\u0022\u0027))" - }, - { - "url_name": "Raw data", - "time_range": "1h", - "url_value": "discover#/?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(index:\u0027INDEX_PATTERN_ID\u0027,query:(language:kuery,query:\u0027host.name:\u0022$host.name$\u0022 AND process.executable:\u0022$process.executable$\u0022\u0027))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/manifest.json index 7bb54bd126e77..b3395d82a9c29 100755 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/manifest.json @@ -41,6 +41,10 @@ { "id": "auth_rare_user", "file": "auth_rare_user.json" + }, + { + "id": "suspicious_login_activity", + "file": "suspicious_login_activity.json" } ], "datafeeds": [ @@ -73,6 +77,11 @@ "id": "datafeed-auth_rare_user", "file": "datafeed_auth_rare_user.json", "job_id": "auth_rare_user" + }, + { + "id": "datafeed-suspicious_login_activity", + "file": "datafeed_suspicious_login_activity.json", + "job_id": "suspicious_login_activity" } ] } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_events_for_a_source_ip.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_events_for_a_source_ip.json index cdf39e0a70461..35638932adb3e 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_events_for_a_source_ip.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_events_for_a_source_ip.json @@ -5,19 +5,16 @@ ], "max_empty_searches": 10, "query": { - "bool": { - "filter": [ - { - "term": { - "event.category": "authentication" - } - }, - { - "term": { - "event.outcome": "success" - } + "bool": { + "filter": [{"exists": {"field": "source.ip"}}], + "must": [ + {"bool": { + "should": [ + {"term": {"event.category": "authentication"}}, + {"term": {"event.outcome": "success"}} + ] + }} + ] } - ] - } } } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/ml/datafeed_suspicious_login_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_suspicious_login_activity.json similarity index 100% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/ml/datafeed_suspicious_login_activity_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_suspicious_login_activity.json diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/ml/suspicious_login_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/suspicious_login_activity.json similarity index 100% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/ml/suspicious_login_activity_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/suspicious_login_activity.json diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/logo.json index 862f970b7405d..1a8759749131a 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/logo.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/logo.json @@ -1,3 +1,3 @@ { "icon": "logoSecurity" -} +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json index 281343975500b..efed4a3c9e9b1 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json @@ -1,10 +1,10 @@ { - "id": "security_linux", + "id": "security_linux_v3", "title": "Security: Linux", - "description": "Detect suspicious activity using ECS Linux events. Tested with Auditbeat and the Elastic agent.", + "description": "Anomaly detection jobs for Linux host based threat hunting and detection.", "type": "linux data", "logoFile": "logo.json", - "defaultIndexPattern": "auditbeat-*,logs-endpoint.events.*", + "defaultIndexPattern": "auditbeat-*,logs-*", "query": { "bool": { "should": [ @@ -40,66 +40,137 @@ } } } - ], - "must_not": { "terms": { "_tier": [ "data_frozen", "data_cold" ] } } + ] } }, "jobs": [ { - "id": "v2_rare_process_by_host_linux_ecs", - "file": "v2_rare_process_by_host_linux_ecs.json" + "id": "v3_linux_anomalous_network_port_activity", + "file": "v3_linux_anomalous_network_port_activity.json" }, { - "id": "v2_linux_rare_metadata_user", - "file": "v2_linux_rare_metadata_user.json" + "id": "v3_linux_network_configuration_discovery", + "file": "v3_linux_network_configuration_discovery.json" }, { - "id": "v2_linux_rare_metadata_process", - "file": "v2_linux_rare_metadata_process.json" + "id": "v3_linux_network_connection_discovery", + "file": "v3_linux_network_connection_discovery.json" }, { - "id": "v2_linux_anomalous_user_name_ecs", - "file": "v2_linux_anomalous_user_name_ecs.json" + "id": "v3_linux_rare_sudo_user", + "file": "v3_linux_rare_sudo_user.json" }, { - "id": "v2_linux_anomalous_process_all_hosts_ecs", - "file": "v2_linux_anomalous_process_all_hosts_ecs.json" + "id": "v3_linux_rare_user_compiler", + "file": "v3_linux_rare_user_compiler.json" }, { - "id": "v2_linux_anomalous_network_port_activity_ecs", - "file": "v2_linux_anomalous_network_port_activity_ecs.json" + "id": "v3_linux_system_information_discovery", + "file": "v3_linux_system_information_discovery.json" + }, + { + "id": "v3_linux_system_process_discovery", + "file": "v3_linux_system_process_discovery.json" + }, + { + "id": "v3_linux_system_user_discovery", + "file": "v3_linux_system_user_discovery.json" + }, + { + "id": "v3_linux_anomalous_process_all_hosts", + "file": "v3_linux_anomalous_process_all_hosts.json" + }, + { + "id": "v3_linux_anomalous_user_name", + "file": "v3_linux_anomalous_user_name.json" + }, + { + "id": "v3_linux_rare_metadata_process", + "file": "v3_linux_rare_metadata_process.json" + }, + { + "id": "v3_linux_rare_metadata_user", + "file": "v3_linux_rare_metadata_user.json" + }, + { + "id": "v3_rare_process_by_host_linux", + "file": "v3_rare_process_by_host_linux.json" + }, + { + "id": "v3_linux_anomalous_network_activity", + "file": "v3_linux_anomalous_network_activity.json" } ], "datafeeds": [ { - "id": "datafeed-v2_rare_process_by_host_linux_ecs", - "file": "datafeed_v2_rare_process_by_host_linux_ecs.json", - "job_id": "v2_rare_process_by_host_linux_ecs" + "id": "datafeed-v3_linux_anomalous_network_port_activity", + "file": "datafeed_v3_linux_anomalous_network_port_activity.json", + "job_id": "v3_linux_anomalous_network_port_activity" + }, + { + "id": "datafeed-v3_linux_network_configuration_discovery", + "file": "datafeed_v3_linux_network_configuration_discovery.json", + "job_id": "v3_linux_network_configuration_discovery" + }, + { + "id": "datafeed-v3_linux_network_connection_discovery", + "file": "datafeed_v3_linux_network_connection_discovery.json", + "job_id": "v3_linux_network_connection_discovery" + }, + { + "id": "datafeed-v3_linux_rare_sudo_user", + "file": "datafeed_v3_linux_rare_sudo_user.json", + "job_id": "v3_linux_rare_sudo_user" + }, + { + "id": "datafeed-v3_linux_rare_user_compiler", + "file": "datafeed_v3_linux_rare_user_compiler.json", + "job_id": "v3_linux_rare_user_compiler" + }, + { + "id": "datafeed-v3_linux_system_information_discovery", + "file": "datafeed_v3_linux_system_information_discovery.json", + "job_id": "v3_linux_system_information_discovery" + }, + { + "id": "datafeed-v3_linux_system_process_discovery", + "file": "datafeed_v3_linux_system_process_discovery.json", + "job_id": "v3_linux_system_process_discovery" + }, + { + "id": "datafeed-v3_linux_system_user_discovery", + "file": "datafeed_v3_linux_system_user_discovery.json", + "job_id": "v3_linux_system_user_discovery" + }, + { + "id": "datafeed-v3_linux_anomalous_process_all_hosts", + "file": "datafeed_v3_linux_anomalous_process_all_hosts.json", + "job_id": "v3_linux_anomalous_process_all_hosts" }, { - "id": "datafeed-v2_linux_rare_metadata_user", - "file": "datafeed_v2_linux_rare_metadata_user.json", - "job_id": "v2_linux_rare_metadata_user" + "id": "datafeed-v3_linux_anomalous_user_name", + "file": "datafeed_v3_linux_anomalous_user_name.json", + "job_id": "v3_linux_anomalous_user_name" }, { - "id": "datafeed-v2_linux_rare_metadata_process", - "file": "datafeed_v2_linux_rare_metadata_process.json", - "job_id": "v2_linux_rare_metadata_process" + "id": "datafeed-v3_linux_rare_metadata_process", + "file": "datafeed_v3_linux_rare_metadata_process.json", + "job_id": "v3_linux_rare_metadata_process" }, { - "id": "datafeed-v2_linux_anomalous_user_name_ecs", - "file": "datafeed_v2_linux_anomalous_user_name_ecs.json", - "job_id": "v2_linux_anomalous_user_name_ecs" + "id": "datafeed-v3_linux_rare_metadata_user", + "file": "datafeed_v3_linux_rare_metadata_user.json", + "job_id": "v3_linux_rare_metadata_user" }, { - "id": "datafeed-v2_linux_anomalous_process_all_hosts_ecs", - "file": "datafeed_v2_linux_anomalous_process_all_hosts_ecs.json", - "job_id": "v2_linux_anomalous_process_all_hosts_ecs" + "id": "datafeed-v3_rare_process_by_host_linux", + "file": "datafeed_v3_rare_process_by_host_linux.json", + "job_id": "v3_rare_process_by_host_linux" }, { - "id": "datafeed-v2_linux_anomalous_network_port_activity_ecs", - "file": "datafeed_v2_linux_anomalous_network_port_activity_ecs.json", - "job_id": "v2_linux_anomalous_network_port_activity_ecs" + "id": "datafeed-v3_linux_anomalous_network_activity", + "file": "datafeed_v3_linux_anomalous_network_activity.json", + "job_id": "v3_linux_anomalous_network_activity" } ] } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_user_name_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_user_name_ecs.json deleted file mode 100644 index 673de388e68b9..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_user_name_ecs.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - { - "term": { - "event.category": "process" - } - }, - { - "term": { - "event.type": "start" - } - } - ], - "must": [ - { - "bool": { - "should": [ - { - "match": { - "host.os.type": { - "query": "linux", - "operator": "OR" - } - } - }, - { - "match": { - "host.os.family": { - "query": "debian", - "operator": "OR" - } - } - }, - { - "match": { - "host.os.family": { - "query": "redhat", - "operator": "OR" - } - } - }, - { - "match": { - "host.os.family": { - "query": "suse", - "operator": "OR" - } - } - }, - { - "match": { - "host.os.family": { - "query": "ubuntu", - "operator": "OR" - } - } - } - ] - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_rare_metadata_user.json deleted file mode 100644 index b79d97ef5e40c..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_rare_metadata_user.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - { - "term": { - "destination.ip": "169.254.169.254" - } - } - ], - "must": [ - { - "bool": { - "should": [ - { - "match": { - "host.os.type": { - "query": "linux", - "operator": "OR" - } - } - }, - { - "match": { - "host.os.family": { - "query": "debian", - "operator": "OR" - } - } - }, - { - "match": { - "host.os.family": { - "query": "redhat", - "operator": "OR" - } - } - }, - { - "match": { - "host.os.family": { - "query": "suse", - "operator": "OR" - } - } - }, - { - "match": { - "host.os.family": { - "query": "ubuntu", - "operator": "OR" - } - } - } - ] - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_network_activity.json similarity index 58% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_rare_metadata_process.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_network_activity.json index b79d97ef5e40c..6ac87dfde405e 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_rare_metadata_process.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_network_activity.json @@ -1,23 +1,21 @@ { - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { - "term": { - "destination.ip": "169.254.169.254" - } - } - ], - "must": [ + "filter": [ + {"term": {"event.category": "network"}}, + {"term": {"event.type": "start"}} + ], + "must": [ { "bool": { "should": [ - { + { "match": { "host.os.type": { "query": "linux", @@ -33,7 +31,7 @@ } } }, - { + { "match": { "host.os.family": { "query": "redhat", @@ -41,7 +39,7 @@ } } }, - { + { "match": { "host.os.family": { "query": "suse", @@ -49,7 +47,7 @@ } } }, - { + { "match": { "host.os.family": { "query": "ubuntu", @@ -60,7 +58,20 @@ ] } } - ] + ], + "must_not": [ + { + "bool": { + "should": [ + {"term": {"destination.ip": "127.0.0.1"}}, + {"term": {"destination.ip": "127.0.0.53"}}, + {"term": {"destination.ip": "::"}}, + {"term": {"destination.ip": "::1"}}, + {"term": {"user.name":"jenkins"}} + ] + } + } + ] + } } - } } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_network_port_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_network_port_activity.json similarity index 96% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_network_port_activity_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_network_port_activity.json index 67c198b3f56ec..386fc065fcd11 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_network_port_activity_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_network_port_activity.json @@ -64,6 +64,7 @@ "bool": { "should": [ {"term": {"destination.ip": "127.0.0.1"}}, + {"term": {"destination.ip": "127.0.0.53"}}, {"term": {"destination.ip": "::"}}, {"term": {"destination.ip": "::1"}}, {"term": {"user.name":"jenkins"}} @@ -73,4 +74,4 @@ ] } } - } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_process_all_hosts.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_process_all_hosts.json new file mode 100644 index 0000000000000..ac3e9f95e27e5 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_process_all_hosts.json @@ -0,0 +1,101 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + } + ], + "must_not": [ + { + "bool": { + "should": [ + { + "term": { + "user.name": "jenkins-worker" + } + }, + { + "term": { + "user.name": "jenkins-user" + } + }, + { + "term": { + "user.name": "jenkins" + } + }, + { + "wildcard": { + "process.name": { + "wildcard": "jenkins*" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_user_name.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_user_name.json new file mode 100644 index 0000000000000..31f4572a778c3 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_anomalous_user_name.json @@ -0,0 +1,71 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_process_all_hosts_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_network_configuration_discovery.json similarity index 76% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_process_all_hosts_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_network_configuration_discovery.json index da41aff66ea01..0d44e7a441650 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_linux_anomalous_process_all_hosts_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_network_configuration_discovery.json @@ -7,11 +7,6 @@ "query": { "bool": { "filter": [ - { - "term": { - "event.category": "process" - } - }, { "term": { "event.type": "start" @@ -38,7 +33,7 @@ } } }, - { + { "match": { "host.os.family": { "query": "redhat", @@ -46,7 +41,7 @@ } } }, - { + { "match": { "host.os.family": { "query": "suse", @@ -54,7 +49,7 @@ } } }, - { + { "match": { "host.os.family": { "query": "ubuntu", @@ -64,32 +59,43 @@ } ] } - } - ], - "must_not": [ + }, { "bool": { "should": [ { "term": { - "user.name": "jenkins-worker" + "process.name": "arp" } }, { "term": { - "user.name": "jenkins-user" + "process.name": "echo" } }, { "term": { - "user.name": "jenkins" + "process.name": "ethtool" } }, { - "wildcard": { - "process.name": { - "wildcard": "jenkins*" - } + "term": { + "process.name": "ifconfig" + } + }, + { + "term": { + "process.name": "ip" + } + }, + { + "term": { + "process.name": "iptables" + } + }, + { + "term": { + "process.name": "ufw" } } ] @@ -98,4 +104,4 @@ ] } } -} +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_network_connection_discovery.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_network_connection_discovery.json new file mode 100644 index 0000000000000..b7bcec8fd7082 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_network_connection_discovery.json @@ -0,0 +1,92 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + }, + { + "bool": { + "should": [ + { + "term": { + "process.name": "netstat" + } + }, + { + "term": { + "process.name": "ss" + } + }, + { + "term": { + "process.name": "route" + } + }, + { + "term": { + "process.name": "showmount" + } + } + ] + } + } + ] + } + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_metadata_process.json new file mode 100644 index 0000000000000..705d79d814370 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_metadata_process.json @@ -0,0 +1,66 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "destination.ip": "169.254.169.254" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_metadata_user.json new file mode 100644 index 0000000000000..705d79d814370 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_metadata_user.json @@ -0,0 +1,66 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "destination.ip": "169.254.169.254" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_sudo_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_sudo_user.json new file mode 100644 index 0000000000000..2dcdee598a0d7 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_sudo_user.json @@ -0,0 +1,71 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.type": "start" + } + }, + { + "term": { + "process.name": "sudo" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_user_compiler.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_user_compiler.json new file mode 100644 index 0000000000000..8bb0bddf7c37e --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_rare_user_compiler.json @@ -0,0 +1,92 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + }, + { + "bool": { + "should": [ + { + "term": { + "process.name": "compile" + } + }, + { + "term": { + "process.name": "gcc" + } + }, + { + "term": { + "process.name": "make" + } + }, + { + "term": { + "process.name": "yasm" + } + } + ] + } + } + ] + } + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_system_information_discovery.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_system_information_discovery.json new file mode 100644 index 0000000000000..23e6d374d27f2 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_system_information_discovery.json @@ -0,0 +1,132 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + }, + { + "bool": { + "should": [ + { + "term": { + "process.name": "cat" + } + }, + { + "term": { + "process.name": "grep" + } + }, + { + "term": { + "process.name": "head" + } + }, + { + "term": { + "process.name": "hostname" + } + }, + { + "term": { + "process.name": "less" + } + }, + { + "term": { + "process.name": "ls" + } + }, + { + "term": { + "process.name": "lsmod" + } + }, + { + "term": { + "process.name": "more" + } + }, + { + "term": { + "process.name": "strings" + } + }, + { + "term": { + "process.name": "tail" + } + }, + { + "term": { + "process.name": "uptime" + } + }, + { + "term": { + "process.name": "uname" + } + } + ] + } + } + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_rare_process_by_host_linux_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_system_process_discovery.json similarity index 83% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_rare_process_by_host_linux_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_system_process_discovery.json index 673de388e68b9..e90e9f9161eff 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v2_rare_process_by_host_linux_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_system_process_discovery.json @@ -7,11 +7,6 @@ "query": { "bool": { "filter": [ - { - "term": { - "event.category": "process" - } - }, { "term": { "event.type": "start" @@ -64,8 +59,24 @@ } ] } + }, + { + "bool": { + "should": [ + { + "term": { + "process.name": "ps" + } + }, + { + "term": { + "process.name": "top" + } + } + ] + } } ] } } -} +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_system_user_discovery.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_system_user_discovery.json new file mode 100644 index 0000000000000..281de366483bc --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_linux_system_user_discovery.json @@ -0,0 +1,92 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + }, + { + "bool": { + "should": [ + { + "term": { + "process.name": "users" + } + }, + { + "term": { + "process.name": "w" + } + }, + { + "term": { + "process.name": "who" + } + }, + { + "term": { + "process.name": "whoami" + } + } + ] + } + } + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_rare_process_by_host_linux.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_rare_process_by_host_linux.json new file mode 100644 index 0000000000000..31f4572a778c3 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/datafeed_v3_rare_process_by_host_linux.json @@ -0,0 +1,71 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.type": { + "query": "linux", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "debian", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "redhat", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "suse", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.family": { + "query": "ubuntu", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_process.json deleted file mode 100644 index c550378dad0b3..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_process.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Linux - Looks for anomalous access to the metadata service by an unusual process. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", - "groups": [ - "security", - "auditbeat", - "endpoint", - "linux", - "process" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"process.name\"", - "function": "rare", - "by_field_name": "process.name" - } - ], - "influencers": [ - "host.name", - "user.name", - "process.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "32mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-security-linux" - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_user.json deleted file mode 100644 index 66f35bdce12cd..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_rare_metadata_user.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Linux - Looks for anomalous access to the metadata service by an unusual user. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", - "groups": [ - "security", - "auditbeat", - "endpoint", - "linux", - "process" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"user.name\"", - "function": "rare", - "by_field_name": "user.name" - } - ], - "influencers": [ - "host.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "32mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-security-linux" - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_anomalous_network_activity.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_anomalous_network_activity.json new file mode 100644 index 0000000000000..a9a77ae1e9408 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_anomalous_network_activity.json @@ -0,0 +1,63 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Linux - Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity.", + "groups": [ + "auditbeat", + "endpoint", + "linux", + "network", + "security" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "Detects rare process.name values.", + "function": "rare", + "by_field_name": "process.name" + } + ], + "influencers": [ + "host.name", + "process.name", + "user.name", + "destination.ip" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "64mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "custom_settings": { + "job_tags": { + "euid": "4004", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "custom_urls": [ + { + "url_name": "Host Details by process name", + "url_value": "siem#/ml-hosts/$host.name$?_g=()&kqlQuery=(filterQuery:(expression:'process.name%20:%20%22$process.name$%22',kind:kuery),queryLocation:hosts.details,type:details)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Host Details by user name", + "url_value": "siem#/ml-hosts/$host.name$?_g=()&kqlQuery=(filterQuery:(expression:'user.name%20:%20%22$user.name$%22',kind:kuery),queryLocation:hosts.details,type:details)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by process name", + "url_value": "siem#/ml-hosts?_g=()&kqlQuery=(filterQuery:(expression:'process.name%20:%20%22$process.name$%22',kind:kuery),queryLocation:hosts.page,type:page)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by user name", + "url_value": "siem#/ml-hosts?_g=()&kqlQuery=(filterQuery:(expression:'user.name%20:%20%22$user.name$%22',kind:kuery),queryLocation:hosts.page,type:page)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + } + ] + } +} +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_network_port_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_anomalous_network_port_activity.json similarity index 81% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_network_port_activity_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_anomalous_network_port_activity.json index 2d3be4593c5d6..905e3f09a504d 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_network_port_activity_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_anomalous_network_port_activity.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Linux - Looks for unusual destination port activity that could indicate command-and-control, persistence mechanism, or data exfiltration activity.", + "description": "Security: Linux - Looks for unusual destination port activity that could indicate command-and-control, persistence mechanism, or data exfiltration activity.", "groups": [ "security", "auditbeat", @@ -12,7 +12,7 @@ "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"destination.port\"", + "detector_description": "Detects rare destination.port values.", "function": "rare", "by_field_name": "destination.port" } @@ -32,7 +32,14 @@ "time_field": "@timestamp" }, "custom_settings": { - "created_by": "ml-module-security-linux", + "job_tags": { + "euid": "4005", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-linux-v3", "custom_urls": [ { "url_name": "Host Details by process name", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_rare_process_by_host_linux_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_anomalous_process_all_hosts.json similarity index 76% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_rare_process_by_host_linux_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_anomalous_process_all_hosts.json index fa87be8efb010..90b5ce73d6aef 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_rare_process_by_host_linux_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_anomalous_process_all_hosts.json @@ -1,21 +1,21 @@ { "job_type": "anomaly_detector", - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Linux - Looks for processes that are unusual to a particular Linux host. Such unusual processes may indicate unauthorized services, malware, or persistence mechanisms.", + "description": "Security: Linux - Looks for processes that are unusual to all Linux hosts. Such unusual processes may indicate unauthorized software, malware, or persistence mechanisms.", "groups": [ - "security", "auditbeat", "endpoint", "linux", - "process" + "process", + "security" ], "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare process executions on Linux", + "detector_description": "Detects rare process.name values.", "function": "rare", "by_field_name": "process.name", - "partition_field_name": "host.name" + "detector_index": 0 } ], "influencers": [ @@ -26,12 +26,22 @@ }, "allow_lazy_open": true, "analysis_limits": { - "model_memory_limit": "256mb" + "model_memory_limit": "512mb", + "categorization_examples_limit": 4 + }, "data_description": { - "time_field": "@timestamp" + "time_field": "@timestamp", + "time_format": "epoch_ms" }, "custom_settings": { + "job_tags": { + "euid": "4003", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, "created_by": "ml-module-security-linux", "custom_urls": [ { diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_user_name_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_anomalous_user_name.json similarity index 74% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_user_name_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_anomalous_user_name.json index 3bc5afa6ec8d7..a362818c8086f 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_user_name_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_anomalous_user_name.json @@ -1,20 +1,21 @@ { "job_type": "anomaly_detector", + "description": "Security: Linux - Rare and unusual users that are not normally active may indicate unauthorized changes or activity by an unauthorized user which may be credentialed access or lateral movement.", "groups": [ - "security", "auditbeat", "endpoint", "linux", - "process" + "process", + "security" ], - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Linux - Rare and unusual users that are not normally active may indicate unauthorized changes or activity by an unauthorized user which may be credentialed access or lateral movement.", "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"user.name\"", + "detector_description": "Detects rare user.name values.", "function": "rare", - "by_field_name": "user.name" + "by_field_name": "user.name", + "detector_index": 0 } ], "influencers": [ @@ -25,12 +26,21 @@ }, "allow_lazy_open": true, "analysis_limits": { - "model_memory_limit": "32mb" + "model_memory_limit": "32mb", + "categorization_examples_limit": 4 }, "data_description": { - "time_field": "@timestamp" + "time_field": "@timestamp", + "time_format": "epoch_ms" }, "custom_settings": { + "job_tags": { + "euid": "4008", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, "created_by": "ml-module-security-linux", "custom_urls": [ { diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_network_connection_discovery.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_network_configuration_discovery.json similarity index 72% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_network_connection_discovery.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_network_configuration_discovery.json index b41439548dd59..73b677acad1f9 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_network_connection_discovery.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_network_configuration_discovery.json @@ -1,16 +1,18 @@ { "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for commands related to system network connection discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used by a threat actor to engage in system network connection discovery in order to increase their understanding of connected services and systems. This information may be used to shape follow-up behaviors such as lateral movement or additional discovery.", + "description": "Security: Linux - Looks for commands related to system network configuration discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used by a threat actor to engage in system network configuration discovery to increase their understanding of connected networks and hosts. This information may be used to shape follow-up behaviors such as lateral movement or additional discovery.", "groups": [ "security", "auditbeat", + "endpoint", + "linux", "process" ], "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"user.name\"", + "detector_description": "Detects rare user.name values.", "function": "rare", "by_field_name": "user.name" } @@ -30,7 +32,14 @@ "time_field": "@timestamp" }, "custom_settings": { - "created_by": "ml-module-siem-auditbeat", + "job_tags": { + "euid": "40012", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-linux-v3", "custom_urls": [ { "url_name": "Host Details by process name", @@ -50,4 +59,4 @@ } ] } - } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_network_configuration_discovery.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_network_connection_discovery.json similarity index 72% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_network_configuration_discovery.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_network_connection_discovery.json index 6d687764085e0..92d678d39a445 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_network_configuration_discovery.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_network_connection_discovery.json @@ -1,16 +1,18 @@ { "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for commands related to system network configuration discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used by a threat actor to engage in system network configuration discovery in order to increase their understanding of connected networks and hosts. This information may be used to shape follow-up behaviors such as lateral movement or additional discovery.", + "description": "Security: Linux - Looks for commands related to system network connection discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used by a threat actor to engage in system network connection discovery to increase their understanding of connected services and systems. This information may be used to shape follow-up behaviors such as lateral movement or additional discovery.", "groups": [ "security", "auditbeat", + "endpoint", + "linux", "process" ], "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"user.name\"", + "detector_description": "Detects rare user.name values.", "function": "rare", "by_field_name": "user.name" } @@ -30,7 +32,14 @@ "time_field": "@timestamp" }, "custom_settings": { - "created_by": "ml-module-siem-auditbeat", + "job_tags": { + "euid": "4013", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-linux-v3", "custom_urls": [ { "url_name": "Host Details by process name", @@ -50,4 +59,4 @@ } ] } - } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_metadata_process.json new file mode 100644 index 0000000000000..95d6a8eac5115 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_metadata_process.json @@ -0,0 +1,45 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Linux - Looks for anomalous access to the metadata service by an unusual process. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", + "groups": [ + "auditbeat", + "endpoint", + "linux", + "process", + "security" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "Detects rare process.name values.", + "function": "rare", + "by_field_name": "process.name", + "detector_index": 0 + } + ], + "influencers": [ + "host.name", + "user.name", + "process.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb", + "categorization_examples_limit": 4 + }, + "data_description": { + "time_field": "@timestamp", + "time_format": "epoch_ms" + }, + "custom_settings": { + "job_tags": { + "euid": "4009", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-linux" } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_metadata_user.json new file mode 100644 index 0000000000000..36c34f0f716b3 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_metadata_user.json @@ -0,0 +1,45 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Linux - Looks for anomalous access to the metadata service by an unusual user. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", + "groups": [ + "auditbeat", + "endpoint", + "linux", + "process", + "security" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "Detects rare user.name values.", + "function": "rare", + "by_field_name": "user.name", + "detector_index": 0 + } + ], + "influencers": [ + "host.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb", + "categorization_examples_limit": 4 + }, + "data_description": { + "time_field": "@timestamp", + "time_format": "epoch_ms" + }, + "custom_settings": { + "job_tags": { + "euid": "4010", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-linux" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_sudo_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_sudo_user.json similarity index 81% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_sudo_user.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_sudo_user.json index 654f5c76e5698..4b1393b236f29 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_sudo_user.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_sudo_user.json @@ -1,16 +1,18 @@ { "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for sudo activity from an unusual user context.", + "description": "Security: Linux - Looks for sudo activity from an unusual user context. Unusual user context changes can be due to privilege escalation.", "groups": [ "security", "auditbeat", + "endpoint", + "linux", "process" ], "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"user.name\"", + "detector_description": "Detects rare user.name values.", "function": "rare", "by_field_name": "user.name" } @@ -30,7 +32,14 @@ "time_field": "@timestamp" }, "custom_settings": { - "created_by": "ml-module-siem-auditbeat", + "job_tags": { + "euid": "4017", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-linux-v3", "custom_urls": [ { "url_name": "Host Details by process name", @@ -50,4 +59,4 @@ } ] } - } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_user_compiler.json similarity index 69% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_user_compiler.json index bb0323ed9ae78..d977d82b697f0 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_rare_user_compiler.json @@ -1,16 +1,18 @@ { "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for compiler activity by a user context which does not normally run compilers. This can be ad-hoc software changes or unauthorized software deployment. This can also be due to local privilege elevation via locally run exploits or malware activity.", + "description": "Security: Linux - Looks for compiler activity by a user context which does not normally run compilers. This can be ad-hoc software changes or unauthorized software deployment. This can also be due to local privilege elevation via locally run exploits or malware activity.", "groups": [ "security", "auditbeat", + "endpoint", + "linux", "process" ], "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"user.name\"", + "detector_description": "Detects rare user.name values.", "function": "rare", "by_field_name": "user.name" } @@ -30,7 +32,14 @@ "time_field": "@timestamp" }, "custom_settings": { - "created_by": "ml-module-siem-auditbeat", + "job_tags": { + "euid": "4018", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-linux-v3", "custom_urls": [ { "url_name": "Host Details by user name", @@ -42,4 +51,4 @@ } ] } - } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_system_process_discovery.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_system_information_discovery.json similarity index 77% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_system_process_discovery.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_system_information_discovery.json index 592bb5a717fc0..606047ce639a5 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_system_process_discovery.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_system_information_discovery.json @@ -1,16 +1,18 @@ { "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for commands related to system process discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used to engage in system process discovery in order to increase their understanding of software applications running on a target host or network. This may be a precursor to selection of a persistence mechanism or a method of privilege elevation.", + "description": "Security: Linux - Looks for commands related to system information discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used to engage in system information discovery to gather detailed information about system configuration and software versions. This may be a precursor to the selection of a persistence mechanism or a method of privilege elevation.", "groups": [ "security", "auditbeat", + "endpoint", + "linux", "process" ], "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"user.name\"", + "detector_description": "Detects rare user.name values.", "function": "rare", "by_field_name": "user.name" } @@ -30,7 +32,14 @@ "time_field": "@timestamp" }, "custom_settings": { - "created_by": "ml-module-siem-auditbeat", + "job_tags": { + "euid": "4014", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-linux-v3", "custom_urls": [ { "url_name": "Host Details by process name", @@ -50,4 +59,4 @@ } ] } - } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_system_user_discovery.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_system_process_discovery.json similarity index 73% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_system_user_discovery.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_system_process_discovery.json index 33f42c274b337..273a7791b2c1f 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_system_user_discovery.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_system_process_discovery.json @@ -1,16 +1,18 @@ { "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for commands related to system user or owner discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used to engage in system owner or user discovery in order to identify currently active or primary users of a system. This may be a precursor to additional discovery, credential dumping or privilege elevation activity.", + "description": "Security: Linux - Looks for commands related to system process discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used to engage in system process discovery to increase their understanding of software applications running on a target host or network. This may be a precursor to the selection of a persistence mechanism or a method of privilege elevation.", "groups": [ "security", "auditbeat", + "endpoint", + "linux", "process" ], "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"user.name\"", + "detector_description": "Detects rare user.name values.", "function": "rare", "by_field_name": "user.name" } @@ -30,7 +32,14 @@ "time_field": "@timestamp" }, "custom_settings": { - "created_by": "ml-module-siem-auditbeat", + "job_tags": { + "euid": "4015", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-linux-v3", "custom_urls": [ { "url_name": "Host Details by process name", @@ -50,4 +59,4 @@ } ] } - } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_system_information_discovery.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_system_user_discovery.json similarity index 73% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_system_information_discovery.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_system_user_discovery.json index 3a51223b4899c..6d7d5163db9e7 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_system_information_discovery.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_linux_system_user_discovery.json @@ -1,16 +1,18 @@ { "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for commands related to system information discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used to engage in system information discovery in order to gather detailed information about system configuration and software versions. This may be a precursor to selection of a persistence mechanism or a method of privilege elevation.", + "description": "Security: Linux - Looks for commands related to system user or owner discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used to engage in system owner or user discovery to identify currently active or primary users of a system. This may be a precursor to additional discovery, credential dumping, or privilege elevation activity.", "groups": [ "security", "auditbeat", + "endpoint", + "linux", "process" ], "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"user.name\"", + "detector_description": "Detects rare user.name values.", "function": "rare", "by_field_name": "user.name" } @@ -30,7 +32,14 @@ "time_field": "@timestamp" }, "custom_settings": { - "created_by": "ml-module-siem-auditbeat", + "job_tags": { + "euid": "4016", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-linux-v3", "custom_urls": [ { "url_name": "Host Details by process name", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_process_all_hosts_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_rare_process_by_host_linux.json similarity index 73% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_process_all_hosts_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_rare_process_by_host_linux.json index 03837cd77a5cc..cabbaa3b7390f 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v2_linux_anomalous_process_all_hosts_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/ml/v3_rare_process_by_host_linux.json @@ -1,20 +1,22 @@ { "job_type": "anomaly_detector", - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Linux - Looks for processes that are unusual to all Linux hosts. Such unusual processes may indicate unauthorized services, malware, or persistence mechanisms.", + "description": "Security: Linux - Looks for processes that are unusual to a particular Linux host. Such unusual processes may indicate unauthorized software, malware, or persistence mechanisms.", "groups": [ - "security", "auditbeat", "endpoint", "linux", - "process" + "process", + "security" ], "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"process.name\"", + "detector_description": "For each host.name, detects rare process.name values.", "function": "rare", - "by_field_name": "process.name" + "by_field_name": "process.name", + "partition_field_name": "host.name", + "detector_index": 0 } ], "influencers": [ @@ -25,12 +27,21 @@ }, "allow_lazy_open": true, "analysis_limits": { - "model_memory_limit": "512mb" + "model_memory_limit": "256mb", + "categorization_examples_limit": 4 }, "data_description": { - "time_field": "@timestamp" + "time_field": "@timestamp", + "time_format": "epoch_ms" }, "custom_settings": { + "job_tags": { + "euid": "4002", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, "created_by": "ml-module-security-linux", "custom_urls": [ { diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/logo.json index 862f970b7405d..1a8759749131a 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/logo.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/logo.json @@ -1,3 +1,3 @@ { "icon": "logoSecurity" -} +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json index 7325fa76b2eb0..bf39cd7ec7902 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json @@ -1,10 +1,10 @@ { - "id": "security_windows", + "id": "security_windows_v3", "title": "Security: Windows", - "description": "Detects suspicious activity using ECS Windows events. Tested with Winlogbeat and the Elastic agent.", + "description": "Anomaly detection jobs for Windows host based threat hunting and detection.", "type": "windows data", "logoFile": "logo.json", - "defaultIndexPattern": "winlogbeat-*,logs-endpoint.events.*", + "defaultIndexPattern": "winlogbeat-*,logs-*", "query": { "bool": { "must": [ @@ -30,84 +30,119 @@ ] } } - ], - "must_not": { "terms": { "_tier": [ "data_frozen", "data_cold" ] } } + ] } }, "jobs": [ { - "id": "v2_rare_process_by_host_windows_ecs", - "file": "v2_rare_process_by_host_windows_ecs.json" + "id": "v3_windows_anomalous_service", + "file": "v3_windows_anomalous_service.json" }, { - "id": "v2_windows_anomalous_network_activity_ecs", - "file": "v2_windows_anomalous_network_activity_ecs.json" + "id": "v3_windows_rare_user_runas_event", + "file": "v3_windows_rare_user_runas_event.json" }, { - "id": "v2_windows_anomalous_path_activity_ecs", - "file": "v2_windows_anomalous_path_activity_ecs.json" + "id": "v3_windows_rare_user_type10_remote_login", + "file": "v3_windows_rare_user_type10_remote_login.json" }, { - "id": "v2_windows_anomalous_process_all_hosts_ecs", - "file": "v2_windows_anomalous_process_all_hosts_ecs.json" + "id": "v3_rare_process_by_host_windows", + "file": "v3_rare_process_by_host_windows.json" }, { - "id": "v2_windows_anomalous_process_creation", - "file": "v2_windows_anomalous_process_creation.json" + "id": "v3_windows_anomalous_network_activity", + "file": "v3_windows_anomalous_network_activity.json" }, { - "id": "v2_windows_anomalous_user_name_ecs", - "file": "v2_windows_anomalous_user_name_ecs.json" + "id": "v3_windows_anomalous_path_activity", + "file": "v3_windows_anomalous_path_activity.json" }, { - "id": "v2_windows_rare_metadata_process", - "file": "v2_windows_rare_metadata_process.json" + "id": "v3_windows_anomalous_process_all_hosts", + "file": "v3_windows_anomalous_process_all_hosts.json" }, { - "id": "v2_windows_rare_metadata_user", - "file": "v2_windows_rare_metadata_user.json" + "id": "v3_windows_anomalous_process_creation", + "file": "v3_windows_anomalous_process_creation.json" + }, + { + "id": "v3_windows_anomalous_user_name", + "file": "v3_windows_anomalous_user_name.json" + }, + { + "id": "v3_windows_rare_metadata_process", + "file": "v3_windows_rare_metadata_process.json" + }, + { + "id": "v3_windows_rare_metadata_user", + "file": "v3_windows_rare_metadata_user.json" + }, + { + "id": "v3_windows_anomalous_script", + "file": "v3_windows_anomalous_script.json" } ], "datafeeds": [ { - "id": "datafeed-v2_rare_process_by_host_windows_ecs", - "file": "datafeed_v2_rare_process_by_host_windows_ecs.json", - "job_id": "v2_rare_process_by_host_windows_ecs" + "id": "datafeed-v3_windows_anomalous_service", + "file": "datafeed_v3_windows_anomalous_service.json", + "job_id": "v3_windows_anomalous_service" + }, + { + "id": "datafeed-v3_windows_rare_user_runas_event", + "file": "datafeed_v3_windows_rare_user_runas_event.json", + "job_id": "v3_windows_rare_user_runas_event" + }, + { + "id": "datafeed-v3_windows_rare_user_type10_remote_login", + "file": "datafeed_v3_windows_rare_user_type10_remote_login.json", + "job_id": "v3_windows_rare_user_type10_remote_login" + }, + { + "id": "datafeed-v3_rare_process_by_host_windows", + "file": "datafeed_v3_rare_process_by_host_windows.json", + "job_id": "v3_rare_process_by_host_windows" + }, + { + "id": "datafeed-v3_windows_anomalous_network_activity", + "file": "datafeed_v3_windows_anomalous_network_activity.json", + "job_id": "v3_windows_anomalous_network_activity" }, { - "id": "datafeed-v2_windows_anomalous_network_activity_ecs", - "file": "datafeed_v2_windows_anomalous_network_activity_ecs.json", - "job_id": "v2_windows_anomalous_network_activity_ecs" + "id": "datafeed-v3_windows_anomalous_path_activity", + "file": "datafeed_v3_windows_anomalous_path_activity.json", + "job_id": "v3_windows_anomalous_path_activity" }, { - "id": "datafeed-v2_windows_anomalous_path_activity_ecs", - "file": "datafeed_v2_windows_anomalous_path_activity_ecs.json", - "job_id": "v2_windows_anomalous_path_activity_ecs" + "id": "datafeed-v3_windows_anomalous_process_all_hosts", + "file": "datafeed_v3_windows_anomalous_process_all_hosts.json", + "job_id": "v3_windows_anomalous_process_all_hosts" }, { - "id": "datafeed-v2_windows_anomalous_process_all_hosts_ecs", - "file": "datafeed_v2_windows_anomalous_process_all_hosts_ecs.json", - "job_id": "v2_windows_anomalous_process_all_hosts_ecs" + "id": "datafeed-v3_windows_anomalous_process_creation", + "file": "datafeed_v3_windows_anomalous_process_creation.json", + "job_id": "v3_windows_anomalous_process_creation" }, { - "id": "datafeed-v2_windows_anomalous_process_creation", - "file": "datafeed_v2_windows_anomalous_process_creation.json", - "job_id": "v2_windows_anomalous_process_creation" + "id": "datafeed-v3_windows_anomalous_user_name", + "file": "datafeed_v3_windows_anomalous_user_name.json", + "job_id": "v3_windows_anomalous_user_name" }, { - "id": "datafeed-v2_windows_anomalous_user_name_ecs", - "file": "datafeed_v2_windows_anomalous_user_name_ecs.json", - "job_id": "v2_windows_anomalous_user_name_ecs" + "id": "datafeed-v3_windows_rare_metadata_process", + "file": "datafeed_v3_windows_rare_metadata_process.json", + "job_id": "v3_windows_rare_metadata_process" }, { - "id": "datafeed-v2_windows_rare_metadata_process", - "file": "datafeed_v2_windows_rare_metadata_process.json", - "job_id": "v2_windows_rare_metadata_process" + "id": "datafeed-v3_windows_rare_metadata_user", + "file": "datafeed_v3_windows_rare_metadata_user.json", + "job_id": "v3_windows_rare_metadata_user" }, { - "id": "datafeed-v2_windows_rare_metadata_user", - "file": "datafeed_v2_windows_rare_metadata_user.json", - "job_id": "v2_windows_rare_metadata_user" + "id": "datafeed-v3_windows_anomalous_script", + "file": "datafeed_v3_windows_anomalous_script.json", + "job_id": "v3_windows_anomalous_script" } ] } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_rare_process_by_host_windows_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_rare_process_by_host_windows_ecs.json deleted file mode 100644 index fd3c03b3a3e96..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_rare_process_by_host_windows_ecs.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - { - "term": { - "event.category": "process" - } - }, - { - "term": { - "event.type": "start" - } - } - ], - "must": [ - { - "bool": { - "should": [ - { - "match": { - "host.os.family": { - "query": "windows", - "operator": "OR" - } - } - }, - { - "match": { - "host.os.type": { - "query": "windows", - "operator": "OR" - } - } - } - ] - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_network_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_network_activity_ecs.json deleted file mode 100644 index d085cfa38c65a..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_network_activity_ecs.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - { - "term": { - "event.category": "network" - } - }, - { - "term": { - "event.type": "start" - } - } - ], - "must": [ - { - "bool": { - "should": [ - { - "match": { - "host.os.family": { - "query": "windows", - "operator": "OR" - } - } - }, - { - "match": { - "host.os.type": { - "query": "windows", - "operator": "OR" - } - } - } - ] - } - } - ], - "must_not": [ - { - "bool": { - "should": [ - { - "term": { - "destination.ip": "127.0.0.1" - } - }, - { - "term": { - "destination.ip": "127.0.0.53" - } - }, - { - "term": { - "destination.ip": "::1" - } - } - ], - "minimum_should_match": 1 - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_process_creation.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_process_creation.json deleted file mode 100644 index fd3c03b3a3e96..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_process_creation.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - { - "term": { - "event.category": "process" - } - }, - { - "term": { - "event.type": "start" - } - } - ], - "must": [ - { - "bool": { - "should": [ - { - "match": { - "host.os.family": { - "query": "windows", - "operator": "OR" - } - } - }, - { - "match": { - "host.os.type": { - "query": "windows", - "operator": "OR" - } - } - } - ] - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_user_name_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_user_name_ecs.json deleted file mode 100644 index fd3c03b3a3e96..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_user_name_ecs.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - { - "term": { - "event.category": "process" - } - }, - { - "term": { - "event.type": "start" - } - } - ], - "must": [ - { - "bool": { - "should": [ - { - "match": { - "host.os.family": { - "query": "windows", - "operator": "OR" - } - } - }, - { - "match": { - "host.os.type": { - "query": "windows", - "operator": "OR" - } - } - } - ] - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_process.json deleted file mode 100644 index f0be23df84c42..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_process.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - { - "term": { - "host.os.family": "windows" - } - }, - { - "term": { - "destination.ip": "169.254.169.254" - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_user.json deleted file mode 100644 index f0be23df84c42..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_rare_metadata_user.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - { - "term": { - "host.os.family": "windows" - } - }, - { - "term": { - "destination.ip": "169.254.169.254" - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_rare_process_by_host_windows.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_rare_process_by_host_windows.json new file mode 100644 index 0000000000000..997e56c2c9366 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_rare_process_by_host_windows.json @@ -0,0 +1,47 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_network_activity.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_network_activity.json new file mode 100644 index 0000000000000..60b5552415e5a --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_network_activity.json @@ -0,0 +1,71 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "network" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ], + "must_not": [ + { + "bool": { + "should": [ + { + "term": { + "destination.ip": "127.0.0.1" + } + }, + { + "term": { + "destination.ip": "127.0.0.53" + } + }, + { + "term": { + "destination.ip": "::1" + } + } + ], + "minimum_should_match": 1 + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_path_activity.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_path_activity.json new file mode 100644 index 0000000000000..997e56c2c9366 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_path_activity.json @@ -0,0 +1,47 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_process_all_hosts.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_process_all_hosts.json new file mode 100644 index 0000000000000..997e56c2c9366 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_process_all_hosts.json @@ -0,0 +1,47 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_process_creation.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_process_creation.json new file mode 100644 index 0000000000000..997e56c2c9366 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_process_creation.json @@ -0,0 +1,47 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_path_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_script.json similarity index 85% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_path_activity_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_script.json index fd3c03b3a3e96..61e3c44fb8811 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_path_activity_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_script.json @@ -9,12 +9,7 @@ "filter": [ { "term": { - "event.category": "process" - } - }, - { - "term": { - "event.type": "start" + "event.provider": "Microsoft-Windows-PowerShell" } } ], diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_process_all_hosts_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_service.json similarity index 85% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_process_all_hosts_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_service.json index fd3c03b3a3e96..69eead8a5d4f5 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v2_windows_anomalous_process_all_hosts_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_service.json @@ -9,12 +9,7 @@ "filter": [ { "term": { - "event.category": "process" - } - }, - { - "term": { - "event.type": "start" + "event.code": "7045" } } ], @@ -44,4 +39,4 @@ ] } } -} +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_user_name.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_user_name.json new file mode 100644 index 0000000000000..997e56c2c9366 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_anomalous_user_name.json @@ -0,0 +1,47 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "process" + } + }, + { + "term": { + "event.type": "start" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_metadata_process.json new file mode 100644 index 0000000000000..352d369a54aa9 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_metadata_process.json @@ -0,0 +1,23 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "host.os.family": "windows" + } + }, + { + "term": { + "destination.ip": "169.254.169.254" + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_metadata_user.json new file mode 100644 index 0000000000000..352d369a54aa9 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_metadata_user.json @@ -0,0 +1,23 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "host.os.family": "windows" + } + }, + { + "term": { + "destination.ip": "169.254.169.254" + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_user_runas_event.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_user_runas_event.json new file mode 100644 index 0000000000000..17ff3e4500469 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_user_runas_event.json @@ -0,0 +1,42 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.code": "4648" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "host.os.family": { + "query": "windows", + "operator": "OR" + } + } + }, + { + "match": { + "host.os.type": { + "query": "windows", + "operator": "OR" + } + } + } + ] + } + } + ] + } + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/ml/datafeed_windows_rare_user_type10_remote_login.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_user_type10_remote_login.json similarity index 82% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/ml/datafeed_windows_rare_user_type10_remote_login.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_user_type10_remote_login.json index a66f0a7c2607f..c612e1fcde0f5 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/ml/datafeed_windows_rare_user_type10_remote_login.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/datafeed_v3_windows_rare_user_type10_remote_login.json @@ -1,11 +1,11 @@ { - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { "filter": [ { "term": { @@ -38,5 +38,5 @@ } ] } - } -} + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_path_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_path_activity_ecs.json deleted file mode 100644 index 9aea3305cc641..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_path_activity_ecs.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "job_type": "anomaly_detector", - "groups": [ - "security", - "sysmon", - "windows", - "winlogbeat", - "process" - ], - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Looks for activity in unusual paths that may indicate execution of malware or persistence mechanisms. Windows payloads often execute from user profile paths.", - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"process.working_directory\"", - "function": "rare", - "by_field_name": "process.working_directory" - } - ], - "influencers": [ - "host.name", - "process.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "256mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-security-windows", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_process.json deleted file mode 100644 index e8f5317be0308..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_process.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Looks for anomalous access to the metadata service by an unusual process. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", - "groups": [ - "security", - "endpoint", - "event-log", - "process", - "sysmon", - "windows", - "winlogbeat" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"process.name\"", - "function": "rare", - "by_field_name": "process.name" - } - ], - "influencers": [ - "process.name", - "host.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "32mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-security-windows" - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_user.json deleted file mode 100644 index 027dbd84de332..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_rare_metadata_user.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Looks for anomalous access to the metadata service by an unusual user. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", - "groups": [ - "security", - "endpoint", - "event-log", - "process", - "sysmon", - "windows", - "winlogbeat" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"user.name\"", - "function": "rare", - "by_field_name": "user.name" - } - ], - "influencers": [ - "host.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "32mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-security-windows" - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_rare_process_by_host_windows_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_rare_process_by_host_windows.json similarity index 74% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_rare_process_by_host_windows_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_rare_process_by_host_windows.json index a645d3167c302..4e031a434cf6b 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_rare_process_by_host_windows_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_rare_process_by_host_windows.json @@ -1,23 +1,24 @@ { "job_type": "anomaly_detector", - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Detects unusually rare processes on Windows hosts.", + "description": "Security: Windows - Looks for processes that are unusual to a particular Windows host. Such unusual processes may indicate unauthorized software, malware, or persistence mechanisms.", "groups": [ - "security", "endpoint", "event-log", + "process", + "security", "sysmon", "windows", - "winlogbeat", - "process" + "winlogbeat" ], "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare process executions on Windows", + "detector_description": "For each host.name, detects rare process.name values.", "function": "rare", "by_field_name": "process.name", - "partition_field_name": "host.name" + "partition_field_name": "host.name", + "detector_index": 0 } ], "influencers": [ @@ -28,12 +29,21 @@ }, "allow_lazy_open": true, "analysis_limits": { - "model_memory_limit": "256mb" + "model_memory_limit": "256mb", + "categorization_examples_limit": 4 }, "data_description": { - "time_field": "@timestamp" + "time_field": "@timestamp", + "time_format": "epoch_ms" }, "custom_settings": { + "job_tags": { + "euid": "8001", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, "created_by": "ml-module-security-windows", "custom_urls": [ { diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_network_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_network_activity.json similarity index 75% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_network_activity_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_network_activity.json index 61bafc6057079..29433578d8e0c 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_network_activity_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_network_activity.json @@ -1,21 +1,22 @@ { "job_type": "anomaly_detector", - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity.", + "description": "Security: Windows - Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity.", "groups": [ - "security", "endpoint", + "network", + "security", "sysmon", "windows", - "winlogbeat", - "network" + "winlogbeat" ], "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"process.name\"", + "detector_description": "Detects rare process.name values.", "function": "rare", - "by_field_name": "process.name" + "by_field_name": "process.name", + "detector_index": 0 } ], "influencers": [ @@ -27,12 +28,21 @@ }, "allow_lazy_open": true, "analysis_limits": { - "model_memory_limit": "64mb" + "model_memory_limit": "64mb", + "categorization_examples_limit": 4 }, "data_description": { - "time_field": "@timestamp" + "time_field": "@timestamp", + "time_format": "epoch_ms" }, "custom_settings": { + "job_tags": { + "euid": "8003", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, "created_by": "ml-module-security-windows", "custom_urls": [ { @@ -53,4 +63,4 @@ } ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_path_activity.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_path_activity.json new file mode 100644 index 0000000000000..b4408258de0a2 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_path_activity.json @@ -0,0 +1,65 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Windows - Looks for activity in unusual paths that may indicate execution of malware or persistence mechanisms. Windows payloads often execute from user profile paths.", + "groups": [ + "endpoint", + "network", + "security", + "sysmon", + "windows", + "winlogbeat" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "Detects rare process.working_directory values.", + "function": "rare", + "by_field_name": "process.working_directory", + "detector_index": 0 + } + ], + "influencers": [ + "host.name", + "process.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "256mb", + "categorization_examples_limit": 4 + }, + "data_description": { + "time_field": "@timestamp", + "time_format": "epoch_ms" + }, + "custom_settings": { + "job_tags": { + "euid": "8004", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-windows", + "custom_urls": [ + { + "url_name": "Host Details by process name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Host Details by user name", + "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by process name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by user name", + "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_user_name_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_process_all_hosts.json similarity index 74% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_user_name_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_process_all_hosts.json index af04625e56fcd..f8f239d46c0ae 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_user_name_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_process_all_hosts.json @@ -1,22 +1,23 @@ { "job_type": "anomaly_detector", - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Rare and unusual users that are not normally active may indicate unauthorized changes or activity by an unauthorized user which may be credentialed access or lateral movement.", + "description": "Security: Windows - Looks for processes that are unusual to all Windows hosts. Such unusual processes may indicate execution of unauthorized software, malware, or persistence mechanisms.", "groups": [ - "security", "endpoint", "event-log", + "process", + "security", "sysmon", "windows", - "winlogbeat", - "process" + "winlogbeat" ], "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"user.name\"", + "detector_description": "Detects rare process.executable values.", "function": "rare", - "by_field_name": "user.name" + "by_field_name": "process.executable", + "detector_index": 0 } ], "influencers": [ @@ -27,12 +28,21 @@ }, "allow_lazy_open": true, "analysis_limits": { - "model_memory_limit": "256mb" + "model_memory_limit": "256mb", + "categorization_examples_limit": 4 }, "data_description": { - "time_field": "@timestamp" + "time_field": "@timestamp", + "time_format": "epoch_ms" }, "custom_settings": { + "job_tags": { + "euid": "8002", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, "created_by": "ml-module-security-windows", "custom_urls": [ { diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_process_creation.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_process_creation.json similarity index 75% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_process_creation.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_process_creation.json index e59d887ccc909..506e7b9b7574b 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_process_creation.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_process_creation.json @@ -1,23 +1,24 @@ { "job_type": "anomaly_detector", + "description": "Security: Windows - Looks for unusual process relationships which may indicate execution of malware or persistence mechanisms.", "groups": [ - "security", "endpoint", "event-log", + "process", + "security", "sysmon", "windows", - "winlogbeat", - "process" + "winlogbeat" ], - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Looks for unusual process relationships which may indicate execution of malware or persistence mechanisms.", "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "Unusual process creation activity", + "detector_description": "For each process.parent.name, detects rare process.name values.", "function": "rare", "by_field_name": "process.name", - "partition_field_name": "process.parent.name" + "partition_field_name": "process.parent.name", + "detector_index": 0 } ], "influencers": [ @@ -28,12 +29,21 @@ }, "allow_lazy_open": true, "analysis_limits": { - "model_memory_limit": "256mb" + "model_memory_limit": "256mb", + "categorization_examples_limit": 4 }, "data_description": { - "time_field": "@timestamp" + "time_field": "@timestamp", + "time_format": "epoch_ms" }, "custom_settings": { + "job_tags": { + "euid": "8005", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, "created_by": "ml-module-security-windows", "custom_urls": [ { diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_script.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_script.json new file mode 100644 index 0000000000000..022695bcf5a7d --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_script.json @@ -0,0 +1,53 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Windows - Looks for unusual powershell scripts that may indicate execution of malware, or persistence mechanisms.", + "groups": [ + "endpoint", + "event-log", + "process", + "windows", + "winlogbeat", + "powershell" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "Detects high information content in powershell.file.script_block_text values.", + "function": "high_info_content", + "field_name": "powershell.file.script_block_text" + } + ], + "influencers": [ + "host.name", + "user.name", + "file.Path" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "256mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "job_tags": { + "euid": "8006", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "custom_urls": [ + { + "url_name": "Host Details by user name", + "url_value": "siem#/ml-hosts/$host.name$?_g=()&kqlQuery=(filterQuery:(expression:'user.name%20:%20%22$user.name$%22',kind:kuery),queryLocation:hosts.details,type:details)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + }, + { + "url_name": "Hosts Overview by user name", + "url_value": "siem#/ml-hosts?_g=()&kqlQuery=(filterQuery:(expression:'user.name%20:%20%22$user.name$%22',kind:kuery),queryLocation:hosts.page,type:page)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_service.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_service.json similarity index 58% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_service.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_service.json index 6debad30c308a..7403aa6b716af 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_service.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_service.json @@ -1,16 +1,20 @@ { "job_type": "anomaly_detector", "groups": [ + "endpoint", + "event-log", + "process", "security", - "winlogbeat", - "system" + "sysmon", + "windows", + "winlogbeat" ], - "description": "Security: Winlogbeat - Looks for rare and unusual Windows services which may indicate execution of unauthorized services, malware, or persistence mechanisms.", + "description": "Security: Windows - Looks for rare and unusual Windows service names which may indicate execution of unauthorized services, malware, or persistence mechanisms.", "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"winlog.event_data.ServiceName\"", + "detector_description": "Detects rare winlog.event_data.ServiceName values.", "function": "rare", "by_field_name": "winlog.event_data.ServiceName" } @@ -28,7 +32,14 @@ "time_field": "@timestamp" }, "custom_settings": { - "created_by": "ml-module-siem-winlogbeat", + "job_tags": { + "euid": "8007", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-windows-v3", "custom_urls": [ { "url_name": "Host Details", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_process_all_hosts_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_user_name.json similarity index 74% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_process_all_hosts_ecs.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_user_name.json index 07e8e872b1b8b..bf9433be24669 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v2_windows_anomalous_process_all_hosts_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_anomalous_user_name.json @@ -1,22 +1,23 @@ { "job_type": "anomaly_detector", - "description": "This is a new refactored job which works on ECS compatible events across multiple indices. Security: Windows - Looks for processes that are unusual to all Windows hosts. Such unusual processes may indicate execution of unauthorized services, malware, or persistence mechanisms.", + "description": "Security: Windows - Rare and unusual users that are not normally active may indicate unauthorized changes or activity by an unauthorized user which may be credentialed access or lateral movement.", "groups": [ - "security", "endpoint", "event-log", + "process", + "security", "sysmon", "windows", - "winlogbeat", - "process" + "winlogbeat" ], "analysis_config": { "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"process.executable\"", + "detector_description": "Detects rare user.name values.", "function": "rare", - "by_field_name": "process.executable" + "by_field_name": "user.name", + "detector_index": 0 } ], "influencers": [ @@ -27,12 +28,21 @@ }, "allow_lazy_open": true, "analysis_limits": { - "model_memory_limit": "256mb" + "model_memory_limit": "256mb", + "categorization_examples_limit": 4 }, "data_description": { - "time_field": "@timestamp" + "time_field": "@timestamp", + "time_format": "epoch_ms" }, "custom_settings": { + "job_tags": { + "euid": "8008", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, "created_by": "ml-module-security-windows", "custom_urls": [ { diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_metadata_process.json new file mode 100644 index 0000000000000..fae44f33b7197 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_metadata_process.json @@ -0,0 +1,47 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Windows - Looks for anomalous access to the metadata service by an unusual process. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", + "groups": [ + "security", + "endpoint", + "process", + "sysmon", + "windows", + "winlogbeat" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "Detects rare process.name values.", + "function": "rare", + "by_field_name": "process.name", + "detector_index": 0 + } + ], + "influencers": [ + "process.name", + "host.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb", + "categorization_examples_limit": 4 + }, + "data_description": { + "time_field": "@timestamp", + "time_format": "epoch_ms" + }, + "custom_settings": { + "job_tags": { + "euid": "8011", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-windows" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_metadata_user.json new file mode 100644 index 0000000000000..561073555f753 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_metadata_user.json @@ -0,0 +1,46 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Windows - Looks for anomalous access to the metadata service by an unusual user. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", + "groups": [ + "endpoint", + "process", + "security", + "sysmon", + "windows", + "winlogbeat" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "Detects rare user.name values.", + "function": "rare", + "by_field_name": "user.name", + "detector_index": 0 + } + ], + "influencers": [ + "host.name", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb", + "categorization_examples_limit": 4 + }, + "data_description": { + "time_field": "@timestamp", + "time_format": "epoch_ms" + }, + "custom_settings": { + "job_tags": { + "euid": "8012", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-windows" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/ml/windows_rare_user_type10_remote_login.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_user_runas_event.json similarity index 82% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/ml/windows_rare_user_type10_remote_login.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_user_runas_event.json index c18bb7a151f53..ddaa942084c15 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/ml/windows_rare_user_type10_remote_login.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_user_runas_event.json @@ -1,8 +1,11 @@ { "job_type": "anomaly_detector", - "description": "Security: Winlogbeat Auth - Unusual RDP (remote desktop protocol) user logins can indicate account takeover or credentialed access.", + "description": "Security: Windows - Unusual user context switches can be due to privilege escalation.", "groups": [ + "endpoint", + "event-log", "security", + "windows", "winlogbeat", "authentication" ], @@ -10,7 +13,7 @@ "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"user.name\"", + "detector_description": "Detects rare user.name values.", "function": "rare", "by_field_name": "user.name" } @@ -29,7 +32,14 @@ "time_field": "@timestamp" }, "custom_settings": { - "created_by": "ml-module-siem-winlogbeat-auth", + "job_tags": { + "euid": "8009", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-windows-v3", "custom_urls": [ { "url_name": "Host Details by process name", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_rare_user_runas_event.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_user_type10_remote_login.json similarity index 81% rename from x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_rare_user_runas_event.json rename to x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_user_type10_remote_login.json index 880be0045f84a..e28ffb4f3c864 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_rare_user_runas_event.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/ml/v3_windows_rare_user_type10_remote_login.json @@ -1,8 +1,11 @@ { "job_type": "anomaly_detector", - "description": "Security: Winlogbeat - Unusual user context switches can be due to privilege escalation.", + "description": "Security: Windows - Unusual RDP (remote desktop protocol) user logins can indicate account takeover or credentialed access.", "groups": [ + "endpoint", + "event-log", "security", + "windows", "winlogbeat", "authentication" ], @@ -10,7 +13,7 @@ "bucket_span": "15m", "detectors": [ { - "detector_description": "rare by \"user.name\"", + "detector_description": "Detects rare user.name values.", "function": "rare", "by_field_name": "user.name" } @@ -29,7 +32,14 @@ "time_field": "@timestamp" }, "custom_settings": { - "created_by": "ml-module-siem-winlogbeat", + "job_tags": { + "euid": "8013", + "maturity": "release", + "author": "@randomuserid/Elastic", + "version": "3", + "updated_date": "5/16/2022" + }, + "created_by": "ml-module-security-windows-v3", "custom_urls": [ { "url_name": "Host Details by process name", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/logo.json deleted file mode 100644 index dfd22f6b1140b..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/logo.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "icon": "logoSecurity" -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/manifest.json deleted file mode 100644 index efb7947ed34f5..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/manifest.json +++ /dev/null @@ -1,173 +0,0 @@ -{ - "id": "siem_auditbeat", - "title": "Security: Auditbeat", - "description": "Detect suspicious network activity and unusual processes in Auditbeat data.", - "type": "Auditbeat data", - "logoFile": "logo.json", - "defaultIndexPattern": "auditbeat-*", - "query": { - "bool": { - "filter": [ - {"term": {"agent.type": "auditbeat"}} - ], - "must_not": { "terms": { "_tier": [ "data_frozen", "data_cold" ] } } - } - }, - "jobs": [ - { - "id": "rare_process_by_host_linux_ecs", - "file": "rare_process_by_host_linux_ecs.json" - }, - { - "id": "linux_anomalous_network_activity_ecs", - "file": "linux_anomalous_network_activity_ecs.json" - }, - { - "id": "linux_anomalous_network_port_activity_ecs", - "file": "linux_anomalous_network_port_activity_ecs.json" - }, - { - "id": "linux_anomalous_network_service", - "file": "linux_anomalous_network_service.json" - }, - { - "id": "linux_anomalous_network_url_activity_ecs", - "file": "linux_anomalous_network_url_activity_ecs.json" - }, - { - "id": "linux_anomalous_process_all_hosts_ecs", - "file": "linux_anomalous_process_all_hosts_ecs.json" - }, - { - "id": "linux_anomalous_user_name_ecs", - "file": "linux_anomalous_user_name_ecs.json" - }, - { - "id": "linux_rare_metadata_process", - "file": "linux_rare_metadata_process.json" - }, - { - "id": "linux_rare_metadata_user", - "file": "linux_rare_metadata_user.json" - }, - { - "id": "linux_rare_user_compiler", - "file": "linux_rare_user_compiler.json" - }, - { - "id": "linux_rare_kernel_module_arguments", - "file": "linux_rare_kernel_module_arguments.json" - }, - { - "id": "linux_rare_sudo_user", - "file": "linux_rare_sudo_user.json" - }, - { - "id": "linux_system_user_discovery", - "file": "linux_system_user_discovery.json" - }, - { - "id": "linux_system_information_discovery", - "file": "linux_system_information_discovery.json" - }, - { - "id": "linux_system_process_discovery", - "file": "linux_system_process_discovery.json" - }, - { - "id": "linux_network_connection_discovery", - "file": "linux_network_connection_discovery.json" - }, - { - "id": "linux_network_configuration_discovery", - "file": "linux_network_configuration_discovery.json" - } - ], - "datafeeds": [ - { - "id": "datafeed-rare_process_by_host_linux_ecs", - "file": "datafeed_rare_process_by_host_linux_ecs.json", - "job_id": "rare_process_by_host_linux_ecs" - }, - { - "id": "datafeed-linux_anomalous_network_activity_ecs", - "file": "datafeed_linux_anomalous_network_activity_ecs.json", - "job_id": "linux_anomalous_network_activity_ecs" - }, - { - "id": "datafeed-linux_anomalous_network_port_activity_ecs", - "file": "datafeed_linux_anomalous_network_port_activity_ecs.json", - "job_id": "linux_anomalous_network_port_activity_ecs" - }, - { - "id": "datafeed-linux_anomalous_network_service", - "file": "datafeed_linux_anomalous_network_service.json", - "job_id": "linux_anomalous_network_service" - }, - { - "id": "datafeed-linux_anomalous_network_url_activity_ecs", - "file": "datafeed_linux_anomalous_network_url_activity_ecs.json", - "job_id": "linux_anomalous_network_url_activity_ecs" - }, - { - "id": "datafeed-linux_anomalous_process_all_hosts_ecs", - "file": "datafeed_linux_anomalous_process_all_hosts_ecs.json", - "job_id": "linux_anomalous_process_all_hosts_ecs" - }, - { - "id": "datafeed-linux_anomalous_user_name_ecs", - "file": "datafeed_linux_anomalous_user_name_ecs.json", - "job_id": "linux_anomalous_user_name_ecs" - }, - { - "id": "datafeed-linux_rare_metadata_process", - "file": "datafeed_linux_rare_metadata_process.json", - "job_id": "linux_rare_metadata_process" - }, - { - "id": "datafeed-linux_rare_metadata_user", - "file": "datafeed_linux_rare_metadata_user.json", - "job_id": "linux_rare_metadata_user" - }, - { - "id": "datafeed-linux_rare_user_compiler", - "file": "datafeed_linux_rare_user_compiler.json", - "job_id": "linux_rare_user_compiler" - }, - { - "id": "datafeed-linux_rare_kernel_module_arguments", - "file": "datafeed_linux_rare_kernel_module_arguments.json", - "job_id": "linux_rare_kernel_module_arguments" - }, - { - "id": "datafeed-linux_rare_sudo_user", - "file": "datafeed_linux_rare_sudo_user.json", - "job_id": "linux_rare_sudo_user" - }, - { - "id": "datafeed-linux_system_information_discovery", - "file": "datafeed_linux_system_information_discovery.json", - "job_id": "linux_system_information_discovery" - }, - { - "id": "datafeed-linux_system_process_discovery", - "file": "datafeed_linux_system_process_discovery.json", - "job_id": "linux_system_process_discovery" - }, - { - "id": "datafeed-linux_system_user_discovery", - "file": "datafeed_linux_system_user_discovery.json", - "job_id": "linux_system_user_discovery" - }, - { - "id": "datafeed-linux_network_configuration_discovery", - "file": "datafeed_linux_network_configuration_discovery.json", - "job_id": "linux_network_configuration_discovery" - }, - { - "id": "datafeed-linux_network_connection_discovery", - "file": "datafeed_linux_network_connection_discovery.json", - "job_id": "linux_network_connection_discovery" - } - ] -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_activity_ecs.json deleted file mode 100644 index 285d34c398045..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_activity_ecs.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"term": {"event.action": "connected-to"}}, - {"term": {"agent.type": "auditbeat"}} - ], - "must_not": [ - { - "bool": { - "should": [ - {"term": {"destination.ip": "127.0.0.1"}}, - {"term": {"destination.ip": "127.0.0.53"}}, - {"term": {"destination.ip": "::1"}} - ], - "minimum_should_match": 1 - } - } - ] - } - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_port_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_port_activity_ecs.json deleted file mode 100644 index 98fc5406cf825..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_port_activity_ecs.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"term": {"event.action": "connected-to"}}, - {"term": {"agent.type": "auditbeat"}} - ], - "must_not": [ - { - "bool": { - "should": [ - {"term": {"destination.ip":"::1"}}, - {"term": {"destination.ip":"127.0.0.1"}}, - {"term": {"destination.ip":"::"}}, - {"term": {"user.name_map.uid":"jenkins"}} - ], - "minimum_should_match": 1 - } - } - ] - } - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_service.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_service.json deleted file mode 100644 index 411630b8c6720..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_service.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"term": {"event.action": "bound-socket"}}, - {"term": {"agent.type": "auditbeat"}} - ], - "must_not": [ - { - "bool": { - "should": [ - {"term": {"process.name": "dnsmasq"}}, - {"term": {"process.name": "docker-proxy"}}, - {"term": {"process.name": "rpcinfo"}} - ], - "minimum_should_match": 1 - } - } - ] - } - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_url_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_url_activity_ecs.json deleted file mode 100644 index 3d6b6884d772d..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_network_url_activity_ecs.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool":{ - "filter": [ - {"exists": {"field": "destination.ip"}}, - {"terms": {"process.name": ["curl", "wget"]}}, - {"term": {"agent.type": "auditbeat"}} - ], - "must_not":[ - { - "bool":{ - "should":[ - {"term":{"destination.ip": "::1"}}, - {"term":{"destination.ip": "127.0.0.1"}}, - {"term":{"destination.ip":"169.254.169.254"}} - ], - "minimum_should_match": 1 - } - } - ] - } - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_process_all_hosts_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_process_all_hosts_ecs.json deleted file mode 100644 index 6ab30b8f5a140..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_process_all_hosts_ecs.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"terms": {"event.action": ["process_started", "executed"]}}, - {"term": {"agent.type": "auditbeat"}} - ], - "must_not": [ - { - "bool": { - "should": [ - {"term": {"user.name": "jenkins-worker"}}, - {"term": {"user.name": "jenkins-user"}}, - {"term": {"user.name": "jenkins"}}, - {"wildcard": {"process.name": {"wildcard": "jenkins*"}}} - ], - "minimum_should_match": 1 - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_user_name_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_user_name_ecs.json deleted file mode 100644 index fa1a6ba9d1756..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_anomalous_user_name_ecs.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"terms": {"event.action": ["process_started", "executed"]}}, - {"term": {"agent.type":"auditbeat"}} - ] - } - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_network_configuration_discovery.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_network_configuration_discovery.json deleted file mode 100644 index d4a130770c920..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_network_configuration_discovery.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "must": [ - { - "bool": { - "should": [ - {"term": {"process.name": "arp"}}, - {"term": {"process.name": "echo"}}, - {"term": {"process.name": "ethtool"}}, - {"term": {"process.name": "ifconfig"}}, - {"term": {"process.name": "ip"}}, - {"term": {"process.name": "iptables"}}, - {"term": {"process.name": "ufw"}} - ] - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_network_connection_discovery.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_network_connection_discovery.json deleted file mode 100644 index 0ae80df4bd47d..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_network_connection_discovery.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "must": [ - { - "bool": { - "should": [ - {"term": {"process.name": "netstat"}}, - {"term": {"process.name": "ss"}}, - {"term": {"process.name": "route"}}, - {"term": {"process.name": "showmount"}} - ] - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_kernel_module_arguments.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_kernel_module_arguments.json deleted file mode 100644 index 99bb690c8d73d..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_kernel_module_arguments.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [{"exists": {"field": "process.title"}}], - "must": [ - {"bool": { - "should": [ - {"term": {"process.name": "insmod"}}, - {"term": {"process.name": "kmod"}}, - {"term": {"process.name": "modprobe"}}, - {"term": {"process.name": "rmod"}} - ] - }} - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_metadata_process.json deleted file mode 100644 index dc0f6c4e81b33..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_metadata_process.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [{"term": {"destination.ip": "169.254.169.254"}}] - } - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_metadata_user.json deleted file mode 100644 index dc0f6c4e81b33..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_metadata_user.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [{"term": {"destination.ip": "169.254.169.254"}}] - } - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_sudo_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_sudo_user.json deleted file mode 100644 index 544675f3d48dc..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_sudo_user.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"term": {"event.action": "executed"}}, - {"term": {"process.name": "sudo"}} - ] - } - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_user_compiler.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_user_compiler.json deleted file mode 100644 index 027b124010001..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_rare_user_compiler.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [{"term": {"event.action": "executed"}}], - "must": [ - {"bool": { - "should": [ - {"term": {"process.name": "compile"}}, - {"term": {"process.name": "gcc"}}, - {"term": {"process.name": "make"}}, - {"term": {"process.name": "yasm"}} - ] - }} - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_system_information_discovery.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_system_information_discovery.json deleted file mode 100644 index 6e7ce26763f79..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_system_information_discovery.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "must": [ - { - "bool": { - "should": [ - {"term": {"process.name": "cat"}}, - {"term": {"process.name": "grep"}}, - {"term": {"process.name": "head"}}, - {"term": {"process.name": "hostname"}}, - {"term": {"process.name": "less"}}, - {"term": {"process.name": "ls"}}, - {"term": {"process.name": "lsmod"}}, - {"term": {"process.name": "more"}}, - {"term": {"process.name": "strings"}}, - {"term": {"process.name": "tail"}}, - {"term": {"process.name": "uptime"}}, - {"term": {"process.name": "uname"}} - ] - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_system_process_discovery.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_system_process_discovery.json deleted file mode 100644 index dbd8f54ff9712..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_system_process_discovery.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "must": [ - { - "bool": { - "should": [ - {"term": {"process.name": "ps"}}, - {"term": {"process.name": "top"}} - ] - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_system_user_discovery.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_system_user_discovery.json deleted file mode 100644 index 24230094a47d2..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_linux_system_user_discovery.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "must": [ - { - "bool": { - "should": [ - {"term": {"process.name": "users"}}, - {"term": {"process.name": "w"}}, - {"term": {"process.name": "who"}}, - {"term": {"process.name": "whoami"}} - ] - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_rare_process_by_host_linux_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_rare_process_by_host_linux_ecs.json deleted file mode 100644 index 93a5646a7bf01..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/datafeed_rare_process_by_host_linux_ecs.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"terms": {"event.action": ["process_started", "executed"]}}, - { "term": { "agent.type": "auditbeat" } } - - ] - } - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_activity_ecs.json deleted file mode 100644 index eab14d7c11ba1..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_activity_ecs.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity.", - "groups": [ - "security", - "auditbeat", - "process" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"process.name\"", - "function": "rare", - "by_field_name": "process.name" - } - ], - "influencers": [ - "host.name", - "process.name", - "user.name", - "destination.ip" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "64mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-auditbeat", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_port_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_port_activity_ecs.json deleted file mode 100644 index 1891be831837b..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_port_activity_ecs.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for unusual destination port activity that could indicate command-and-control, persistence mechanism, or data exfiltration activity.", - "groups": [ - "security", - "auditbeat", - "network" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"destination.port\"", - "function": "rare", - "by_field_name": "destination.port" - } - ], - "influencers": [ - "host.name", - "process.name", - "user.name", - "destination.ip" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "32mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-auditbeat", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_service.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_service.json deleted file mode 100644 index 8fd24dd817c35..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_service.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "job_type": "anomaly_detector", - "groups": [ - "security", - "auditbeat", - "network" - ], - "description": "Security: Auditbeat - Looks for unusual listening ports that could indicate execution of unauthorized services, backdoors, or persistence mechanisms.", - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"auditd.data.socket.port\"", - "function": "rare", - "by_field_name": "auditd.data.socket.port" - } - ], - "influencers": [ - "host.name", - "process.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "128mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-auditbeat", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_url_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_url_activity_ecs.json deleted file mode 100644 index aa43a50e76863..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_network_url_activity_ecs.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "job_type": "anomaly_detector", - "groups": [ - "security", - "auditbeat", - "network" - ], - "description": "Security: Auditbeat - Looks for an unusual web URL request from a Linux instance. Curl and wget web request activity is very common but unusual web requests from a Linux server can sometimes be malware delivery or execution.", - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"process.title\"", - "function": "rare", - "by_field_name": "process.title" - } - ], - "influencers": [ - "host.name", - "destination.ip", - "destination.port" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "32mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-auditbeat", - "custom_urls": [ - { - "url_name": "Host Details", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_process_all_hosts_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_process_all_hosts_ecs.json deleted file mode 100644 index 17f38b65de4c6..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_process_all_hosts_ecs.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for processes that are unusual to all Linux hosts. Such unusual processes may indicate unauthorized services, malware, or persistence mechanisms.", - "groups": [ - "security", - "auditbeat", - "process" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"process.name\"", - "function": "rare", - "by_field_name": "process.name" - } - ], - "influencers": [ - "host.name", - "process.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "512mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-auditbeat", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_user_name_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_user_name_ecs.json deleted file mode 100644 index 8f0eda20a55fc..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_anomalous_user_name_ecs.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "job_type": "anomaly_detector", - "groups": [ - "security", - "auditbeat", - "process" - ], - "description": "Security: Auditbeat - Rare and unusual users that are not normally active may indicate unauthorized changes or activity by an unauthorized user which may be credentialed access or lateral movement.", - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"user.name\"", - "function": "rare", - "by_field_name": "user.name" - } - ], - "influencers": [ - "host.name", - "process.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "32mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-auditbeat", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_kernel_module_arguments.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_kernel_module_arguments.json deleted file mode 100644 index 1b79e83054251..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_kernel_module_arguments.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for unusual kernel modules which are often used for stealth.", - "groups": [ - "security", - "auditbeat", - "process" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"process.title\"", - "function": "rare", - "by_field_name": "process.title" - } - ], - "influencers": [ - "process.title", - "process.working_directory", - "host.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "32mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-auditbeat", - "custom_urls": [ - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_metadata_process.json deleted file mode 100644 index 7295f11e600d7..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_metadata_process.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for anomalous access to the metadata service by an unusual process. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", - "groups": [ - "security", - "auditbeat", - "process" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"process.name\"", - "function": "rare", - "by_field_name": "process.name" - } - ], - "influencers": [ - "host.name", - "user.name", - "process.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "32mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-auditbeat", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_metadata_user.json deleted file mode 100644 index 049d10920de00..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_metadata_user.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for anomalous access to the metadata service by an unusual user. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", - "groups": [ - "security", - "auditbeat", - "process" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"user.name\"", - "function": "rare", - "by_field_name": "user.name" - } - ], - "influencers": [ - "host.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "32mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-auditbeat", - "custom_urls": [ - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/rare_process_by_host_linux_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/rare_process_by_host_linux_ecs.json deleted file mode 100644 index 75ac0224dbd5b..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/rare_process_by_host_linux_ecs.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Detect unusually rare processes on Linux", - "groups": [ - "security", - "auditbeat", - "process" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare process executions on Linux", - "function": "rare", - "by_field_name": "process.name", - "partition_field_name": "host.name" - } - ], - "influencers": [ - "host.name", - "process.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "256mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-auditbeat", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/logo.json deleted file mode 100644 index dfd22f6b1140b..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/logo.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "icon": "logoSecurity" -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/manifest.json deleted file mode 100644 index 2d43544522fef..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/manifest.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "id": "siem_auditbeat_auth", - "title": "Security: Auditbeat Authentication", - "description": "Detect suspicious authentication events in Auditbeat data.", - "type": "Auditbeat data", - "logoFile": "logo.json", - "defaultIndexPattern": "auditbeat-*", - "query": { - "bool": { - "filter": [ - {"term": {"event.category": "authentication"}}, - {"term": {"agent.type": "auditbeat"}} - ], - "must_not": { "terms": { "_tier": [ "data_frozen", "data_cold" ] } } - } - }, - "jobs": [ - { - "id": "suspicious_login_activity_ecs", - "file": "suspicious_login_activity_ecs.json" - } - ], - "datafeeds": [ - { - "id": "datafeed-suspicious_login_activity_ecs", - "file": "datafeed_suspicious_login_activity_ecs.json", - "job_id": "suspicious_login_activity_ecs" - } - ] -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/logo.json deleted file mode 100644 index dfd22f6b1140b..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/logo.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "icon": "logoSecurity" -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/manifest.json deleted file mode 100644 index 7e4f20bce6d5a..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/manifest.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "id": "siem_winlogbeat", - "title": "Security: Winlogbeat", - "description": "Detect unusual processes and network activity in Winlogbeat data.", - "type": "Winlogbeat data", - "logoFile": "logo.json", - "defaultIndexPattern": "winlogbeat-*", - "query": { - "bool": { - "filter": [ - {"term": {"agent.type": "winlogbeat"}} - ], - "must_not": { "terms": { "_tier": [ "data_frozen", "data_cold" ] } } - } - }, - "jobs": [ - { - "id": "rare_process_by_host_windows_ecs", - "file": "rare_process_by_host_windows_ecs.json" - }, - { - "id": "windows_anomalous_network_activity_ecs", - "file": "windows_anomalous_network_activity_ecs.json" - }, - { - "id": "windows_anomalous_path_activity_ecs", - "file": "windows_anomalous_path_activity_ecs.json" - }, - { - "id": "windows_anomalous_process_all_hosts_ecs", - "file": "windows_anomalous_process_all_hosts_ecs.json" - }, - { - "id": "windows_anomalous_process_creation", - "file": "windows_anomalous_process_creation.json" - }, - { - "id": "windows_anomalous_script", - "file": "windows_anomalous_script.json" - }, - { - "id": "windows_anomalous_service", - "file": "windows_anomalous_service.json" - }, - { - "id": "windows_anomalous_user_name_ecs", - "file": "windows_anomalous_user_name_ecs.json" - }, - { - "id": "windows_rare_user_runas_event", - "file": "windows_rare_user_runas_event.json" - }, - { - "id": "windows_rare_metadata_process", - "file": "windows_rare_metadata_process.json" - }, - { - "id": "windows_rare_metadata_user", - "file": "windows_rare_metadata_user.json" - } - ], - "datafeeds": [ - { - "id": "datafeed-rare_process_by_host_windows_ecs", - "file": "datafeed_rare_process_by_host_windows_ecs.json", - "job_id": "rare_process_by_host_windows_ecs" - }, - { - "id": "datafeed-windows_anomalous_network_activity_ecs", - "file": "datafeed_windows_anomalous_network_activity_ecs.json", - "job_id": "windows_anomalous_network_activity_ecs" - }, - { - "id": "datafeed-windows_anomalous_path_activity_ecs", - "file": "datafeed_windows_anomalous_path_activity_ecs.json", - "job_id": "windows_anomalous_path_activity_ecs" - }, - { - "id": "datafeed-windows_anomalous_process_all_hosts_ecs", - "file": "datafeed_windows_anomalous_process_all_hosts_ecs.json", - "job_id": "windows_anomalous_process_all_hosts_ecs" - }, - { - "id": "datafeed-windows_anomalous_process_creation", - "file": "datafeed_windows_anomalous_process_creation.json", - "job_id": "windows_anomalous_process_creation" - }, - { - "id": "datafeed-windows_anomalous_script", - "file": "datafeed_windows_anomalous_script.json", - "job_id": "windows_anomalous_script" - }, - { - "id": "datafeed-windows_anomalous_service", - "file": "datafeed_windows_anomalous_service.json", - "job_id": "windows_anomalous_service" - }, - { - "id": "datafeed-windows_anomalous_user_name_ecs", - "file": "datafeed_windows_anomalous_user_name_ecs.json", - "job_id": "windows_anomalous_user_name_ecs" - }, - { - "id": "datafeed-windows_rare_user_runas_event", - "file": "datafeed_windows_rare_user_runas_event.json", - "job_id": "windows_rare_user_runas_event" - }, - { - "id": "datafeed-windows_rare_metadata_process", - "file": "datafeed_windows_rare_metadata_process.json", - "job_id": "windows_rare_metadata_process" - }, - { - "id": "datafeed-windows_rare_metadata_user", - "file": "datafeed_windows_rare_metadata_user.json", - "job_id": "windows_rare_metadata_user" - } - ] -} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_rare_process_by_host_windows_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_rare_process_by_host_windows_ecs.json deleted file mode 100644 index 6daa5881575ab..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_rare_process_by_host_windows_ecs.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"term": { "event.action": "Process Create (rule: ProcessCreate)" }}, - {"term": {"agent.type": "winlogbeat"}} - ] - } - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_network_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_network_activity_ecs.json deleted file mode 100644 index f5e937e4ae717..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_network_activity_ecs.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"term": {"event.action": "Network connection detected (rule: NetworkConnect)"}}, - {"term": {"agent.type": "winlogbeat"}} - ], - "must_not": [ - { - "bool": { - "should": [ - {"term": {"destination.ip": "127.0.0.1"}}, - {"term": {"destination.ip": "127.0.0.53"}}, - {"term": {"destination.ip": "::1"}} - ], - "minimum_should_match": 1 - } - } - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_path_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_path_activity_ecs.json deleted file mode 100644 index a9dba89bfe5e8..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_path_activity_ecs.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"term": {"event.action": "Process Create (rule: ProcessCreate)"}}, - {"term": {"agent.type": "winlogbeat"}} - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_process_all_hosts_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_process_all_hosts_ecs.json deleted file mode 100644 index a9dba89bfe5e8..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_process_all_hosts_ecs.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"term": {"event.action": "Process Create (rule: ProcessCreate)"}}, - {"term": {"agent.type": "winlogbeat"}} - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_process_creation.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_process_creation.json deleted file mode 100644 index 124a5d17dbb9f..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_process_creation.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"term": {"event.action": "Process Create (rule: ProcessCreate)"}}, - {"term": {"agent.type": "winlogbeat"}} - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_script.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_script.json deleted file mode 100644 index d6b11501ff122..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_script.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"term": {"winlog.channel": "Microsoft-Windows-PowerShell/Operational"}}, - {"term": {"agent.type": "winlogbeat"}} - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_service.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_service.json deleted file mode 100644 index efb578e646189..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_service.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"term": {"event.code": "7045"}}, - {"term": {"agent.type": "winlogbeat"}} - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_user_name_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_user_name_ecs.json deleted file mode 100644 index a9dba89bfe5e8..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_anomalous_user_name_ecs.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"term": {"event.action": "Process Create (rule: ProcessCreate)"}}, - {"term": {"agent.type": "winlogbeat"}} - ] - } - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_rare_metadata_process.json deleted file mode 100644 index dc0f6c4e81b33..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_rare_metadata_process.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [{"term": {"destination.ip": "169.254.169.254"}}] - } - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_rare_metadata_user.json deleted file mode 100644 index dc0f6c4e81b33..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_rare_metadata_user.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [{"term": {"destination.ip": "169.254.169.254"}}] - } - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_rare_user_runas_event.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_rare_user_runas_event.json deleted file mode 100644 index 316e5c834f0ac..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/datafeed_windows_rare_user_runas_event.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "job_id": "JOB_ID", - "indices": [ - "INDEX_PATTERN_NAME" - ], - "max_empty_searches": 10, - "query": { - "bool": { - "filter": [ - {"term": {"event.code": "4648"}}, - {"term": {"agent.type": "winlogbeat"}} - ] - } - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/rare_process_by_host_windows_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/rare_process_by_host_windows_ecs.json deleted file mode 100644 index 49c936e33f70f..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/rare_process_by_host_windows_ecs.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Winlogbeat - Detect unusually rare processes on Windows.", - "groups": [ - "security", - "winlogbeat", - "process" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare process executions on Windows", - "function": "rare", - "by_field_name": "process.name", - "partition_field_name": "host.name" - } - ], - "influencers": [ - "host.name", - "process.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "256mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-winlogbeat", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_network_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_network_activity_ecs.json deleted file mode 100644 index d3fb038f85584..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_network_activity_ecs.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Winlogbeat - Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity.", - "groups": [ - "security", - "winlogbeat", - "network" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"process.name\"", - "function": "rare", - "by_field_name": "process.name" - } - ], - "influencers": [ - "host.name", - "process.name", - "user.name", - "destination.ip" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "64mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-winlogbeat", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_path_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_path_activity_ecs.json deleted file mode 100644 index 6a667527225a9..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_path_activity_ecs.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "job_type": "anomaly_detector", - "groups": [ - "security", - "winlogbeat", - "process" - ], - "description": "Security: Winlogbeat - Looks for activity in unusual paths that may indicate execution of malware or persistence mechanisms. Windows payloads often execute from user profile paths.", - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"process.working_directory\"", - "function": "rare", - "by_field_name": "process.working_directory" - } - ], - "influencers": [ - "host.name", - "process.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "256mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-winlogbeat", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_process_all_hosts_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_process_all_hosts_ecs.json deleted file mode 100644 index 9b23aa5a95e6c..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_process_all_hosts_ecs.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Winlogbeat - Looks for processes that are unusual to all Windows hosts. Such unusual processes may indicate execution of unauthorized services, malware, or persistence mechanisms.", - "groups": [ - "security", - "winlogbeat", - "process" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"process.executable\"", - "function": "rare", - "by_field_name": "process.executable" - } - ], - "influencers": [ - "host.name", - "process.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "256mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-winlogbeat", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_process_creation.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_process_creation.json deleted file mode 100644 index 9d90bba824418..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_process_creation.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "job_type": "anomaly_detector", - "groups": [ - "security", - "winlogbeat", - "process" - ], - "description": "Security: Winlogbeat - Looks for unusual process relationships which may indicate execution of malware or persistence mechanisms.", - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "Unusual process creation activity", - "function": "rare", - "by_field_name": "process.name", - "partition_field_name": "process.parent.name" - } - ], - "influencers": [ - "host.name", - "process.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "256mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_script.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_script.json deleted file mode 100644 index 6fff7246a249a..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_script.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Winlogbeat - Looks for unusual powershell scripts that may indicate execution of malware, or persistence mechanisms.", - "groups": [ - "security", - "winlogbeat", - "powershell" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "high_info_content(\"winlog.event_data.ScriptBlockText\")", - "function": "high_info_content", - "field_name": "winlog.event_data.ScriptBlockText" - } - ], - "influencers": [ - "host.name", - "user.name", - "winlog.event_data.Path" - ], - "model_prune_window": "30d" - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "256mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-winlogbeat", - "custom_urls": [ - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_user_name_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_user_name_ecs.json deleted file mode 100644 index 7d9244a230ac3..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_anomalous_user_name_ecs.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Winlogbeat - Rare and unusual users that are not normally active may indicate unauthorized changes or activity by an unauthorized user which may be credentialed access or lateral movement.", - "groups": [ - "security", - "winlogbeat", - "process" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"user.name\"", - "function": "rare", - "by_field_name": "user.name" - } - ], - "influencers": [ - "host.name", - "process.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "256mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-winlogbeat", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_rare_metadata_process.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_rare_metadata_process.json deleted file mode 100644 index 85fddbcc53e0f..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_rare_metadata_process.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Winlogbeat - Looks for anomalous access to the metadata service by an unusual process. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", - "groups": [ - "security", - "winlogbeat", - "process" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"process.name\"", - "function": "rare", - "by_field_name": "process.name" - } - ], - "influencers": [ - "process.name", - "host.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "64mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-winlogbeat", - "custom_urls": [ - { - "url_name": "Host Details by process name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by process name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'process.name%20:%20%22$process.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_rare_metadata_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_rare_metadata_user.json deleted file mode 100644 index 767c2d5b30ad2..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/ml/windows_rare_metadata_user.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "job_type": "anomaly_detector", - "description": "Security: Winlogbeat - Looks for anomalous access to the metadata service by an unusual user. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", - "groups": [ - "security", - "winlogbeat", - "process" - ], - "analysis_config": { - "bucket_span": "15m", - "detectors": [ - { - "detector_description": "rare by \"user.name\"", - "function": "rare", - "by_field_name": "user.name" - } - ], - "influencers": [ - "host.name", - "user.name" - ] - }, - "allow_lazy_open": true, - "analysis_limits": { - "model_memory_limit": "32mb" - }, - "data_description": { - "time_field": "@timestamp" - }, - "custom_settings": { - "created_by": "ml-module-siem-winlogbeat", - "custom_urls": [ - { - "url_name": "Host Details by user name", - "url_value": "security/hosts/ml-hosts/$host.name$?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - }, - { - "url_name": "Hosts Overview by user name", - "url_value": "security/hosts/ml-hosts?_g=()&query=(query:'user.name%20:%20%22$user.name$%22',language:kuery)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))" - } - ] - } - } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/logo.json deleted file mode 100644 index dfd22f6b1140b..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/logo.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "icon": "logoSecurity" -} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/manifest.json deleted file mode 100644 index 45a3d25969812..0000000000000 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/manifest.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "id": "siem_winlogbeat_auth", - "title": "Security: Winlogbeat Authentication", - "description": "Detect suspicious authentication events in Winlogbeat data.", - "type": "Winlogbeat data", - "logoFile": "logo.json", - "defaultIndexPattern": "winlogbeat-*", - "query": { - "bool": { - "filter": [ - {"term": {"agent.type": "winlogbeat"}}, - {"term": {"event.category": "authentication"}} - ], - "must_not": { "terms": { "_tier": [ "data_frozen", "data_cold" ] } } - } - }, - "jobs": [ - { - "id": "windows_rare_user_type10_remote_login", - "file": "windows_rare_user_type10_remote_login.json" - } - ], - "datafeeds": [ - { - "id": "datafeed-windows_rare_user_type10_remote_login", - "file": "datafeed_windows_rare_user_type10_remote_login.json", - "job_id": "windows_rare_user_type10_remote_login" - } - ] -} diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/machine_learning_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/machine_learning_rule.spec.ts index c5352268805ca..958657d5329ec 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/machine_learning_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/machine_learning_rule.spec.ts @@ -66,7 +66,7 @@ describe('Detection rules, machine learning', () => { visitWithoutDateRange(RULE_CREATION); }); - it('Creates and enables a new ml rule', () => { + it.skip('Creates and enables a new ml rule', () => { selectMachineLearningRuleType(); fillDefineMachineLearningRuleAndContinue(getMachineLearningRule()); fillAboutRuleAndContinue(getMachineLearningRule()); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/add_exception.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/add_exception.spec.ts index d41e86fb9c96d..1a73ca220379c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/add_exception.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/add_exception.spec.ts @@ -58,7 +58,7 @@ describe('Adds rule exception', () => { esArchiverUnload('exceptions'); }); - it('Creates an exception from an alert and deletes it', () => { + it.skip('Creates an exception from an alert and deletes it', () => { cy.get(ALERTS_COUNT).should('exist'); cy.get(NUMBER_OF_ALERTS).should('have.text', NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS); // Create an exception from the alerts actions menu that matches diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts index 3fcdd4366da7d..d9ad137468045 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts @@ -46,7 +46,7 @@ describe('useSecurityJobs', () => { (checkRecognizer as jest.Mock).mockResolvedValue(checkRecognizerSuccess); }); - it('combines multiple ML calls into an array of SecurityJobs', async () => { + it.skip('combines multiple ML calls into an array of SecurityJobs', async () => { const expectedSecurityJob: SecurityJob = { datafeedId: 'datafeed-siem-api-rare_process_linux_ecs', datafeedIndices: ['auditbeat-*'], @@ -78,7 +78,7 @@ describe('useSecurityJobs', () => { expect(result.current.jobs).toEqual(expect.arrayContaining([expectedSecurityJob])); }); - it('returns those permissions', async () => { + it.skip('returns those permissions', async () => { const { result, waitForNextUpdate } = renderHook(() => useSecurityJobs(false)); await waitForNextUpdate(); @@ -86,7 +86,7 @@ describe('useSecurityJobs', () => { expect(result.current.isLicensed).toEqual(true); }); - it('renders a toast error if an ML call fails', async () => { + it.skip('renders a toast error if an ML call fails', async () => { (getModules as jest.Mock).mockRejectedValue('whoops'); const { waitForNextUpdate } = renderHook(() => useSecurityJobs(false)); await waitForNextUpdate(); @@ -103,7 +103,7 @@ describe('useSecurityJobs', () => { (hasMlLicense as jest.Mock).mockReturnValue(false); }); - it('returns empty jobs and false predicates', () => { + it.skip('returns empty jobs and false predicates', () => { const { result } = renderHook(() => useSecurityJobs(false)); expect(result.current.jobs).toEqual([]); diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.test.tsx index f8f730c67248c..fef1e660958f4 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.test.tsx @@ -23,7 +23,7 @@ import { describe('useSecurityJobsHelpers', () => { describe('moduleToSecurityJob', () => { - test('correctly converts module to SecurityJob', () => { + test.skip('correctly converts module to SecurityJob', () => { const securityJob = moduleToSecurityJob( mockGetModuleResponse[0], mockGetModuleResponse[0].jobs[0], @@ -39,7 +39,7 @@ describe('useSecurityJobsHelpers', () => { description: 'SIEM Auditbeat: Detect unusually rare processes on Linux (beta)', groups: ['auditbeat', 'process', 'siem'], hasDatafeed: false, - id: 'rare_process_by_host_linux_ecs', + id: 'rare_process_by_host_linux', isCompatible: false, isElasticJob: true, isInstalled: false, @@ -53,9 +53,9 @@ describe('useSecurityJobsHelpers', () => { }); describe('getAugmentedFields', () => { - test('return correct augmented fields for given matching compatible modules', () => { + test.skip('return correct augmented fields for given matching compatible modules', () => { const moduleJobs = getModuleJobs(mockGetModuleResponse, ['siem_auditbeat']); - const augmentedFields = getAugmentedFields('rare_process_by_host_linux_ecs', moduleJobs, [ + const augmentedFields = getAugmentedFields('rare_process_by_host_linux', moduleJobs, [ 'siem_auditbeat', ]); expect(augmentedFields).toEqual({ @@ -68,14 +68,14 @@ describe('useSecurityJobsHelpers', () => { }); describe('getModuleJobs', () => { - test('returns all jobs within a module for a compatible moduleId', () => { + test.skip('returns all jobs within a module for a compatible moduleId', () => { const moduleJobs = getModuleJobs(mockGetModuleResponse, ['siem_auditbeat']); expect(moduleJobs.length).toEqual(3); }); }); describe('getInstalledJobs', () => { - test('returns all jobs from jobSummary for a compatible moduleId', () => { + test.skip('returns all jobs from jobSummary for a compatible moduleId', () => { const moduleJobs = getModuleJobs(mockGetModuleResponse, ['siem_auditbeat']); const installedJobs = getInstalledJobs(mockJobsSummaryResponse, moduleJobs, [ 'siem_auditbeat', @@ -85,7 +85,7 @@ describe('useSecurityJobsHelpers', () => { }); describe('composeModuleAndInstalledJobs', () => { - test('returns correct number of jobs when composing separate module and installed jobs', () => { + test.skip('returns correct number of jobs when composing separate module and installed jobs', () => { const moduleJobs = getModuleJobs(mockGetModuleResponse, ['siem_auditbeat']); const installedJobs = getInstalledJobs(mockJobsSummaryResponse, moduleJobs, [ 'siem_auditbeat', @@ -96,7 +96,7 @@ describe('useSecurityJobsHelpers', () => { }); describe('createSecurityJobs', () => { - test('returns correct number of jobs when creating jobs with successful responses', () => { + test.skip('returns correct number of jobs when creating jobs with successful responses', () => { const securityJobs = createSecurityJobs( mockJobsSummaryResponse, mockGetModuleResponse, diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_modules.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_modules.tsx index e7199f6df2b1f..5b05a4e4509bb 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_modules.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_modules.tsx @@ -11,14 +11,10 @@ * */ export const mlModules: string[] = [ - 'siem_auditbeat', - 'siem_auditbeat_auth', 'siem_cloudtrail', 'siem_packetbeat', - 'siem_winlogbeat', - 'siem_winlogbeat_auth', 'security_auth', - 'security_linux', + 'security_linux_v3', 'security_network', - 'security_windows', + 'security_windows_v3', ]; diff --git a/x-pack/test/api_integration/apis/ml/modules/get_module.ts b/x-pack/test/api_integration/apis/ml/modules/get_module.ts index 2f0e2d3e9433a..5810b4bf7e6c8 100644 --- a/x-pack/test/api_integration/apis/ml/modules/get_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/get_module.ts @@ -17,7 +17,6 @@ const moduleIds = [ 'apache_ecs', 'apm_transaction', 'auditbeat_process_docker_ecs', - 'auditbeat_process_hosts_ecs', 'logs_ui_analysis', 'logs_ui_categories', 'metricbeat_system_ecs', @@ -28,15 +27,11 @@ const moduleIds = [ 'sample_data_ecommerce', 'sample_data_weblogs', 'security_auth', - 'security_linux', + 'security_linux_v3', 'security_network', - 'security_windows', - 'siem_auditbeat', - 'siem_auditbeat_auth', + 'security_windows_v3', 'siem_cloudtrail', 'siem_packetbeat', - 'siem_winlogbeat', - 'siem_winlogbeat_auth', 'uptime_heartbeat', ]; diff --git a/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts b/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts index ed9aec07acffa..fe9bd5fe5aea7 100644 --- a/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts @@ -74,7 +74,7 @@ export default ({ getService }: FtrProviderContext) => { user: USER.ML_POWERUSER, expected: { responseCode: 200, - moduleIds: ['security_auth', 'siem_auditbeat', 'siem_auditbeat_auth'], + moduleIds: ['security_auth'], }, }, { @@ -94,13 +94,7 @@ export default ({ getService }: FtrProviderContext) => { user: USER.ML_POWERUSER, expected: { responseCode: 200, - moduleIds: [ - 'security_auth', - 'security_network', - 'security_windows', - 'siem_winlogbeat', - 'siem_winlogbeat_auth', - ], + moduleIds: ['security_auth', 'security_network', 'security_windows_v3'], }, }, { @@ -129,7 +123,7 @@ export default ({ getService }: FtrProviderContext) => { user: USER.ML_POWERUSER, expected: { responseCode: 200, - moduleIds: ['auditbeat_process_hosts_ecs', 'security_linux', 'siem_auditbeat'], + moduleIds: ['security_linux_v3'], }, }, { @@ -139,7 +133,12 @@ export default ({ getService }: FtrProviderContext) => { user: USER.ML_POWERUSER, expected: { responseCode: 200, - moduleIds: ['security_auth', 'security_linux', 'security_network', 'security_windows'], + moduleIds: [ + 'security_auth', + 'security_linux_v3', + 'security_network', + 'security_windows_v3', + ], }, }, { @@ -149,7 +148,7 @@ export default ({ getService }: FtrProviderContext) => { user: USER.ML_POWERUSER, expected: { responseCode: 200, - moduleIds: ['metricbeat_system_ecs', 'security_linux'], + moduleIds: ['metricbeat_system_ecs', 'security_linux_v3'], }, }, { @@ -169,7 +168,7 @@ export default ({ getService }: FtrProviderContext) => { user: USER.ML_POWERUSER, expected: { responseCode: 200, - moduleIds: ['security_linux'], // the metrics ui modules don't define a query and can't be recognized + moduleIds: ['security_linux_v3'], // the metrics ui modules don't define a query and can't be recognized }, }, { diff --git a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts index f09376212418a..a6b4162f42ac1 100644 --- a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts @@ -311,33 +311,6 @@ export default ({ getService }: FtrProviderContext) => { dashboards: [] as string[], }, }, - { - testTitleSuffix: - 'for siem_auditbeat_auth with prefix, startDatafeed true and estimateModelMemory true', - sourceDataArchive: 'x-pack/test/functional/es_archives/ml/module_siem_auditbeat', - indexPattern: { name: 'ft_module_siem_auditbeat', timeField: '@timestamp' }, - module: 'siem_auditbeat_auth', - user: USER.ML_POWERUSER, - requestBody: { - prefix: 'pf11_', - indexPatternName: 'ft_module_siem_auditbeat', - startDatafeed: true, - end: 1566403650000, - }, - expected: { - responseCode: 200, - jobs: [ - { - jobId: 'pf11_suspicious_login_activity_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - ], - searches: [] as string[], - visualizations: [] as string[], - dashboards: [] as string[], - }, - }, { testTitleSuffix: 'for siem_packetbeat with prefix, startDatafeed true and estimateModelMemory true', @@ -412,159 +385,6 @@ export default ({ getService }: FtrProviderContext) => { dashboards: [] as string[], }, }, - { - testTitleSuffix: - 'for auditbeat_process_hosts_ecs with prefix, startDatafeed true and estimateModelMemory true', - sourceDataArchive: 'x-pack/test/functional/es_archives/ml/module_auditbeat', - indexPattern: { name: 'ft_module_auditbeat', timeField: '@timestamp' }, - module: 'auditbeat_process_hosts_ecs', - user: USER.ML_POWERUSER, - requestBody: { - prefix: 'pf14_', - indexPatternName: 'ft_module_auditbeat', - startDatafeed: true, - end: 1597847410000, - }, - expected: { - responseCode: 200, - jobs: [ - { - jobId: 'pf14_hosts_high_count_process_events_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf14_hosts_rare_process_activity_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - ], - searches: ['ml_auditbeat_hosts_process_events_ecs'] as string[], - visualizations: [ - 'ml_auditbeat_hosts_process_event_rate_by_process_ecs', - 'ml_auditbeat_hosts_process_event_rate_vis_ecs', - 'ml_auditbeat_hosts_process_occurrence_ecs', - ] as string[], - dashboards: [ - 'ml_auditbeat_hosts_process_event_rate_ecs', - 'ml_auditbeat_hosts_process_explorer_ecs', - ] as string[], - }, - }, - { - testTitleSuffix: - 'for security_linux with prefix, startDatafeed true and estimateModelMemory true', - sourceDataArchive: 'x-pack/test/functional/es_archives/ml/module_security_endpoint', - indexPattern: { name: 'ft_logs-endpoint.events.*', timeField: '@timestamp' }, - module: 'security_linux', - user: USER.ML_POWERUSER, - requestBody: { - prefix: 'pf15_', - indexPatternName: 'ft_logs-endpoint.events.*', - startDatafeed: true, - end: 1606858680000, - }, - expected: { - responseCode: 200, - jobs: [ - { - jobId: 'pf15_v2_rare_process_by_host_linux_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf15_v2_linux_rare_metadata_user', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf15_v2_linux_rare_metadata_process', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf15_v2_linux_anomalous_user_name_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf15_v2_linux_anomalous_process_all_hosts_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf15_v2_linux_anomalous_network_port_activity_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - ], - searches: [] as string[], - visualizations: [] as string[], - dashboards: [] as string[], - }, - }, - { - testTitleSuffix: - 'for security_windows with prefix, startDatafeed true and estimateModelMemory true', - sourceDataArchive: 'x-pack/test/functional/es_archives/ml/module_security_endpoint', - indexPattern: { name: 'ft_logs-endpoint.events.*', timeField: '@timestamp' }, - module: 'security_windows', - user: USER.ML_POWERUSER, - requestBody: { - prefix: 'pf16_', - indexPatternName: 'ft_logs-endpoint.events.*', - startDatafeed: true, - end: 1606858580000, - }, - expected: { - responseCode: 200, - jobs: [ - { - jobId: 'pf16_v2_rare_process_by_host_windows_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf16_v2_windows_anomalous_network_activity_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf16_v2_windows_anomalous_path_activity_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf16_v2_windows_anomalous_process_all_hosts_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf16_v2_windows_anomalous_process_creation', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf16_v2_windows_anomalous_user_name_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf16_v2_windows_rare_metadata_process', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf16_v2_windows_rare_metadata_user', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - ], - searches: [] as string[], - visualizations: [] as string[], - dashboards: [] as string[], - }, - }, { testTitleSuffix: 'for metricbeat_system_ecs with prefix, startDatafeed true and estimateModelMemory true', @@ -723,110 +543,6 @@ export default ({ getService }: FtrProviderContext) => { dashboards: [] as string[], }, }, - { - testTitleSuffix: - 'for siem_winlogbeat with prefix, startDatafeed true and estimateModelMemory true', - sourceDataArchive: 'x-pack/test/functional/es_archives/ml/module_siem_winlogbeat', - indexPattern: { name: 'ft_module_siem_winlogbeat', timeField: '@timestamp' }, - module: 'siem_winlogbeat', - user: USER.ML_POWERUSER, - requestBody: { - prefix: 'pf21_', - indexPatternName: 'ft_module_siem_winlogbeat', - startDatafeed: true, - end: 1595382280000, - }, - expected: { - responseCode: 200, - jobs: [ - { - jobId: 'pf21_rare_process_by_host_windows_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf21_windows_anomalous_network_activity_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf21_windows_anomalous_path_activity_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf21_windows_anomalous_process_all_hosts_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf21_windows_anomalous_process_creation', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf21_windows_anomalous_script', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf21_windows_anomalous_service', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf21_windows_anomalous_user_name_ecs', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf21_windows_rare_user_runas_event', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf21_windows_rare_metadata_process', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - { - jobId: 'pf21_windows_rare_metadata_user', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - ], - searches: [] as string[], - visualizations: [] as string[], - dashboards: [] as string[], - }, - }, - { - testTitleSuffix: - 'for siem_winlogbeat_auth with prefix, startDatafeed true and estimateModelMemory true', - sourceDataArchive: 'x-pack/test/functional/es_archives/ml/module_siem_winlogbeat', - indexPattern: { name: 'ft_module_siem_winlogbeat', timeField: '@timestamp' }, - module: 'siem_winlogbeat_auth', - user: USER.ML_POWERUSER, - requestBody: { - prefix: 'pf22_', - indexPatternName: 'ft_module_siem_winlogbeat', - startDatafeed: true, - end: 1566321950000, - }, - expected: { - responseCode: 200, - jobs: [ - { - jobId: 'pf22_windows_rare_user_type10_remote_login', - jobState: JOB_STATE.CLOSED, - datafeedState: DATAFEED_STATE.STOPPED, - }, - ], - searches: [] as string[], - visualizations: [] as string[], - dashboards: [] as string[], - }, - }, { testTitleSuffix: 'for apache_data_stream with prefix, startDatafeed true and estimateModelMemory true', From 5b5f5b3e9bc2372c787ca91affbecf5d465fa901 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Wed, 18 May 2022 07:42:11 -0700 Subject: [PATCH 011/150] [DOCS] Refresh screenshot for cases (#132377) --- docs/management/cases/images/cases.png | Bin 82573 -> 90691 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/management/cases/images/cases.png b/docs/management/cases/images/cases.png index 7b0c551cb69038837cc022d634a877bc1dd073be..b244b3df16a209d204797c86adbd1ace3b922ae1 100644 GIT binary patch literal 90691 zcmZs@1ymeClrD_ByK9i(lHd{~Kthm&1a~L6%P@mW&?u1L5Fo*QaCdhbhQWPsW`JSf z&+h*3?VhvmoLi@>tGlcFcHOG#`_*^*lfJGdDG?J91_lP{D=qbR7#P?m7#Nt91bF{S z$hgf>|2CMu?=)30DkoSD{_V&(7{7AV(ZLY-mnXo$jB~-j`HxfnC8mD~0|P4$>;IHs zpX6cxpFC#ee;mXsrwhcuc!BXsUFCfc=5aQzwc$MTfE5F?khNXX$q8AJcFhY)`R)hE zQbI%pe*ptSJVPFVmOx}A5f&E|Y5|F_iACq`CCLvkR40@2DQPNq0Hr4sk%5=NFk~B_ z9#z8M_;7;%E!(fLf_)vz=J+WoF#oq`zUZ;wi~N6GW{X92(tNZ2$Ayan>wn2crSA(I z_5US4;+TX;^H8t3XaD<7l%Ks8W&a;lVKrxjM*~EZWIq1-e?0~@*8iv~!Z-beTMs3K z9=KGnm9n(K{c7(d<>RFKItjte?x>v#Zm3WnZ1CcCTQ=%fZR24@U%%Wu=cRIsdI~7B1G8I4XBn`$)*w=q?kC zN}Vif9ER_zr+|Jj<@lQ{`crhtNo~#+ID=ej=bMqyp{zGmwk;o7uM-OPqKQd9o@QqfMOnf6eQ~37?nPNi{V) z6}c@QX2L#^w|dCeat7Y|A9ej8AsVbHMvb8YfR@hnKk$vKeM28QVKK6 zo>(&Yo@aFup)!N`Zv{Ex2>F9U$*>Kt_Y^RhI(+0}0-tDv>ffGH4IQ6PA~x0+$}L*Jlg^oi2x`;+vYAXWic2n!I??^-8+kY0{GU zxnq{jEwB_Ccxth5nU6oVxQP5c>KgmsM)QAr`h7ZK))y~WXIQ7=)tQ~t$sl2xau`TZ zKd;Nhc%^!xo^bU;$Ed?(yicG4WPjupk-v9g|U^4R% zIGMe6c=r%TaF#K^v%bXhq~*=8`wA{Br#Bc-hfuEy)ZLvis53Mz9qsh~zZ>xd&P4nR zED6{pP%&4}lH_Uns-i6B&!?%@Axdwi_TRh-QKZ|vvf07vbr1F7y@WBnvjnDI1A+(QFhh{q$Mnr!R$o1gnZUv$IZ)5e1 z!2r4v23380FE>?LPHVfDPX6riUdbzscXI@AjQ8r2eq(uYX z&v63wpbtTmgTtRo!{lB9S{o}WCYh^DcSe4s*!g_`R|Z@rFD^%sD|?KlRf#EgZiC)t!Wc``xJ{Ll>m0U6ZrCOBN zG)is*RO|oD0;l%%x)__7_xUHfV2%|jczpfmJS;jxnJLE8TGNsR>i>LkH%N*hd?!*Y zaXo$-&SxjZM*G{td*23MV$C$K6HjtsgQ>(i5Mjb1Vxp@hN;fiUH(}0u!DA-;?>~rj zC{$uAn`?>s3%JuS5;P8%kuQxJ1T3AXeL8E|tNCSf3m?FHdECfrgP7TdOL@6Ff%FMux!27ph@vwrm z?X8ZZ`7+m2MVBr_@g zr!%U$8x^nE*jPDnNFi!`;W|O2vJ*9LQ4tN`1)5#%zetrr#asBSz^9D%B>PPpL`U}JH zE2Pa_GY|=)r?V<`<|HN=$~};)k0h{<4JT)7!+g-P{G{h*9tkH(tsR#~m>g_!Hl^D? zM11|}?rQR%(``f&b)H!9P_h1Nbl`xijQ6XBI?y5r61T6E;9aF(kKbirTx1_F{d6ZY zku~WP_*x!mc553W^o+k#$Gi=ft)HP4(e`>bZjVSUy4hR-SA&dZ0Umdf9hb^dquwTu zHi&_a1)L@}X5YLQp(Js<^$ATn53{v$$;i7&D$&CC=%@lIJ1Mpe1XpMm`s{5hImJp= z(JN;W=lTgcbvJpm0NW>|H2U)OHpNxi-JI%^&08j07D#G3(tk8?D6`+EjD6qB-az2$ zJH?fD8*<4kFSk5Zm~eDUkPr}KE6Ec0MDnP)qd*>n{>#F>e#tdblgo%Id&I|f>yC_h z%6YnGdfJ9Aew&qBs!tn`hKkvFAr*d?98m1~apjr+KMgkGUectcif2V#l z&mF*8=*K4{rP!dj>f}5^Zx45xWy>{`AHN#NE>YR21e}05o$c(mL16Zdj$3mJOW^cS zi=N(n(37*ol$07r9p>DwWYXmL_<9t3Xx|!WPZ3h>Eh%J9IKe%Ugep(G{z_eUBWhJM zqLIvcwBRyd`?8oCRg+WEifGQXYJL|(SzBKIz?UWT>vJK_#m;5TI!9Py#I#T-j8l-{ z6rihw;K$ytRC{#hF_`#UCp|2Em~j#y!pP!4R`@v%(8QhRGDGbdNel*8mfLg0PN#Cnp>KI9qBsq;tVUh(*I85eIG8 zGff_Bt1)5f**+*#jd=dr_|xYXi|yj_NMvqh;;mPjTS_&%cj#ygo~a7M9a3`;vIU!L zN<_5RK;czJH(Y4K&luBQ>Z6tEJ_g6A(Ka4CjYm?s?xN93wC<})hm^b8_kP=-B=3KO7)f49zeMR=tHIYU6IMzUYZ|4Q z+?6!v<&)q2=(;sj*RZd*U)cif>p#0Io0QcR8W`j=?!~*7IL7SQLY(O4v0qls79K~H zUkw6)n#pXgdWz_*ezi9M)vOKZ)tF?~Dm8RXdM|lE-}m7G_qZ1c^Cvu0pm6h@)Xc5t z52|i&kxBJuSFsR!lcAYEcE`}u832_7{Mp-*`q&p}*yzRxPC`zO{1Alint6hSA&v5) zP14!$0`Z=hfP6C1M5X=<`O}V6;-l(oP(D4Ka=?Zc8`5|6hTQ_9p#7|5miUzRLe9|Y zEAW1J&yZxARFNIjW%fi3cIC(0{n>wVnlMy9({MyFHW?lVuwgp2y= z#q=iJ*Qb3La$Z14^%kF$Kr<6w@%3U{#|H>z&lr&k86IuYC&Fh_6Bi_ah&UP1Y`1M zG!0$%!EYBe=v5{!!}C%1hzas%vrs-~g>{wP+lX0?{{5bvr|mMiN5O0Xu^WY&*@A>iR`jK*KUQVq{M*rkxr4NBbc=0Ate15|EYljc2w82ci&XHN7?USs2;!$7GbvQI+MA|vY|PuX+1wIcV~*U^F@Q3(9c~PsljbO>vUMu zK&v;o#|xRBwY%e!+J7G|Cp+u4o($OGg;wd?OMA?9{d)Fs-vj5L?@%3+NLRwZBhv?E z3Od6NyKQJcQ|J%y$=|!bU8`E!6^3Me2{#DFE(UjmHrE<9zjn|L_l^MGa&sYMQ)MBV zQ9XRQq_w;{Ob~~3%g}nMI$7Vn<>*h}R=q;lj2e^CD-f{jmMRkL4zmfn7at#rAqkQ! zrU$L(bqB!b2@dd6lfLsu|9Sax&?IIizcMAMywE|NgO9MfY#_FRav#NJm*0&k+x&E1 z_&*(WFDk5CO2X%EyQV0rT*WWbh&fu)a*OeIL7lR;ofWG#f!#-}=GEZ<*m>!CqOg^{ zRZCm`^Gb6P(YoOj3Qtf7Y*jb0wBKT>m2cOWyr0!7RQfn}Dx+ay;Y7~*dL-8Lw)%yD zz7Z`h#EK#S!bN7%oDzZPKNWPxRj3JdIeG4u;vW&zS?zJKl|)RFKYjj5>f-cT$_dRT zNWi(&_FDYtJ(vV1zDmcmei>xd02aO)%%P zl8P0V_KsY;K>t5BJ3T}zYzlR;`Prk@XfZDrgTmja>Z8TovnT}P!jXPSz3>W^jTu6H zdDh|(vUHnC0m@o@O;1|;>P(;xntk}Af%x!y4}hPPAlU7E*Na&>tj>MOm*RRCtbLeX zw=l2Wgf8Lbh`J&wkwngs*GVt=)&CfXAD~T?i;7il`Qg)`RzoO-b-5Rbjq88;vA>+T zZHZ>)SqjO$djqYy-^*dHh)Q56xcv{v^@zltf=(-#DtFmV}V6L5xPY(QomP>^MHe}Q%QUkhHEX}TTO z*$v6X}AW6l*uin2MaPA zc|lKPJ zbGbolF;dd3gYV%!QTU}+J9*mz3Hjc*;*+M&?UxAVfc_nel-NwZW&WTgfYL?uQ5jq) zJbZHM_CV6Hm>@8=e^S%=QJh?vje=s?C;)DIdJTYl0!sa|8&$p@yVU&y$O*K3Vo;(J z=zgYfP0d+ba*xnVaNcX7Sy60(*`_L9g0+z;1 z!dgA>)_nL-yQD#)Jzt-ugw}u@M`KAd-6q$(6qHjtH(i=+ETa-=N1DYz8V*5t1iw~f zo>*7>nor&8{|zWJtjK@5DDYhrV6NWV9rf13QYCC6onA{!xXk5NPQpKkcz zksku4U%2usfn&ichO}N}G=gcM6k@Qo+~b+KjcuRB2L7rxrxox24|+_9V20#DRPvt3 zPThI0@n$xM(Bl}HB*rH+BvZ_$TD@ERsG`NbH?1T<^;(64RT}+F$I0h8=*DBOv`hJF z!97*Mo;*}?c0rBho!c-BqDX zfee(pTyFP!{bul9Gc&oUXt0$_XFKqL;0t9gx@P7ujog<7#3l*bbzgDyw1AM5r!JM* z(RT$^FEbgE_{w5hKG<})uKDI*CXetxXT>e^y=w;?)ueCbkFL79>(x^%6v7#qyo1D`Kr=Kc*C z%r>((B7%|yt%cq|h)@m(hRh_0B>wgD?T5bgd#x9lXSXwcXHfT8R7J8b5I_N(5;qo+M~sEyOTYSoAz@I$on7%%S@e z2z1MVVjSKLnrYnvU9DfTIBd{AB0#hHJxXUA;-3ubj4>eu=f}YVP$HK{m3I31H@Z{k z?j)#gJ>V0(@g>hv_F4Vni;GtHkU5OOww0qrV>t>%sFuC-L3`Lnu@Fx)mOxC7#?W@7M@R@cUZRA}qJv zmMFuvysGPt-}RDjRuz2;=Yd_rH4W;ru+?~ zDHCwjmZr0CFgXx)TrpUQW#rd9w{~)AI^sw4h!?MVbJL4FzNlPO^*RagpGs5?TvOmH z{y1N{QEL78FlQUn^Yn=f!@y_LHQv?0qXl7jv!x#7Sk||~**Jqb36~hp1S;2%YIb6; zY-_!|9VPL5ss^aETzjCyem}3ztazu%_jUP4cq*hfD1Ms3dwkwv8h70Vur&^9!~&i{ zS)<79Lx((u7IRG+K7KH7=+#WBQLzejfHcDW+EmrlKEJQ7gZd)fMYwFoSNsD`(W$11 zX?rG*AkX4wI}jV?rD_{)o3`M2k*2(MAYwUP%6o>U^|V7C9%h^#gMK`1e-uCRvjHS| zoP7=UZ}{ln(Tt!L%=C8H7`+1`)Co_@crNbeLh;%nP1iyH^lduJ(h9T<*auf z*TK8vx-a-0eIey>{UBS|0wdE2HlBc+ix^$|`EF|f+8?@l?ofnAg(VSuv4ZF0pLh=@?LGR6wk5cGR+HZ$hwHtZ zvQ@NFnxrMFn7@ksUa$GXFk$dtK=Dv8Rwy2gO78h4#Sg`m_jN`CLe;KHcjKXFT89LB zJnhR@X_9W41>tKpAveYuP2+&rtkNPH68wVAEE(Z-76JwV8qovkE(j2P`y+eF0^bHg zy!K#b_Q99)oSYOr8ldu>r|kQ-z87y%VL0e;=B^PG_?^0hJ;0OfH2c)MAc^gso!;H& z8qv}g0VS=O)I3fs+*{TUim8eMH!JnQdMO}Ps;lH`RtQR8X=8r|{uXd$f*AZBvHL;i zcr%c9nTKNGSguP9hS@0;h(3gM9)z~CnD1wY1q}(Ih}gEuGHckSNT!wog#Co~0YFK? z=#YKF-3PwSUav=9vCnT}y4sk(kc6qO%U4NtSwiazEK>Ub$y1dku7S(X*&iHd7mxjV z&>7EUb0pf2%mdch%Z{1KArC|A(M-~`nEu{Ez)aqce}&N(H34eO>% z*~rW+=<0y6WM3|_<<1<^M|cWLcdrBBO;#U>ZR$7q%KPOboXy5PJWTo-{X$5x9I4aX z8d~M&>N^)tYE%r2AlUSCwVE6S2Rvcq8lMBto9WC2=medCyh4pkLseJ9?!pxeT(lb9 zUE}VIxF<^MqK#R&z=x8C!LXRGx1*DVEjKpy=~;5KA4ZCR;r# zbu#6bYgElCeCkKUTWWJD!lO2zg@LO+=NRZE>g3TUcDxHz|Htq?DCn`)N`<2S%TQ}L z^>j{q({Bq1$8to^WebbK;d~-p^_~d-PUYJ@{|8XixFx#-iZg@wR*lbF<<$1I6U=)^ zhyh}qaZ46|`F+VtX!O#K>FcZxH0Ug217mhiMFtiei=5#N8n_n2)Vj zDFLJf$l(+o_{a*URH*lF;h3|W*5@N@S+%od18j}wfgc)l=c6htf4(?+?oszaMZYvep zEMI9B$H><;8w52;ZaQ=GuQIBB%PvytBT+69xaym{1Ees|z=K4+*o+^o^A%0^q4tsB zIdWQ}Nj=I&5RP(EAuI@*ak(qVmU9Pe57DnT{T};9@3t*Wgfs5Q+~*6aqa_FvPqWP;auZ#wah8zCMIA^wLllY+?e zMmBI$s!^C@N8wrZZ`yS7l=ANr>6Z4-Q$t+v!(``JFWJCr&Y=AfeOxxDi=xll9jn2E z6=EVZ-V1&wb$c=B%gLXJU9(fwt~*&umRJW~j+V_WJv`Y5^VI(ND7$Yh)t0)*6z;Ig z)2fcUgtPo;`J949w!=}PMeUE;(92MnL)i0KvmaJEu+6cqBm-X68 zS(5Ii&%wdI^eq7)J;3SBA`x%9WTyFKN^G;wed0giHBx+@;CSXrarOxZkGNi;i>7m? zZ7t$wN#4yFOAL0|ByqlMpyQhqR(a@D!b>l<#nKOGKmM8QR2>t}HZitnC(Ry33-w?8 z{{eT%>V~tO1+`U6X5IV8=ZtS$+3z&?5M&oi1320W?Xr=Qx!2AXxVF?b2N^ljJ$t<0 zyl7*seG5OWOT&8iKNZpY5h_e|i?NumqMzO zgbspZbibHrd8E^$s&Q|DcU&9lCG_TyVg+~}ag@9?ZOU1-EEAMx>hF|KxVcf4QA6xeQ@1?(l?x<2&<65m zlEd%X9_-w>3Eg(>xNc&92x8v@CFRx)zZ82`s^2&NmhMYvgH1T+o}8AOG0xtHCj|mQWDl9zEF=~W zO_uzB@Kv6rXdr^paNe-{sl7yD_5}4@08j2VD?=FfnsrH9B4l z`K#p@u7)B*py}=B?VYe2 z`I%i0x#%{g;A6Gu;Z)5b5DAI18IotoZVPOT8c{(=B(acA#g_KpXNyZPK$h_~SR>Ry z?qxLfPmv4NHQI<}is+unxak6!m3LL&I}?fa<2{HgI1$0ZI0cIN5x3=BU&kw=;$XE# zbgjT0<-s|pk>3W7lnL~A7;Qr(WO2m|wpu>Aw7kw{4I#envGnvm^>|GZs^e9(py74KL?(Z^z?p=J?$yl{MMF|tyYt<@1 z*OmGjX2*%LNmTKk!ks>eZl9f#|biQ)-znR!}l4n9L;Qw>FQn z+NFSvgWN+Z7dMLmQ(%_mLE>eppU+zSk4)b?m2f{VC~EZH;(cOSessYt6l=W`Z1ndXZq+Lfc}wV1>e<_m62AS9r-ktGUztG5yzSSF$$SZj_^q^}1FpEBh_h>XT$j_!NDI*xhG? zEc{!PX#-V^HOCTJ33i(YLGB)6mab!KN?%1~SV}Z{U9p9jB1b6zvxR9&g$gJWY-~+L zbYk~WLB%-b=u0@s#L}EIHvdQh`$lSb=uL`Yk0o$D>8s03xLkCgLM?H) zO2lS5#G&&t4=aKUhB;Oe5sik*=Ei<`apmL`tDL~71XxbtnCrIK6U09{TN%`;ZGT2} zw(4~$mV63XRT||a@N1js;ts*!T%NAf4LN}1hXubPaB|#t;`FRMogehTerE24DiLtA zp+Q8UK7{WPOwbcO*%k0YMFf|B>xU0cv#+N4ng&aLqrWwJXr(gQ%efKEKY@J&TCXK-qjlJ*KM#cKF_OPit$2GKW4;8)R4~L!X0Pj^U zx+9x*Q)N8SWLJXZ-OkpUiSKVB}vVF%S&aM#RS!y|uj%a%iqR27_D-eQs-@{6H zQQ3<4hzgxb7g5Ou3!(b!CZGVsi&eOf+L`y`a#fRD&%|j{JO15)704WV;10_ia3(a3 zpRD&<1q4QELT;pc9}S2m3{R{&kxI3y@z(27qiMo$b-BrJ4f~=K6kdGlsg-^A1XjT7 zS3Y<~Z|saBLT^JUL)s@Sffb%3xYb@8RD4S9?q8(1*%=lT!ydSzVPWtKrmVw|4SfJ= z`guN2bori%6EJWUlxP7M|8cSexYo;*>h;HNqT$L!gJJzjXmR&D?;fa`R3U7;TeW-Q zGG#D>kUCiRTxIB<@@Uah<|DuH?yiwK%#ZGbKAll1w4QpK#Mkc0aQkCxuXu$?-7n$@ zE3#9WICHnT>WK0CBTwlnV4PHC%GE>uc~s6#y4Ar}B~QqLS-rC(z5n5U<@Kd?IpZCi zz^v9!_HOCadoX7pt@AO*H8EXCPrONNbK?z>Rme?2pRYn;Z$Z{DqQ3G!*{>s{ zw>41c@o-KE=?S4>DicwEfjz#I?`Pa@1HnUq>ZSL3X z%9%h@=OCl*VFrGW1XbsJMrmMB*hwQnbZ2ku#%qO2lg$F}1Xak+@a`J3&j7(SrPhvudG#>gDr;#AZwqhh zspUkkkTlt|Agk+nIr${iOMa4&3eL^NSi3)7&r0w3 z#vol4m$>-hlfy;JlhAJ^_a_sg6TiQNo4+GMe1f!~8#AFbzngy73~qg|H7`|e%g32z zQ_dkKAx9Eja^1_?rCoAJe>8?M>N;2dQUcOx2sN#_PyWY%L$k~s#^7{(s*6oEdCp8x zLqSJJGzp?PysGg(k~U6-ocdb&@a2h1sAet*r$BJj=8Y;Wk5qTiUK3aQphD`s)l;&4 z$i$`X?pP;O_O)K{KjKkV{$A7Tcj$Fljb0pX${UG(^FaEllVx zqw{vwwv`y2ElUH{w~q>BQ$B51pr!Aajpn+Sr`q)DD<>VAuw41%%@l63&*-#-j+Y(w zf2Y6svS;ljRztxPPR(u}rl*Li~ae7yqW< z5h0QpeZKdT@=@T*^^uiuMP>qRLGTim3R(uf#aC>Ft)f&U6-A{+#KD>@tWwL8ckNWHo<`c$1%h2(-qL;Ytp9I#PRz^$2BR2>V!-#=n zj7k&7x9IMC1ri*V-3eCZ`hO#QIZenD$bx_a_Vgf{)jrzk$@4;7r5H;4;Q;~^0dR$} zc8zT`G|tYH5-M71%dn(ZTv@N&?K1_1uYzK^h`7jPe-7la%cpt{7Y@*}z4a*c?e&pj z8m4Y$KaQjz6@9?h8ZjbNV*%R|JWku?{s0cakMzIKVs}t1FXn12AO=AznihTbg|k{q!l8K{c2OBlGb*TYZ?k0qJS}e+ReZ? zP3%N0u`0Voz7=l9TP-zbzd11OU~Rl*t6kF@l$U}`VKHt#N{1H0Rlh2xNpyDvu#ph% zac)@gMYq?TKQrJbK>!?$wI76t z0z?#k5~!?ovJm^J@vRbZ#&N=yT{recU)Zfo)ql( zu!mm#Jj$HtY*)o5dAa*7RoOmp4b2gKZtP@yo{|jCLXPYEA(kyWZKBL3Q2&GkwkTj6BPV(!6aBA_gR3l-YT7enH8=$yYR<0GgeL}1 ztFrz$=7uDkr3p_e?=+|sQlmPNN2nJ0$7WJb99)mzYs+%=x1aBZ-A6V%S3NXL9KEScb^bF%VEqYB5II@QpgalNReg+T#UM|4 zFBUzA4b*{jRNnjL^x(Cg5r1ES9d(jPca7@BW)Ol_El7^~cSFRVR&v5YJQW4l&mb4?76^Qn+y<~clkr@R#_RO0gxUvCV&9u;^%9-#xyB6dIut)DA zHB?(5UkFlsiB$C065(PjX!$4KGh;Y+GfdhgRJTt(%z7WfHUIgP#c!Rz4kqsz`SkWE z%J`*`wwF~jsL5OHuS~5)zsOGL^I6qBSH(5oMRSSSmaFw)K=vDEpW{7N!)f;aCT8x^ zjoCrX3}cW%olw;EoA}evg*?CbA6U%fw2Qvi#g!Og2A_Y#^4;k?=>a(Qu>jT~CgxN< zeXzB*mQS=Qkc1v)^gx5omkiGi=;i#I!A`0}5c(JIgzxy9TQ}m~1cP)txVFdRx$n31 z?YhiUyJMpV1nKzkGNmv4q{=a`;y5a2@g$nEm<|!sQz^~G7($xsDw<-pJ(1mYuY`C~ zoE`UoK8}J?X+#(QNU;6WNqaVRs-}UadkTNuC3VPU19jEP8T8%v$l{JRC^F^*vlm&D z$Y7|wJd`R~OP^(Y@lG_8`EeICT|L8`vw7-SX+Ozrvy#csL$Y)R`Z*XJU$|l7)JLKS zr{&=9Y$}e_F>i-~OnfLw(1PftCFxUi z99maM`95s~!I4C8UrnN-8_u~DK+(1nrn)34si-^_-?{Z7^51$yBfPgZ3LpHGs~cy zE7#WsGRf$E*a0$!xn~TYP*m5jrPAgEp;F5mET({@T6qm6j5sKmU$`Rm%ny8M3_zfL6e|M&|oFYlDSFVJQX-}fnq&{>w4ZE zXKAS6l5#)05>w9sHY!IGlx)QIHjy$)tadO;kf5e$mv=;cGk1wGWa;GvOl7~=O0?5s zCxr64wr(NJ;O!6Cy32mNi)H4`Tm>R&%W?mZf@uu?DJx*RsOp^bic@ae-a@XNf2{Vv zfY><|k5_jTdoN@eKWBM=`9nm^V0bV23`2L!s{TirL1z{%GX~&P(Irm+5(>3x*7i@A zZQ+`MT&ZfFub%@OXkv&1gFma1UWGXI%Oq%=I70dh=Hdm|_a~bLEAxdcva-Rep&# z&{3faLj$?+pSdeb2eWO<)@9WWTup`TF^+VAW6Nk%&d_e3I2LZ z_5nAtT{8A#Ymql4b5u2Q5d_czb8L=>+Ffe2d=?$fOvi|;Ut3=(y?LzoeAY_~3DKw0V4Lyo^tQ2A)1 zoBC4NN@G@nuiM6{R zN);%}Dpp(OJJidnMz(v`%kEx3VAmV6QA=MfbU?s*uZLQ|BK>4){*0!;TGfb&jcQvP z{}-SY?6YdDk01Mb4eF*4ltLbtTu5olR67du-oy4_LuE;h-6>to?~aE{?=Vzb z&vehlWHsk1>j!c&2%xVMVWZ+8vwxgK}yh zn8e`GMq!hdp32URfMcxK(r(X`VCD6~z$}$;yxM;;S=@b(flgX33Gp~|u~X!13UelM zlgd?}G6Q%Y`?#KouzSS2v{O30D_NlZ&-k|?HY^&QHf1Bn9=Z3!u%A!Oe*ypAW+~^N znMB{+yeU9dN+|q!L$OCfcwTzju<_gGl8$y|71c^#F<_P-qe(;&D)jg0_uB~f-B#R^IxmZa z_jIYO!D(@n5!zl%kXLFirz88{r3psuW6({IM_@R-k6tvC=AKtJSH?M#?(eA87XqM$ zVL4uoynr-&@$7pt|60U{7dP#TN*CJWIkU2KfI}BIH8CHIXMex1;+=E`maj_Hu;WrJ znzQHl(oG^tiuBr@|EQXl{6PYuR1WOPcmTUV6cA9^f&(weHbN zaKyOW(VEQINpHqZyVxodE*~jNaMyRcA9dunBv%KH@ro4orBe+3uHKB%&*MDp@`VaF zy2?JXt4n@s`Qmwamwgtuw3Bh(RuDAqtN5&!Hug;KW$S9SPsZSZ{B-Ktd~)Zd4O5&C z1W#1P$Od?Sta;J?>PSou6Ol?!ldk+zfA@-@{*+Ih$t$>uv@+WmNl}0gddc6$qvC1H z^w8h+-{%bUXrce-UjS5m z@om6@YTNxYIM^S_E4!ij7EibGtj9si*Vx#2L}HD7i_)+Iq!MLpxl;BGs7*fpoa zsy};TIgiYx{ek6ym%HNuNA7_fw@^B}003F`0Q%tiidt(apicWD&=o0fo*TCIW>T5+Df< z!QBZEAi>?;-QC^YNdh6bySuwKZXLAo1{!&td+s^+-Y@y}#&~bMAMX!i_b5i~s#Uet zs$Dhbtfc(C#d2CQ&p|GFb#0>!Q~k7CWmRn(c8sab)GMyN|BwTtURf=Ull)-qi%g{J?-^-t8F3&3HnncwY z9swL3hNj00=mwk+E;K4Ik8hW&I`y|x+YUfa>PGZtMOZPj2)MMyF}iuo;ZCIw=f{pi zu2i|nsHI<%7rh*;pRhl67jSQF+pRe#eNiDop(Fioh_D0g^ED9mtvsGgwGi*a9gayc z@{1zX&ylUOGiiATO2LQU8ltcl%;!G>7HXRQSlz05 zGQz}MtDgpt+v+r56=k?LUw`8iY%?#i?AgD5b$owW-(|i}DsM}!c$@-ZV*u7(JXv?V zmCZM_eip(FF*wn2iahN>fDsty>ajWiP-GP|^H-%OKYT&sO=mXo#;H7djMK?~=3>Dm zzDKSZQDf$nr*b-6#W}w7_WjGR*(qxtYj^wJGhy-c=&{xVnv8ADpKY1iZr7Uc1^V}T zn-!T-SABclB-ZgVx4Cn!%&t-wop(F6^-r~YI1|Pc=^Be#LOI(bZeJ)`)J)MC%t2qZ zZPRs|&kRDjvMe3JRc$$1$N5Z;BbEvU^5+C_=5{mi@vZpWxnp%-tV`D4dcPG(8JV{H zFmYm<>(!h4Gn03uS=&1hd%HB&TE^%nfA7i{esZUWb8m{zD&<vLRfqsUjq=n?t$k@h1& zWSn)3H=6v}*eXjQxFYQHAZWRN*DUO(&qy>zwkl80%P+V-1nm8Dl1xls|6s%D^>CIQ zNc&X?#QTS@TOy#5VI_jns(6FYV^zM6bPU^n6e~;|VJ#&)UZq{>J8?Np&7r0HU)IN;|iz8<-!^m3o{zjGJ{x(HfI+Sk35y-5q=Rokw=G2F|9$Tds%ceI{XsuAhiL zie#svQ$G?~5Gy@pBwtTvddc6(wOwlXdT?5MSn3l6?`zt&TQpoY&Y%MN9tU;uIWxB$ zCvw_su*7LZoB6AT!n5S3e6EyNvk1WRyRNqmyQNOWpD}=3@t^RKgF$Kbg3D3qgO|}p zTJK*T359VV%4%0=LJwHRGB||mkL#tR!I74nrjUYm<&ycurxVXX>MX+>2Qj0s*z?8F z$nO=j=E_PAd5@GwDmNh~ZGbh8mfS!qDxJ?)d+8o9@3!CR|1Z}0=r@Izjv_z+Q%k*CB)Em>X7W1D^$(P z8wnXYT?d7D?RVSn-+ohFp~DHjB)ZskZ)$VDR8n+WbGSgg9tTw9PLk(PPPneRXJUhO zd`-$_rih+{?(VLLwVpiY577gK`GewSkfDpgsi$EXrp_82Z2g<5@r!X|LY?tzd#@Y2 zpo^NZoWVl7LNnW73=5jx3%YOzV7>pefiWMm;l*CO3o3k*WtKK5!J^Y5N^tUo6IJ z*UdQEmhPzvQE@nj=l)?2Lx$@ixN;{wDaaXLY5vic`1K%EPW6ileIV-0rg+|xd(!Fuu> zlX+1le&i>U&x~jt*L*>i-=uLTO+5lK2aYEbbw{YPT2?-83tE|ax~-yscb3cdyk@t+1pOzRb?T{`2Oo3BDc|1+^p?iLJZ(ktG;z>twoli^UsYMtm!@_s zsXm=3Q`RlVG5Nh|WJi7$o z*V@kCF|4yo?X_tiv!vTm3Di_Z{?C*prToyV0lD$H7fG6A?Qf=4D4vr@K3BN4#ni*! zlNLV*lzo&N-E?8aQa{nLIn)$J z$H=&)dT1Vv2%Lvb)xZ3(c1_9m_^g-j1xw;Uut6GkNVl$&z9dSCGToI0?L%fy_ygY= z{v|l~u_Cv;EkbcW6|18;+P~ z9Ey~>c=6RQ=?@BRn1k&cM!b(W{wUZt4VU&4r)cI~TH+LR-YYfAjYZ$OCha`(2xCry zJt_!U$K_pcB;Y3rI=?>?H2Po~a?SR(G;*m`J)x$})O*y)#7Ba-1_q8~G-EFW?{4F=} z&^~Lxk*xTsJ-}~(oB4E9?8^R%^r6e=;@(JgNbUkiDCUGc?q4zgh&Hdq@|sOK znksG;?wYOS?;(*|Y`tFmm((Ixl)|;UG&71u1JUGO$%j^;^!0CwYz#ijIuvMCP)8H6t`*^{ZwsQ5c_gf^T~$hm!|Xr@AzLSu;f>1AM%T zvnV4u2wO*Hkq2BOaB*V!DvMR0mRPHu5y4Rkcd_(6J9^R>*W}!b%OC%0pkszm7Yu>$ z%Wl}VlV1xM)(?IQ(H*)vbkAVb--X;WR z^@YG~<-DnWYF&7ZEXDIzK29Zyi_6My(~22JwsYQUX{i0D)<;sf2zW?yzV0h@QWsPe z&Tqa0_KI{*gfKWqm$fq&+El#@NVkw3H&M5b*ZW%YR0deqJ6lqwEQ9d!rR8%ZlD;aO zolrKzC+ypa&DSk(@pvF-hSYuiXO8bn?$xOaqQn2uNAg1;k@OQW(se2jmmsfKbGu@0 z42^(BQXaTBX@q3tcsqvvw~jisMK&TNtWmz~o)u7tw7%ta?{fQ%k)h^+@g=w;^_fkN z%HYDl4}+EKL!uxktua(x2ydJP5$p@%&P(|(Ev66zh0Ym3k1cej_+ukZ&d8lT%HyG7 z)FwVnoP1=f@gCI@La%Q&wt-bTt^EeBPdg?up0O2_14=O`D z62J7o&c1*D_*Z)TpTBlVenBmAFa9+X{!z>M>+81m(Bjk zIy?X0&>oX&JS6#(K$4cMP&Yq5ISKn;XF>lf@AP`b8SS5r{O3W4k%nS3ClWm-gXUi( z?f(@*!#d%C|AT!f@Ui3UCi>tdKmH}9^S_Njbj^nSpHcX$&bUZKsYc*d;FrSxo{xXG zB&cQle+THVItTx64%02`O#N%dqsO694Goq5c-nvZ()lWmHWsYf-_Az8!f72_d2=H+ zn>AEsRLYa+JjWa#Jg8Db~{cBE;o1_SLMOb2ye_lrH2 zbuR(JwRKrUFv`M@7k^OP5`wD^_Dh&CMwU?i*XH*BVK{D4=#ZK={!sbT6vq3N1Oh%K z|82wn@(!VK4Cn|Emj6)xLua6G4DzhkQ=rH?A$qousVlQj+hsGZ|Nuh}#)oA+vck%ztQ9RE!ulw-9R3mU6|98jocT5r@ zW4rg2pdRUd5K<~V79_B=M?`el86>O|ru$C}|9f!5I=Mek*sk%VtTW{X73^u+~+if8ID+Alm?C7NeT!|G_UY{Ec9q4`Sm7 zN?ng8$s)S$RgGjyhW})RRU3);$Dsdtko3#oN#x#QQ0!g>#I^syd!QP0Xi(p(=~37G zkL24wW9m;26|kJZ;wXFl2aB18`awk? zY1D?zm1zQCU}37&GF6}%?4}6e2+*)TU#<-*-@Lzlcq}$CYk|IsTSyOl!e8&SgcKPY z+aQfgr}k|ammxPc7BIcp6KXv)z57e)k8hyILGQ4r!r^fK7>|(9-1%U(n$P__%jX3H zB@C6G<$P&PS{eaV8(^GU{?*&JLq4~=oNOy(j0dx4S&Qqvy<(cpPQl4<6^FaKBX%;p zdAx4IsHsVRNg@9DI?4|5ttWPvez31i>l9Ag zad?7>tlNg;#io(+0~hyxqU@n>ZYa8LXY4H(F`CEcjHxCL$49zekN4snWZ3p?j0HGhbhLiHOLx3)s#E(;nQm!=>E+@n2p)hBD_Bd# zH7GA|6;QTb$s&Ve_K7!>nzkV|(K+{{#PYmv;Ij!;kF{}?q2j|=MF(SOjHc7|Gpj@s zUr&w}Ip^Ye5%2lztFPP-`CE>v<*rvJg*m(yQ?FfeRjv4SMm_i;)t-cfftd?F9zX?; znQCWwMgH?x5^jfn<=pbNqD6QO*1B5 zyDp>V+W0K*%3phK7cyMm=2b8p=p|_eNes9`+H62=kbZT{n=e3Uf?y?;$8by#H!PXHpeAk^ZLF<9;ydOf z>YL9OC2nARQm=BTi|!P#U8^38z(uY(fwC3nN|ZmOR|twxG+^0L{*YuUwzcEGySt-Q z8XkB|<5_h-aMR`x>`-qWD@t~;b%Wz`>$8R_zX}QxYdP({9{)UIN2imR>&3A+ZaKeL zD4UK{a*OG?yK9>5dJN?z9)Z`NYT?vo3CkQWJ}#*1ZB#LGc0As-L=!?1F9%=5Afhk`*P z%ao~Yal}L54n?OwrNDDEd~=_e(}rUWmSdwu%1poI<$@1%6G!nttwTQ%m6&6?QA!*iF! zwB4$E+QdgVVdJrr$zi@zzU24JS9Lc6x5REN##w}ht1-^GrMjlhX098MH6|3aaYIhK zyw>OV)t6NmHSn92K$>mso`iF?i@lG>2PNfFP4MCpCpDmUa`Q-hCdd3Q zUf{;>ilIr(SD^^^*l<2~`}mfVdF7s|Im~COuF>bq1OZ^sX&dJCspyl8IbI)r+Z=e+ zHT0KUtjZR@^WY@Nnm31OcQ~c$u>8=3-BQVM$#f;i_H+k$>HU~YO(s{1uj?_hTx)I2xoXGm)XQ{o*Fgr_ zY29=G$Z0)2NweXdGoc~J6nocxcgMSMRUwz1G5-bf@r11JZu?Yj0^#dE+hcd#k>&An zeQmj_mWhuKtqb}+66kgY;{^^WwlzPw`*L~5qXq9%E6vGjFz8j{nXG)6d}V($@m9J| z^b5*&8%Dbo>NVCo=!W`6+&h=g0Q=$`h29lv%~nlN9*z_s+_1;kroXMOf6B1uE|r2O z0o)8#GUg$50a$Z3oyf1%)5>KS2yT(mGjhxm%~rtE{Mw@5D+2Rlln&kZps&B|sJ=ej zS&v==UrbmYL{WQ5dbV>$(LAhGN+-Yjsv2CF{oJ1f+(;gD6^ULs!mFD_wcK`n?(e?z zE!(QAn|eHfJO1-1w8$#wlzBRr!^B-*3J_ZHn36kvE%Sl+t0w~VeM}YXmt#b8 zKbl=Gc7uW?x@mJf$X*8B>M2gxdtO$Vp0Tp@i~&aoVn5J}WNY?xHhSH@TglaN?El!{ z_joIhy;yA=dA!&@Q!e)1e0ds?ly-j4a=#5bk9%6w^B^4 zP7{ui;v4SQKMf}u@eL!it}}sb$r3bp0_g5!FY({~6oWwdofZ=tcLuszDaBmAP+6>~ zvz*EApZHA)zPfBA|s8_9~*|R`BD%DOJZbqQ?`= z3p?%0`d~?Zv6?9xfuh?0Sy8^e9=eM5U7gD&27!xp&nLr6HiqcK$_&*0`ZY05A&+h> zY;?d##bs5lZ;ln}8J6sq#YnfOv@3AdWk#_%m7NO076JU6;%wMeyms1I%lK%LNBQ?*=Y z(TH*X(QW*UNVCzbN|UxxfztE3Db@Nq>(&O_ONIaXriR1x+RODLd3OsdP*LnWJ1cmB zvSNg5h3Uqo4TER1D>SE)-_4|ufs&GP9OL*fapH$+1@ZAmD7Z z67za$Ag~mrTm}hTl)G;oTR`drbDA9hlcwW|=aGDCMGj|Zg4_B$*jnFS_KAV>Wv3Qr)z+0<5R+>aNx7&%mte{!E!L|u#=}}fj`4~) zIR(Q0cW)XgPTHIgxoC%LCfc^v+_hfp-`CizdH~hD&w`7x&7SN!g$AMl8{UblZ5Y10mZ|j7@QIB<_2XgS#%SF4rA3pi#{|ZE`XK zur{cMQDPOmX9au|e7G-xc;4SMcx5+d?THrTW*x`oud&&q1O=ey2(N#VY`Ew`{#oAx z*bx2dZ#%O$#tk_PL`af=IsE~N5v(e)T{_1tcjrWJ9&z1U!iVJTG&8JSCZj2DlDBT=HqWiz^NCxuK;8p$ zP~gD$0I&!!WO^5ZMx0zj+Rvg&8)bjeqU-a3>g;scwVhjo-VN{?*i4sTEzg3|IISTk z0w@v!ZcEuH8IM=4+yH%rOX0KAE}8wq*()dViUGPxF^I{;n!ENII7!?Ut$o3DyeZXx zMg`0s+kw3PG3_QFLfj0t9 zbXMeD1m;-}Mexfyc3o$qlGG7++A3+sUUmsAy&Az6W53y8=wko6dsKmLocgR)+J-*F z{_Sfx**mXQ&#e7G)xKl+;6>NrV&F|&<@7@v=~K`44)0->j2CmnZQe~YaKN+uhD3-P zT=fJ!PwW~HcsL)a0A@KN?x7^XhUGY}Yp(${bURAm@0gohd>zXt4@Z%`freV94_(G< ztuIczPStj;0?t9iK~1m)dtP)Z<(I;r>?g2Y^*qOadi#T#fCEhrlH2MhQKm#MUxLBI z$t7Nj0@(-sR>LsENNy7=P+*%ovN2_ALVI*3$88L1!lza=**wSV1$fLw09;<)?Q-rc ztmD+20KuA9n}_btK+HvUSfu<4!=fB+!z-H0&2n1swLRk)jPa7|u`_4W#F{U@?(_tF zU=!rxQkpe`w{+XGG`Ba76nezO|{$Qy+oZM?? zXFmDi6pL^de{`HlxyX$~_78jzSk1Dw_0dmTRMixO_i13hWHF$1e%>i&j z?uC$z#U?bx}(hX&80G{rJq_D{g*Imxd+; zSA7Che&@?1mf6rvXJ!np5ww^3C7PhTtX|^M)O?#v4(sb5E4yGO!6y@_BAsT3Pg@oU za@E2Iyof)&WNA&A7xNe-T$V$o5Vcj8^TUm#47e{p4j*W)RAK_WV*5!;LqkAD7IbRV zQqQPeJzAHX-&u>IB5WqWV8kzXVsUAawbCXt-gG4lM-}`G;`++XZ)siq2L(yJNi@;; zMGk=7sE4gjftNA(Od3CExWnIWIXEmMpEGjPthN@)6Sql1*RHzkqm!gmY8MV{W(-ON z%70unSZ#4V=MY7kb;-BC45|`(!xMS4$)LG&BwE70+pDVUeb_ql>C>)Wmky|O9OZ&{ zCoZI)niZvMds#OG#72@+9eTd!7KGLDT;}1VwyEW#L84f>Wl_e`mzbX9Gp^};Y zX-(gC_h`7;jKLR{48wyJC=|kcpK~7K0J_;;UOeO#Azp3TNUjKToZ{`I-+!O!_{9&-v-QlD^2PK7&qGL-d0r_5TffOgzg^pfvZ z)6skxc1pl4girEbv1&9ZheUQm`#n+31bXu>Bj-4aJ0{q4GXY&zC7U)Z-hq$P_a`dE zwM)cuX3q?N$zcX{Vb`dx!mmC2cH^xTJWf{gG*mK~Exy)i|j` z>-tuS{uB3%cxRyjDPL;j`O?lgen?SIk#><+bStf-fB!+k|U^6v8^LgFzLAzS}9CrQ$yY26QAwnUP|* ziJ|vsL|JA90##TsLdH!K?}TiEk9RzdMA5kyt@qSEr1w>=(phmH>ZM0>^c$cUe}!p# z%EIsbzS#*p4R%jMJ&nzIsOZc5txOVYdJ^1b;NL zxv?pZbI)WKCUa9Zh^BTW_K>G>yqkAT@cGj1sU20ksy-JP{B0}JLZkrro=(ng zE>Jj6z{ia5)B@zYnrs7@W$F+i7@N6XG?WMA3B#^r)W1i-sP`m$>4Zd0ci(EGB zadFibZM8?Gp!1#tguW-U7T^hhg|H${HSw|yAxQwnjiNQ2k9fe8Wq3E~G$%;BEi|~_QV59 z0zH~l^nu$KiiYGa!_4c9Jy{%*r0zi@>cW9o;J|_YU`LOKEXQCd#PrWCcHWwUDF6;2p(=AQw zComee!g*mBm;_zb%I4U|C33%~QR(3eag2?B6|$&l`!Hd0&pZ?Ss8aE1>?F{>{B3ub zvY4JNc3uKEygultAyvyGnb(^qM}7Q@seU+Ks*(NB0R{b+0@x_`g2vHOvK(i|^{>^f zh)86p!|$nTm_uhD73MGADIwXmY?BrBPN#{#$daxqn)#e#x9X{PRzV!=aPAkOuKH3A;BJ6Tqb#aX>hCnLpNVfo!d1gZ+`M^0huv3OC==QCgPO zIm)5w!xF$%TVr?b%X%=a!chb-ZAw<>^x+DzrRB}3NAMwofKYYV%-O*+U|Toru$(Ym zBd2{Hb>`u66Gsq)84;IZxnaetO*Q(W&~bV~4(n!P_XuMPmLWVVN>O35U_fMqyL&GC zbSop3EpEen!_RW%mRrv^irtN*(!Ct|>DOe0tHmjHIHyo*>xrpTHeOkIHpi_6KI{Ch z&&{yWn?1}&?xn$Iezr|_##Obak&;Qbg*lYr^fzHah=` zR)@?hk~Zt44?6i#TSlRYZ)(=o7Ubgf3W#VrJRrx2Qsa(@SUXvum!C{v9#Obix$>$% z9#YCPay?mrd+L0&V8fUVO;5j^cU`|6CLSh`diUn$!-qE2KBS7h&Z}d-d5z~hZfJDQnI6Dt{+tRKOd#mS|>#twGa=`|rehuGdPUNcNIgsMS&mVQ)Y+~V-@GKA9HA)SJa9DVS zMVm!m)e%&rFPe{HPYAgS&bcoQ?wV-HPpOo-_@Z(o8q(7Aw}^S@bZfBY8;lO> zGa)if^guqpu!NXC2@LELuW_d%`vC^c*>)%rXVD0~B4_N=xNi1@1GDoBIZ#rPreqaH zNJMn<>9_f)D!S|LqWBEXbt;(eHs*z49g|#qCOo)0wp(StnIRr!DdSnUwaoU*?vrdl z&>QE13s~XM4!Mr+4sDm>^{=zC9+oI`z3$votzwWMf$02u-IF{yv*X8(<4bK;SV`WG z3g#v-Sr_A=Z7eyr;Rb(~a9vpAgg`37U~&eS4m zW(ow+y=o|$-zdHsKP^9UZnj0d(hl*&*NF^g<@amVnQz0$r~g zG>|J>j`G6v9KUqaVQ8sR z1r5pNm7$T0O$A^6K-vcb2`WsSvW;3Cwjq+T4lhSIe*Yx&2?4=z;}YWn9-2_22me5K zyOuPuU>ygVCc}UnappHDO{PKG9=COjh}83afF_{sNp%%l6ZE9+aOL7-UqJOnwZMXq z9KVg4WJu`(an_l?Lm(?frGg1FF+W@7=lvqt+tV*YE^~97T*f(p4!7sHD}D_Yb3}&c zc>@D{+eudSxrunCrj84vj#JWdqVVe~UEPRi(RkbO?Bwq3D;vw;h(~5AzMnMjnPNQ0 zDj&z+$|XT{KrHP~d0u)bzyeApj3b{VAC^>{7t{~aiw)uNkGsX{yd+suXP6H?D;EY@G(&q3lskO*Qyb%_ zXp!;gohB||(fRgheni2@%FFkpwPH;iS>Q*=7!rNiRLgPOQtxT@c)fZzQ0MA6YyxE2 zzI_vj#<%f|mdmR61FVsLl5%+5aGNl zZ_j&D8Lyo5XVY{!^rw#|2^>VNsfJNNwlO&CsKrf!K?gF{a>x7p_)Nt8#>?dMz)#Fd zC%5x&8Hq_Fdrh6prldZ;>LF}n8f819OICaLW2VseaD3O@ow_V1r-;UZSI$M>ccuYdl8Oz96$d6|4ChGS!$&Q1=s zT(F=>ihA)dr5>J^o=~Bf=P+Vd3$ShL3ih(g)oJtK5oSX|^2RFFm#X#){Zi~nP0}zE z(ChrxKRBD8hQ^hSWP%+ozYsl7pq()gT?rYX!Kaezyb*eEXZcD(zzSb`Wn!+YbVjSC z3vDHBOsmA@|Bq}`+Cfc@R zG93;OOLGUN5BME+_!+m~V|{LJHoxkyy})l(ZM`2Awp@Z(*RG;rGXeIU1pYR=TdU1GwGK*% zprgekMBd#A*NsI>O&m_jPx`V49c>^X8n{9*Y8v+tCw;@NaIkt2W1q3^Il-||T`rWn z=jnYr4<9p>wK9d!*e}-t!e^m}O?O-bdfql(%K&Dr9m3V3c6hD6+3Hnf4|jprVN zL{DOOpKGhodSqC_H!nt2MbC~|z1lMk$vW$L-a&IY;^Ylvy}QqaVXvwy_(J~OY!ugS zb>(>WGWQq4^{<^$gcZ!)krk1|0$Y^$*JB7oG2x~Zs>m|$q4qe0MUEaE)wLJzcs|ai z3(xb6zkkike|!07=!xTqOgzdmzb155-X2Nzg{-sN_`4VIzRb>Ps#2(_n-AAZ+sS4( zPLxOuT%Nt6Y2-fww8~}#M+UU^YUvDvkoP-u zwM3n#ghV{yF${;tY-qkGXk$UCz`VMF*DZNuYQtiQ38mMnA`wn{Jq$>G@%*1?wxXk7UCXmPtAx$qcjvUO2 zi&_=*CPa(yONtFO0U`fj6-Gwnu&(8myQ_nno@AG!PUf`WCx&WI5#+p|~0otc)|W*+z2HffA@?j^aZlo276fC_QG(H%&Jk zyG#?vc+s5vgQW#>2~T=lhhOF-%4ikDM+Jv44-;!-!*?PK@{si=;J!Pxa#fa$(zA~OAD?}hrF19^Gm)ebT^|H_@)h>q+y1{qREbi~WXgxT@iQ)Or@FC4goFd@WcR*Z0ot!kq+~tx6GB zRCC4i8WHTW*>bsUv(Wfr!6Y*`V=T)M-kYZD82uoU0WygohliG;xNl#-Zrl#79INk; z_)ieLa^k92G~eOo(z#$u^|rs?uIcCSf(I@ftKmNlH<~)63_JM7w2;8w4y$>Di{i%I zW<}Om&yu>4>@v z=fayv@Ox;b+j-E!*lv?-9N`az6XvZnZcDC(?;#84RSG&)V@byT9xqc*9c;SYQGsIq z!7m@5Tn6Qc$ODaWjroinb*ecl89{8-3%sK5oC_lvp|_M-=6HwoEA1T`IQ2R&=xi3y zv>IOWy25DI7Xp4bzn!lfKk=|Rk?af#8gE&^0k;xuddt(Ht{2~26Eig-7K7V-ppeib zO*n#+@vhVKA~J?)WJ4#Eg$1;r;EZ^_QG^kc3C>lwY~%^L>BM5zzaf$Sl?cpMWEG~h z$WUDchOVVbeeCWba#zZg3I2;T#hsoX3cMg^nD>nLwp*{a6U2CA&6M(bYJ?v=PTE;B z)Tw=8fybAgOy$m@|3_+=nM^VE%vRTWmi`(Y-fYcSu znaqO5xTRT_ocFMlE#gk;hy35zX9`&u295X5!=!7s#|ir9V~Sek4zO!0Li|GCF0u7y zyVKj`9XHRStn`dtq>9*^=SrVpy-_A}4T)zS4brDWX;FsaE*CiwnP-#mG?4BJ7*VJU z8o3y{Q2wrl_`oax|WYhBaw3wf*78E7`Sm}X51O;7n2Xd z@!Xcd(_V`j{;6|84sU>4spxO$qR=KHb5T&LYQbsUPbthdns7VwjGACgRl47>of!IX zUmGPCQ57A3M~n{RJ%4**eCTs%@p~CnlphWlp+(=~FAdKBMobDVQBm}@S*_Y?m;K$I ze~N{IFypq!^f_h6e#eDFoB6ddwG2{L@_ShrGYpGoO?XPDJG~yvkfVsbNcCk>8ETS}7*djwjIIvnYCKQcXPRC1aW++p_Wq5M7TUpjbFFaeT)CpV27E{C z1kkQrV`?}CG_)YLPXHseEG&NARfpK6oggnxZ~cUnVn}x&$Mh}d|ZX#xP30viQPD^lic_Ig**g~vsmzCk%MDD5PY=# z0sAc%>9MRjhD|`#03Z4z_r{@M_O$KHI%tl%rNlt zzdA48M-t16!oE2;w6!{3cE%$ow_VV7O+M~;dQ>UXP;O~|s#9tAa+i?-nB@AN850(L z9l`e>q^z{KBFbdhhJVjB-0Xa8rOo5&G;!Vp%rdr)F+XP90f5?Fk-T=ak~QA_+dCj= z{syWm#AGXz5-XgeXk2=}Z;v5z9aG6k_Aqp+rFQXb;o;%))#8yPM4?pGg;)u4tlX;( zN3TFyXhhx^{+~bkO*qlCoab^f^JCOR8>_mj?SluEvW>pn3x)CqRbEefWniL zY+#_bkh$EN8W*GQzmoO;x{MFAzVuHI7U-{UMz^ zJm0Pj7;Jo2_Zd!14<9Du6DUB`7g`xkkdT!uJ&vm=Of!n?UIUP%`-8BC2hrno6EnbJ zigjhTa|#~wO&^?@-WS*G2|Eu3gAW2TOE)>Ubd5L?|MD&bK9*~9&)vF(_#&z)b}lxy z8MGccP1Et4cdU#;WjN5F7YvOS+IBzDp%ktFjKvBQRL-vTX6MW@wWjGGDrH+}nhiE> z*2_r&)&i|hP0h)Qy&r|`EXXn6*uy~PR+t9p8k~lvwU=v9Y=iu>$G5f&%Af~%Emkw+ z&?)mRwG&gazIN0&+9CwVRf7<6n7+Pv)x@Y0^ac0|XrfX-fZ>s0WnzAFUz4TqVfQ?YAz< z9qr76%5_hVkcswNmzPm?Q!dO7^UR|ve;MGi=3{tA7}j`xF`J*De&57uW1)Yp?g2eev+o#e=iGr1M_+1)qxAtM*qvx zgZ?y*hBl?zWx%=Z_cBazp$Jd0RYvyTsc?Cvmm}#GJN)3)(hdn5j9r z-rvGgPMFE2; zt;gGXq&dTCwHl(tXw$$`_fAvRNlm~BS<@b>A?|kEuLMZQuu%sz&>3n6xlpic{??W} zH$-Mk?e{oW5krV7We%Sg!}k}PMc$sQ8C!?e2=Ed|wET&d64q%0R}dA!`-sk#=%pv_ z`Lz*VW|Q}59`mfb$@HN z5S$1GbOyr2aQuP8BlH0~ZcAs{RS5VeHWbFM$n5v!Pi$L->0eX#l63O+cTW+cfP=ab z<%U_}AEr&xduSQ8H@uV2|KG*`bDsV`HHrozR&LE`O20-bllI!o#ft zUVTUbaf{9=+`~-*qMxp7zjuJ%3X^tlB8cGz+ewPft%Lhl-=#aAuHk*+e4CE^M$vTj z;#MOc$JHgl+C2ScsCu;Y7z5$+%7{|OOdpi=mP7#QhqhP(x46E&HMyQ>pxuEE-XSDhsyDU_$!xJw^+m_ey8o~C<$%1Nx6HHXne?*c6J z0i$#CF})qV+3lChykUW1*)7O^o*XeKL7>ddEfd1d*&*^GMA~y#8u4^iG0k#H(Py-_ zZ3nSeOj47N?_{44VEtj*Yo4PzJtkXN*=$cH9 zBF*SSb&?gm9$O0g!}>Wa9ur+G(@g)YVOiVilah!N4B+Pkj* z7h7im)z-Ie`ywqArxf?%ZbgH%XbZ*Np+JG+?xZ-yDeh2;6nA&`6eupip+E>4BoJPD z&VB#;-aU7W4ar~(lD&4a*V_C0=5NjdthAaogbAOCq93c;GBYaEn=c?9YR;5X|6=Q# z!r?w!dA%rrF&w8=y+v6hvhiOF@k=QbJS3>0beoOGU|0GL4~l!ejkr}tm6;iS`~sfE>L!(D*HgppdPyT5{d zCRq0t(v-EdV&D0xTNdJb&^QnUZ87#((d&_0EiEn4t0v8YWloCzUIsHMe-RVRTX0s? zGZ5*YHhLwd*P{Jzj#7~lrHhketZ9Gy)28RO0hcX*5#(8!ggN1t-RF{(ryoKOnDH zOo|Z}=nmqeSEKl)rYP%v;^bgAm=nFN%WXlcMe*SLO#(f#oM{vsW z*iXr9eBSNP|K!yQlSk$~jQs<^{lg|^atgdz_vLl^foqfbHZy!D33QBg&&wE2rT5BS)%#;Sb)i!uvK=Aq{;nNmM8Z z!s9|y7-y#dCCyf~5#9hftoMzYf4^>HPf%3UNO(*>Jw7y##EI7xF)>ZU_edA@BY)g& zM(BHv(BoG6HkQGL^MHU?v6QqLWfI601#x#o3%|>PRxFT9R2s~&lY|}ZsHrgJg!md5 zWOgEk+C3M|Cyx(w6ha?P=JwoWT9T;lV690^}6 zdX;_uu)XTH)^?@Rr5-$6pmUv%2Kn+}mlB1k=k(RVg(LPDl-2xWx2$1!V4&rUi-Dnk z*Bevo^akgLm8HR9lY6|qx!@cUehq2OiK)>K@Lty`4DlXyv$ozo3%_SH`&kZaE& z8yt;QgI$l@&;man>a<@}nG0E-R(fhg5J8YiXLrS44-64ot`~8|?CeY53?*6vNJny4 zY-4U9&7!awrRt?x2LlUoUF|F}2xW7{(Q*CM8&*rvm6)?*K_gWs*>GyN%fu#$p0)|G z^KT_8{NZGjs4I2V0nEZG;iZ`Sx_wK+-EE2={X9K|?%rQ=m+bkQ0hm(|b}P3&X*rj} zk}`DJl2lV&a=0T&K0F;aQOY6NN{Wi^CHW;1e0;rCoiIf}y)MXjuP~5Yp?&gX8F!Gj z8?O;xmEbd=xz_Pf^4@qV)j}Ua>C|Ci+@6g-Si9ut8|3wYmlo;=G;VEP0)(Gu3@uG? zQrL7lJ#=S8*o}T0ge@}QFLqz-Ed>XT>tk;N-TX*WM`Oq@@*8afk{yQiKu|r`BkcS2 za)BFx$4{eB??;oT@I2+wsR=lO%c$nbwNo-8z z)$VkllhgyU^c(4q>W@Ii$5==`-j{2Cv46ae<}puL(`Lg?&bg<$@!~}pqaCwRZBm=} z9!%m=5jw$a-|ynA5KvP&2Oy}uyL#GujA|+hUv7IOQ`XXFA!Sesu9m_6h*Na96u{fS+FW zBRhc(lbMa}wfJ3*t_Dr^VU@=1j?Hk24kz1tzmrv91&DZIX|Warnd#8)o@_!9Ufrv* z6Y9u9JM$SF({4W|I*gAU!Mz;La|?MHwi<6Hg&=}g8FtfOQ(rpNzR17VuQ5xk!~nwf z)xuWNIQ2hK3fZ$xBc>!ZD-DSKE)(I9R_o!#NcgiA6pY_Z7&PAK({P_YQ%f=|Ba!a^^_@d_QP0cNv>}%A`<|b9Phk1yH`2+ zE^*)K=jZu54DG&d3BaEQ7Qwe}w>LB^n324ILyu!L+ZTsvRT}a>1v(j;b>Px?dE@Ml z-xAX_TPc-2Jb=2ZCJSKuf_#0^4?kO?z1D=+7r`dpoA3QLZ&{AhEe!OH{IhW>J<}u} zSIMqtf7Akv814A4Qv05spPoEz_Oqzcm8veJd&uWqP`V4+H>d6ZnyTh~>h$%jWy||e zmlt^7i>wLmxIB91^zY0+Yswjd1NXQVE4!aURsWVI>%*`~_kFuI9{L~J7sXG|-ybOt zt3YDy3b8(#e_nujQ}S)mV|K;rgwd0~CF%<{qGCw}gR-q=yK`&}n;q(r{aU@WI2Hr0 z4{gCL=~ev&1)ANN<^q8?gC0Xd!&J89m;vT2B3CwPJ=!bo6f2~tVl1hz;`$rRK2at3rZPxk?U6s4e{C2#!fH|P#`fSIcNbXxlU)x*ZC zf{BsWo;5$nMCJ-v2BG`-{7vZMWp$TF-F{hM+Dc2BX>*ZM+R9rGya_@Dm8z2#=|H@D zz-b>FxGR{`_c7#sljo#Zx#s?pBfr6-ghY>3*~o&g)v>_wu7^Fp=pi!K`!k2*V*%CX zogGWR+d1I^f~s1S1yi)@CzVK%?VM&N)xZ0p`8Bca?KX+{f zPpYHUt}pZ}ZNb|3s))d!e%0fdaqfl?%K2tpb!e=xiFFdQa+6b6ZV*A67A5Zn34m5q zFHQ=f=Z6SOgA5SG)i<%w935`bNuKPUsi~0y2D`kVv&T2vaV{3567sV>he+1ObHPh( zP0UjctVC75QOlYz-gTOBoo(^^Kq1#z9XmdT#uh`Ge#C*Mr`B$Y7Pu5EytWy0EsWFh z3@$gn*gMcJ@ag^_H6c3;Gd5GOynSWlM@N1BlcJ)cm11QCXoZ)D%_igQAs)84t-5@p zn->wREx2QDNC1&$=xtmuGB3%SXV~dl)@wkw`7!o|)N^1Yf|JPYdjxG8==1N=RBPbl zS&1nm)er;F>MEBPt<=yhAl_v=%Zy2^QnxorA;YXw8_nG{^9gj@AJ?bBDw$_WPdXR+ zoc7h|rMw+F%L7E!#OQWs3rrQOH94HYiwt=dF!l zjtiZBEG34@*_U4fzOv#|v`|p{))+hYF1=GR0SZ5G?*9lw^uVvHkMj{OAEXI;2PX5G*4-1_GRU`!r`eHuNez!Au8gKlBhy)H_DSSdH!jQxA?`q z_eu-7$4n@x+RgRv>aO}`aF!*~yFidVM{44JPV#OC+pyKuLxlxT#4;`Z<$U=Xe`8I7O!&C6b1xX4F=ax={5T}pS zqa#gTyK^Xl%aF3mQ5QWkPkAUs4F ze&>g&CH}7FljQYQRyt$1T?!p1~0bU z=U8@0lNkqBD*%yw+GHBk>=E9&y;)GPL3v?ZjR1k-XpNYPJnMKLTk;&z)gud+#G_8cUJXgaAJ$CASBWAY2C2DuF4|-h41qNmZAPp!ySI>s^ z9(+A=_%6n6O#w{hZ$o^^j<5T_7_P^?J-A2cU1TICo_zbst>5Fe$earJ%pVz{=rRB2~z{R?lkqybpmC+=|9JYQ+ZBK#@vdhJ?Nd5W?`5;Nr%Ap+IEI% zfMbr*8m8>Zlm#EAN3MR)g8D<+r`H3Z0e(c!IDI2dfE(&?GZEXigoIh!8%WW`rbhTqdPs z#~RxRwd!YGIwuFcShj(wpmi7da1HV4#Q|w1JA$3%b7GDd*R!nZ?)sxU@Xq|OX4C@$ zPK9juKd*Y^2S4ABjTgTQjV-Pi>d19d9s4|X=W(=PVdDEG`ga>44hf=pASWd86|_@$ znp(zMPkV=K(?4y9ee4_3k3vialbi>c0|XkShDq$mhumFdqrmpEpus3N47v-_jGYLA zp+)sVdB8q((nr#vuLIU)`+qtialO|DgHgoSdS@SLZP8R=NbK;<8D)^QdoU#n>M$xS z?aDjcsoSDdIrseOd1ZE#E^jJ$+YP{bdq2#|52`s7RqnKT`g(1TN6%)#$Sp+qZTn$w z>#!vMix^S)3mQ#e3$ewCp;Pxh&Q`Md^{T*(?Q3E1WJU(L=ls(C3Rg3JlO^j^*dMeYmdytM;ShW%as+utRv?7(j2WhF zlBSEim!tb^|Ep5wn1c-BcP)nNME#EQ9Ik6mLJgn1)icZ*J z{Uhxgb;z$15x~Wz859vO*y1fN=*wc&nJ97sQHE-Uj)H%hU=5?TVVXw&n<_6*ilE!tkcJ=$_dF|r4*><2dI(d6+ypS z51g{}cc~Hk2Vmf%+mXC4>|m!!)4ZytFiANm4L}ltP)57c{}?Q#K2@*w<7Z^E{WMv{ zcdtJ&9nA8=M$S`zJm2-VRdr30UG5_usxPwBBGe#0iq>{$)q(u;uhpAPYRqb}WyG+; zQPw$BIJw0haY}Bg^&02p#ZN;8*t31)y>w{{$w*$^Cx+2Wf%|{CICLvj=qH7&d0ib* zTW*^K47YiFwrk`&yF&l6Qu>IjK)gzQV#Ueei9o<<+i$*KRJaaeog!1&dB*lVNyIm` zw))wmq-(9n-7lVy8E}XW?vr)|G2-z@cK>+}ysX7IJ}AWVf;Wm+gslmBryILZw+=mr zCaFqTgC}p+I~Cp0b|CRm2pk@eenQHb5Nve6;{EK9-ipu+_4p(AksH^u75obQ*|#e5 z)Ag4TSdGgqXfP~y+$-o^Qn!gF+US^?S&IH5XBzE*IN z%6vUUArq67z2gsG!&hM|4T+3}E38{kNp*}`{NVkr5Wv(2hPJ|mD6OkkhELgZpT&D^ zgo^)er|k(^`DfDWH;g7#Udavlxbv?b=ECcvNyNYj^L^A^eJ$Bc7G2B@JX9y+Pclo;`}h}HU(Y&~bkz{_ zaFZANO!mj+kZtW$tg2FN=%ned`hC=b`={^LSLG0P1Xv1)--9|1vi*4C{V3|XbKPLZOHkka zKN7@=PyAIxpdH6E*nBEE(Kq&-4?C941Yzg7M+Mk^{u^vdQt34wj5|~e(NG!pXAG`9 ztv5zSJgG%uIhKWM;rj{O;9q`@Ui>|B)=%3#&^Xqy^K3ceLq3R-hX34-a|nU250I0Q zWt`1jI#oF3MH_`u#766yt}Wa#D4@2b3N*N7?}&^@GXRNYnZO?VbZJk{8geD_6(;&q z-&`#~3>Y8J5PY<6)z`WoVCClclFrovKlls8VgdWPv*U8uzcLP!VQ)hUG$%bk*Q30P ze&dHxJHeh>TP6C*f+wZXq_sfEG7K6Vmd<*+ARj7*i^0+b&PnQyO5X`xQS}j*X>Fz( z2&E^3-b0DJ>P40qjl#1rNCLaDqSz{re^OVZ?fd^*;R1+3K5M-tPuB+tThP6;@>iqjL23 zG0u0yk*s>1zLk~X!boTyjhe#5jY*FrLp|eO6s35sULF^?NZVbGQ_xQS>_wmno+{S3 z)9Djlzt`0{!MQqZR*RlJ2({HUnmeSXk#6Ss##s|jS91Hg?_&Na|@``wR{Rw)~p4HC6FSQp7vD)N+g6 zke1~V{4)61Vi8{pl7i20w{1|BFwXXYtJa>Ui#UcfyLM5BCcyd9d1pdihKw31Dn?y= zJV#IV@E~kcfp!SLcW1%7iO*#i)8fY1Lhs>7cC&v(jd8fY|2@kilA*wGbU0_1{bJa{ zfHvUe3yw~^05jf$->o6kvzZHC-;*YvlACs1$elaICE_#(Tu!U&1v>-gy}@y(3xs3> zluPcMavO||+%K~tXglyP!X6a0zOr#z`wlHG#EIPVl2V9%+_8b<2Q*0D*}TOfa&|d! zTPh+od$G7e-g{H_Dm*?HC7v?nYQ?F2bTV3q-t{y{U8bOc_OOrVk6=mASI|z1-Y0%S zr8g=HuQqbkT*(B}zj%}dJ-^uE>;7oE*AaAdPF^;)OUZeu#os4ebc$@}seWNNNSwZI zFQT2WMFv)Nnm_nXg@}2NICrFcZ;fpOmTGRb0N<0aj~iu*xi7w3U}^e@m=N8`@mk+n z&*sBdmyXFU+ES5WW2kCTOtL^>5T$#T!)euU{MB3L<$lgCAq}|EPGP4dbMMvn(o)TW z)@M-?Qf_(k3eB$XOUV%`s?5LxV=0RFQu_B729M`x2#8Ohu+vg-VA}}WKF)ZFhOAtG zKrZ%F^#Z;1lf0*}?fGFUNKA!apNMndr021%x#5TzYYedqZ=Cq$>9*NHy%LWkh_7vX z%AETJ#H4}?I)zEogPQq*hYAC!RDAO4iAqTAm0!$lo&}Z3&iA07)wOE5S`})&VqTJ? zSNMSoPaigRbxrnhck_6kQJ=h;scMDEhc4xrRq|qp)L;KmXsMiVHyL@>aAF;@_=<@-q!Y9 zg*7reo$oS?WTdQSq^2;`P~@$JO&qBsGzVJBU_dvppHJO~R6b^lpK+d@e!|F&HLk$JWJ?-Rs zUWqn%O&j`KRo(`^@44wy(m%dNhR?+hkj8Xa?0B9*_295e;aeqI&~sG<0tf>EPHNn$ zV%4^i9af+Y-gVras~T80iMaByeNg)a@jDvq>OiRQ(Y2%-Ezc6of}03bD(**bxZQn5 zGH;CuO*zuX4qN zI0+Qke=o|BpMqrDe|#)H6{m^3N-#O&*vM_T=;9i2ssIl;zV4D1U!h<<(mK~Ck~z6; z6H$46JJwQ*gwW~lUwg+;3e%9^01772%=6GTzG(auqWwEWHyMQm;P#h?2OzA5R~ta-%=IWF^sZDbe`7y~7Q=Nw+?3 zTmUy&6gV6Cjy^p}o&B>RVUbNYEIYiI2fUAs{I*Xx*E||l;!4d2rUUw+mMNG$BffSZ?BJ6i@TF4t6ZK zJ-JxY*luj7J~l*T(S8J^VW^8}aJxdJb{XB6YMp7P;^f_7g;hL# z14358a*ZX^#bGfCa6k#_3tD<@QSfUj_acoN-71|1;(?i0!qUpe{E~AWvF=o@f++rG zL|u)$HMu1-w5|V1=DBsmE&v~xD>&?%yE@wDDK_np6^8CC_3G;jCvRr-Ule-JRX+p8 z`)*WgaBFhoeO#&LV?fSPfXKj(8gswv<8MWI%k~M^xq*fK-OXMiT25fTlc#p_`pusG#4g${2TR$d+V|xzsAx=xPpETc>6duJvf~_f z<~AFQLWD^gx$zK6R2g)Z8XQfU=hzRnB{{)g11`jeWvZ(J{xp|#NL}qPoOlHBbX=Ah zo;Y78tgMQRLK5}59s`;CmW6xy)M#_-z{W2I(aeA31+P9Qef4LCdyP3Bd*`TmB#68L z_;x*l^h~j@d!y0kYS;Rg`U8fGNx!rOMhYY=5v7gmg$&Y0EGQt-CrNkFS^^)C$A@tC zp;VHWCwUR#Fs1*@+W?f5lje^1EkQZ+^&pqNH!Ne=?2Fx=b_qC)af1zt!?Vv%$b5F} z;;P$z0n5r(Xm7amf~DQu=-J`P#f<*p?mm9izFEBT3&XUrK&`k;tLpe$5Pt?$XAI}@ zH{d7y-GcBPhi%oLAy6QgTGxw(ZqpsqE%#ldW6^V zlxr$d=4onT<-`fdNyuII&ItRTlP#5*Zk;*!9_Po-63t!g;e@bY!f!yR7aK&HesWp+xD& zG#Q`A(hht|-;$&4Z9a^x6|y05#23Sl8ml0$Gq#hKa;?v;C%Z`et%bn*Q>^tSZb@Av z`~b^|J*ndN&w43on>fzozTr3%>?3?UyY)q8f8HgDmyHDc2<*C6Apfos;4yplcyV6A zOc}mFz^il|?53(veEYe^k=BYV#1?06Y@!~ zoTfC!_1j_r7e8+I&9nJ=;AYHI>>7mieb!@rRZYz~8T)#{I2EeRqq$`5*_`9D(+@Y_c|>K zi+aQapt3?V)M5<25Zp0~xk0PIVSI?1N&hF+*6DpI}3Z&PJ zB`v3}_7>^l3t91Rxt~#Z>q*Ma?-Wd6#!8ZgKvCgZ8`jmv;K$RXSm_t7luj-4Ve#jJ zZQ}_}$cZm9!_ragl`)yvxSEd&*-C@x@=Q}vD;3IW{KMDrw<8w{9fZXmM}@0(O$NMF zd_U4RbHwq%18)W;siQ#Jy7&W(Z5hTfyvE_=Zd@*6dl*Q@@ykP|Q<*mslHj(?9*;VR zLExyemRUZH&)hCDUr_QyTFq(a%<^UWOieFsLMnHXt!X33S{}Inf^i? zd%v_*g3{f@MLb>nRx|BKC0eS+)rp7L&Pf2b)~!}Wgtf*jORoR?)h-JS@RzA6A@-t; zY#SpJ)BeHJ6Gp!q+Fc9=?dtxUA(xo&jgIw>+K3V?xbu4S2K;S5>h2Iv_mp&lew*@8 z`s2cCw=n{Ty$-UHYgRoU&@V^8X;kw|&cHt|8!r&QL#h^nhA)Y%X=Irv?DIETjq4gP zk$IMK+$qe}WEte6v1UczA=F8C_+u%bnH;_P<1OK5*Q#5~cJkvripSRTVKFP0O9qRz zjoC&eg$ge5k;;qHiji!LtHXdyY|$Ib^L@uJ>@m)TG=3-gCCePusx@gQKI1<2tO;=v zd+!({`pY?-a|e}D+kYu~zNs4g6X5GrVkA%xm{jziqreaXio}Ux3ob-nz)P7Uj%VI4 z>SwrDkshXBp6$aXy`;oY7P0!Oh+b?mnWHWhuDpjyCSU3E(y8fNw}>Zs{c?UQBV%3R zV@YzO-~IS!%7-r5v&%7d{wG5MZ`A@baBa4RCZQm&@$7T-iLsan2h&0~I?24M%v-a~ z#(}{>k$9afi?}Zu60{jxrd~u?%%NL$NgeM{XlT>TqUL=i-S5{q5O=)fZ%c{_w)thz zV*IWg$mmS^0{vM68m^9~2^IYW*&sBpA4q~Qncs-kurUq#pk>&HDYmoZwIXxe$WF#( zb#aJFFcM8(l8hy-#&~7F%ntB$+5z7zICh1Ne*L)07_R7xoTsxR{B|M>gwU(CS}8K@ z*|L9L@}J?F3iQ8C+Bef8H6{K{6Xn)erhO*COhtu3@ND9$J6`fGS<|L8!TFC4bREI? zgUb2O873z(esNz=z0UK`DxnhErxs^jyeVsj7hkGdcgp|hwb51NL8-0Jb97C7s7>-e z$iAWqwv^d#7h8PRwRKRO<>a-Xu(bHtR_+|&;l=l)eQaz5cr0wR{XXo=b(Dq8k5p~F zSG^01XMui-fa+^~uoe=7gPLu}w1>2b#;rKESuA`2i0r9AZ#BtpltgdmMZp`W(N;@z7 zPB7CCyWoV6T1PJyJq{OpO@dKjqdTBgfw3R)Tu9s3l@oJKKcXacIsT25;ADQ!<9d~K zKHYw#fT=xT5d#+C*uSgR!D9*(=;tarSh@aq{FogrVllI1#~Ubh^UK1Mc85HzOZy9< zAA!m2e2Yt=1P%6i+tZjSn}x0F3w6#1(2NLkZFvbjJODl+{KCrn!MnS(jFl~9X z<`?lZQ~<@1C8k*A7ytdjX6A2cieWiNo}~>JK?QU1@55N%m3|NaoCe^>HD=c28o4aB za*GQ*eV+KOb}cLBnTY_cUieuJO;=p3T|0Rl$n&)LoXmH*RVRtYDg4+;E)tV#`?v*1 zV5@fG)i_Gl=2h-@$lT44T;kVcuWlqM=`XaM zb9S*kphjVTi&Oc(&~H|qRDu?8;(^2?haF?#leXR`xaVbNc9pjiPN6YnPvnAVl6(5x z_-&~KY6RE_h@Psas((5~Is)T`U|V7A60uk&OT1Rp@H{u7N)cb`Ln705?6&qeG^DHm zHdXStKe(UliGb;~eq4P=qHje`zn~fABAN`73=Ea{~4cYNo3`g3$^qg&qdYXVWg!g#Au{Xo~_7|Y1&rfNb76**xi!tk2q>&}Ng)3}-C+p~VTrR&4ybkniYH zlQ&tG#u-ix)R}7UqUXVO;5nyO_>B%41(57d?`@>+B2R`*9Ozjd5Bu);=FhE;a1c41 z|7ibKFP$zi92f`X%m!F)-u?^{#l4uWsmxA>H#<0I4E%u<4zqUm6*)(zRyO-$=y*Ly zbw4zA+93C|dZlLa8<7PcXhjBs`76V5)tbjBI><`*b&ZRUOUTnXf~VD&*rMl@x+y zZK8Eq)~{+=_jhxTJ&3oZs#P_+&P7YZ`1+i7VqI?;$-aZSCU(5DX)pZ6r37~OVc9-c z&mKQf&R2j@dH5DiF`TZNLl)jJuL{?kLXmV20b#<1$Wup-6Og@~uY42bMY--8n*i*x zqeJ1f-sxXs5E3Wx?HRK}l-F-GpUpUrAY`of`<_0Ewhl18`28pF!2$0(?;Rb>)SI*~ z@mFdk8+CN9g%yP+JF&N`Ch5|~(bqfz9G4FlN6PKsZbjl3F8HECFZYhaq{6RxxTv?K zKZiqfC7`v;H#mNBF8l9&W^ct$i`89r2O@}adbX0iLJjdcy^_Zhe9z-5qJrQrcLZoX zALGGMBN3Av>(?FAp|DjYr#3-|grB>oBEqTrad%yAYT^{4A~N(G)pNDyS73Ej4|p}= z>TC!D+I}ST=;ISujqZd`g0t><0@@C4_PatiG2HOFKc?;v5D%hUeC7y(c%K|~GXwov zRm}qKsq|Q6az$*_{xX4Z^czl+h^;A(0;=!Xr$8}yOpA1|*@4JmIGkX+o7-w47aISOD!s+3Q)1MUT5jPa>X-Cjq#5~@cN zRw1SU)xByP!k8|2WFp-=T|pk`>!^a*5UI3aSrX@aoS0ZKvg(rgcbKZtSFTU3)ET{2KYPWf9Me1}JwfjQy zLil=giUCE|aTo*J^n}PrdXBuK8u3?(_yM(GM{T3YSPII`gIX;qwbTpoVG_JLd@i1M zopTMSt_`_5XQ|n_GS2UqfEe97me`8a4E;LyT#&d*`^!Te8>6<43N+E(_>B#{5giE- z0TyExHMdiJSNZwNWr~VZx0Of9(c`B1ra8>d$iy}6YDXKHExiF=mEz-@D%;9z`cd3& zQ(>$SvB6-r+S8?V$KJDI@iJB{1B|1_ zzteh~r5m1X#}0}!ck@ZCa=cC9ZewI;9|b<))1v)jq5J zt#5qSZ@XN5cSB^x`hJ$s!TF7H=xH3+M48QS9F*h_lvz=<5_}uu|1~fN)a(z$X~O|d zrqNR@sJF;69S>eS{CqB}Hnb`mqU*4)5p}<;LDJ-;JYHi}E`&7h&5VA=B5M_+QvHsn=jc^CnW#a6w1wPzlBQ-6@sqGl=0it+2hOw|-sM;&zn0(g zhWzS_zSzFm?LfyXr=8Vq4TuX5tM|!p)`VbU@A|s)A?rbxkzsEFWz~2%?)3Bg&bd*n zWqHUy5q?PK>7aAdcUap_!)zel&2BCFv7+b^$Dexp(8Dl+<;nZWUjon-;YLTY_FYL&mMD!^R@8U7T1UCRvNE z_aO=Luj0S{_0&Lq^pS7$8G3G?5PMb`8rOcR7uZV^eCA{9Ny`0Gw zZ{{xi%SOwZRU?tVE?wRu#((}NQTj_RLzVeeH$IDofpG^UdB&u}U%UP9ZtW=aJaR|g z!)84Hh2%xfQ^@{bFM$8SAqKsmCrI5oNriKKD3f(eR{0l=|CA}g)G_D2iS$w&iAyP> z1>I{9p3NyEZIE~f%yg%!0nnq-R#+Ik)DFoGKN0f?1iA74tU=Nji#8UY)H^X4$qN6T zSOh`eSb9#^IY$oC(+?72zYcnylnhAI^VwRkvGgFuud&=99;JN{ef<-Ow*{WFNJwW6 zJ-s1A${(Ur+NVqYsjmBP{BiPU)ZafvX|YQ}1MfmUE5^k40`B-HS$qg&<$?b~l15+T z&j|?#tg!JvcPW@n8?XQn)11&&H8xvTYgm(_NTI+CyU@~@@KnOom{8nE*-x}S{RYb< z&NR&^A~P}Sv%#D~LYKdD9wZnTR<3-mu4jA|=Bnyc=qTk|8ZJ4NK440iV(-1Y`-8un zm7=hnv!B)Uqn+yo=G!lq|8o9Feu6T#Rl%9(fK(;z?1)6i4Uy7gHM>b6*9^vSnVy+q z4TUccf&y;sMP}R@G!NVdp02@oy>w#*jfZXK(alpX?XFqZJV>a+ie-bqP0*3($Bckzhj!EVf zWivUxVz^o4;CrT*!L^4-t{u4$0nlw4l_U0D>@vZwhU7%;!6fYbE6zqQV(QM$rg!%OYivIpa?p;_w7vqpa;-KIc zr`OkDLMhJw7%0uUyombW@hbDg_D;8s1I*?4|UEn z@M(nDFzeZ1&bc@TfD&4<4u>p|%h6GxPN!GSgqYAM0@D0L4Q)yl*P`ALkv(aO*l)(X z)><}2hebY_ERA8_%)GK>J$@||h767ve~l>Zj!>$)gsKkaBr3!Vlp3)WTng)Py`EYi zYn_Tr-x+9;7VW|=YVkW3-J32eY0XuT{ad?|HG{r5W%ly()nS(S#tGJp66FrjZYyLIO{Cl$bzf;a%rx(sO2jl;QxZe!?Wz$`nKj8j7|Nj4t zRp~F_Thv}@_rI{|B7fQRlhmKR|4c9cJhA%vmrZwX_Ne^lPyak2R6tV718e+(|4$(D z-{}gu z9h0fm>kaWnvD`BM&AR>9n0k<}v&KNU+5N`>++|F2joBUhx$A#^K*#cQpPHR6!;p)E zEYU{P*NJ0@=mLQz_t@BG30+uI9w!wgspb@Uu%5F0hu%1@cMk=yrEjSY>0=79n(TCR zQft@Lkx_?cmm3!}!vnGa?xzLmv2akUU#lw<(TI8iV%Y8@+}jxb`PR*+%=lst+{Co@ zD6XRzRf`u}ruXQHp_j6g1F~~mc!YGsdM-p@ALnpnt~5V7*72EgH$ED#z0#HAZ{F^| zbzBN}H*AxfjH@!8mRJD!x37k)npoCDMV9d@UeKy&6E;Hk9Q0u`UK|c2$*1;YS|80g zMe9tS9{JrPV1G=HDjy0`di}1Ie1TJ9?N@8JI@P}49fmJjpO1Mb8H@@cwuYt_YvF`| zZXe*h0p(?{bbG$G@=CYHbZx-h3b7FB10+j6g$dxbHP7$g)G;STvMrv#(TlKiaw>ZdSNk^Su&V;*EQnktjJOO9OyaAVFIJb5inSxKq+nkC?q8{&Q#NfK6A zz_*VVE#g)kx&t<`eXFdwl^Wgf${i;^v#e|>{}J*Zdu7$fi(KUAUQyyLnzDYqZ$Bz#*yY;#fGHl{oJJeRH2|4Y5@w8vY z-aWY}AN0`qxm@7%wBxVi%0HW&5}o}Z((pv826;)nl40%Me0hljw?ABf^^H5NC@)+K zviBnWeny+aF84@=>H}OCU?jApX-*GM*UTrEum0Xn8u-eGw-?8J0KR-{G7OculQBuN zQA(?9aH;OJ7;dz+l4$p8xWy(}3cY6OKQft85ji;!kKYbYDPPcQm56Eb_UdDq|L((Z zoo_AcF@_sYlH)<#P63r8lv*tCL|r{&z%EO?DllLAFmr>(|Jb@xG(#-W6KR_ zLoHYa?ihFu)=u)Qdyw*5K*`&_3RA`8dnNRifT8w! zz1~xOE$X{Lq36#z3T=1W^540Q+P0Ocso!DihR2=nV{pKaJ}UmUFAAk^i1rbIqt^k@ z7HB7^6WV#5eUa_f&)s$rMuR{4qD?WlrMA(iNPs2vfuJG7Gy616c=#l>9_4LcpxzS8 z*BBp`tPip)Dof4=j)f9OG3#vgnS04afx0{J?zr~`yp(Z;ld1mcx+*kXOv=oVls64T z9)ilzKfZyEEamf@6RYa+$3k(ful&?%WS(^#b3xNoINba5rAzltA$64Anxhw1l^mc&AZ+q^}7~$v5(d22RdB;W6BfrQ2*RMs` zLq(4jiJkEKfi;OBt)E)*8pZFbm?ZJBPx7K46YB}8X1t4&%TmS_KgP?RAFax59VO|9 zm6k8uTS;=>$zkm#Rp|4Ng+-@XZJLf3>0Cv*p3M-`3xX*@JACo7C$3w+$yu?dl-9+U zie7W}YhkJ-7&d+6LT~K+_A=EFT#>PJ9~RB3w1oN67FdIUMLfcA>kQ4O9bWSS2-e|{ z6s)qGAzP^$ii56$OmgRz1|^e*USoH$D_UO@IU;yCXXQ?DbhV#x!mTT*M7(9UhP{%; zh!vH5t6%$qYPU;vs=IZ|zB4H8;e0NLBjvd}D370&SnN7m-Fm-fz+)?<&u=YjoP?SM zbYrUjIJrFed6FYts`({XwyNs4;FHUuPe8podk;M+Rew|#_mBM$nn#e_axnl|QH;oKA?@ht63+PE60 z7ogZxk+#c?k_shy+8l@-_m1}0qC(d$!~0j$5SB^+!({P^X0Ir*&7y-fb#hkCB2Y_I zq7E%DLEOr=I*ExcY6czwZbA%2wuyhth{$n364yW%bn1m8x`U*;z6Qe)*; z#II090-gjR>+xL#;r%7-W)#Wi?<=61u9y%PD3Rt9Kkd86QPr4GMp>G_v;)P zI&ce3%r$*8CevlfZiC)%MMW)%y+ z=awD(l(cW}NaZQ=*8wC|0NmvczptHJzhf-Qr@A@GBRbdDcCs~P>-2BGNk0w>4hxZp z_B}XP{$ngqxD(-8G}!PkLyDCfu13Lx8r}De-VBorHT=uJAKqNj5inU>Vo};kXpUEi zVp{=N&tA&1lIi;W%pUkkq)=HGNbnsdqgO~s z=*YV7U9@APW3yx1wr#7Uj?D@>>ex=jwryJ-ci6G*?qG1mDOT80oM4gH4(RBEB2i{`Gh&oiuuCsSIWfu%6iruD5;kFXD0t z>1}oB0TON1dhyZsAy6>pWKt^7re9C^u0o4sAJ0(G2_G6EnlLi_ROU)&Ki7h_?`S0O z?#l+GcP@x|DN81U-Qjj=kyUJ$j5Vk`PG+p?#Srn43dN+A2E9}Zyjtuou9geuei6Kk zr9d6zwBqbx(x+87elow;1hYGtak)#x+WBf$QYt*|a?1H58MonJOQYE*dEc5qE^|q( zT6oqXy|gqpPGMhiC&=sWT5lFwt*>1=nYOKPpo#4}L!5>!*B=db-PY8+kHHW-cIORs zOdBs!Ob!g^RVnabQS;Go;NQV9wzU@NsS-Z%HTJSRSm*dvmrr%D%;&*~J=%h8Mknxo z0d!!O^OWS!%GQ=qN{u!Q*pt1%;;IXFUt6Sj@VUCK-!8BR=+|pJQ8hTaS)Vnk%a5FhlA-`WVz$Z7U?dhj=$_b{}d1LbJ*(2 z?2$fYv0Z!k!cKo3T2tobR~*wq{pi+rmY3{4r;C;=7U|%=M0~IO@_8Vnw;wNq)G+Zy zThGtrkD=F7Ieg+>JX2Pm*a6oJIr>hdz{~lM+TD?agezOi0Cp69kE;ru-wv5=bp-a* z>xi%$mT$r4B9Z*8i0oGpNDX7oJrlAthhDVkYkdiV@D_NHJ*b^M=3i%Dx>17cLg^q@EzFW3COas@t~(f(dpy&k1N02`nN5z(=_FY=JI+9`n+@m~TjH$w_G61NLD#uRf!g6f zDR2dzG)!fhoXE?gHAISi4y)Yfxu(A~jQr5Fqe#2@#`O^X<;UJO-->fnoh zZNGG#y>6QKn$JR%KHkw7?jGe}L@1j_eH;hodF*rZF`FDpo- zz-Cd#!-G-pj`sW@!TmE9%N9lDZC^oJuHQH-%hf>)1&L;pcdbQMCAzm2pg)$b7x7Td ztN*_L;+}dryWL&;Tenp@E8J*!Zb+K{MO~m!I=DG!99Um6-%y@L1()nK8Ppy2D`d#l zy|OgB^;3p!+A7m%y*5bZbP-(5gb)Y*lEWL~kky(cmC>jGK4_!OmtuAI+uEj=i=`G+ ze`vIxat;?smSsZRHY$$~GP}cHF^jL5JX2dfQt7ypNBqplq4}iFH$DCaA^y51*);y0 zPqni!B60BZF4KD*dDNylKE4~WXq3_<7P43=$)!fjYb~fr*o8|aq6CobAwO1|^&W`1 zAx#7tXkBj(r)V(8u~MCjBF8wW(b6=<%0wsz`5cz!x-*B_lI3$gDjxLeB@%+Cl*`3M z{IGOL=Wspih>up7`c5XL&w-OdWJt#Gf7Y7f;5V%(?f6pSw=R>d>`Ii}0k9W5+jO5c zfj%Pu*iZ^D=%4LNY{=n4YZ)^WF{j53U0=a>I+Sq++s)1?LpIZoq-MBcj0EbEH4ouNI}KVM?K?`vxiCJHP@cOB_)Mk$Jgfvj|Ggj{jY=2^;T<8XyFT37|jP z5h5qS$AmO6k_Bw>9`vzU4b=!Zhp_|em7s&%W?@nuBx?I-`kb~F=MHPKQNh_qYUxmI z)PA~f%*t+WX*@+R1tRqMz^Z!Gi0|j)lg-A8WCc}NrBcEQYxl2<=|J1QSK9>VNb(08 z>yNNhL7wD51cLe}k?VUx72zl0PS;)XWAGV|MiOtJrNMytZ~$a!BHq-*oa(;(!mcNk zBo8pI*+P+X(O&Ec?GJfwFEDiNBUulX1*Z+lnpp^27y1t`^?y05fg_S6vO#>n6!x;P zPJ|tSFDZRpwf>3a{5Bh|#~UAhS#Ht^-y+nl4x8Z61_y!!Qs4y&g-c4H-jFFa1WdqZ z_tnZ!+$5BL(i9T&c#A^iY1wFnY`0=)&Y<@jTR1FMjGd8vV?Vs+_=BOWz%dEx`czQwexawNEMI5U3;Y1L!dE z52>{kLyLMI4}-sng*N8ur7Dx2|1E1ky-}3?tO+~GPe-t1MWa3wq@yv)A<#vshd6>3 z4uvYHW6Q<=gQ;0Co!N3GZW|_h+el%T=gdJ8rTY^PpS0DAS!s=TboP4hm1pVCN?cz1mSl_Uh-sY zz^t@;CSSO^z&E$VRi-SQ6f#wC8V&J(ewR z+BUqW)1NZ2OrT5GVQINrid}|qoN+&n`aHGq2oBlOvw%meCJ{l_pY7>I3SLY)aJIK3 z;sPZ`kInWHi(LF?{f|2uFzbCeeM4NTBFpxHw^5KnI=pZ#WIPS^ zn%J-ub41XIFrsC5AJ7vqct?lf_dX^w6z`MgOAb zH{a!Su{qXu*4e;3H1nj4sI21%2VTr3`c^cd)yV%iz1JtTM-|Qlw(QWK|1*51k{Hcz z6MZn2>GtzUuj`8O+_l1uLQ_A+0R^DYAVtHy9O*!`x);)Iha_Y$it z)U-AcZTl|=5~`2^X7Hg+{G_Ht%mD<+?2~LJ{mQl^Ezjhc!PPjE!J0mlIUbRxlt}SP z369Uq?%>C7q!~)a+JgL&{Nz-+O~H-~RreT!>7)75;1-Cd=z?_yV~B9Tl}&YSm9GQS z`k&Ffjx*9#jeaE|Jyv;d2}~Zy8shIr(0#DY9+ukY@kSR}-J%vr&0MVCjc%?S!xARM zKn0&PQC_wF3+@^LZlZBgHGFlb{i&qoTQrN+&{Me1$?#obBvxL%wArNTnVx+?-gt9; zuYHyROqELonpyDZIZk?M!tyUX!5jCbI}ACs<}H%!ccNEb$POHqFu7 zkWU=Bm8kXs8PeowgpxIwfatl|03wrtL$DW* z5SZkTGKEEn*}}!(P>;x{A0r}gv-d4xE8R?~t`gZkc?vCAcgxh5E?*&xnW@hy8Yq`? zotB+y*q}s7Mb`w@Gb5sYdB7JZNWx+~ogh=I2d>&YwatW8fY%+v?ju^?NRWInyRG=1Qf=z_moBVGe2Hw}F<;8o{^aLVBp$#uoWtvH4=^`Lwh$sAZ zUXcDECO#C)NBqfTuK|Xs4BPi@PWa_jU)Iw^>#|KqI!1`R`24IXuE-w&%zIE1m1--_ z2SfNz0=~Nrf

F`0NEhdWjyOYO7HpIP-nE`%nC>%fXbe6$UKsS&XLBi#p(XiWxTd z!$w0fq}K;A-vf8~$DeLJDCJE)0{zO+#4Y6s-x#aqi$3La@Yu6kB-vyU1c8 z_K;Oe(q}?m+5ssgRVbv>32jKdmU9ul-WJ?nkp#UdY0mi6&9D6w6TcnS%Su%!Ak$FC z5-2n)_}60?I0yxA)!{w+Po}+@0_KCkUq6Dwindb4*n0_X!%wlKpjJU53|1t<2Muu7 zaH6+PRvq)_eQa*B0-T?*6erlzM~}8#KRPCY6y=ZElmIVAsDwZ;v@%~)zIp>PgqJ8j z$A>qlXpP>N1_0px5A2{7tn#Ul0Y0m6yeolx90WDr@gv4)4lfxO@83qJ3X%NppER9pg8ls4!j!Vt`Oi(yHCvr!z}VYZ@Jg7<=B<9M=V+k2 z+yoG1)J?nc7$FDpW3dSpryEvOII-1xD`L}A}az*3x14>@lR4d1eXjkWZiAZOaog!Kg8niscg~B;96b$luPm4 z4#2NmPx;@@-xA4St%iHuH;|)eb{iZ-;x&-K*1J7<5)nhM457YGV3WhHOT#vqqcUIX zmTd7(jBhpKSVA>*!PC!!yt;28Dmg{3j037!&pC^(Q`8$m^UL@tSH7m8Eh8D@>kbTQ z?-s7|!m-vC+TB~s!sFQ2Z7zbZ+qfK`{$+@3Wm3seCf9JR5RokRe!tfg^E%P>!6iH6 zHMSHa-+|ADiq;|AU*s`Sw`(}GVbAd8G5r$9N0=;TIS(96d2k*d$}XZGkiQyfUtyEY z`&yiVZzD)dkPR~DnjYL#89$ebdgE>}hY229P)lVbAFQBeFg+Gr{_+(dd%nKKP5Cd( z?_b^&5g`~hY~@#q?a$H3l)hD$4`D}+UldnuKP{4%%w9fNrB^}zc9FuwFfA$}kGDRh zd``&LrR@B-h~HXIEnbo8t@%)Azj=T6lO-gEzWv!|xp9t`JyDu6wg?W5NnAeBMCtgx zF_s=M-$ogz1`t%g`F59nySS?u7#V*fgD(U*e#iY(l1NE}BJ#VG-B_6af=#K<5gC$Q zGOeVkMg2OcRL^24B;^^f9j0W<#L%okmnv>oDgjcwalTDtb>``AyrMMF)IblFt6M8F)VJRJ4!o{7?($7U&=px`E~FqeV{ zf+z0!C`$l=-(x!Qsq}YfhC__}RjdusE9jrIQ7Aee*#uML> zepbVq0s5OfZe%>Dh?{9>j`sXB%AV9}ON0d1JDh@-FUqMN3#k~gH9a1>Ohax5;0^b4 zf&}+aCx5bri1H+haFlDr)ffF5FP`F42NNNs5~M#i|H@qNJ7tsH+x#SFM(h~-v|g%q zU%r1&G{0*(V?b@YW0>o?i5#DyL1${))nC5y^t=pZCtDQ1Az$f+=Eq(#WaHISZC1jsX}eKRCWuItUM&Qs%6b{cEN9%R zvclgT9Ygcyqy5+sB0JOlEdm1cml(UAOd-~kG0ahpN(lbYStv@;B~1_P`I&0Q6xEI3 zR71DaQEYQtFW@%^{(FszY&@qUyz#u#Z%lb_aK<6-w1_wgK%7E3G_6OhWqe>AVDR~b z0lFfxs&6l+Zdb?YfBtWCNFnp-TN}A8+Crd`qdsj6Bx zG8n)l zA_G^d?*Nv1iMvXZ=_^ZvGpoe*P|)yD-)Y%#VoCU$TV_)>PGT2bb+-qob|u|mUa?-0 zZb^r$>kQ(R=3$@p!z^;NDf^nv=ofW_kJFh4@FsV*JwQEqxgdoy?CItRH zFVKtXNOmP1)Ny-Yd7rp$$KveeB%WQ8D(K&YnXig3Y#NmY^hKd{JugCuA0 z!C6!tsouRI-fAB&ZWkCgtJtv+wQE zx$Vbd5$SBjgL)5v>ZP^+Z}{ndz^I54y`?uSjV4vP|ADOj`;mWpVSxZE7$1ppZ`A!S z==HyyFsKYE?--khWc!=-zol3H)0*M9uxZ;wY-@8iCjaN914vQDzB2a-gmPADO#Gi# z0TUznPGTJ7GifDN5%N!WDGVAq`<;Bzc~m-_Wo>YR&&? zHvWaKe?@h4B9Y*}NfS~8@{n83?^Gt2#n_e{1v4|MJ$>C`a){kf54V$kk#o_n-Gc-7Wj}Y419D-g^Ky-fzDi!I z^vWXF^ zvt%lR9&bPTvcn3=;dFNDFU;}a-#ASDZDH>_haS@1Z{E4={w?uI)P{G}>zY-Dh_9v3QCQz+~{T2z;j(y1F}Ni{XEc97O_+1c%A@N2-BG<%eX7pvu$t;_ zkSdh{%Pq#R>y;PU_xg9Dk92EREFs@T>FxMwj5*B$k7~*NQV&5J0{6jWHugpN(Q~`$ z5Utc+F>$VeyCjboy(vL*#gc1MjrQ-pOOS}oOKrB<>~@~gYu!ty(;lVOEB9e>RC)uC zYN6s~8o$LF#DvTJ8pj9>gP^N?7XN@%*9L!9@4w1T+Y6?`c8;DCU{zO*IPs)$G>I)(XK3^?i<{r4G9+Sv+@y{C z=C}9BxxFN-O_s5&aK3)^CrxiscAFOhJ``7)xld2#DicmCGOssZAgx9m3!V38Xv~~EDQoc{7H`qvwW^gnRa62lmXP#pk zt^=-jDYE!-v33Y}>Q6b2?wnC8k+rn6AjR%bsl5i5m!&i-^(lHLKQQrpnB1`#Z1nL(jqwk$HSU_KU| z?)OzJ|JuL8Pg*Gw&@S;#{SWd0LrH+cRvp_$p5iF>yPz60I;7X}T31P#=A$`x&|~Q&(r_PcZqq(iDQ)-nhQLQ-^^4zQ>3FtYRf*HJ}**KtjjDO zmm}_4k4wYn`<6CdqQz1bgL=!`-*vlhAqd`8_y-ARwJ*+!UK@jLL6@@$J*hGy$aa74 zwNA&z5=#ngV{_(r2iy~wj09zJ=*tNDeGDnHl{ke*x*;02oMbVByU9o`t_8o zhsfOHRtHD8D!vV`xSy|a=+v8+8PCxV{lZE~qEVGCSEp%$NB!}4u}qD(*y#rXP%(#} zhAL2tlAnz+2NR6|?Zb7&gD{ij(g0>4`chy-tk}g9IMd&sN#R1t6WEE9%Sko7Rm$qZ zE%~YCn^oH{{)mn2nU*PddRwMWt)+SBSq-5YOxVZKH-vyacUdnR1bH2DIbbsR)jDvm z`yJ-*VS2+p)^LA*?X=7EuJ;~%W&aQ2X`ZuSa~$3gp@-L>aDUu>Oon!kd>rp5YX-mE z_zXwke$yDAbvNUvG3+XzaT0m3SVM+d2S2Tn=MzP=L{Si;1G|QzzU;s<XoZIxS?ndlA6(TOX)9P+12_Tcw&KIC^%!3RBXK@o zA6~)1O3_DK>&ThADH6eqd>MokGU!1%r185$qPPUiXvS4;;{0`3mO#s0T?T4u=`HdY zDBqfgyz>M~gsl)TGfX9LRl$L&GOmg9dJ1lm33I_{s;<*o7$CeG_HN?c8tfMQRNop z$==gFQ7>y0y80VK$j9=1a?Yzx@m$xtQ)@=4QONPb#B@^`^`!l$-{jcRLE5xe(rs^g zgCz@<{;V7LYb+a~BtX#ZB&u+@bAp9y4IR3`N-oxn1CZsDjd{iAD&6F>n4kv1=RL_S zz+rymD79;z6LXKxNlX4EQeNsMsyk*v~A_Fr@xa1vnp+}3SkZpoiSWn_D8*B4}HneV3vSPwwZW-4PF>5 zE|aD{(?ylzBE0d8Z9er}N4SwK0Mo+lh_OzkWChS!gh1$Vs;Fc8i_Na;(Arc;^oA7bjr3_amQy$jD zj~M#yHiAS-rQfG5mn&sGTb>nx_4!tA{yooOF^mR6bC1bbZ_@_eM@?Znx%F zh(#Q8n?=Q~=r|d$eUiop2-HPgg`%dY+!M8614BYLrl9 zQKcZZB`2Tsb@g;$ib;)|pUr3Xte))E{1j;y^Hlb}BU6Jgxq%Pc$Ol|j*TDfpqN?R+ z{7-cH3&WTmtWQ6!?#PcfxkGqm+I#&8lyy_0(LL=po2CNOeLz*uKApzz>nQppv?5w7 zJx7K9XYfTgm?WuMgJ!fB8*8b&{33!MfS7oFTa00c2f1n*PWz3@nO!!|tSG9F zWQNZK2zYwGEn4;JgHp|c2LrPspUW0?=v|QLH^G*lkoF<_gHX(J*~0h5d%0f1(7!XD zEe+o%_C~`ypR4^A+bX)rbNg+xss5M{$Y*{u3{dGA8VG3&xjS#m`FeP6y^q zeyl0^_!ZkW+Hjm4@ilHg3K5*q`3iA=#s#ABGJbcOy%91SW*V=3w$MCUg9c9}*NgEr zHfBfBLVta!B&)SJE3ut&X&HU*_)#(Xu>y{UMQS z9zOXf&Qbd=JPvB1QT(wslCjs9#}%>#2KT5e1DUm@vn3Ta$>TR(pEc?}2aB{F!|oc9 zFM`^Bx_}jZEXF4Vcuabd-2JlqSe`&mD1{fy=ab0pT3OGrmf15^+ne)VrrbmK__76m zO}h=y@>O?voYH#VkA3bb=cuzdb+?iDrA_wgCIo3USN!`eRtRhC2B2B)#we5UjrD0{ zht*;8@$T&Tn_;Fp)(P&nNyS|imaT{0J%yp)_UMzww z#SYAO4~!&mDcS2hy%HS1TYKONe9r>8u){=N#f`Kq z;G3dc1sy#`fz$`J8kK^w?RxFkg8Ph17$g;;P5UdF*PYiu=cgl;-$80FF@c(t3K^74 z%jKj1#w;p_?rU0ls`aMxw3;GI?7-{}_X>(e*D|!P@X!7Yl&se!ELXo3$HtD+l;7V_t+Kb>vgETr8gzx9zOb^^ z&L%RwK88S1MEl-PdCVI6O!U^kME zGI=9lPd0GKi$fd#9>=4lka?3hEym@rvDkKqcp{JsAoePno|tdYGJ4X#jEg`Y9z8QE zt|+Pz80L-vpdNhl`en{)G4F}X;agj8BpF2jVf1%*emD5hHRmMcy27cz12QO2_szCP zU+tscXZh{DIE}amm3rM+fSXoZztkdNhDIg7mEb4GwV|eiE-xrRW&(B3O@cF%qPflU z8;Nbw9}Gtfn@Fu0Up9IedD7MP&t^l}*LN{`8pSa-_@JxXnmu_aL_a@o>_@^El;!ok zt827gR%v&A6@UGcir~Q*<-nT+Nnt)GiCl@0L#W8thZ7Fx=G87 z^o`#xAMtrdkC2T@F{{6lh;}YWnv?W(o69OQ==t{a#p3pW!RGq2)P*aWZQ$|goXaS& zZqHwJ%Zn9qG$yd4CvHq~3HEj4$%(v3^1i$_h)DTiTnv*8kI5Us;&1!K@8wejtQ}TJ z+t5Op#8Qrj>=%b|IVKHN&fZZIv05T}Dn5@3)ldtdA#l8N>$@yo6qT}7-jt~vpf*ff zq#HlD_JCw>7i*2lx2F^86raISen2v6(XGF9M~wL~T8-}N;hgV2>-`lwP`%c%JHkDv zmtL86{B=ri%%NvWO;h9M@0i<$^}+}FSb=R7C>n^xV(4inUs`&!0=FK-Pw<4NO*d@#eOq~ll=2oZanYJgh6 z=bLTB-CH64@WoDh_4#aFI>ne*I1M$kqkqxY)TzcsW$SU+O3LtyjhTnF$5k2js;^%46ICh>Q0Q-9(`l=K{lLM z^UmsZ?vIH+r54mHi_l;WXhItKXj`O*uL}(@Q9|-xSd8bJgZLB%!C47HsM{G5oU1>sBjCwkKXH^J>}fH_QxjDwX_6D0|@yXIlg>#VtC-MTCoiIH1hVz|yJPCRDwpCgo~eyTF;!ct%PhU4>2 zl9iS4biHd<$z2Gh#A6t@w1ErX4i0rw0bb^EctIPENvo}V9ADUWFqS9>^#@~2joQtf zJ4qR1w0mH8RwmYu;4D~%4+cx!{SOlhjxKCcOqCu-s>exBQD2s3jX}kt#k|+vUAyH6|aL6+2Y>!K3D0PuicxbTur`g^(l zPL0vDTGgdV{?U*e&0a&@t2)uEpR)`LD_C~zr%)isA00orO>O<3!r(8 z<_lrVDN(DH227_g(EWVy_x=RQySUkxY20d9FK!dsz2fF>E--|Y8LXq^#nC`hOe<0g zwUn!d*iHFM1osaVwmSU@*a=gGj+E`Lm|%fkpa@Y{eF#K8*E`M9?X>#pg)2&5b_GI& z+is^vQUrV+Pjth;Q9?{oEGex)$_$44bunLZC6`Xe!Q1)&wDIgQ%Y zhfjuT;buBLVf8t`_KcWE7W^owskNQH8|&|iq*l|Hvib|$- zK?jc~(+a~L70oyq+5+=nQ5$A*XKCTi`n+e|aQ?{9%F|I5NFhNPc%{Y+5CF?O_uF}i zsAm8o#&|D#BxC6U9dC6tMvQuu4{$>6<}rZ=?VN6$GDQ) z&=`NzTg}J+R=McM5Uk#11ADeTsKqCYb2jK}gXBis?G~k#E3H&oy*07h!6+0G#1E;e zrFh&IwkO0raG8IYPp!G-ZeS-IC{L0`Ne zWg72BhCoS^YA>jjL5=FjBXcSOC^%86PPJQ8BJhNe!S3D*Gz6O~DmEtqNv-DxYj<11 zAE4)9Mj8QM;?ug;YrB!F{|FryW%uYdrMfzKg{*)-z8gzkwI|UePJ~qn27G6ax9Q&f z;&DOHD{iFqqU@|91H4_=woU1p5T_XP(6{ zV~H9AMjnZVXH7|%AcafiRY|=fQpfd3$@Ox>n?9J`teg%i6^jF|XmQM`RVVl%RdU6ReR)t*pi_MaYc_H+^Y5!+wSi#s^Cb#Dqv6sc-&nWTdWc8uiog zFrV04nibg9BT9fOUEr|lj<&?Ga9P3-3wGNWwvit>xl~N@nRcytaBUi*;LWr*&j?3$ zZQOEEWNK4R)afqqHp)YAh3}VQaWL&d>ejEp-(>jj+Te1376aK>;DAT#ij{16#N6n( zs+}>BhU2k6QU@c%Zu5F4SV)`&r+uDvTI}tpuQg>dn+YwLF;1r2mc5Wd7QPcVCgr12 z%8A6^6rztX({h2j7|{MM*MJ*H^oxz~i;NV6M^e9IE&ig0v-u*o$U4xxohIcDYiRh^ISwZXuCCD9&#t087(# zmyH6IurjR~`!$pg?WYo2M9;U#4`5&WTYF9mAP?4&p$WSZ?ZKy8j$k4*mVg2Ha8@wx z|Kxs@CTHZSyU*j_i7XOsMv%;aNtcwo#muSNCIzhwBmUZr1dXXfPVm|$XpRr+ueMwm ze<9x2{MUPyr$n5{Ckymcy_9$B`;jjJ*zAO=heJ&Ig*jm+5d63}&ldd!GtI|X<8VWx z&4$76@VT1Ns9Q8TQ2oTkVB6oa1a4Y^V0ndVg=^pu?9k&tqJnSlywC+DnFUrBMgvnL#TgeRHp=k_IgnK4is#uW-Wq2O)yh8{XKZ2W0A_+_$%G9BPt2$jd@-q?HyBYg$o=vvxfRQI8p!Nh>iV};%H-kI zp(n-33?wzA2z_kUX4&tZ%;jM3Ms>1OutbF7*e z@NF}_vktQYdw5DegSbkek-yTY$B+S`QShgdZ;Lfl0Y@M_jA4h>oRLkNF{)5WE_!!# z{}!O;5>cN6cFCQK+G;J}Vq7@Ymoa#6PIvz7J*EAM)B?n!xJWi~CG3Ert8Y+JR^Cn< z5%(Ywsy#c(zhB;^D0kg)((PKMq-U#{I?5JVXRtk(WHntuEjq^%?#WMJQ!j0O)W;y= z^Dj}B=G^{KJKp+d)weaqZ!wQ>4I{;!(-}ie`@>^$1jBq(Ja0F1NuixQ4oa<9-$Gde zyCNt&cKa6RXS9W7*K@Ta0aZo79`DE_Xg!xTB^|ahAGp00T134M(ojo*S{)lq3yQ+i zzsWWctI{Y@fFD{iE~T$vA&Sd95TLIn(=QLg6p`ZW*V%Ymg`Eh4ih;mVvs+Gb>_}o;I>o0sBua&d)fz0lVO_v*YEK)R+jwG?30N zL^1ZTPn260PS(3V+poLW!z26SAD^m4vZcKoM`3y8gOzF>-wXN94jaOIJD+=OKS?8x zZf69OKY))Z%SJvD2E(JX+x*jYCOe`rwk?YcQG0mFp6VC1#L7I=E@z4I>US=i_rp(J zZl~@V$WA}ZXZh-b8qCKC)*Nc193NN?sW3m)nVrrT@}FOCX6A*O_z9$NmAQD;qUu|1 z#f-s*LygJex&+_FALBM!P~tsUWa15nQhPjr{=4av99Hp}b5DKcUs3vU<;T-*Lnxu5 z*zGL`YQcCSA_pY=AZge&Yjm`*V;b==7<8qp6z5ijSy-49{RflLX%>7?H|qW%(@R6_ zFsVW<-5;p-sGyhWRyDsgqUlM7773B6Wwkn)Po`bxPE02S=Z-i#DAOw9@T4#5s$SJ4 zeAoMfokzL;Dt*iEe%49=Vc-P* zb}Ul|=QjuJtg(1v{pji8Tc2oSKJ<~G7-Qf56p36KcgCx4qtYu8y1(q1$^>tLZ3J=c4zr9 z?zSnnU~W#-rCpLig&tgTiOGjF@nS91cY(XwKa^3{_A8_Au(^tiSZ?C_;P~Z?jZh)b zr!PQ?lr3ucUhnpZ^t`H-3(NF*08`qWEyylntGFbuiLEo#>3wJOk#^l0)hf^r6STFN zXWhc?Y#>|b>v^}>rq%4$8`S~t;~F-lOF(ru)&RpQEiK=MXPdu2AW0!C?zof8qu@|^ zIqM9g1Y>)%d6B!{m+)1~Ouej;COAjdvO7pTw>n9SOu7!i!y=7_sk9*^5HSnXLB5iu zrAAw;^=dvYWX8RATu?=Ah942*iwbZR>WHwJNOH{hV6I_YQ|}P`Z@DG7y#ERPL`cm z2ad`v2g<;fk5eA!wTm+|b-VZ)WnBg~*m*MPVBg_Nec?0s`(kn}^BoMT zv_LAx8oyqNd6%oEU^jxVFOF~eb*$RNptSRJsi0*zwCxN0$2t=JMB^BG?Y07C6G^Y% z?e73)?8s4z=y3&QL`cZDa+v*|wR~$}CS;L6 zJ9C)OCt5^V_7;_=4pv7w3O$<2RYMDZZ~FQ;NhS<+41$XWIuk|yglF-kI!^9Bi7(g2 zhl}*xkG9|}bC)bcr&Pl4+LcS}U~r}{qZW)r5$RiW?N z_Ocs7IjuPaAL@0TwhehG(Z#O6dzdFEN`D2#{3^~Qe1Sv$m&jZ@maKjI#+)ZkHjTSO z;n8>;J3qXLuSh`#(cvZlxSy{s04{;>1>M@Gjbw!d2M%UW6sK3_ey=}v3I65Y={5Ui zU$&yhZ2Yfh1PkoX`6|MMuLvTleyy;(rj%OM1Sccg`yoE#DJpBe`c@8BV@C_8=tY(v zdPJ}8N|3d2Bz8ajsWF<5Z{-2Js&sA1%AhcRPP520UZMOf)1ea@O5UT%m>z;6Ms#PE zG?+P}y#AHtR?FiqR?x_i+PamO8B_ zeN8`Xu*Cjoy1~ocS)duF`s}u8MqiuNy|)Tld;w?Xdp4M5I0N?L4<<&OQxWSM)qPp* zBMU8G8PY24Is$s}kR+8p50)fkeRMk&$!iHLUPb0F%T#|lLg#FaFyw*D-l{SWs{6mn z{faf)(JCqYww1IC9Z9QHS)ZJp-U@*{$E`Izno8|%=aup}x;Yp1bES<6Z4yx)gOC4<w>Li-PE642Z5|&d!{UQrNC}&on5N&wOkS>X_bK6nr)czWQ^2pE~ zd)axLGW^R}Ss}*K7e)qnm|_xjD$?WDT|i(9k;lK+vHE!{_|mP4W0F~?AF+W>T5G^| zk6!FtP2&5I*2AVUCzGYm?4O{xhA|$`d9~vbQ)0eT`+j6O{!%+haA?iqqil-GPAg}1 zf9S3d6{!N_za+riEOefPcRuJ95c%|$7Wp*hKN=#~vSj8bu!Az?2BqVR`&B966s(#7 z=QMPs!Hx&J4vE@V&G;xhWUJ$n6E-{Yi?xV$I}bL`brE>*dY1yNgi)&rXi1#h*v?I+$h;p zo`B_fmCjM270C(Dssnx&=4!FCWRFRkv~Z7oIzBk$wbq!k_IV@v#eAFRK?^#a&Y=CL z{fIQBfzy*wL(M#PvfUw6lx%>F5%ejFy^-YFgB0Pq$XJ%tVT^I_9v@PrEWNxN7-20PkH)p&%iWuB8A$>Jv7V%0DaP$=m814+YPLE#e z-=YGcLHSkn!Ay6o%zTeutmxZ_HEqo5;aKx79>d#%z8A}f6G2f8%W^8elmy0>OR*lT z0c6uXKf94dM&I2;PI& zI2^%%t#IB*_m~q4Zz2M(cd0l!Qk%1qCF50DUbt-jQo<6BqH`?Soz%XL`}u9O@YW2> z=%dgZ^*U$G0@VDiL1voHOI|pwAl?%4OQ8zO?ZCE)Y{i@HoUav;7^V$N!+|Cph?40^ z0BMDr!|N6bokdpB`){%dO7LLtpCCp@RR=XQ{PSB|0~u_P(n(uN4e1p(?Ayd|?=1a` z4LJoE@=?|=I1Gy@iPBu$?r-1Nu`VbZg%FLIo;Vga5{I#)Uy88_=8kFv$z?y^AD#Ph z%JF_gVr3IU`193`cD8ZwMS)3QOqoajWi>{W| zEgJ?kX~n)Yza2HUqR)c(ZGEx(`KqYW03YT2D@?qv+||(7B?u(Ow8i>mFZv*&}y!FQiD(E`8!8-+9w@O7IvCzf^J_-|kSPcrzngyePv>;)5RuBONTh2hHk z6&9(AFq=o$@E^Iwpbt^){zZMZQ`VLI=QD)w$U)@hJ2M-Q$^P+-xlj;&c@DbB;{QU{ z6&C^#l%<4Z0UztLHve5?w_JLW&LYO6)50gSZ#0{yTs4H_FqfE zlM|J>?|sz@hVI{q`Mn51y+sqLMGEV&MSfw`&UDPox;BKUs3g&ujY&1#L>LUuEdLLC zZy6M4v$c)lnqUbK+#QBMAV6?;cXxM}!6gvf-6goY4ek~^xI?hP-M)GDyPtROZ}0Q# z{5w^rs%vU$rs{56y?Wi<*LAIg+2X0;v@hlbO#Ea22K$CkvKGa==D}?LYkC1CRKAeOqW@Mm%k%x2Q)zoJih-B^6wB80-6sDHiLo44xg%m@F*Lgj6FkhvNpS&s@vu+mQrSGM zl+I@MT%Jb_Hp>m75TZMCkrYmf$Hgio z!g1(mEoVHA9dK%{rQZdx z@6eM^X4a?Uuviz*qh)yEdsBhGs|Gy?hGR1@k~G;+k2y@VTqF#PF=VZ_I0^%F zS}3b(jYpbc5hW%wmrRZqh*&qr>`fo%91R26u~8su`Y9L2p`-eKOrozeq~1fxmb1Qb zY}uu%4*E?tA=gJ&A(S$o^o<6>nK%@q<$*5Ab*9pmx<9vLeiI6q|(1_cID?Yf0M zbDHGJq_JsP@RG25b-4Wb1xc_+%(T|-L&>0C8AnkQIRxpVk}tpst>>r=q@w7H&#yBW zLr{iuxzsg@In_zL9Na9qjj^*WHVEpo_vu9?A&-|7RUthPFr<>$WPZU}jvhmU8n zD4NL-6vI4qpZR|D^YcSSY5^FNm2~O8xu$(Hjg;wjt~b9+f;GX>_!lcCU`VK&5gY@b zv-T{4f%GmmHX?MuaW9zDPG^9EaC>_n03f!cRAaSw{E@T!gJ*&DAFg;)l5F$9U4vRK zo%(d8Dc)oxzD!`!ytPFj5G0TCeN(SQFI9dwlCHTbu%h2=mw@ujvKZp=vIzs!UvUGKP=p}5Xbf&eT z#{S{Y18DpONSl0~z)yKKspd(Ll_~|noZG>RM{v-c+aW<6zzFgP1+ruZx<21ukQ2H^ z9RGwql~8uq8b32_E@sKP8$_62OWW?I;icT24%%WF~h2?sxSyoWgeYO_8}8goL82QhujNNv?WVhfDYF8mj-s{l~)0W`{!~`3AFb z(P#y2e=A7fG{?S~+2!ZY2JC8d0k5ht&E3&AD|5PMM?9~1KKtfuEuC+{d(*?Gs^33D z&hGKIIHp3HN88(Jf^&dQc8`K<2+&(R)<;@QtrAkkHa z*6B)voo^TUvg6;50TKuz!SzauqC2+g8X<`=!$cqBQaS8>)>vv5mOvz$We39&Lm6hn z_}J(1Lf-dS}n#{+EB|9C2Vbdn4~x>&4&A3kC~E)=_=ce+1Uyh(8v zi#AuQa@1iT<)K(r7BJszul+q5jkzPaW&Q{}QCWKphKZxtM>Od^(d7sYW@+T|6Ta1Wh@m*2AyTth2)FNs^h20MgC0l z#^Owir1asnqV`A%b92Le?ho&UJL!j)JO23@klQH&omPVaEfYyEFYI39*)LfXTsl%3 z`RqX)XYui=ECa3ZuW(Jooz&9q`iTsSL3&7n$z{$Zaa6WS9Hq%LA%)Ur&Polg)Q}z7 zV-mxmr=O&9zu^OoeCa|KDz>`JHoBDe^}O{WrCer9+Z!YMW3nCw+^=I8_4g^8uzr0N zA5`;rvBby%uoxic#pSE2I>a2+5}I8l{IIY=d&8yD@#J*yi$gX!2OsPg8obsVwS2E$_5vNz{O|PbBA!L5c$yybd z&lWlY0{-J^PwgrK{+#j}HwVlbg-;E*jIru&;8hCXEjS4xPEp#JiC3Xnua|T*U#7P3 z4CrNkL5EA`2HtigGHR-98v*#-o;fe8e~!A)!$ zbg8rkf~lsQSDmwkUh?|HDLrQjm44btR>DqcvceJL1_&S64Y%wKQGLE|>L+*IgO?IC zz$=vAr=A!s#&ze=X6_P@wO4)NhY-ddlbN*HI2jN0sPS_k2OUg@GrVh+as{dmG_v5i z!`XQ3^k)wwrK`_BKD?1BT+=p~UrwHXZeG1hUr~UALs-*C4`8yPiY`C(ch`r?+5Qn* zaBM*LMwa-9CVOd216LQJyvg(nrf97{&*)tpt!fM3BXp*&tL9y>i9bf4? z03^8gCkTj#7J>kF%!Vb_&6NblRk zw|mZ|KUhDFt$v$^PmAUBW5<6_)KEOIXZIdCbpby6M^ypj+ zfiT}~{m8kK1^mXR+w2G?T%oK2VV?n)`Z9A#{y@T9%$CFDKAl&R#TJPSHW+q;;Wl~OTw=>+zp8?fKdj;+Xt{YXU4>=-nF2odOPU^~ zX2_bjoVcrC{O&MqcR5|q9!&(0*@GU+X8PJU?|zXWO0DxWotmiBcxP9?CwjUgyI+?Z zN&3?Mky($j;+u)l$#N%dWaP2o5b<@0vECUU?5|=esU7#6D$zkr0R8*hlQrB^Xt5Y# z?r3oy3n8~;(ntP+{G*rwBCF4StM4}LkedgX1j#=iZc9lx7+m9?(CvT1ewS@jOMIkQ z3|lok_~o-1+AMsLKbL&_eH{gdCy)+j8{f8@Du4$7%AXsLQ$gs6rxH@bg$>-5&qRfYc*1}2k^o(UF+bMQ05;-PpQ?ws-UN8n>7}vQ z-!U&cD!OHZ00$TtrrSreO03pKBRj74Z8OmypzAKXO)tU@=&9-{lOyR~-&143Sno^i zcD)35dGxy{NzocFQ@*v~=(J-pGN9?i1)cAWCh}yfjHbyH{P@P;*h8|n;i_0w@Ep>? zOUXQX&O;Y(pYxrH`xA6L@n#G$qVIe2>h;74UtCJZ-j;X{is#6kejFqBjbP;T>BH8c zr#>jBcM&lVimCt27JUfnp8AHu{sg?b$+um|P?`39pE%#>poQE&6dDBh9kO;Da56GL zk`EwSQ+`_chW(MbM&i>as|S9DBpmq-6?AlTI_;m7P0CUM$1oJXIwv!^7$91hGooVa zJgrp5gV*D?t#T&#*Ducuhcm66CR1R7Jit&)R_vgx_6Xt`M=}INjk%?BPcmyho@~3~ zF3|kJsFsoZYbu@rBBbl8b;6+RK{>e1vti zaR$eo!6ZWWZPZvmhuxYOt>3I+Hu6nnVaE3Ug!Uf!KHd5mq5UDa2UtzvPljh=KAAmx zrCD!=HkP9=m;H^2V8LE#52C#Ez;#%O@9h{LmJ8FRDES%kAWr6HBUw^~n+MSA98HzB^dAy(!=YRvZJ93!0;!C1FPG!eiI)-$d-<&qPTaR=`R zc5+-oa(t~Y_y6WRq@i$srogpBC}I6k0U{jXDbfv)ivPCdN5EQ=`^HT5lEk3yS{zxf zUM(4YvQ*P9Di`uPSNf&S?+uzRJd5Ses5G=S?}85U2g=|aC$!G*;ee_g6kK}$8?Ao# z`Q@_#LH`}IKse-vYOXcbcyc`mAW_rB=D?%-HQ}?NP-N2K&8IWN4HF0TNiuoX0oop3 z9tAmipb6L6<`RLD{hZvA>up=hPy`c4pxqcZywd0hC&}GgC`eM|1?7d(Hw! z7#QI~-6fYx)3x!-J^djQL~wDncX4ZIA&{yXhypfN%Z_)eH&7kqob2lNn9TLk+^r-p z4H0s$rvsV-J|ZIT48~{V`V>j-QYR3YrYI@E-3w~ia~j6jd^=>m*zDUw-|FD`h&Y?R zkHLy^o6`!Y&V2?>ZD6=3-XVbPRcMo*&)Z`9g?*=ddvA?!(fEHYgHimOi8?9meVbJP zk6-BPB6csO$oU)Tf=bGe!OR^yMUFbWbkUm`+gJxZo1^rpb>tG@p31d@;|-Ulg5jq6vvHk|@a`79nW3Kshfkn}g7Raj>~8%c^nVW_He#j>n{yK5OLAO2%9kV0UvybhtGg3C`x+{3^=&*kwg z<|+Xt-Z#gOemw?{fWkZb_GLiWUva6~c7^UeK?n9vLLV2Nb`i%>JH6aGX)s=2UuPI;#FG1oZ*y+8n*4fL-PeWk zdfgAIc+2`HvL|qNfFgzlAA(Nf;2vsL`AA)B|b%emB?&A?PG zuT{*|B@#sMi!3&a^Y0xOj|>K(r@~y1C9Is9b%m^dLJep3cF>fb_Kxsx-Vg@2V+_8h0CWS$8$uD-OkP#!-A~uGakDPA2NKTwKGBo7ThYvyclFQK86N;@6d!3} zndnOLXZ^CHLaaW2M8-_2^}6_S$&9lAlQKn|V)?S3pw01Iq%i-ujkBTK#PlL+=6w;+ zF>f5{aR5N>z)M<(-|gb8>D}S}2sw-iHnNU@Z0dvQ?ctivO#I=^eLUH88u`IJ7!5#c(BgM3q)y8^*Y` zUT4j5EAidXMU`lI1OXmaOkB&UNoY*lp;lDnps~38aJxY}+gomJ8@2ChV@h3#g~!%% zAR_~P_h1xW*^3{9Z^`O8Dg!zDxHeAgwewlW+|&YjKU^MlfqY-Qu*drre>jVTf21oE z2!JsRg-~6?iBzLWhpk6yjOCBUb~>r#%T(lPH!?70GGy0ia1B`AIz8<)W$ICb<0eDmds6uA4kVX(B>SPCGkDMA&g3i=KtP1V(gV zR{WVHujcU(U7q5B8uKH6*lMhB!d|!YlDj)y7kh$+QwATnVV_^Xq1d%!!$08i(b`qO(7sROQ6lgj;2(pC=tGkIi9)ekr?%bgGzNYk=5%OjxGRRJ4bKrA`o%ZJ?J}q zQT%jr*K1kzT;-y`QP)cwppF(pL3wB-3%`xQV+j}_m&UKJcev0=4rs?oKxR6sTphp~vPWJDVkU5Jta+!BffEFnax*N-c`3;KJkR5jlx? z$>X}H^4Di=+<_b(CKZjTNq{rCOt$i=j_PJhwpI+6Z$ISD$&0)~5LNx#ZK;4*$!YU; z4X;{EQuy@i1D8Yc=o}TK5Cg_G1ZXDTXFoh;=C4{TUAH=kaX!im0s^uyu z>l`XBDQ(_dA(6 z$tk9LE!r&(&|4l7RH{RAye2SJTXJv>b9YB;`t+)d47OU$zewHQtj~Bh3(D#~`_3Rs zk|qfVVUZ$tx)=R$!BE7+{>YeH7e<>41Ct*X2op#;T1AAbAg~yyLYJTWQP8SnMjJm? z4rV0#>^rg~lBNdMCv!+PTobc(;N9AjU&0;u@C$piM;TuqehDuAN9Z$|v^*3<`5vAv zm~eWy0)0>6v&tp4QNqt^*D!(ig9G)Hf+4-3L*w!1UQDh7c?kad?jse`yn87RvqeUg zW_9K-N)Oh@T6F5AKTmYpe<9t;o`D$*+0(7;5+dT$z2$tf_=V+8Ht36<$0YmYd|b4R zCHV+w7(l6{JxPtbd3F)}o*MM?cGOYrd{v7}>@lwEiaGkVG_UaqPfgO5+-+%@bgk7S zA7M*>n7cBL6^fx&LP5g|_Mj3nkbA!fYXZ0ZGr4lSLL9(lQps6fS!mf2ab+OSvz|p~ zoKGlvBzMVLzobXU?}}3u`Uj+a<2lwd3st$uBb6#E=)1p`nIprw({{wp!dTZ8h(j{X zq8J_#clK1^<>neuKnIW6EjLJ7&6UIrh1(^%dD%~_<>%+`-e2rIzLX|C?HGB!xPP9; zS?X^lkxkTBMe3sH)gCRie!Tc{uoo?1f1xIQ8_&ChY~nzNH47Ns)^&7}3jvqXdspLW z$v=1K^bhrkuCn_QCwm~>5X1Mkyw&i^#jBj>lz<-`3haNr_`Rvfrp;Bxx8gNdY%>7| z*^+S~+P|4?Cw;agEDtnn)FiSwJA=B%#hvCRQzIWe%S2;s2<9d5OQoN2Ds|g#4FXq7 zchDJ6>1pPQ-X=%+j_mwApMQR^ewF9Nr~{N4KyoqWd@-Vg<;F>2Y>&xO9rOCxaWU~V ztwYrfRx8v2)51QVltc(c1@Jrr=C8AMFnXylmR)}+9;mXpCv|ckx{?2M3Ty^Fu?@6|lPhA~X6|@jv z?L4Oj?oh16r{Yoygpg`{lEerwSfjs^tuf_9|N5} z-YEv7ZQ5fK_DrQ3e^I@^zPm|y0-suCGdVnk#zy~QY>+*Y{T|h%txK;Ob0qLr<_UbA$j17KW2VGI&NjtZO&57dlg$Y zY;1ZfJuZEf=Qr%d#3T)g2xYa(LOHOI5L+%Y{c>@2!>;$GCN*D#hR>J+ z8sq%>T4k@O8h_tzOj07W-vuJav~Xj$#uMxC##lbGWG~jvD7hM0zqak1DL4LkVVR^c zaagL+sxNA7*z2)3f0NMNUGztG23a+Li`+PcD2e_R%-Ufac5Q=Yaoc#}-l$m|3%3CzW}7kn zpYPFw?)dK&Vm}RNHueD?p?=X+)MoA&Xlxq>knlaL8m9>dmit|wSrZc{*J}Bi=P*6_ zAvI}W7}b8y2rAlwh9H69$F$2|qqw?_&{AA!iEDBRcsznJn>w$5BAx|?&X@UY_~@z< zcq)1E+?UvFfMzw~YidHCNO7WR*az$4ytr34Ix`?*$=Mvg%{ooovyjVu(AH;BaO z7;PUuBwQBqVDp-bfRVOTP~a-C6mN}Apx-=O@02%_$)Svz>m*+MwBt_| z90hlN7dqhjYLbX#E69*E)tT0BljFbV%WJVYON~A%g1t_n8o>@`zegN%6%vZ?X3c3Q zdmFjjD|eDYG0G^aUVYNkGbX@#e0&sOLLmT zWs`OeY2!x^_%-UH}!rr?&XU{LR z!MQWbehj6V-FXkQr|GI1D*g4yrMMimGe-O?3T2u0iw@CJrR1N5a;O(D^U$87Yjw5e zy0De?n#@c4*UO(DD92&&A0<`PZ|je=NHnD2d2Ah0V&r5}!pq=10k)bb03;;65!F3lTG_mICcuG>f8Q(zUa5EmviN4}Bi4yw!6+BCF8c|ReSyL)zK z9c(>5F~fS}p%Vd$xr3x-IT(>@3|B8D1o+JNe#rlkvgwLh5+TxJl^=2>{3$%@IJBX77J9#axa$fEa}87p`-asZ@BW;jR9U&O0Rd!JA5c&Jdr(d5?^g7p^TZpX{k50o|(#@W>D&age+hS za5@w~PM>7j3k*L$W1~DEZV%GDT(~S)f&mIaWDXYPET7iD?s$<@syQ>e(|C4RYED>G zv#k1MB_){8L_YC?D0h5$D2dE$`Z&bAO8wX6nO;2338#H95=ef+JWEYTRC^we-?Ky? z9GoT;Y4yFH%@DrO|H(vISlAIfRP7bj1J-1FHyBknc$&sF;I)f9PUtWI<;!ln{5g9v zJ&$3#YgH8TmX2!eU)o${;ily}>8gY)+e7>0HQra5`;ODBzZTx&&puC8tLkHhjc0 zz2v=Podw2n*AoOSW4$nkblWo3Bg;okEtUfhztN1bLrmhQ270kmMB6060eroigJ=Y# zPGQ)$D>9^$R-jJ3%j|0b>no9y62ENJcd=&6pDs5o6uJ;mX-y%MQ6DHK&pz$pYFPAk zl7z!hmelj0D*n9E^}!kZ7hoWk7oynG#65I0RMd85OYmd^fl^@*$jQMHO< z=6w}79*dpOm z(e(1}kUo;R7p5=aesY_bAy$|X9fV6%v26<)S>?0cQ`9V|J*S$-=Afq*ZJWDJ^2%FU zGFVZ+isSMVkq-KivDX-F$eOR5S_Hg$s!I1|RjHql*K?FnvsmS+0}W9Yj|EpH&C!F& z-7WQVs`;s8Y3$=Ji`F8k_=MqoP7oPD9AbxRoC4L3(x z?S#SHfH%jVXuH>}ECm|t+UI!$S4k;Npq{AUhUvm#AboJAZo}Nqe^IV1EFgY|1Qx-% zt{WH?SQtsKg!b(E%lk_mfH#+ENSk=s!>@5M*=M>-F}mc~VyRoe({oOv^)9ayE-MRz=i&Trx}URlfRvi!QL82U}#Mc2O9md5a*z~<(`0SZ4Pd~lEQPZ0C) z8q8D`lXjD23V6{bt65aZJ>YSi=a`ONI$tj$)l_uN#_(n5~hfG0k^C2|V zuw~}X^Tblw7wzKuT&8}16+P_;YeVD12-((3pyZT9fm~M_gJfA)b=InF z-5AD9qM;3%rF5!I|5Rn(C(zp5l%oR3c<)`8Y-?R=y0l=4#o1seNakd(n_1@ufPy0P zkrWYBnFr}fiQGvf)LCJ=CCeTS>hW@N+wr=;g^gu9$hWY~J)R%ClAv>lTUOz1a4!tI zZJlbZ=z|s`Z^u+iD7#WKbyR}C?+YJkLOD&R*QFH6wm{@q@AzPamBlkMtC}<)j8m;Pq$wDTW$f5LElZq z+uFttwER-1oL|S$QQLnJWjhb?AT2?8VJX7-vBSEl5a_2i&F-hT* zxRpN5+;#-NcfTg^Pd<{}u0gJ1 zcYj(8vDx;tG5bDZgwhy~VL%`^Eg0P+%2s2wxAFy`YcZMqMV_SODk|d7FeY>A3&uih zdp3P9;$|D1H5Zt}BMP)SzxMuRiyglTwJCrMr&W19EmtZ;(@Ipg?3o}{=DKxFzA^zI z?plvTI(WZU`T?$pNO5LQPIeRin2N{d_em&JJZ9&8Z*FG#VRBkuCywcs@0(AM+N1(A zr)^0li_h{3{#%O<)8_Qx3d$XebPL*TKsP!AX?%7sJg;966|tc}bqoZsA!3rQ=#nU$Ek%9ekG8AQ!A>fEUG293(4(Mpx==>n%S=i6~-LjiTJwW*w{bkdxM zURrF8G)<@jt3!7J*D=#BNl*Q|7hxnX!#@I7jKKuF_#G3mIxc%O49;7|GaEl!$-rP^QmA=+NW1Q{qcM6I(P*!J(p(N>*yV z>o}eWyzeGj7q1-m6~vasyX*xJ;^D8tS>Mydr!Aq|*t;$b4RTIq{>mqtUSCPpLq=k1ck2i6*r#H)LWe$zX zSI*p2Pz>J|PBrYevokkji8VH*VxEcyG}Kp12vvkVG_ip}<`Ii^6twNZSk#O39l{nu zJd8NI7+$|mg1QHcGxna3O228oJnCXK*;(vA=DJzl*R-gdISH?wZ`#1HTu53~s>S$s zpZvZlcMK?oK7y6^H1BXc3r<3a&}Xw4%1i8vnsgElVn+?+fwT5t-!uzbv~b`O)dWx; z1r;H_U&?ZvZCI5*$7jj&h^SxeSX9m3VskC11 zfG6Nq7UotCYA^cjD(6iihL|_fK@THlzFa-S)hgqwEy}XaZVq~g@xkzQRov%I01#9xh7 zS))>Uc9LL=;KHbtpdsNwH`JiDuHkvbfMrM?jL^g99t6|vqyt{Wcx6I?I>`YN1Oxj! zMXe)gdns?v_)8?evlc2ByS29OTVs(zW3`mR)gbhue~hvbm4}ktoRj(aV}JBh1o!Ha zGmU?cDc_E_Tix7Dx(+~@_s&VXyOszB6w~HGaR8EpP^`M8j(^rJ4;~dQqQ6IhZXj<6 zNqfB~fy_oFg#n95mSSJS04m*^#ipYw1Wol!g1hoWj=jnv)gPaHNnb>jjTKo`Rui|g zA8LZBcN89bx~cVhebvkwPUnc9Hf?0u>Eb?zzT+)N%gX07w^wMGbpl8WzRI8(sJ&w* zd)2CL%raHJX*cn{>UNgR`qKVrA0@??(!^^0VkNTmt<00 z3U+!E-vL3^hHHD6o>KHKKbnw_NYKLe9&VxOZync8l*PAapegFMCjGAi`v>^z=d-Ib z{lv3sLQ}=tNsxp;93z|o9EwV-+r#A^{DNiZ40a%Q{Nh@LT4B{ChxIR>m{W{lDQ=p> z<5w!&n3ueC$9am<@+Ee5i&nCRCsnLw9N2+8Hr}sQKUUbmAKlf~V3lX78ZsM`pIZli ztZv%T?U&jCPHs^h6zO{VV4}=gb(!U!NlD13?p2a2?rtR!?I(_uaITXP9e~<1_rd=$Z=k+I<`7&;*RjNNwO*%iL#YOngvWl zmzTg>ceM3#C;p1u*1DYF>L}tw5Yad@u`w_ywLEnC29T_O(6+zb-0qKTtAn<(ykkt` zm;`Z7jeu+upqvCFA#&~sjve+J>sqYSx`Xl_;?n-7$xHeWd7Lk|UMI+R7BL);jVKqJc7G9WbWvx=dmCin(j2x; zAT<|-NkKm0i(_Oc*rw#8>l0HMO*v5e#Xy_EUZ>Lgyjr9y>xaxZVXh>Yd5?$u8x}e} z?qrMpO?Ll}#y1U}V}44d@(h8nDH$FnP0JtOAiJ2u4F=*ARFpY0Gn$=?HV7|et~0n? z1?iT(%9CTqd%+EDPGr#NM--f0qJKn0MDjC!DK;7_rJl4e5k%8HzOlEL4jZI+V{qI= z6klTJ@si`KlhEOnF?(~krVM)B2TC{6X}-={9sUAEvCTS(S>%!J?OhBJC%YFbfzhTthtn0zj}q|qL`%B-u+qx}8~jRJ1+@!{hB%R7?> zXv=Ks2nCW}=O615u_t&_Il4F{vUgQ{5#ktC$P*zu8kqdG3t@G~g|0k@;_vt#sWfn* z_1Vv~8ABENqMn+D3HF$~tAGQ1I zevpurO)7rmFUGmzq$niecT|cd=B>IoPOCeA@}!=9hUjFN?v~V!jtJ`LWKxyrXmUAT zCjNC1VV2}4f`x@;`UjO_Z+O~xCRb4R15Hk1D&_3Dk92Pz!xB3LDhCJgp^$PC00r+3 zY#Gg@_(!eXOjJ-DcaW>Qq@5voiM`P{rADhlhz`F#7`&``+|-_0J5XZ%98IJccz+AQ zm=Aw=L6G_@`qSoh6JUJG9~j&Hl}9ny34&~DN@OSd^D&)8P@Ycz>|u>F%*WN!XVmV) zzoG;v_O|G6VlXXeaI;h6H_`zqd8k9_dh2xwwns3 zq2;RlZ&OhdFc?d=t@9a@lhr#giJxS*QJ6}MtPjj{MEa*K3BWX`Lc!l@dqaD8{j>T1 zQ|mt`D-vp~2U{$3<$p%;|N7j-`H;Km8^H`EFaNh?{#`HR;USp0n|hy!>>wum?+^Uz zJH)>H1D}{cP&~YUxBG8PgpAflG5$GEm7;5u9%=z{IHveB=6h1-z z44K~g%eVe#X&Hc{ZQ0mH?VkOgDfu6}|L3QGA>>co|972q0N5D6+D<$Ehi_Rz{8!Vu8?h96 z)|L(*GdGA-mZ#blURxn?&c6-SKfiZRP&xP*y-pAjOo{zJ%!He~e_;EXA3W0E2KKKJ z?M_ElaA@8y@B5c8_}}OM=7kKW+Z#41)PD{pDYP{;#1E!dqYeFceFk@s!CZM!_lNpd zHGfxJkp@|W)}U{M1pn0y|22p)+1=?w!~FN;)tEunQ{}^v!(WlZf0X~fbN_8P|6iZE ziqP&n9O!M553yAUl%1d7aP0A;wXmd?%al7=c^`&urN0L zqIexR{?oQ=&|t*n`-i_X0jX$z$rBO+kU2Rx8@`O-?p7wzm^#jJB`MeM&gvx<8kP*b zzop;)^M{m1Zb)+Z!Nqc+BptH6R~>THk8>s9?;0AeF4vz(q@<+E;NX*z`h!J&MDSC@ z@LcA;`?JwJ+^uiRdVBxC|8`SK$mcB*7!*V$%`Sd#$5El564=h`i^pmHf3@wN-~%VN zv)I??R&^Ly??T5l2}id!N5qSp(D>cbhfvd%3lmJ-(=(%=l!e$)R9%r>&8rK!Wm;(09Tf3Mm;KVy)QK4OdYJ_~*PWV#ud4QGFAzy4-zVA_A4J7) zwuf%k`Oeg+1>3APP0V8`uGo8{|b506B)GZJ7!3T8z@g7R(> zi~X>Dm;Fb1d30nndK!qJ$LkF0lw`5osmWDxC9Bb{#>fnpsfo^Kl%U1I$k_Phso6IV zJt396i@_H4OAjW2Lnu?I{U(PktP9m5u^RE0~CCFI8( z?&8>eqbT|N9A)!sl4}Qf9{yX6wA$0RKPiL>VODn_tRgaQH<6LzoC!6u1z|{ZIUJ77 zKgO}an5eG$N&mB7GK9HWE#qt6M#%50q}6BzfH3=67jCkwkmc3Nufe=ebC211LZ@E? zOuv!y==f^YYP~)l$39!{t32FhaLw#bsyTsAS{YJx6vn;;1CfreK+oey4GF~^<`r{c zefq6dQRCikM4Hb#-kvS$=y?2FMD<+>ZC^9_h$!yLN%_vor{*b0Pr^a+S>X^NB_1mM zsW-^w?-8kTF^IWlj;5T(KSk>xPeS8)2d1@L8d0-mlRInHQ?u@xMiU}a90~MEWK89d zE>o|zs@OyGGK6s4xqRyyEZi~}pMA5(v#x4Srd$1P9|$|oqi0GCc}rExy@>OOf`n*c z5^>+MUy{5ZA8ha*dnNE#uT3Qt0$#Q7KVs9!XT`@A=%oY)j|~YdLFW-ky&DhsuedHe zNI=P{j5>dXi5P6RB-H*27+okzZgx?#=Ej<_^Z?d2O3`<4lwbqIqlZSL*FxvZJ1pc? zcLUoaBI>=LPK7-4&!8I9(N&JGaPatSY7zmuS_>~tdRB{7WTz_|rDxJ3LFlW+$$IWf zZMLCow#&a-D$kbMHwb4-m4T8{Qpqnz`yj(U>QepcXsyevsCJ&OY8V7 zdBx7!8y;I@K7-AUw9$GV!_SZZ@aESG)(0X6aWs+yChcbHj^G4Ft&UZsllHq4)foRT zgD_~l4vS|Hiqd@(NIK}zHFBU!g^pQOYt>liIqwG-M5<5#52IWUS;54+3Wob5I=4z3v?x*s zmKWdLPB>RGGYR9FjF&Lq>mS}U?kD54!gUk~pEeExx7Mq*7bJm0L>!4cOi)dWqYla@ zsmew^konzfZA>#fWxa6NB&k#^R~d-5XZ~N#t`E*==fj?kP`C;*y4q5WMab=H5Q7od zH}^%ZY6|+~5Bm1C0(d#pw5VwY51Icb6hqm_Bgml()WKTpK6k<_^GR=i2k##wdKU<4JrqDw5shODk&6{vcL8;o-7IYIp5%(umVIx;T79 zd??EpX`XsADZm?2l{@ zqNlo94Vu@2+>eywKXpm3XR+#az89))Bmj&5XNbBeOZqvt;r_{MB$`*8l8j}qEvES= ztQ(k%{=z(gl7~^jw9Oh-AGC0R+q#Jc-2gdKYa3Lb`3+td(|A4K(KO?!S+`qj6NV(4 zvtO@#E&NR?ZxoFe*8aEDV}YTImgEO!Xl0nCQTKx zUridmGbY`f7?c07-}}p=d8SRZ86K6&7VcT{&L(knFD5iTdj5X3;s2Ye0^e3%KNz_6 z)bsr6E5Q%;{jNT}>8ZA_Juu^hpZ=TaJ579I{dK4NhnX1;RaY$Kn)N|HcA0PO=>n}U z3VAAMy}Cwel?UtEPA~hqS}OcoyxE&Sx4GU2{_Z>V?QZ*$qN55cr7A5ofAKy#X}QN^ z@@2;i|Enpv0{hZmo}Gy-ojhN~{_mDkF)=CA@-Hu& zYH9bqeuLZbBgal9_-3{5DAnMZC;4HC+TzcX&b?ymkEo0|zB;UT%Guf5XGMIIle*3) zUd?fThPNZHSy9A!_nw-wDl?lm{A)P&DtXtDyL1#z4Jld2P}> z750Y@MbG}2_$BY%9ags;?rnegq`C_B^2WIJ)_knufBX+4%R94pRZcbe_;!EZ4OM}A z(Pk?(u6CHyJ!ISw1EHpmlyU(KROmD$17I6 zW%DeJ+>?`@W{r8Y`7MEYuA0V44v<3rRC*UExxrai$87l=h&28-(TeRW!`*Lro83wse6v+Q%}BY zulRJ-Dokan^Gqw1tmY40J>RNL&+VHW^{{yNt>|C!U+#DxYq}R}{4kkk;liiA0eu@6 zf>?ef02J+88m4+Q_uTXphPz_oWT|DB)tqkfKb?%P{mH@)oF zT~>eO!u6HGm(Q?z{Db zcg>HZ-H}H1wE0wAb#P+~>PTK{#yggkr5V)+>8nyj#?GUhCA~eFmONTh{&d zE}ydZp5Es-{a&dPU&+O9&WhN`_10pgZ`2(_!y{9zGp>DGbnaRC!$Yi-ck`Wm|8M@Z zY17hP?W$h&+IrLR&6xdR5!=Sin&MBp?UPhBm4mgbV$Jr&U1@r^&~Du)kJ|l<>@y9& zW^8V%HvE{h9X&$;#~TWQIYs=R{PCU3964>aUjooi`~(#r!6en=|-M_1VNl$K+RcBmxm292(~+xqkj=IBaJ18{Uj zdPm0`Cd|>5w?e?tmEG5;8DkE|ZD0hBuH-&YD&oTQJl#F_kK5o;fL3^RF`iU=HlRj-f;T zx+sSBH53X;5K2;1=(8L2kq(^x=K-8HfUt1PhHcmCDox)v9C{lX1*8szTKW@(m2dCf zt7>aX(Wn+Q)-|@kNrU|a!G3|A0%ja`i-F)=E?51UmeaA(bZ*wu;}s{9ygS`X4>0=4 z$TI?ZFXdNf2gh&ZY1q(Wf*1;b|JZG;ih`vQ#CN?(!uNJ|ARF+I;b%}7!7oIB-%LxG z?~T;o-Sn5GZt#W#jwq`hi72!rNbGL$+&D$)LF~V^Ui%6lk9%|<1p#iS_z1akZ~%7C z5>^QR;m$vN^fMFfjHP1&_>mzfEPTS`{S$r`ac#-*_YWY5;nss;4}mK!x=?5H-VAxX zfI6+8P=)tW`_G5{r*&6!zfcK(^IBXtUmcWA^cZ3$GHZ@&Mr<3)s~ zzJSq(&q@%Vp!vH7Z+CU_MEN=}>yt)8ck!%g9B2K9k^gIX0R%iX)ejf)b*d%X7&^np z=K8OXb&Fvy36}U3(7`EdrkH^M&})6Lf8}{BiiN)*dn3GWG@8PPEUM zByyYaTAxMA`2Hpz{t6L!v~N5O3@0o*IF#2-Q~2-u)J+X!|22mhxRzKo>;1Q3dk-@a z={x4X(G+ea^k3b=1p&oD<~iN21`2iPe-ryR%Yw!jl~@30b~n6Irs`kj-v8BBI))ow z!I{lTP`%FI`>X(X!GZ|}D-8I*2Ik_Z{(XYHrRos_yq%YPE$rX><4bmF!!GvfB()0unKEa# z;Im2pjn&`LH~*z86#!YV(3HR|ST_2+zfZGPE8u&fv+~FvH{yRESvNF59-lb=zwU*!#o?~85_*tfbJR2z@QN#=>oWPjfeJddRR^5+Vl z+vBFSH;Pz!x=@-H5erh;@awc6waI=n&Uh%}ye{Io&Et0GcNJC~xfn|rE@ye0&V$gu zoN-$RO}<-r?tGT_ldlNLNWK4mg^ve_5A(SuV$sZfS2^J9IYr@K=}j8&^$ zqV?iKbOaH*uD8}%cyBcBsn_8P&+SnrjhY+vn=Q(fB5+k9rD%L%wnk^cediT zN;Hx{<;(FBwMa;G5_b)i(UD%8@5!l;bo=3?Nc-yzAVy@hN73HJk7NT}=LkyP4bA(R=DBDlm;yXNPL z&zO;X_`C787|`nSL0PKylEOb<7r$hGUrYcaoNC$Ux1g=50N%HbXVnO936F4FO9+Hv zQP&?>=7}Pj^iGl%ynNRi9$maF6mLM=I&c@ld1{vUph`zAom3ukxz8wdW390h|4_FX zcQ)DjFc@pZ;?qTb-u_i(`ukhJ^$-+SwBR^HF`q+cRosXX~SDKvWHzuq^ zU8AT-Jry*6dVcPz?MoXdQLRYarJW0nSH>>ZZfd>L=^z^))T;UP?%s8PVX?`n!gx4C z2!C(Cpcz zJVZ9_^WM|(a)aVL=3<)}Y54&pk=E-@)w2Hfgh!ta!Ta~J&Ii@I&b8b6vRCT%_I4J# z7vW;vvT9N2aw3m`T7zvIje5_-m2S7a!B|-WPbGxC*AuFd=f_)d6oy#U-6Soub2E4{b6wky5yu0q-!ZeDCHLm zbD~13m->20I$fxx2Mp(gP-=tTgS^+PLNN5zqr4}|mcAs^?DL{>3(g0r+I$=|QHL-Q z^Ee3izp!caM`qKV3rbb4mGeCerLiB5-n(9)7qB7uzV$$SRvY|a`!fQi%@SL+!Hnv` zY*WJ|5w9n?P^HO;z;wkc6NUPBnN)30&vME1FPX-x53*D(+3Tplpzxg#=pK*&`Q(P| z`sK@{{VR)Cy_;J>>;l;H;hOX<4;U-;49~Mi3bmq1eV_TrFxU*bKj%O5Lv>{z>V+U!jol|qeum4xU0<) z^W9Eo3zHS>O$gX_#6{Q&r4p66D!yL*VN7qZX5C*a5+LNTjxCUklhBBv+Eo6IW@B>J z$sdddv||%yiMW;g97B=HYJwdlsw`bH521O!B&Qe__gJ^H%I zV*u;G3h~Xi&QEpAZygGf1Ux-IQ{oAm^rMhqBo%9PcjFXWYq#8eXU5a>)MQt|5QpKf zATj7=akV>NZQDadgD&mw%B`54A)rTsk2Zo*6xSCnk$#i8vr{}1$0^U?GPV+Nv0*FC+{Ut%EA$qbP?HKz+VDa=MldQoA4P+=Jc6#Y_}OTP-0L%!@?iIXNK zGZHk!2;eX#P6(8skNA}-{m}PrymMyR5AR~xMw7rRO2nHvem#o8pP}+quhvMPBrusy zz*owD5bq}lGM=vN@+y>h@4)Od$BWNm%_ikEXQA}3+G9-*Fx$8BOzd&o^j0$RBEWF4 z{zjmh)D-P29{S=BpYeLPI7T1*xmdd^i{14kBaYq5bkS!c#^GHKoRHs>y9t%ddhu9M z%*Z@VFSmX(y0EXNF_MN1gqX!_7^0@n@LEodbmvYK3WW)o%5PbyJKB6C^<-3+O8#Iv z2%Tkg#)HZ7j1UE7;k0TVXVe=?c2DdtapJ25P4NepAz?yS#|0RxTr?i>@N%^!*dm za7+Jg=Wyrw2x7cz6z&YHI?tpd*eWSP6=$*IHKwtLV6!68hcGU*>)zHEl*0oZ<~N$k z5*jY;Z1HGgR;*f>M<#6U)h()b$J^wHhl|W;XSbasi0XOud+PYQLzWaT;YraOYx}elP^+xIbh;2i5Ex5~KGs5SuP$J^~u~XsHc%p&9Qs;2J!g zk&!o?Si&Fd1~#Zd-;ps?_fLR=HZDZk9T}3ZMCmfu47vJu5$48-@ir~D5q&wX{VVE> zFjYna!DpKv2BNcL&_@!z3(~Nt<-5LfXJNd&w^GP419J@QoRBO7Ec07nWJ?%zZjxFm ziE=dflrM~jKqcUsHVHQKJ{n`8HFm_O9-iz_B%pmZAsip+lYy$2zj58 zd~(xfn;-@N!;pVM%InrkLKK?>1@Tvc$eUh>Uk&id5dqRF?tFntIyZO|`Chhp4 zm_bTCECoItB%vXljX0qdlXVRkh9e0K(9pM8wbti}wdPZw$1`~tk2?;jX3Rzt$pRjh z4m}9@6KJ%AP5poC$B4sIILnAsUT>OMV}0~mawDpb<%DR&!8mO>JzQzjxV#^ktlPsj z-T z^4CaF(7ziA=j&#-7}h%2Ge^_|?tj$6@*uY#Ebj6jOIJVtHOg#skPE(EgHn`8z>dU$pxu=1u)R^e_3Cy@vj>03ZZlrU6JZ5(nH+51u!bgwO+9yGYaIUC{5PG}(c0XX9 zx;P?kM{PW=iTT^+3~nbm&4yzL@y8psf{BXIoC#vao1;}bz`Vz;NQ4z4+`@?N+J!}r zDa$*dt41vt9r~2+$3uaWCA;bK4DRZlpu9Ll7%pVdE{hHOvq=oBXamX>M!aP|;ikf- z@6xEQ#T#;1`2#Xc=Tvy{`opvM?>$t18c+i@5o(|7!x2{oGigq`kpg#D4f~l~RGxq2 zAu4d2u81}v&BL6KOgBl}SuFnXz#=R#4m0%q?T13jJ$}U24V5bpMPL^SrUb4PEauxN z0yp7H*$!2fHFQh_0n_#r5Qh*>b0{0F`yjAfRc$acm% zTWEnOy!S&Q>?5<@PK3QE_%lPC3#q>TB(f9B4K|3Itm$26B}I|F2;E1w#BT$Ic~f!b zI0C7=?Z^#3)yV9VgO|^=dq3d4YAI7GS5i72%nAlruYKjh<&vuCW0d&a#R2cMR0qFK zp?pVIcj)77r7|=u78?F6!ZyK7f>EtkTC05KN9nv4F6nbau>$oe;r{1NOEb5M=x(6f zozA0O&My+XK@#|~2-p&TlGS!jVe5NGlZa?ft0Voc`<~xV*&IJ8+%NZ7OwUwGKF1Vy zjP^6&g+oB9)D|G?iYZ+5GMUoD_JdRGMjr3=e&Ff$=C%MLG==2gWOp7qjNQln{f&i5 zO5YP0z82S*ah*r08Pl>z9wjhIGI$+ZN z3AS|7lZpSgW^$^oTOjtZ>xumo>arFZ42GzG^a%nS(q2%XIMVj;Ws%Xt$0b}mPd!iA zlcD1r1jmW4x;>X#v5I0~YcWtX8b@raQ8e++R1jLF^7wiZp&vL& z&kIY}C3!vj(BK(!q)!s*djt*YMSn7;g$+(LBeL&X@$p2efYtwF1+fyB$qT~%fAJz- zV(2vbY(5V;TR~7HA!pQ-=SO}Ke7Z=jy0JH}9oTc~;~!VP%f#}^?6Q^chvfwF^YbqZ zGm(6ekTCPXiD$~O@T@kLjL;wQpN)#QuFTsGDL$fq5hlAQ@JB3?$RZMU#BKsyI`w;k zhq^^Ge%Hv=*pI;V{9%~z`qdhV0dA7+GHAq6Fdt2$j9*;zz5ktla_prN2a&vz=q-**Y^zFrk2BR$e0E1(ru0XVHmhLYI5qNggr1MoEI4FwV}Qzd+)h6BEO=Y zNhzdF7r?Uq7+F^g3EJYeN0Sl1 z`h;brMi52x7tgiI#*1b-#sw5}d7ji^T-a_15=}2$!R&~H6sQ&DbDi8jrZeA8InhD8 zR~@xfR;RedFhL=Acc6mX>qUub<>(;UdcEC!H-qTRb@}R}U&49f+&Wtb)PXJAt3VV9 zW1EK|0!$;Oxe`E^9j4+N_^b&PbCz+?`E-{~M33F^E1%fLE`=aP`ft!QE0r?_=l(!T zUfYi(6wOmgoun*W>s(`xwjbZ>vN*Az(?r%hz?mc8WBd}kKV;~B4$ylk^-`q&_4OaX zs@n+2-fu2N=!_`8u){>htt3r)-j~Zkw{(%19)|z5l*DsBV1sH}vHd}h(CFq>cEps5 zqe}}ehC>x26adpT`B~=zor5xgC=_6ycHJiK>-ZIPl08z{nk&{dA~R62=C|vc90$vY zwZmCv`>@^RsDEY3xaf60wK2k&*Yt*akt@u8{D&iik3!8z^0Pe(kZ&J#5K$e8+vR|i z*4-Xi=Oeu8h9j+$rsdq?w4pw{|(Z7h6WaNb^fQZyLCK&{pW|s1=_FW)+>#0TBthAT4G{h zTgIO2cw!IGKdhj%@l(weup9gM$Nxn%eyLD5wVHZCda~6bb<^3P!h`}mb)xXz6|DQp> zeUXhi$zRTdcue+{Pff<$bnu3F$uK3zWMpKRo~K!&u3tC?n#yMRJYD-;sj0QJ#cc84 zcnorZ$XAUxN_luIe^b@BDNz<^FLMfSbW~Zl%y;xUe42*s_xcU$`7m#*-Stb<2ZpKq zu=tNF&uS8Nt^X6aCs(gXm-rS_lJ_Z+{PtjO%*fNhqd$3fr+lGf3fRiwuRlfVGd!B= zL_&t}ww8y8ecMrL(^R2R%uDHw!qRGG@ECflkYdEbaYn8Wy}u;;zXxGUV9WS=2-p22 zl^jlB^d=qT{Cc$((+?MbeRheC{krTr7C=cr^8Q0_8(F|*6kCAtOdCk_Pv5DE3@2_F zE_E;&@>=YP1?T^alA8-qpN~>qZC46I`KMVlt(ek4f;xj-=snezJB9jOW-|>k`h$Uu z54xydHK4*)e=UNx4aa|Du1t%?u=l+>Tp)PQw}u!o4%TEiA*n0CJI48d_h{gLb358< z&@9`VylmN;4kgxPNqUx=wn4$)ww-$?YIcyU~6MAar%zWV%J%8;PsN zQXo%#aGrPAT|-4mO6s&)qE_+kHn9Y}kSDPP34=J~iN;ac5WE4nCpD@j7pu)KT2UlC zRJyvlhF0k{5SavpX?M4jda)uFP5q3IUj0`ygwUjR5331{4y%-sagaEOg`s3NgO2e} z4{Kc{n|WJ6N-`~5OBhx!rW&qU82t37_&0D11K5SWxSHeP_-vZvrrXZoVAv#;=T@aJ zSsy;;9mFuK`bcK{Ex*n|J#Yux%NFbXS&yUqPpK2|;Px8|YutaO;kdCO)N27`JI)Ue zj#vI1_Kvm)5o)N6oRHH3Vj`97Y3L>Y5#RqKqfV27w|+deV~Fal;!5^s-8W(_R?K%! z*p?a8i(SFv8ql|Vn?#1B1aZLKH=mF@5xZ$l)B7jf4|W79Kt%`FX@CHVNaonXuO;^V zAEWi!$>#OTew!Ccm;|Luvkj#^DfXd^tN>BsjNT{nJ6KC%FRLUS^DwS?`zduue01>Z zqX-h;L(T(VVqzXfp~21am8J`WQ`*W!M;Ua`nJQ$4Rw$FKYF*yTF&Yg+OLBI5ZxZkBZ5-01lQLr@7JO@c7IG>XXHzt1ohtBjI| z`WFD!k8cxeq3yBeC*>1$@42I<3zu-!>UV(dOdcjbYAbj{|0LuUX1|?o9pD@;qi(|P z$ZOp8K(utR5+&qQ1YXpylnLi>^SRQXQjHoM|0kQ=qO4sF^uzoS*f2CqgcTFN`)acZ zs$!)=&jJRLg^t%8Hmfz^z0pkc`7pvFQV3mXeN}sBCy(RNf`dl0Gk)MR6pi{1LA;gH zZ(bET@v3D@!)KfMJ4YJwST%kEY4s0z>HJvKbFMqTnU4Tf>YiQ|l9G)Mq8yzR%&i-{ zW}|J&)ZyQY*5<&!A8AFv1VHFpb!I_qKv?v;O&F>b3$Q!DUu)b!x9+5CDC|3t!iL6nH^KSQ8b)%YD8gV{OP=ccbgxnhy+16eiVF z@x~6T5n_GH&1b5aR5W$BE9|U{O8wC^4!Nz(-aHb`qw8cL47!|7J7Ur2@ladN@*6m^ z+SzK4F9pLQ6JyJ+I#EJ7uGnYU>L{Y>Ei94-lpExz`u zKczgp-+8&5h+-0^(Lui09iyO>PI8ket_(@%uz{TCYJArp??kkK=@<4&mOT4k8F|1xYDT50c>*sBd45%K_m zP{sUaybJxE!6`>lYtIVZRTTV9^&;i(w$%V=(nStTWEluR!dpGeP2-xcvmlMjAr2y! zA+cmsGB^ma)ANbvqa1`5#}Qz0G1vVILg}=~LYbqjHExPf`&r;{?DX0(SGN1^pP~x# zXsCIkadlYwWhXgR;m}hh`sBNnW?CaPQVb;P*$V_C8%%z_Lrs5M=y5RN*ZFa32YuOo z7eY8&Oi&VvAau+j*fPqZsS_J!lNW$bBZ&4xP4MY-CEoaiIij@2d|zYHYfjA)TZY0` z-Bodhl)uU=DIKQ5NJISWK?d^UJ`4#vEBc$jo%x^rx_&wyaCX8ln39#6-Rjd`9owq2 zS567hF2l&NpNo3RS*FcaVd>$*AMte!^l=_dv&soxxwv4L>If5JA(X5g-Oj^m-sRp2 zI;$qr9H|*9WCZVhvw?@BV-aq{uHGB1<88CKNBhig%*-ylDAr?_Q8wT?I{8}=ZS0-F zt6S*qeJZ0FaBEpkJ2rcsD5?7ZE0r6U(bPG0p%(B!4w}tR`??Ti_6OlYW8UQ`Uf_rw)l+M69U{Bu@y%untE&FcJY9S1cB=)+EN!CzuEbj`l*0{pVGvzfgbNYIXW z;TE-MK%>j{Q2ck}rCR*y5;IN@9cXCi=^8ZwEOavXQjU~63X#JAHVb76Dq(m}v^bFv zG(!3glLNuc*L_Ll0>Q2)r8+4S4=)~v)s7cU6Kza7?Zx`W(15cpgu=~>m|1m+QmqEX zP-Nzfzyg;p?5h%z6*4@$3yo>|l$%wgMp_b@t@MzWnuICdZ=EBeV2wO3$GvWLtaNaD z%koI;=alBx)haC=*&P{ad=%=ZQfbuZXm{B5ow2iRi;g!r)(0 zduv9NwHF#^%cPEbqug(D0qCugSutY##Tt{?hiiTgV-nUFLG3rr z)Ow}$7w`Hv92(FmZuX5W$k{R5G=w`kC%;NVN zNkZy2luUn($+Vn5y*Lf&g~Dw|a&cK~EL|59yLwPQkWB;cIyEDWzFsXLHiD>PqCX_# zHa9c#b=vL!>EImv!+v=F=q$owDiQsdn+1_h>&@6dz1$K=9;!d)iPTpqaz<=~QLRJ9 z<8Pcdow!#)BjpZu2gzINw_`JCcP8v+Q*gE0Phmd6>n80C#Y^Qg68KiiTL@H;>Jq^) zxXq2NI4XIsew8ZkOn7cdb1%SV`=z~J%Hye_DZffyWz@1@jDA?Hoo^(xQuc|2CXSz3 z8`ByI_t07Nbjl$!4?j*qc-92bt}zgUoJKvX)lnJ8numU%%~q;>_MB3-TV1`@C^BUD z{f>=)U1yM0L!nqxDT~9gBDZ7L1Ta}=?}_%;#rH(1CS|TutKHs1dww^BzyNqbuv#)mVh1EbU~N^VdIr0iXxe7&tj96GzyS$hVhId;{jfy zNc#&o+zs-Lel7GnE3P_mqrDDh&vowSqyi-lEl2fWt|1LlpHwKNLhUH*>eDCo^Q$IG zos#_|F`kLkON*)R9r|!WxsAdn2})$t{?5wnzLhom85MM^yEN1l57L0uX=xbH4I`wy32PO#PV@kGllYqbn zA0${PME8uII7n|f29Sg&U*F4r2w;~)h#L44fNKI*D*nXGz^&95A8o)VEMsQ$LchLk z0Vsm2YB2~ysV3O&anws3vtG07{uwZb%`(3n5+{SvYp7Wrb78IR=EY;6-m&z>663;H`?EeArudW9DwU(gg99g)qs`pB z&?N~No88EKb1~+6j2-V_SiY;41A>Wie02#KP{^>O6wjZ$ysL{%Oz%<7QQ<3Y;}-07 z@KpE`Wu9(-dSd_ireHNkb1p&`SZ@+POx?6?5EV`*f)Y6Ns-1Z09MlPkLNAHH0TWpE zu#izn;rTyA>7m6Ku+K=`M>xm?Q2mml^&##cv%xWt1OFPq)oZ6olfdkEPP+d7 zWb#Ljba59^7(yNgrM_@Mog~VfKg2Pi#q#A)WzmV`ekwS@AzwNjGpvxskjvhdIjW}; zbQ()4FledyslFINv2ekSx(P8{TdPfcdx#- zu*XYYc7aa(Z1;I{UL$j5U;KR?pu1Z{?Q@4a!vvy;EU8i5GRJkk4$c;L;BBTCz>-E0 za@um(RMMd*Rj5HBt#r`@A3juvh%jO#7F~B#8PLxa*udWm}qiKlCWm4I6qXR3}t*<; z?8iER`hAZTgqOjY3?AswX?6bUgR9h<#`+3_;jvYd51l>0<3h>+1b-fqkDjxQN(y?oREmKn2hS%?JYWG6%pa)MDRFl z?O2c*B+{CF@mN9Z|H1Y3DG<`>4qw6MLM*0}!jsBU)G*(thumzb-I*6ZM|Uve!MnBA zJ?i|-<$i(EKP*{oJp8`O&?px(&33J}F8-25W>pnF1t`C(Nkb zOo#7RLAj90S>7*dG>Yw`$I$z-RR65^rrJ7*6VS6AN}z!a zoU8jw50U!Oq7?t|l*gIVtK#z%RR$U!TX(NSvEHqb(`%i@{LpA}o*44hqNCu)FMnv1 zsUl-V!Fctyk815A_fPj1DehGdr`H#Z+CTdviK!e62a-~2_C{v)OeOn}c1Lfssbt_S zKqy`NGs&yR$+{ir5?Y41zGgIVUGbou*ZI86CoV(Z7+~tzC0iyIA8N$dmi}gjxk*i zU~fh@e(@;lzhoTuR=WfM4?f!bPdQQca~6!mp{~DSUS1jsl#_Ko30A1Tz&3??bf_Fg zvFHoR7oQnuP^_R6GUJqc^)VGKnxO7I=8A}$Y~7JYwyZ8V2#l~inL5f8o9yC=!#3#< z2NE8hEHov%ZQgDKuuzVjYP6g-4|l%sjitngTFtcA+}jL=c(`mAl}*=a>ER350sy~4 zCbnFGC-Y11kdk-FCUFAJTJcO@u~}$^?XQF2x@dqwHYGc3-9+ruo66IT%=#K<&W@cQ z#3NbijKtO_x;j>`Y#d2oTu6Wo=~KadE&7sSaC}(a3^FZf866B`!^RxDWqJ{h z=E*9_^bPa^6|DN_x*BMkygo$`Pz+2Vy3E{de$-msg%o_F+?7V7bh)7Wh4iIT^~<11 zYV$=5TUmn0ioq7iXrof@{Jt$O_7+Xfm#hYnFCvo+8O&iBSM4;?uiO-RYU5-!A53ud zURy5aZRJHw-}v=3dN?@BQfeWV3}N1&z)Ek4ck8lfx>xb2V7(R4`!q<`1BLE1(g`-R zkl~J8Je18<{I5sG;poV3l6~4VrM7$*<9k)NMykZIi-*xt^|c(;9(SZ3`Z}8wA_6p! zTVEG}M(WaF{VY};VWUK*o!eDMv~%B&Dq|)~4V%AB&aZBg1Mwz4gUZqUTh~Kx`g*S= ze;oM>w!=JYdi<`ocs0>)Lha@Wy0GIPwUo%3qD3FckfnyG{bq;XOlujx8%DprAVl^R zU_xudIZ8+XOU4=UeKa{{)^OU0ld;joY!d{1at?m3qQm1_Hy;zm4S!7>u?A-Fo8M}J zuv>#k%lUpctZp56Xnpidm{HHNM`1KTqx797Q{m<0hKe)Y-jv2Hk#wNR;tosoi1xkX zAnL@!k;YP1C2Fxq@@pjxO_a>dc1LN5d+zn}Z1OxVzw3HOre&z6)d~|&R;zu-xgz?j zra3*|s9v`!7B-gwhrhwUkgOEM(oOfm5*oasVTe}}^hC2FcoR6dHsYt=6NXZjrljcy z#RY%-TIUNo&|paYEXEs%PEDLFxRZYC{*fk|EQ;v1DWLNk*>tVGFijd&5;m#pXWI== zsK7HW@Xy~=E|&}d#}!w-ymlIw>EU;cl0?4JpPU-OlW}=l30T$X!W|TnP{P}^AjEjf z@3Dt|w>!0VF_V0GU+Wu{Ot&1GfO%ULMfj8+ad_3}1%R-`FO+c-Xr8aY>%J%qSsbre zUB>{x-D5!qB>b2TmbNhFb7-PmLcxS7;0X>q`Sv5oosOi)@OTR?#SNBG|Fb3d_Bl++y*C& z(y|-#TRwVeHmo#v4R?DyB4-i(TkBFz`}7G-m)`N4B0ez}>M1Y02e?3JAL$VsSy_#} z{Q84K)pg0B3lo#UH*ue+x!)<}H(b9MmdVe3WgG~o_lk36>ww7Q4zjQBp?sE42y5Qg zl@Hu5*7zjS%$RmP_e!?gy+%$&XzWLm3WR;}rY)4hVTFlu;my1KRWgr+J4NI(F0biR zAeX7s{Kb;d9Xx7(i;Tpn&E)iSPadYwrfFjFEbA1X-8d%nn#tVYmKEmD=TPyjj{|q@ zO&}9zAo748$vN?OzZ!G1?#h}(hpi9GtZK9D5}d@CDU->o%COzMzQt?gsP3ZFlZhHQ zo{<_q>hIaviScP<#IMVUX|ujttEq=(dh4snYMlOU!tEZ__u8Kcu!2cbaEM+FuG(vnTWV#3n@L;u+b`59sVkI1pgbD0F}P$7*`N+BQ7a zhs$JhPk;bgLJwza1T2*e?&Ut(HQ%gX&Awy2U$a!ubK2e?(+l-@tR3xUlHeeUvPR4t9E4N~xVL(#E1%r29TmCB^VV)n~M?}aOKY62gdfOs<9 z25Uz{cf_G)nek**)U#f@^7UZUs#Laujx_;4?6-;!8M&N0gvpBv6V)6;%XqC&Yja#S4tu2kw z2@ViVsk<(qTXK+_{{D?Ia}{UKVGolEk` z$?r)}Kz8eWYjx~dKaR}3Q_nkNX$&=Z&iBB)F0m8g7?W2E=#M$x&@=!TV`9MKTs&t0 z@SNjXxkT@~{m0?}sEB)mvC4Pz=3J)qwdY`~EJ4turam&;P0c)M{GXs1>51q|yvE=P zE^vDE*AdDNsKvtr+7g4*$wp6Ai-4E7qu+2udlYkkGSUT-t=+(SbkRqouaG$Q5C*cB z9vmao&O;|cYhLq0i&U8ljq@wVy>A_!>=yB1uv3^g#LJr`OlI2-_dh*3EYPb1pYh?U zcfWU_N$G6Fq4`mXcj{0e1LLb?ze#R!m0$3jW%=^f!kcS;j9)%C@x(mhK zrMMB-Gn+l)9C1@v2|0SIFt^l->PbLdKiMRYF8bmb|3(-|x%X@O2w2$faqTZQ7qAKM z`T+p?H*oj)xxcal+diwf(x%JsPgvD-ZjU7(;lQL~HA!VV4xtJOa%N6|zU!|aXo2ca zC3%7E^@NEi-A0&leL_&9Je}8hRUGz%;Z0SSEA)%*IOW6jTI-*yiI?u-oyL7-P?9h~ zPm*YpWw|tg2ZF%+HrnqAx};R`mY_*oa`i255aRO0YSPOggY;zPV|A^@r+Z-9Q!CAC z!Fu+-EU9_nr0tOVBFrId^bWJJTjJ+ndoY4Q}boQG}C(l|B9P9G{!(h zX}B=GOUYu`fqTU( zCKev%oWofyV6~4#c$G((sDa9)27{a zjv46;i&~DE8JhntrxZh15*{L-g`&lUR#p90TBX27*#bue4gKd}qf*r2noJB2rVB^q z5U>+cL%Q+k3F!QNm+TEKDff6z-G$OP?ZUni;=}kJZ=Lr?@^doM+8M7spXU?7F5|@& z`eccceejqo?MLz#X07|9$MG_%E^a&+vj$9g>N3zv^Z7Msu1w=R-Sb}YhpIblU}+a< zT!O{(Ws#amEz)kZY(PE($;Oh-BG^#|n~sQili7RU!3v}MwAAMzqRC!k2w5$*wZ1Rd zNQ1YN=+Mv`>5mVcE__LHsZx_6w-M#ppV_9k}mNcN#c;tf(}X5TIR1;b$%()UMl+a+6mG-o5iQLLA*aeD zPxAhabFT7@X4I%LVHqHQ44L1m4|!1swp#o9!XUzW&IP8{BuW4x2>E75sq2GGPkFwa zB*}toxp1CjHbXB%l330K$79utb?aDG5-tehE#}1h_mHy0YYn#+97ao>Aqj7M?f7E( zYQDsqN}k#E5+ZxdGM#H5$aoP#b zzzTEdt!cAZ3_Is5GN{&8lHD{CqPPZgd)_sshPSG@lg1ctd-6I33+aeJT+4~TBxXae zn)L%-I%^Tgh#ayLZ@htz#|=p*Ye{1!k-iOahn*3ra$H4ilxD44v39Gy#tkX2F^~~+ zokTrnzb|E6s}gv7W5b#i1?Ymdc9*I4wI$Qmnyij2_syzIYKC*g(&V1{^9=`CMNklX zFnb`zZepE+{+CNj+lDQxz)@`B-$9|#D+;vkBB$Fx|~|L6pqoc*c{bl(ds z#XgD`4j>`EPKyJHs*}3PaQW*8RoRzIt$i+%f-f#fg^q_1GvG*eiu;UM2aHsVb^q(tJPo5J1mS zP5#@E*Yg&Rdz=eajD;%cGos;e)Xd~8>^?>Ev&{Mv42Ti=*;9L~*%p@$E>F^%JwvC*mdTYI=61&kB2vg6<-M?f>iWt>cN!9qN z)!x0FTl-u~2jzIjQv#LNw>E5(3Yh1+a0Jbu&E{@^d>jjgQy+ZXBldgs&j=m?$PJ;! zywek@lt(-C>)h}FXDJ~DeKEudEsI%%r-~{IYWro^toY<~@nH$1^V1-za(!x(U+BVs zGBjA^w>UqDwlu(rgBnH)bKja~yL%e*8EQQDmzGMq%c&oD*6`E7lkhtA=~?VLx}hsf z(FOv`I|o>tqYefi0|eNITdbj)Tppsv+)2yw!v}Yqh?T9EVY~ksaa6A(kxmdQyqS-Zi4ZdD^Le@C>~?)HIF_ z>WmK@@Q$IVTFs2?>m;64MlAT$bmb5stxUHu;+q6PV_=ck5ou%8x*ndPr3P*V>_+3y zjCDn<6sdgQyELJ+{1Htoz+kp{>hW-ef8J^j4Rhs=1-ro#h{e#~1ags?+u5pm`{G8a zQlCGiC94~z7d~}LwL8IUa>IbdyBkuIBb&^hiP36atN_xC0!*q0%CAil#iitc3@E)% zrfQ=3e&@NYeElw2|1KtWpi#YOZ%*p|a+jR}PWwtz6ydQ3nrh;W=JY#D0@@;nLCoZ0 zsJNXZa=YOmga8-X3YCeOwbP9*X{?idS?z`MzL$yAM?x7NoedtH5%WF`>PNB93pbji zYMeG-T5*C();Ai)&MoI}tjv)JQ)bv^PS)}}rfyi#&v$4&U+?&MParfuR*BJaeZDcH zH^ee+UTUl#u(sn=5t%axIJ)_y+%MyhWz2VU-@32Az#3uzJZ_0W%_=)%@q|fqoW`$y zB1}92=w%L?9u4XhDh<#2qNFj=UL%=2Jf-?Sr4xtW88hOnS)-3xEple&fCP%-?`qKiEu;Z!Z z5!%HHa6Oro8&0M*8t*9c z36%x!Y0{0hoP}McLu>jFvFdv3)z03<%ve%uR2MkHo9@iD9xb=fmIitUg)g9i%u7a- zns}4m0n`-iV^;ho7`L(Pr^XY}%h0|F8;zNnZ3@E$;)qAk3i;eKZ&ML$716YwdPPJCw_D>r-l6DuwEg@q_*+=*M9+c z&7(1^8hU+oiGBO7(MTp>>@wZRGLSTaCY!#N39*?Fq9F*1AYoVce!fO0D`q?SKBUxi z5i$)8fLkqKYjXoI?KIY%l?I#*m}x8p)>nPkuQ$&AKGplBI12&hh8U}n2`-Qcp27Hx zm;W#;iEEr$pP8dlw~L7x_}*<=CUb9R9OS5@KRx=G!QfH_hT>P=#n8_1YJ$xTwQ}ia z121+NL8zttS0tcFec(aHbpBWN>12pF&s-#m+2&!AU5S3b@H9w&oeR*B z=Sk71&nK^~O6#;}VzY ze%2ro=9fKG1M@={6-(A~FkR=-!MRgK-!a7D@sOM?3Q6ZjRvL0S21$WaPC3V+)?XG( zlM{V5NbE_xUmeb6&;?%RLLGRNi_|BNa6f@R%?)w4VnE}eJ0OHI8I3$Ht6mVWzgVMD zvtDHPT&F4-B7_#ovJlYv9r}@7pl{)6!`*MdyV~piA=jq(Da%8aX^^YP;blz|pay!F zwCICNMJoxw8|0M;v|0y1bu~ zjeH<|=GB0S7$(nOOdC%n%YIyS5%*a?^GzD>2-m)k{az!<6N9E-isbx zqvtzkRn4bl&Z>?WyV@IGjxY%+>5J5-3EVb+p321+uB-Ak^pUU6ey6XTah+q2yBNw6 zMqK5&gZ~_BxgK5`^VyuOKT<`kEJYfHjq`K*u{fJ6)i(?F&NpQzY0Lxk1I)1=V)%do zy#sdIP&h|H_a~&6TIf-o8g#pc%a`sLuKFqyi2CEuvP#@#je6QP87)uSQBM1}^J-_t zu|JZ%_@EB|2$(XH-?n*TingM!RdPa8q*-n|gA4Qvu~}<}ejx7pNrWoInQAsk9V8KhPl<+9`*b*$mp~41=_CmB znMR_}`sJp)8lxOZe-pAdyx7utY4QR|gTdr%r9R+Q0^jju%>k}X2Z|&hb?pRPdTC>x z_vMpugq0Xm*m=QDQ(Hj-gqugbzH}t>f57z{)2>fDA##oH8q45jXVq$%{J4{X?-Z$x zZX7?!U8tXJcXxz88{0C$CdMMi-rUdDNt`_uky6UmOAbxEAD9$XI7rzkjR)--Pvk8B zoxc%!+pi1Cd_2pZspM^;w2FN5QAnfCBXeln93}})pX;T!RW{h=a9PGDov#lK;CQ(j zj&PhgC5lq7uEIk6L*+XY(0huiPBtD@=fHZf+?JgP?ceG7c_u>IFM#|9*7yLT zD6Am^sOFr3YiDD@WV1VeKdl3Y;GRc?XQyHPYxDeI6j_j5WId037qC5tq2_m?izouq z)&ul8z}95;H)>>|=ft5P8k>C@-Q5ed0a_=m^jkGR8{t)_G-0(8V*#~R&CM@dKy`>d zXnN-{=96A9PR0tTK#B;WaE;CW9kYep#E%%y^Uz^5`yTug5xP{@xFv$Xl#R z#i-Xjx1yu32ed*WQz;r40gFKJ+5h6`$y7UWQTZ@9=g==4*hRui(rQvlgNGIyQ6L%W z@3)B_4_NkEKTq@sotyb0DZ9fI8F=^mZavEvNw{uBg1DAv0ajs zt>X_&_Jtz>e$~(l8q@~}Gu$e>ZCmSA?Z)8hd1btfZ}_X?rK>5JSh30jn3xKz0^fCy zdOA8(4)d##B_5FNz_)W%sK3QEWxL@ad-vzUMa7OcBkkuVk-++QaS=ei_S*(>V4ccB zM)|Do+EwFIiIX&<#}eh_yq+2_Ah$v;zkx*`O!Fy-yGe0ow5>0Jt5*@7nIl{dPCW z84ghzFV1Fih!TO-S3()+ zWn7rQ5qu`bg^^z%k7a+@1Ya~EAP;k=ALYChprU5tRQZ&{LrZ#1D|2VVaR|!JH=k%{ z`%#xx_f34nLvy|O$Fb90R5Coc@;veOn0Kfb-q=7{sdIL8Q@gc%4(IqAPZw+A#~PI2 z_L?$qp)_EOwoK~f>Fy9ddGV{+Se7wBgCws%<iZc;VYW#Ca`KEZ@+*sc&s7n;`IsW{25De`BYy?S$Y zw_LYnYLt}(6p*+K@a%SWD`O?}jfVSG6K3Z_7y#+$sZ(7)h$Lx6#tDLg!DCcj%-NL4 z4Wy}#3|BC+x!$+!mq~x5OnBgnjuuft?58_JdKq|t$d<5Hbh25;>E1eIr2z#cBB$g!8WdeU{=lfolH_j(`*9DjfLmU z$Jt>$^95lp_n9S>BiK(149q+f^3Xr^cRdIZFO7YDp8f z<1P+D>|gb*esQA&-OG_BPPD=4KkZz%}G;j zLo?R_UK@Wx94joBwi%7tzadgVWSfISQ~qzQjz!yT65n!$)XVabz!@qGPCQ0m=jSaCmZ4T^j4XkBsaC(?*Q&iDaZ{pJFFDKNIao3b(vEVH2rSM5n3&FT=;` zP8$Iuv`C2)==q*C%tAjK{y;qRymls(g!hLpM0U7{RnX!DjbBW<>u4$Mx;ua#eEYry zY@^ClmVxge z4SYWz<3kcc(L;(A2A0a4y11qe5hn54GqqX-OhEYF0a`J7!&pwy%Q~KiKLxjTnN(*- zUqGMz=!1JP`TCY&HV`;B#yOJjTams7+t^1wb}u9TAPUbI1ci3jLse^!K6)vbBuCMP zmKv)j-u`yKn-L!t)$}&NsTfB4dL0)}aiMS6i0iidnGCs%4?)O(Usvh`qA(ck;7`D6 zlyzBOISe;iPY#?B{z=fBxO6YZL!pHcK3qzWT1R#dvPpQVc^`T#781+g8d|VHN>Jt^ z^%kK)Cmu91D4X3MB~9?N5bnu58m1$Ik(}vCcc+wf2ozsEf;f_8F5AudNN5tI1e#NV zBhiak^5`030XY==H@FbFMSODp&>+a9OC7*lpRWLe`lR`F4m)>ymS|kU7dwWmtxb&O$N2*s^eWqNBuInPr-#9&{FW}*bo@>ashgMz3=PCdY20zAHikRl6up)VXVi}36mW;&OsNZ z0vW7-^v`B}o2+DQg~ff*8O;)OMNv-h96Cn?5k~Rl?IiS)$#r?jdx7eMbOxtQjx58p z$&$Oam)PSR4ptgy>$OT?Hm>)~KtGHJ6hlb^6FnQ}y;~P~>Zhz|gXp!0cZV{ykLF5F zHFN{Bm1ltl=viLMa8oo!yk@g#n1M02y8+R*pvy+bolb)7;;DG27N$aLaAAD*Cv`mJ zlBG8US8UvXNAlD!P9~E0HoJ>qXuHY5mh><{*uj(8+$gW5(xj#IXk*K$ vi-D^0% zt3XF{VWtHj5Ay<*)2T~62|E;~D0KFr+Zds5<|I$!`PLr}S>U@Nv7CE<*T97<&c z9s+9rKt$QJw>FK9V^U$4OR^bM@~5xAr}Q3+Febn63AFaRZqx^8EBRW^)kB|wQXAiY zmO{6@w^cXx7RN2PIh;wKgI|$7PMEWvmxBY!ecvHlQU^CW5{#3) zHb)p@Mkh)D*-OYWgq(iF_YBy%cr?F7KX7b?&lvy#q2uqwI=cts72aWfq=P$lhVsA^aIr1VjXGFnn@j!Rc@b7;=wVrwM-4}92&#jx~EfY)y@eH)G z2l#OCgcst*K#m3t-|40u#)={X-b<=_d2ULi&ZzrWekh^W@~wKA!3er&L3;}( zt8R;Xc%Tf1ahCwruMSrr0Y+KTH|=PkKy~k?C5=73?M(HB+QFM1YTNegE5_m5&2?m@ z=Mq2trz|q`J9RM|(OjZw1cTJh$R;a%#1BLKyqM7(V(3`_3ucW9`zB{Jm765McUy56 z6Zs>Q>{t0}_<3|=^6&dlk6mIOw-JR2^w!bC4K;6ZmS`q&B;SkEP$BQ{MsNUlRF_GB z$7Ko&iUxZs_ioS^x2`PPqRq2Zo7dc5ObGQiBfzfX;M$BTWV0LP&nuXagp5^G$0qm# zCXcJAFZnq)I+ZE{V~-0z3vZ=5nqjDviD0uM$-Vs~eK#*oZ){=T6MtubEOo`=cjQiu zgHTtVE{^JrV}+}Nb3gbh{g9YGUdWs_ds3U3$76H0599+bp`${0plO)xCY@co4zn!} zLL9z>~Vw>hd0XmCv#w?*QT11hrzf#+Di4^U;mk=-6VGU5~2DtfV|DeBA z>aV?2vV_tvpzmH-zPc?wHJoEI3q6%KQ(cSv_&_M}aB;iNgGks*^sqspNGJ{=o|#Ne zPX?h_o-%*pQ3-C&fs}Sp%aTX;dQ8a5M=6PpR|y#aiBp=?pZM$DM;|yUS_-@5SGA-2 zfd!?Zl6h-U(_->iqDG`aTp&${*7g8C&Qj2OkM*Rza}s8@o3qhK+Gc4ml|&{tXad&B zTLt+jwZ2Ej$~4Bjo@cvN;2~bUhc$^=qpy!#%+G~fM^vm*HncD_;mT&>COx5fWv&8iZPj$CtbM;89d}Dm7pL)+ z$Q5g`#Wfq=ZP0bzdTuNLO9FQ^o0<9(PRQdOI)JwX5jR5ngA&Mn|J8F0VMfHq7dl$5BmUR8LIfEDW}ElCtIVyd(&e;SIyQo+5n@ zy%Igc8z@?*{Vb0Zu*6A&Paf_bHUrY<3K@E&PvbI7%p}|7w@Q`6ViUMklIko}Ywo!( zV>e?lTWQc_Q`r-_>4ClHEXVqs(U6P*Yz8$(bcq3O^6W1|4WTy^^yh7g>dj7J)3_WH z;cxnNaX!59k)Wo+Eq*zctcmD4TYKIud9P!CyIe(y!Xb;wsO?oil!?-Yyk9k&Y<^?A z7V&;6wRBD6pw4IIN{QoD_*;d1wR_VIgKq<4N<18kR@cg4|pw1AVc|)WMijNG9zovyhdMOME%bl{oU&0w{znFV^yp)U&KxpXFSiZ_W=>12MHmH-y^-{^kK z0;q1iY5i0nB2E}ro#Z7$vooEA7o*0?yK^jNI?`5CYJueO4YW z2^464Z{Y)|sT>oDuBoUiO@AWj{n&9PblSvv!UO!o!-_Nhz~q*7yWaiiM5m~;_`%+w zH#@G^(Wdc|V6{B{j12t_+W6!4@US9O(Bl%S7G2=Lm>*^rDHf;plcvuty2;~2h}9@x zkQ)^6%%Ts;Et!BkZpOyfW^A^f*9QS`RL0iP0fWp7LU!fctPjYRgq|P^?pg4}U%=)x znD`=kU3uLZD}LX|2^IV$S`Rs~rajcSlMpp5fQK2fHJ7~uK6h15bU7uk>m^=d-|x>G zdSaeh_RfeDJB=#`?@%ByG@j%pRNys$JugicGZF^CK_g|*!vqu?EJHzcgCW47;MpeJ zm1;`-{fqVs5od5u)Tq=?&r=nMy$>=rkQu0aN@9Y{rB7qOti^%X0W#^gBhchPN1>6f1G4ul z)RuFNabuyg3eV3Dmq*5;KZ~qCvn| zwF?XIuRADEGua!Si=hTKP6#zrImx_Re3Tynuv9l{{37cLQirIG#kz}nhFnhJG)Jk) zgMt=ihe+qbeUK)NHyhJV?R1&#=e*49hLuB~f6niPvxUIhe*cIT+iI{W2>WZxeBe8- zhEm>gv8{}xiGvM452g=+W$mc5^#WTtI+Dg*t)}&i&p6^!Q^QO1ANR^2G3|-B*ze!u zd(#HwaM-7Y{WkDlA`ex19XHP)7}5BmL8*K$qdnE~Sm_wX zz|44@Rf~NKq4#YU;&5KJxz77cu?IfzAOk++O_1KI$H{82Kj=z@P1Ie0ejnCj7@M%| z_v~PdaCR>5U@&Vwc&KCQGCqXUDIZUyB;i3!vnvC4j~&A%4B9C^dcp1wCkg*j@i-Bs z=yXI7RZ~lfTIQwv5~BB+AjxXD(mdZ9(H?kI2%rO(3q*(9Tw6JAVf6Rpjs) zG8vS{>h%laCmM*^Kkm^jGE54x?h1V5>X`%xu)t!HE8}6;7qD1aa>0PG0SW5_=R@V~ zaaJjOLMtL0=E0;SF_%!b_-;E4T@uyG668ihVw_Rht}(E>y_EbUG@(npP5I_;nL>|! zhx++qXWPsFI}a~9!k$`2Y68d-U3>(JCab|Aw9@-QSmAV1L@ULi&em`ui#o_*!sIsW zwki`wLia1uJc@C5H4@ukCPu2;I>X5M3lS#B$)H3I!nRzx0sWY0!yQ@6hV3HPSx8<~ zVxYG;g$hcSfNz^9p+Us|pb2PL`&Ozl&E_ld*epwN7;O8{h$+j3WWwek7_)|`^}0ph zVi;|&(J;tT)`$bGT4aFkz`#}Rhwl}_o&KKryiMu=ntB4aici%;MpRLOr^Ou!s)uZ5 z0=JOyYe7TpT*c$_iE>0K6&gJM(Fl=|o5Tx$t9)@MgVtYG^g7ncwwIa>qWH+?gL%3F zP=~(GG2#h%g04D--eI&c_7;jZseONm#08B*HaZbOxEs_>#X^tbc?|WWK<%+zr9!u| zlPusQyZ0T|p#f%xxn8za@VOi;@R*@s#G7GHHhdw>a*+*#QxT!k2;~PW@R474={r)L zk~Mj&enH4!Ub1%B*n~v;7ttX(im+db%b&*AIVYd~K0S}89+7~D%IG|C8-HqbyTmwL zBV1OV?ed`StwR1zI2a;kr$+HdnJB+2m0dK=g&*M?*Q?ntnwKA_!-G?o11H`^5VzO) z^pkfuh$^Jl*@7VYC<0#Y6&_`436*C!;P6bHwG1#089nf@p37=>Hg)fcZ$(CQT#;}# z!O>Wz3c>T&^M|9^KT*M>6u<|Af+3-x%%TE~#ff_cJQu7gEQXzg3XjMN9xy zz79zFCkB*JyG;ru?JtFGsf^?`E-!rpgw1A*gzfh>?B_da3oLO zSGJzwNk9maceFSmw+J3dI+Zo`nM7j?mRYdv7K>y@Akc*V>fPBNEsXVGkY zn9Zi0KXoEO#!TGZldwTi?Bv0OgEwpK& zP;q&}OK`P1UQ*OQk?7p?{c!QhD#5tNhF}~s({=T-bHp3KLym&I|1kCO?i>2gnyX5$ zha09nqLIaGK@y}_8&TXY=hQt*P};!FEBl`N8+YNtwo&KX^y>MHBH>VfR7Ov#+Z7Rv z0|GzR16`s@g8SNev!MeM=a+rsUut!_E%VbgGU_XkFQ!<#M|0Nr5zq)W@>s3KYSLnP z(HmVqd9meBy-k;{uqW?g*dgbc{L|e31eK|PawEFxZC47^P8B2B`koef(v8dmHk>_m zG6GyxE%ah2mhUdmMU6Ua5#D*a{R2`FUo_RrH3D9EM6vp{e}8RhN8cx2keHfLhAjHfPI^*)QH(_y;&OWjKY=IA{!FQui zrE(aW@l>hXi%Wp207$S7boGjC>L7*lL^tCN;su5M>Q!+6b&)CpAn`xiHxldSnj^XE zrF7{EN@-$7@zzB^P#bCz-oRPF$1;{|SqENVw*_F1qSdE|)-~ z1Vx42OfhM4uYF>{78JL)l`(8vil>?7?%7@K>N~Zu>Tj^==c7ay$#CPn8~#iIJX6>8 z76x2sjhCji-(qcgC*i5uDJ@NwuZfl{AsM;O&8aVADhi#}FMK z9%dc6yxMV&MDs-{g%;X_IQDon#Wjwog)d*$)BX*}`Pw~@?X~|hVyV@RTqv-mL49AI z8=uE>HdfS{ess2v)msAOKE*YYIHO(&;IA}=fH@S z$cfCc8aMUic>0Y6rK3#xwk0@Up|4MS;*_-U2}`7@c!IP%_!%LOi?Im8s|yCSl%Obb zHu|US`5}YmBt_pROeNU;ZC^+sH)AE5S-XDqag9?S)?DzSvmEWjm3MDjSYDMy$d4GR z`TODa-B+`vb;tRbOO*KdcwJ&)!>k29QadSl3PKzi!Rqb&Xyg6RAT-r4v-T2-=~E$32b^~rZ@t{aqu2>UQyn~m5z z^t7nVqv?#N(3Nv|(9>mFb<1fdoT&40qVlCBv=^Lyn@ylBWSD;AMV}I~aBoAi2QlgH z&8{Czuv5vP@yzp8@~bNZEYj9!cMe~Kt5VOMe&mxcjZomoYs0`?FAGU(@O4qWtQ!Y^ z5%p*LerZg~!F^J8%o;;y+#l`)b5Tq)$@3c*TMjD0ghE3yU$Ly;b9C}zG)mY z(iBPxP($?icoB#5`Qj85sVO8fc|ZFOUH;M;AP|b)QZqC)<}~rr`=p}xVB^r?OaDT9 zJTSCV1TE*g60HDM3+aPm{gYe2nzo4N!d5?}h zGP-hfoiI{CJE74%U&oU5@8~(Pqe;GYnsb1-q7Z{m?ODQ}XVp>4j_&RQIINmh=N+O% z+UP6}Rj?LTq=bYaz-{6ao75xMa~C#>wwQ(q(f8rBjcXBvy%3`5o<8V{tIO9mgDr_? zs0CK7*53Fnwb=OdBY-{ZbBS(Cbc^dGJ9H*R&wabF_dd>NN|~s7aMd(_1Mr){w$FH- z^+G_9ByJ=9&!c+D6%~^8OD+H%k+4UkHKs03d_4`nDI{gfzyNKd_ww_-=&*&e($8OA zBSp0vn*8qd9wWDJFK`EJuv(Z#Tdx}`={AUJ3B(Sp89-; zrI{k9mz}3nfuuB5`Jl>S=U1OIo6S?&jtX}be(?qf8d!!K9i%&M(FHR)O`#O%2^b^& z>+3dFTy`WL8->2Rm?kww@@0%l^>W;wQ}+ViIR4=jowoUn-;x7`!zB$r&*s<(WtnTD zsAB2wQ9@BDaRX(=mAIee>PnXY43gq(H*M5+Ymro`G}gK0{ZTx0z^B@X2K{m-pOMq6 z3*bSTvwP6#F9SLU_3ytrb>k#>v2n2rS`D;cr_5r=#|Sfc9 zdcVZohenJV0$X8~pHhGWrwX=v7xUPBe^~D?AfS+_8cARa^#h)8Ldxqhjh;GpOlT&0 z7`HTs!>BRjA9+CPY8-udDscXS_5fo&Hkv5DjYU^(cGsrG{MXDVl-ht)Qo)PMn?euD zoU`eFZHaZX98Yl_H#R7&C-pIxZAkdL(Lm(n9;jFC*>x`c1vMyt1G;R5qr`)h_=R}UY9@gE2Ih9watK#G~(N2xP#@!8K6Vr$faR%@h<8vCRH zg4E(>>nMPzJ*Ka60IALth(4}MeyPyrq&h3*LZw-KPg6!x2mw|c7PClT2pOiln3Bon+kt&xUru^&?+2T5RKo*PL7zdi6AFy4?n%Yl)Q+E4NPX}Q0sSfp{6jRK z)3@ur<`l5Mwiq9NxfmMA=HKfy47shw)%O}V+KhG@DRW&yrIcCvt(<4O+6eD*QZ=A@ z8mzXn>?B@|0*GB!&P_(#^OL|(-5FP#8J30+Q0gcV|M%4J0EcuI z#xOr1fC-WTA-(5FMh-A6DK^D4EU9=O?gyOZgE~CKuXkA!09HKC*IDBh5nYgdKVBb` zkd)js&a{q5VY)>*cEtw<1_v&C->;hvAd>(v_r2)hhC53cbgIAYF^mLd$Ql{_az2~> zQ#owAT>Fh((1N`B`yt1zD>=URe|)jmPhtoGuOs>e5q>~bb|@s-)XapP?cUyjQv%_7 zoWPum3x|=3i5hTE$FEh%ryECKl#t-*FP2P%pbCz}$>=RhBDRTfaI74w-i25r*OW_K zn2Yda`)lH}S$&s|rgp4?fM9S775hp{1|$CUTzpO+AeHk4UMUp$+dID62Pap0!H{t1 zhv|^uU@Zx+7Z%V>2yj*ks{LcOP&O!N0zMnJus0BZo0mg#rJN!CKfV*$B-l8hSqT4n z0uK$~@(&DjYs8ik@HZPvuWOlgzCGejjw*YUs1jJNwVcr%KSV|U9R{$P`3QkLF*sQ1 zy!hJl>5rvlz2WPJd-DXEC@|u59yI)GnqU|4_)5n$m-Bf8jN9LpJ^Qqa@&|VC@@%f>F-V(HgLD`*p2dS*IV(m zUEs7q3h%u%bgg2fe>;A6h>)j7mHL$>N`D0rAN@R=vYq!27vt>{BRYLfoq3nRewgh{ z(Yu{Wp}aE-EUhu`k6>g%wKhDje!RT7S9XrAdQb!;kQT5vfOr-V-{K)?J~=t zY;TkE)@Ofat=dIfnB{6yL_yF`&a#y*m@@(HAAwr*|6ZxTA{m{#_EEG~>Z4=!wm==G z?#*duMZFc>Uvz&-zp2Hhwp_4@6dM8@sENT4`X=6Gb{5|b;Sl4SIb#$=uDXUdtNd&J zIebvnWOUKt(cm2#+Np;Y`^|vW;BcDYv!Avf!(ZS1>jAJp{<(l|uvg{$PiXUZj05I0 z7ntj+1XkF8%KjRyW!5QZo}EI?AQ7R=u-`k&YRYa?x9DAnft zAHQ^=ocyiV3IhXk>Y@Yp?_qLU0r_Nftm12||NGV?Su!pu*qo$LZRx*;7K@_Du}>wK zTeB+t_k#ZSJ$^teMer22n+|^e18tTLytdijXKKd(-je?n)JWyHfr(fX(lY-<`+y!B zkn_R0fdGuY|6#`8ANnxCL^l=zutV~H(YV9GOyMqWnbY_OUejfPiG5B}%Kt$7tqf+w zZ{@YJelV>5_X7XL&da~Jlob*EPqZisFlI#^*>S7=gOI}i0<=q=@2dYmv%&)FzLlzL zN!9{eMc7K<2kf z-{`Z&F0VDwH+r$Af8uZ*Et-kAIQGWi$FFFm8W|=_{O%T^q*`AtZ4&eTFIj=H9?PFt z!#ySpp`j2Nld)AVshG@SnyhOP@4vGPe>2YCn{u;4`W`%VU{LUh_OWxyKMWb_rw>mj zo2!4x;Pg+rL-3dG5WMDV{lo0Rzmyffjyn zfL+1yS03sAc>IgS!ICVVQY7r0Bv}0i5C8ksKGb9| z{{R0n|KFD;85de{ld%lhtc3I5vId$!fz@7Ja7>sXy5QRtDrA)FyF-~~)0e^GPF(Y` z09>WAvcY?COXL++1SlVRbJIOdGc~X?3tEXjE%>Se`dj)|0{)wu1`$Bi`hx=pM8q{u z%E6zjUL%8p+;8jcx+R$0-a;@Q&yo@W%tB0yMr`Yn_lB+JAcI!(eS=o}0Uhlf;mPL~ zies51F%%P~_H&GF3Puj=y}E2501mF{UhKPSs`Q5k^}u`kvp$Gj+;87LChu*VVq@cn zvp%ww$VbVtf1M`(j|%%Uh>?lP%C4D(Jxd;?Tt#8(Gb(zL$-> zA1(Fb^+iF64hUe(pJh^`LW#sv=8xj&E zsf@S34)=nx>zXb)y}klU%EMWjd;)csvQAkFB`*~HG-?_25l{m^1cCSzCFK@y#6?qA0-r!_N_9~!JT7dqna>@9IMQ{_ARCct+Zke zIp4rjVYq8OVuI?!+`N{zCKK7vj4iRG-i}fIgAjmZ!AWUI0`9Nt`@P@_>wmz-KV94} zfvUXp%GFL`$Um~$z!r@FBQPl?;Mf|LyslaRoeu9jm&FbiclqPT?{YawNXEDqB4AAQ zKa%m=u>77f(tCaOufc^ZxF2E4{>!1+BNw}e>PH`xBrA{qVn>1n(2%=LC^3_hKK=<* z#e-xihmoYqk(QUEN8J%-Qgu3w$2$ZQ;Gk3iSI=Xlf4*P?xS+pxVdYQV$mlaD{#%oN zivt3FPU>qpxWLM=D4{&irm7%Al^34LKA!HA-2da+ekH+`O2Yq1VS{WYpAzhG7f+eN zPIq^I{Ax%wSA3-Bt17iQo<7;}^^5rCqv0icd1!RGnxY@UzcNLCA+8b&)KBx1PQVY* zq!hG36#oN;hS6c`%#RrUV$_`=VOrXWFPzm?#GZQo@i|0E$RB*-&<$}2i4DntOqzcx z;=awcG_Mc8jrAw{oouwWBAn^B*OZVqGUSl~QBZhp{P!pYKH1roKSLoSBRj`wEwL9! zJC_A}7t#FfEvm9Xj#4udV-AgJ&wN@V&!vb!K|rYQZImrZLeq^~VqgVJ_6yL&6ttYY ziv;i*VFecI4c``I?)N+KFGk0UI%DK9QcX+`&pvL$UwCIF1--2$N58GVBZRsuVb`G+^VdjP{I1DRSIa(;Oh9g<*gGCit*5a`b zXT6GV@c9!?oG$;!jRSx4NuCucjbNc#UnWY#qBjAJ4pD$U%X5B{WIX zY=)YQy^Okyj42 z7V2)EJ>pZtN!?1z+gQoft2R-ZpBkEj*!WNGg|l{$xZu0K`3g8Igv1M7)Zq9=Ql`xv z(!ELdyi+L~fI6fnI#DMJ`(@r)AU1 zoFy*2;)!)LN1nXYZlyofJI()mtmh6hE)e_EYpmBYSm9Iz&BRXL)z&i1$@1#RdOSp` z+BAy=wcxC$mhj}Ns05`Z-r1EBpKjNa^;Dtj@YdwAe@M%J?AL0#{9=T>V%D7R zCFkp$XazU@T*Boli%1q6|F$Iv)JklSYu>O&o~!pxK{(~3Pyu2EwYpBXNiM@f19`+PEcK5FL7v4bmJ5k4>mw<* z+8sW5-cL*FJZOmiy3OBLZT0l$TU&)jo&}khM(4|PtyzWMYnk;LJ4xIg;01GxX>o7L zTivgH>S+O|ED8kA;*XkH#c@2dG zVUsZ&lJDN$5St(%2i=jf&I!-eHGXu{$BHbKJIy~w3_uOLZ%`*Sx@hL zZ)T&Pu_M1Vq9no^dbvkP$6fl$So<-vtNBa9LDkjRh+uznjh%d{6A4~WSKxF?%tE{H z+y<|{H#m2I21)rVhyFjk0Kgz3Mp5kj*e=CXO-0O?msEZG+Wkx{mKKI^>&AL zwI!-=+3$|jly>iGxOD^PLUvJUBOm&Qc&Q_=x$xisoAzr z(y{9!@M^%F4ivU7lmv3mK5va52p{Qbp}~U`96IP=ZQagFDOW;s8!CYYMKMMEX>9cZ z6|c8FKM7)W?~ryV)|9+^hiS_xDk|0_CfeGOv{tx3t;+yL!9l}`9_f67`Pq*6IGQ3+pK3~-Lov3X&Z(SnbwF+$B*UsQ6 z(|J50yhB{sxot{;+e44W$to)Coj9>a06sJ*iFZpr4%S#~G()}IC|X#geohmbDsOp0 z__S1yEU0Cot)<0qRNF#0-mdB|G(A0SbDd0OJyR?l_WDTyA3v?$-3f!qgW$a3Ub2$` z$fxnUOyKDtk4;wMQ{bVq+Pja;a$bzEt(@GDT8Y{=3oGq;`m|4tap5cf2#rFz8Gai6 zJOS%a9vqS7?eTn<*!o;YzbCswXI+QF^NelOw};#{_R4R%O9QB9`o%6Wdu7@UQy*Tr zy3{I`48XTTs@FrD8=x;ab|w2-X%t_HQFn-DX-B|0Ppg3dxwhuI2YF2bJvz~tY@2Ib z7U$1`C$T@uCeYhf&G*|})?f*__i=aINc6QX=kG%$%dAtNU5tC+-1mRN+$}`%&sRxm zX9&EqehPw-Y4{&Es^=MNa4Od&$_2Wqsi)&t^*{C-Qg$;W&e*1#HNkc$-qb~4mFSq9xEH(<+emvbC^QUcgmh0x^Sg5*rZT$k* zhox*Rwmu?OH4>W2+zNLqh&%%5u)Eicww9fbZTX&T=S%tL0pU?QF{1MyULO(&S%1V3 zADH|;?;thap_Q*?c5`*H*q9&Z2O(L{{d%;!Qa8-SzRAt;#a+}ie;-Nbv9@zv^^gp@ zKUWluX)w=v%@)4t)aA+X%@%GarCV5g?=+6BnO{7vXg8>G<9S`{=rb(Eo0E0qPr23* z(Mx4iFAWe|%9ZC=Mw|nmkO82F>TM_AHZ@m{dvUkyXWr{wH{Oxb*JO_Y>Ot;^$%~Bk zUZFYCe}YQ3^!mMW2HrX5L|$$Pi?~H{ysb0%+?%~QjUs2flGR|!-|U0m7d}<^8jPry z24hPJA_aKe-=F)Af0wvl*odlE6nP(S!$Lh1Bn1x?~I*@^zMB~tiDj5xh%OAdvL^Id?%EGjm^2^`#9GhnQ6hC z&K)d4Iiu(8)Yx{rPRnK(QQ7~QU@=Biu{Jvw&A>h2Ji@}i)+grm?x_xA7fyFHZ7A-?Ey}(m@9KBBQuz5s_f&gme%5Ex?FyGyV7BeVbb_;~ zb^x2`PNP{mpL_9|+jVBW$>kU8#hmt6F0-Gy&bLt)a@im?zjrC5${3Ni?lj|b;PMt0 zKvC%Sr||RYJKMmDf0JINH%#vPo#6R^#2i5pA3kK)D=YxFs1Ii5WXb7tN3p$fAlt1U zzT?ud$}WhX2UgI1QwX%fUR5>zE9JgYWM`9L3N4ur5trGewd2_*e6QSI%qh*WU-3A-*}koOzPeS2(^-7mTcFJiSCzoVLS}1bMm> zV4;3K$*}iz2(L96{*IUMCSQxkw^QnLTJA|&%O|RFTj}dbIzOV-BM>;z&ASoBaQ7%PgT%uMGN=Y}FH@o#sY%m6srX?2lacYMN>zP+Mb60ANJlVEUs-^+YM5<1`omAEw~nz5HtzF zo#5_H;RH|6AO$1@cXuh=HMqM|xEGw7Yp*r;-2cCyb9-*i4bR}As6IyTqxUgd>u>u$ z7km3g!i#!L&Zd_$h^s~`Gx)P%K%Qg^O#}Z*u=gOkN<$9y?ef`Y`FhoTP~odzfEA0EKDG-qV%NadLh8)< zTtQ~(=pjjSHHhqq*k`viWeLND@c`75G&|o6^+O&CGtJgQbBk=c7!zIOY}?ZtR>WM= zQ3QU-o6e;GKNRbgxU{kGg?tmD8d5VKE!8TF*$5aGe? zz_oezL>JSvx)B{Dr)kM`o>N2o@@1ri2tqP>xVDE@rY$6B{f9Mm91K^&{$`EhdwK#=?uJ^v=elRU^V-bfNi7+lg)j;e*i$cX zyIay^VMVa$wFh!Z5M05!H=yy$J};9|@{Bx2{h?9Y%Rrnw+_{x=*Bd%&3}w-R*8B6t z2^FE-P8yQpt77Ai(DVYe>;bXbFX1-_J1S?20XqVZEKx>7hb(Iz?!{*S2^)6kql=0g z7qxYUw-ir>=K#hoV+H#A;M^e%8Yl&+RwJ=C)6znn)xI89U8ij^=K0TQB;|?%ORXx` z&`%Z7WTNPxUEvt^+Rj6oh6|6^H8pMKkAJRvWylp6kPXMx=lE?F`iVJoP@+P8i#3@h za72HnN0~R_l*~9RVNn&(`+B``InI}pLLA&tjD5_>p#`%6yY2xy$zt-eaY@B94TrNO z%WM8);P!rEPJdVdn#eeDIO|aH7B26l;a+^& zIwy9@TKwuR>BX+=n_~a#{l25TbZAign)^+oS(N$p)STgw3YrInNKJiyn7AdjQQpYm zjm3tT&*4m|%1=lm^=>HEN6)@v!3AS2w2eW|ky~wxrk85maJOYCFuzZ1`Yz9o6djri zoc?pWkS}|pLIr{$xSFMUW>&Q}!tM8LlGf#P~M{mcG=7FIbJP7+xaR$@mb}Gebd)&pn$0T=|m`If%=~B>3W}# z;rLruw^T8+B7L7@4tueyqxq3-1?}IwnM9y#4w?$=D{3n7r#YBA)Gux>7#G)Na~}(N zeYL@gj~$h=W7Mx2-}PBS^qRDxp!vz|_IgMX9=irS+KD%LR9BiZ&7sSn2;2E8fWwE5 z1#j=qNMR^@-%ljX$_o z<;1(B>HlQ2&9L(J;=xEuhWoS+l?nuXyi1qThFgjt)x(Ave<+{j0vUQjcfs}qj4*ST z9gdnfiHA}2t>++q8Hi0yBl#ULN zk0B=-hH7722Z8fD6<2&f@Lq~ASgNxpM7 zXmGjTE91Dw-QCt(-(GTLDA8;UZGbze&O=Z=+z>9s>QSHu4x{@pioJGW|Nfm7AA2{Y zFl?XEnGyrk_9sBa3^=Z>X-Fl+2AIu^+MjT7O~$t?G<8ScAmDNE2cpw?@LM2Jf)W(V zFerkL4>;@dq(YMPb^$&4!9igiKX&Fx&1b&uYR}CP4>)Ay@4mWR-w041{PJ165=8bX zK;z}>3)ik}={v5519ttoB}%_rgBGrPQM1k`dz-%^SiD$?rYt|$@eTKf)Ed5QnX};F zS6y(Wfcg4W*oV(zuUI_0lizB|K4%)SE@PM)fV)0-WgQ+f<>;nc$^EEhYZ+gIwc zr*eYiJ_7qYl3u5CpsVrQmps=O&70~m457SY8@%TtkU>l zab)hDYY6U3JbwBHeCAvglFymj5-!;cU0asiqu6AwiFRQMV^QTThwKDitEJ}RAz{Yol zC@)VFk;_{yOO~B>k**JH9OlEZNUXI<6g;l%pe@&`BMF2T?~8 z8aJMAR{3HWr}SRr09Z>2NPmJaR>Vi-yfP%&gCpLD-8OhycuO9gp?-JxA=-vqJni~v z?Z9WkXyXJ?^07K!L{s*|5-IqyQGLo%zBpEY;vl9H@nJL!msx&)&Ci83Yh|MCfXPU?so}cSx}F& zaWrQ2;SfVxpDVG&oxWZjE-u>r+_NtU0X;6aS8A2y@@A_ni#W!SoeG2U?xQ;`^%V=PLI0~WI!zBU!*PJrG#^;mpEbu-N z5mxlm*zJoIOgjcdWdHE&&?WSrbhOU*Z7sKImxnF|t!JZ5*RX<0cV>4at;iNK(=}4r z2C?hCHWj+Tf^t``cPc8i4&Ow{RDb^}H=d)bNrHD49M|SNgO`#CG7tP?xCLap{wd|9?mB)Sz-UOm{+xuXd{T}U z)_{0|2$!*A84h*3Q1038Gi@Aa_Ylc0eLyrB@A(vt19#T#{qjQ;RJrWCyseV3w>Z{z zcToNByYh$&QT-q;$OobQ*}(gZrCy{Ls(>=%eY(zMx8B(^nzDE!I(~EUR8&7$`WjIk zksITIjZ5-S1I+P?L<0hN34!s(qhdSe~CmRw2&sc(UiLNqa@K5$Vv>wD|e^eR) zq^u|u>{nV3?xpy$jv<)To>R)RO`~(M*?)S($c> zkKxkmQYq6U)bBnDoDDs9AN=B6gHET$TF}fUSsN5~ z|Kb*Pg%aPY^NcTqEZR(#KF;XccbpK^+7Wzt9gD!4%F5@oo)XFD5I9R^_Gt&}Em{4N zD}_nQ>*Fo3x!Mna^t1OdwiW<^?VZ|E(>MR8xUS*6oAuVCIUv$<6sv&1D0*T`ECvz^ z;MMh?3;U=9V1?KmNk3uHSSalp{O+jNT$TQPky+4We5pJxLQQq z8i+}+Wnzz^lq;0W&969K75LtIEfLNVvx=i&?;Xxf$PNge$x(%o#5?@@6^x5{=&e;A zY=?sw)^fSfmDCV`$)a3bPRp@ff`3vUz%UA7IN404j zCyuUWSAoB8oK%=|o!5$rIXx~GjI`(*EbQ~A1KF|>pMT;l9gcU|KK|afznYCoPz#hJ zG~Zt0Iz7n_MYTA5pq(W&*9fHQ^oS*g>dQw#3Bq0T`-=w7mMw)YQs-z?u0@a*|2DJ3r7f@XzM zLI;bHQeFow)SZh zb=uGh#`q()@2v^-(XtBC8{kF}>@-fY`VLR6QF^R3Y|PYk49ulxzz4f&*Sj3EIsG-K zNri*a_{ZIDu3~<&B8zPV!d%i65*~p6sqjtqodi{^T>++Yx)9LF0=`2|=lrZV%Dy+K zIO@KLfBp)^eqD+=?Hk~n<+LlGJ1gL@0mSL&ee1l|)nl1`Ic1T0M2B*2ikGbBxYZi% z+X(QNytMf_glHJM)p>u`2YyR=$&&-sr9aqnx+uk6?D<+(Qhc-aNmz!GJRS(w96ge z;kZR4X9->0NbFJFJ1_nc<9bFWQOzYr<#a959edySvc?l)lB~7OxRu@(uLLt->Nw$U zBP>%eZc~hliD_1&RL9P0j;t7=PSF)o!PDnW!dfQ7Vq$ELS zv_frj<%z=Z^8!Q#crvSPE+ZaAYrCw6IYul;f4)?#*hKz>(c5RO6*^wj-IM5IPuHx= zo>)uqzRavSJi)_DA;YTNLGW~I3H{N1;o&L#fK;vmgq8?VpO zz>Z$8r@N&{ZgKJDW12(sRD(^wBxKF!UEX|p=BD>2`nr?QAPA&_1u%miJqo=N9Xku> zQgR^9k*T0M!_~=U1zD=9z8%Jc;%2P%g!XTdD78*A;*NCP6PZ_J3_8SH3OW zf(yB>gaV z0gm!=c{BRh;>D=o!s$89FF4QJO>lc2g$uUfj5B@Jv?vzq#r^bx;Nq0SMfGZ})?ohe z$OrdO>zgjbcM{WRSKn|_FU89XeR{zL5a{8c?Vmsg==ZM0{B@VIof+BGTRVb_5hQ+< z;y^`m`4)&17A8c%{8#1#E1XL=w4rjnK>yZk;rnUgqHXuK@pp?~`ITL|N6Xre8_hu4j+7V&<;doC%XzsA(^0XV z^j*o^{e*uIds1=)%p#H(=UgHdoc6Ku#@`Bou>0ij&n9{Cw1<3gE#kw)H|n@M+IsV4 zCE%H^$D{OX*H$QhTb3|*XM?uXyJ6wDcIYPU+)tkRot8MB=J!IMyQ<|l4`^Y#J9HC7 zA5vGroUJ<`3iaJHc{g751ENm_Izsp{N2P9Q*V*E?4L%pB&IZX~+>^|Vi-y~IRbMr> zXM_B3)YIcW_XZg`e7!KvZmyk4pujnpeep8I~$CL6b+W#R&@-?mpEM>-8I7pT>#&w!MJ|=5;c} z$UX|+gJUE(eI}ic&Dqsbc70F7$xzna(;9Tg$;Q5xDsxB zm0EkRJQUWE7S}R#bNL-T_>Q4l?u2ApDil)K5zU+qhn|6{gxk*B(nak_NbFx>}z znRAB9`O^XHsE=}*cmIXTCjLdLnla=a{!OUBsc7`0e-PYw1?r-&P-1^agu{{iznLIp zgi((7@82xXPWmc@xRw>+t!eE#a{4|=;GzZLgBtJ0 zf`5F)5gS4i!j`Fnn>~%jM~p776UgXcl5BR-OQSzGzKjZ{e~({_9&I5|`PE-6;Bh5k`iw!n#2 z?&pAEF$aG{>w?Z5vXFhX%lXp1juy+N_)8Z%33ByFs}@av!$Mj70xU#zQge%4@+=R~ z@AsN`+*)<4v1h8wC!PwT#?aNv{cSEB zg)}05E&IL?>HCX58W zh+RCyt=i$QKYhTvWttTq-%{k@p_!~>wAf%8`jykAxB1>@{+LFT;$L9LU)&8GyK9JZ zC)QBr<#SnnO^Xr)(*EeH6vD9U;BgW67hZ~sTWREw_c-Ttp*Vz$6sjI3 zm*P~*O@p)Pr<=@R$MM6)rUe>4iWxw=|9KmREaj#i>ijZ0T~CE`1(mE(g~x!6XVL+q z<6p0d=NIQj{wWr2)=Zr#W6vT;)^DcxoKG3Db{`A3h_y!Xa~A*8K`40HYE(g=6SUa5 zWjo!B90-Uo_iv4lf{xRCk2oas4F`~gR%B#?vtNJhcyByV_NM1^jHx)%h235+VQ`L+ z)buUsv6AzePEuxOaL;^!_0nY8)SH7E8KVd)A~JIxHP?qveXWLs$Of9{&GUZ1L&XJ| zU;hp!;B(T4S}IDWi8Wc!>8CUhb4c;S%|p7BT#N^bp!y4L(2d60_82LL@7RD2>*>9D zmUTB&^6~Z)&)T#NbQ-z>COS>W#&RlUe@^iOsK3?z`(FR^0uk^YWklRsO0zhEy5sX( zJDR+oe*uK>=;_t(ijt&Bx4+kee;y?LRXj4dT{`*q)cWrOYHB!?j`wZ(*ZaS5z`xu3 zDz*I*PKY~u6~FlpXY~*G9PJ$(N=N=uU-0ptZTn@$jjR zFwaqx_}}yR??%-*;UGL~O8t9+f3)?ViiQAscoBt^dlW0f{{n9Qe|VV)x(l%s=AyT8 z|5#T3eMM3+f1Pv8(e*MpI3o>SZ*HL* z0=EDAb43h+w1q~)sKnMCHTTr#v^4DDEPdHaU zTM6IQ@$k>;%to*q%75NE$#|qlju3bo2GE7Of0o?@OA0JRfUR|<|D2Rx-;whk-+V4C z;|iYmS$ZHl2aGZ0z_>|C=5?-Hj7peU7`uo+`n3{slwp7MzByNEH^1y%ZOxG{`JBYW zpiuWj=%yVuabAd_6?=~WKV z(r>>(`qZJ< z*Plf?7XEN(7oPVFkGL;tDjr=1A8IfEi0|t-VSwKCM-j{}MAuqf&yFmEKU$4x9z_3R z8c3Pp6*Qz8klCbXW*XD^zjlDbQ1E?jUz^3IP_W~YRB;li186M6rh1pN=3-1ZH@fk7 z*{ricvdSnKDVg_+r6oaA$mH&@jVm&gzts;%}&Dhku&(yI9bXR#&(99B$tCO-T{ zLe1fa-*gO`4MYZUwUL=!9Zc8d#0t4)6_&v1I(Qi;}NYlKW z4hEa9KAl#Nr17oN($OC6J-z*lr#g1mcN6-Sk!mrek+(3;=~m*>WvScD(RUI)-p(uB zw<1)nA2EZRK6!2{q7hn6?OUcgP+2(ikv&}pB@3CtD8lPjPiLcV9b{K!Y^phb-t41o zEBh5&B0E_ZyPSkvW5e(B9sR@3Y8FRbaxL9aW5S}AevO-rd}fSpylrZu`p0CWV}ui$ z3FGU&7*t)oF9jnZw0pm2EfxtjHRf$BYFxDH%(mx`+{jeW$A9|Qh}N-i%=U7=ud`$h zw-?SME5R|bMPlcV=XEYskPaEqNwL>|C8vvczVt246jRVl=b{_U=7W!BZdW{qwtTJ% zo0jLP7(r6FF%2Xc2Qw5v&X=(*6Ykw(DY!I!|MnlNUB@ait3HtXvD(3IYKLBQ|2Bt1 zl&R1EmFWD6FZ5IMS(+j{KgaS++2UHg0Pd@~KGsQZqoL%|>-**`omU5~91PR7eGWw^!c0`EWw^wIzHyeXaAy!T+G{7RxR<(>_ORZMNoWk_0SQ_DcfoGQOAz$a zTz%b3u?NSl-Vr(6=Js~7vjo`PjjiZm4S{V}-pDni&rsLbw}fK6O}TPbb36FYW=JUV ze7lce%_dI4aaueE-)R;Jqb_A~G2DFK7X$e@Jl1AiX|a!M#k}iz$&xE`hPb!`Puhv= zGf;0Cr_E{_8J=`1)zlI;*|lVe8rMpOm3Ugu z{FPt2R&WEiZlulA!;sG5tN_G6qkXzAZ+Agh{9tiFA2ZSCf~KG?wq>_u%n>90(9tl; zg#Be5Q0v*uDiRzZa;$K}@4TWR`s;H`&)cvDk))ffStDU7$io?d4zc+aAmj%9mQ2{9 zI5Y?8ebu>35T{ZNv2Pc+lf|%$#mXlmxrtRc6QtuWEx0@2FiRl%M>^isApS35uCIj;$|(( zuAo~#ue!;&D34|t^J=CZh!k7X(4h4|+^PFeS9%nHW0w)X9!}M#J$8cn>S9OSv{$7D z&a8AbzCk+!6i2~-4GJLHC433DBd!W=aFvtz=w;9l6vZYN9h8M+nS==*OeSEl}|H>l(pfMcQ-R{Q&P6gO1CXTbWvGlG2D ztlZwSKly@X%c;0z&seb~E>Szm8JHOJB+7+_R@V=Pe8)?CDpl~L_?}9+elvtoDb1iH zh#D~Kl2O*xU_Dt7c<7+>#xYtoU82i;M1Kt~5#JZsd(HP@;0?}|UaL7puRMn+@nDIp z)--pevMG}ikA&ow7ySYrC9%2dRO9SwPebj(ynu(P@UwO!f1ft)II=I~ z-t};qca#NZPvmsvGRA!@dq)k=2y6545U)Gy)q=MmFcmiHiH!TyFnK~=Ih+{|kOtE- z9ymPLK{y{Q11V-3V|9UTVJFz9rR{c7pO`wbF7IC-hE^T+hRo+e$HF_Z%=$WYsEOMy zhDAAP=cW$@wZz3xe_pMarY|xwBdB=V;q*dq4w2+K*iN&a-MotYKweBtFC-<6YyIwT z7!OxDAy2*U?`PrZDhEOL>gy}oW8}oQO!+x9)F9-nCo-XDq$T=JapgXhvH>l-_JgP% zE&XzvvCJiJ!G)VR9piG~1UCiSHPKJ%{=m;Dny{=e$K&+=jd#p61xbatQHWfWfl&Sx z3kh7ko;aC49!iKhvj?YG2~9@p@aTh4;3vxH#-d`=H^|Zt!8w`>%N4J*f_)@V$_HjC zkZ)q%4f=nI5e^z2s`qMT+W)S#gTK2y%5+GM zKKT^b$a*&Lml)2rJ^9y+q_@vm*6HWFb2N}*IU=fZD=DY2>sq<&a#iKUkk7XOqGE_q z39t9kPVYsj7SD-_ORT2kRn~l9%c|_~jfF8s--^y+a{dCyL;Ho4+b@gOZsuemm`vqpxp8{5%?NIOmG?wItlZ8XM4k z7X0v+d44{@PDrj*0!rDi z!l!4)`q)?E2$3-gGcDStimF2uxRQ4Z&Mp_x8kprRXEY%_N;VTE=!q=tix!5kioWw! zZ}k~yFZSX5j2+8$kD#Opzkh*G$i{0#ylYlwWz9botsuwt}g*U*2K#rt5obTg3v{p_rF>`1iiF)pxT)TU6& zb9-bxRD;#fSA4J+#yic!sL4v-DUC_^uFY+}!Yb;MV~zcE$*E)bb?I`0Y1S=cQ$|a$ zL~gpAOZ1fR1@XdAIp}NKP^|)KP8LXfva#_`S&|VH`x#@?xUd<#`3OzDpk5QKHe=Ih zme@vTEjN${?b2Q&^mWdmw=c|+rYjLJD%V2a#=6+$P(Q+-vyHpmVD>)AOy1o(XtMU0 z#d8$_4R^WXKy7g>v(|L3+GvNT!Nw}^mi&C*K7#kk6c3GxqIxn@GN)DF=ZmR@_L`4z zyL-enEJ?qTcX{4GtTu)}7+oX@@3Iu+yksp}pdAgn%tAU=EYXwnF!!k+^d;128w`&@`ar%bY_ z@}eC?UcA*vO3v;=P=cP^A?r__h%}bNxmhmWPZ^Snyr}i2HX!@)PL9G3nVqUE;A5~o zky1@n!&y4RHBKUPmY!z-Ul*4*wCxd$k*gpu_%3?EEqd}*4#SVy5HpBk!mRMr^Tv6; zFi4_=-CI(!O9c0!OU2(b)-3>YfZy-cqDhSxB|B#teaPZ2pMWUlcW#5FC6*bkb z7*3tOpBhlqK6JLB%QUL@y4vDlGbpCn1+pOq(|z>ne*85-ve_Yu)k3hgaC$Y&aZOx; zrXpNX)>=39nSq26iQ|X)0>tZOT|UngAYyX&Gu*zZD5f9V4ZsNL!Hf1 zof@y}(Ks>N5&n-gl;&wKlO5M~ z%`v3oKR<&h+%QbwK$7#$K0$?u-#L1p)+(KMqsybkM7&vOR+Rr~&>@ldXnP8~K6-Ai zbu%pQxj_gMNzDNxcYeOne)=KLhKs*0VK5xdX)n?;PCP|kpRpTh;%MLp>A=hPyMJnduC`L z%v+bYS7a)2^~rVC(D{jMwCNWaAqAEHdCaGx(r!=V;V^`^6epP}SB`PnRYfi6Kc)4F zJ#ekfWh!WgNs(`&_3U>+LaXMh3ppKbFL1vx@j?e$GC5Hixx1*sh}Jb?OpZDYuA)j` zea2oLQ>Z#@H?Y>m3lfBEiBZi?h5a;IWR)XhnbE~2O@;}SA4BsX~fOLxWIwAf45T^-;>{x#8=s$bYYLCO`n6}9G;nsSoKw`KnMfW)e z#|3e2@UzV_2CPsfPVQTap>0nQf89&Te6Sv#>q%B2N3bb?F576iQ^RFCwYa86J=+XP zxv^E2W=VJdDmLj#kcbGI{Eyop!b(@RAE9%qrN+~4wD7e=61BGWSJQ9isGXq9h^GfC z-^ZBci__{++YD(VBrQX3`K|+go8#|H&%2DfqTC6ip|tdTeB@>gx;GD!Z_*_z6yNR^ zOTyw(IZ+T&uMd5GFfcGIs+Y_>i<@R1I$cQTz&gl2 zXi=(!BN!9%7?XOPGS-snkbhwd(8O#LH&hKJDpuXqo`_nL&$ z07vpfMa6NWh-V~^%IDsUsX_SJWmt%1Z$fNI7aX*-M>~~o%CI76X8uK+J!ELFfceLw zU>$x=U!zYGHRlW1(%V?dICQ6X=>GF7m+ls+w+TQjZCs{r($p63RTKh$cj+AYMTSVz zA$5u>ceEZqP4e+_V6e5W#=Y8ooEQ^x`^v}hyxw0xnyu>Or@~4q? zq^_>d`gUK<+i^O>ix+zDh8Z+)?1#;E9V@3c$6aePHF>ux7|4J^Bu3~<<`Y~M` zNg{Oguf`BwKY|B-q|Hge%64sBovw;uzDh-D({4s1M-|mn$ctFiF2o>C=FRnkQw%*; zOSjHP5gG&3VDGASmXOMr=vJWF0$9+{Y)G?A{&kf0^+7t-ys}yizun|`{jJ}<62x!t zoRDe!1H$R#R;{H>1?J2wDe$BY!UX%yQ(R)RY3T#lC4C(i0SiB9lrRUhPF)V8^u&v zvaa~e!L6z|=?|W{9V5Ua@{g5Ofr?acY*jw+t9c3H-k0}Ec0cJ!u^AvK6(T~E+Y?Kx zRQo0kpVb0`iWWk}$r}Pf_A8oh(K>H6tLx^{EoxUyD~0hEE*$L`mk0}}8|;ieJs91i zGy@Qj+Z$`DmxFF!+{Egw2l0JGxP)OHy+p7HmMA5NzNyLp>mNSl)rQ~P1ga@z_^auQ z;_}dr%5?PIIcomE0u0qZpCA%{*b`(0m>iuZp9!D@(vUsSpln9WP?F6-IuzB}8+==% zTM6#}OUv@VG@sO1kTJ}!l0GI1YX;t|Gl?Stk@=|KIL#uarMxx$3}!1MBo)sM&}2}x z+$uU?)k1r`e!$vFzy_VH+9^)XbSC+iwS0P4q}yEgiIKbM@r~Xh@(Btdd#Z0}(DD!u zlVy1478voC$uQc{9$Rny8QuKwCk?~l$H<-C>8!w)2?_P{-G=$1#;ymK?MIdQ2JX$2 z<2r#r%w|_xsM4s#i1u2D*~$ULfiwDw?c#1oRcy%UfYrYOjr#pRh~(HOui36JZXq%7Xf_D$NSZxhdYR4?<4H9 zf_Q21>k}n6BXSb%n=#b@M=|GNtEIM#+1f*-I{a()O~0i0Mb1NfsGex z{rN99ilP3lv%LLge-*txI<;V`G+;O|ROxcFeKqloo!`ylxF&g1xme<*c^fo++q`!Q- z_QM2JZ7*C=V+5KCU5E3qmG`a7D>AlG6KEX@X2T!eUOUea?2`fT_gkBwLr}F#g5Xi{ zs19c)y78q@H0u6$7laaI%%}8dn=pK}Q}k}42699O547xxw^rYa$0u%;#4-J*_rq0a zzOk*yG*4Iiv>EOUkGFdByjwm-N}G57kxM0Zq}oi@X?-DruZBa&Y<(h<*N82R*LHy~ z1r_>nmT=>T=HAzBhb`Kvyhe+byGd;4qDxVA#4g6f^wa+a$I4jssbvu5(IGD;WZ+rR zqAqj@n7G+}Xh}QP4YFE@Ez&~GkVHg0Q(k4S4bFV*lJENRtiUXEV0sfg`H_xUrR-#C z&S4NCdCU_jy0K&V)o52N0*MJKDk~PLhZTE8?x6zik7jyOT-pLjrJz)O!-PfRbK zY#LL1NAh0i(-hHQs>o=($r}le&}r0qc*R__L}PYNl$E2HdqBhEY1j$hg)lNFxgf2O znKa<|zC$eOpBqerD%5CgqoU`PB7;PeB6z*b)DL_UaT(G&+)8~cFDhHARU{#Dr&-Z2 z{0JxQ8}3b*;3jvAqE!C&f_iS17v&bV0Z_bvZ%=E4$*N=`{oY;m;E0_qRq^LAYzp4- z(i}PJrT=IFsJVpRXrt*{WO%nwxc5U&UX{)9raf9QVb?J|KQvKbV4`r1zJN8Qj*1@A zGwJyw7=N2ME^Js+L9tn9l@dh5NR=Z{?#c36SAMw3-E9hRk{eU);{+`E$30zd4KL%7 z=4PI~T=9nTF*Ba)M1zepZ5}F2Rxl$Ic0PTLVuo;hD*82(fyeU_mi=XUF?jL z*ej8-Yarp~*;}P;5E!QoWV=u|@MIw>L$bk-uHY%bl>YDmrT$`YlYc0o%Fe|b2YL6M zO~a@DU2CC3UCgWOB*apNyds3egZ;{JIk!+7xDeB^D@2!1IEcY`g_Lh>{ljN$U$% zNM!}}EetyEK6I5rGSgMBmv-y2oxJn>CWVXBNwIrti%`=cEzc+7#%>{TYk1myN3+=Eg3U=T zy@5tqJRcgRR^Sy<5q{DFmsb{#07DSynxPM`RJ9@ImxD=wB*vt@kKE;rmK6AZen{O4 zu}5@RbDy#B{6RW^hx|6@ABt4}0T%es`+Ks@uOIA7VURriw&dBbEobwR2=0d_nY_Hb>b}f7>(+Xg z)>^LAu-EUs`d?e8MMi|nKU-ax+x#g2rY0fTDq30=)LPE`A?DDwZftBMCMMSS9bxv& zM@QEM7sxh=As4LU)E6OIX*hbGZ^r2;iewN8q)!|t5D5tx_kmJV$jW`A&8B^qu#y`b z@Qct2V>0(0z8cHN-|tdZq`$9-(n}!*hR{Q^_}0Txew=bLjiq*TPkbD=l7FicoZ)uyUyK ze4StODW8kED$6&?RZzT5<|)sZ>-=)FhZ%hE;^B6l17UX;W{jYQZ%?orjY6H;s`1a4 zX8GbiHv?w<_&B)Q`57rfR7tGbAD1-@-MyYxFCJkqv(4U-cyS+rniS33Z6FX>{3#oL(ozse;)kVnNn*Cz z26IM*C2iuZG9+Ljj$sC-b1S*9Z#)`SeL2gIS8Tj~{Q&cH>bL?oTS>&O*Xe$J zq~pp~ktwx}O}SHTNvv-2x+6D-b8tfw*ix3H*@0@^sD9I8#n%V;)|HUcWO65esa^j! z|)HIekucai}Cr6w}y~?6&%O@Xy1h~uDoyd9fA?0tI7G?GfFj~{sPQA&kon3 z?k83Jb_<>Ed*eT!eM%eGMu0}0-2Y@Ic-T3t<)t?t&F{h6x*p##c{*OR1j6aTVXPmQyeKg7p z+i9$fYU|iP!WFiYhf=tTd)8h`$jHd_u(_CP8A)%=Rhqt(^!F9F_>65wPx>uXXN}z; z)f5~GVYRS*khLjbXwob{`I!6OOxW}E+vVQ;+qU}yT{ACwl$?M$nY|R$3V-}XWb~Jo z{PqF4&YNLe3lJIjn3OI^>v$eb9WVMjpZ{TrpyJBZ(dhqZtP2))(I{P2c**pA%3{=V z@u}6Vc=*9A@b}m^l`84#ECIW;)7=bl4}-}(h0w%=ThXA_uV}h<`)MaNl;X_Va%B3f& z&&|ot>B?R$UZ|M3ct;s#SEN&*Z-5h{hexC1{;%#B0!=Y7-gUn|(HO69GownWOZ;(B zBwYJKQit@!7V*5F-zy~X<}S7Q>8fN&L^bmJdFq~_=(Gx(bK|P2#yJv`^`XA)Yv_c( z!=oUPT#&JE1|p%Foh;q;PpQ5q`^jfA7$ zX|q>uzPx`j7u26A{*de6Dl&NL2=N?fj3ixd2N@m?nrdt`e#%!h<-{OfVA}@ zYJilKwBSN~aeiz}^<>lIK)vvd2dQ6zHYxf7eVHgzcs2_Jgt%x(D?2XA-B3_Cmtu2<$wVO3|` zV?Ix4YNL8}whSwh7U;-_S7VZz3g0_JX~n({cU|mqZRjhIwao-~st|MjKYV>-c%;F$ zZk$YPO>A>!Voz+_>B+>lHL-1LV%xTD+g9iH-UrX#_nv!xeqG(Gt5(&wYWaOf@}e%B zFMr9hZ3miPCmT?31x`JgY!dSPitNjZ5$N6577*}>z`}82l)ziWxdSv>u2l0Qe${`u zM&R^07*)+gAfJLpgMZ5N!G!@Y`V;&GqnJ_s5~5nlv`+C%!0VNP@2;=;i+Rmvi$jz7 zX`9c?+~H0tA$C$mBZH^YL`O8O!jx1!o_46q7|zxPoD9`0yBr-CG2Z#9tZUk z8Q-<EN%ham|Oi354;|Hc|l-0dpQW4#5v1frjM^D5o5)ENT zQ`zce?hj2C<^mSe*YVV|p26z3SRcalzVr;SUBE_~c9{BkRM_3~R4&+ZNr2^7j-VQr zZQ(qPcv4}4uenIIzs8KUYj))u2pmt>39odmKgc@;fSi(R)VvNLxpR#Q-q{o>VD)P( zmcFaP=hM%8LAU<5BqzVuo7b1KW^v8{gZmU>M^A&#(Jb<85Qv1E`^7%3z)CG(%Hl0Y z4zdAWK9y}^@${~d`_R^s8EO&ljdAM(A}DOF)?_TwjiRhV??bE+w8lRISE|pmxgt2} zWft7kc|U@1aHK4?i{&;f75Z06T-U07o4#8BTcTn!m=W?Ozy1i+6#x!}HWF~%t?`?^ zr*^JoJc&gTm1)>!P&9^PGcUAjdu_^HRj7B7mIn~Mdd=>aHqHX*j<3)u$5xODf#Ow# zPIaC^e^Wf_%sP?2di^zKuLuS;rfO2Hc&w}ti+oxb9kv4x(*~ei6V?9Y%(~{mM*T4f zxx!LptFxmM6*TMsN_4%SlhMwju>N6WkS*rPvAT%sYHNME(AE|8dDW?|&A9Aiv+-*U zdQ5ePj8au>`+dIDL47Tg$K^Z1C?XeWGOGAu=AzAEpBp9M@4eQRr2)S+R5;S88Vh`< z0^XlH?by7+-XT@37VbPGcHzU}FyIRalE(b=<8W=~QFP%d82Q7P{z(mxDH5s;s*JAJ z?X(x2a=KWBjL0Bsl)u(f^xk>#c5TY)53}HdLtwYsOEE(ATrFr}q#lH#i7Y1z@)1t0 zc)kc6Nad_!UaVM}27$8c|6r~MA42H7gM{_2cp4907gr;A(^0#>K--F(eF^zUboe?1 z)~M^zu6`cxX^o*#%as+AdUr!DYt?kkZ&#l7%`Gj8CzVut)?B`2@N<%IEtXt7@rK2G ze)-z|76A_`2tKje3r^EN6nk8$aBKFqXlmQ+?x%hlFCZ<;7`WvlfD9O|^f`aqihfg^ z^5Qe0c{LGQo5>%#=i6=`ZUnRll-%V$^3WhXtR_F83@JaJfijN#4$kwVDV6_RAtad5 z*%Znof`16qF}h^=H+sEwSOv4H@#2%$mX*VBuz7K45I5@niAc5DiX^ajJIc;~_1G;a zP2-60;EqxzGD&-!LmUX?RFpH@C<;IdBjPg!y78f2KZ07<`69AU5*JD(FLCw#-Pj$F zb~-;G=y_>Zukv1neU`J0O!<*nTmkWVRH|HTb+u4laffrJ1&-)t6Dk{DiO8y-P0YpSy3Do?L&NZ9^iX7(5V>fbM5Zj%Tk1vpGFR=GZV3Z?OO{ zuep*N-MT~OhrayBtR!4G)7v$haKQ0&)xp!&QD0hugkZF48LZy7--nf7sB}{~4Kq}# z^khli^O2I^zXR)m>j=%%7=u2{CN$-G4Pu>y+``hRfsf?cqynDXogatvv})xcj;G5) zj6Wu{J*jlLT~z4hhmds_?3*1=Y-abuEB&F%$5ZdZIeUg;S1*i*qNMx8h<7)gcNYDA z|9#dXD5A<9dEIw;^^E0WzYLzK-jz`-n}5E+SlH!ozelO{{-8QpZ|Ok%`WUGNWu_Y* zpey_6;eiln>-Ki>x7O&z=(o*Q;suX0UFG-^MSw3K#QeNX;KEgfJ)jUUc4+RV*XSAa zLHU}A-44>Rl1aA^v8i)!bW5QOPCi)&B6Uxm;+!QlM8U;yMXQ`wT&R1IcKpQ z1P*@MJi@v?U&_vztm*!AnyZE}*_Q1&E5UE^S{l{l^IoIlFXCS)y+mXlnknEDCDY69 zvfVM%duYh-S6y(GC|nIh9Ar=Fe6#+x${2-6^i6ODI+0Fw`sVOG2!YLh|2N36W?6l4 zFej=ZW-^$Al5-|l{lF#Bm>2#mYvAd3Bo>sZF z0EFT$!T;GFgowwcqt=Ul0(A&|ws+wr24jGS-M*b6UP>@*qed&u zhVfa(G9k49>9N^?u2Jzw{NX8qsl*B&8xNzd-<|Rr&vfKAaIdj-@pZO(04@+L5raPR ztJbD_S-DImw<0_a6HOn<+H~0DkK3oGHs?A@_c6Y znOglOIjfcEXGoS?i8W_JzcX`Mof6djc9WRwRSOwB^`(6_vt8E_aG0L#GBtX&@-37Y z#+>VJ9s!{+Y{rNmmWB4h%q(~Uug4+-=^t@wCRF96j+S{5!=0?Z#yKOo8lkLuJZLQ9 zybNd00>HNBKnB2>r*gj7_jVp}lch^Otsdi{XoLvGU2v+Mgb@KLkS$@d{`{qN@frP! zX{Fcgc0Yl*lbDjW8@|(PO@E7h^L>J&FUWonC+3qH_#Ci4#J&w*v`6yIk4`*;*9qU) z+@@4vhY{cp!a);V(Ip6$N@B>pwIVOaKqlY})cDcK3gJF(*KSnM`uUklKtnt2csZ{} zr&3Pm^@ik2qWBZ~=kK;e^P!*v;Kv&P3nt$phr~|dvE!ty7q02o`};KFUgq zMYIX*#%KS?90-t@gFwi(n6d!aiK^WLgap%5Yx7bwd~88a_U#oSCA0}pjpunyLd zgt7eU^c9-*yKS~_S~FUB7(6!~>s4om$ElaisQ}VfZyGL#0~Nr?<3*c_U@odKDF6c^ z=x{7soV(tp%#jiPO5f|F0*0%AB?GSaYkiGW=6;S|yGD&IP8 zp+YW%6b2whm=2SfOc3wnR*lTbk#BGx6)rdf(lwGC%2Vl}DZeTf`65J=4SIl1mF~nU z14UwsY9sT-!7h{F>VqkpkInedl2-L^V8mArE#27iqy9(v#Y`qUyFG5PmI;>9L6toA z-Ll7YSE|?OYTN24+xt*9IxO6foWz!JDylpgnYcYFvb9LLM677NI z7Z~VL=esm8v3Ip4yE-AZov)8;Rf5J(af}K{vkktNztTBu6#Wvt%#rr}q6pVUhS9c0 zna1VU8m)uL_K2apoD_oSa1>~FqI!PpK+p*JKd&+sNGQibiv{CTm!NWi4M5+KsJ4o{ z{rKD6C|SY;BU#)e?+93KaENw1nHAcsda2CPg5@A&X|K1mw3)PV#3Qfxiin35A{lvf zH5?jVamctP0ec^8e~dCisWGKW`ptRVWk&aQJ&H$=vG*z2Av`}nesXM4ICc->Y!M0J zQ=o?AV?REcq(6Dd)U?NQ>M7%n-|Sc?W>z;a<+H)LV^@UB5aU(PdHT;w#Gjli7e8UY zk*{7+N7^DScS09W3qil22TP=FjdSAc?@Q2XJ*J2fxtg0P7uBzcrWdv)(GwjUjXky8cq^^hLV!=9qA$lj&LRgH>!n8r=0%9KpxV33`A%f{w?U<{`4%tt$;#6$}5_^uTwxw@6U*m1(%_uLQJ(3PFiJ}!zeg6 z`=g|jIYY|60zN#5Lgv5#bJf4Tg*P2qv-dD}(L!Kw*Sgwuzbc}-K#pt`Vt4qv_qA&8 z{BQn2lTx11A6MP}e+CvoD)NHX&qECijpUOd_xJuUgkxr!_2^eMn;!h(PqU-^-tQEe zO@6@2t`d};XajP^W>+lqA+cBHtCc!=)@uFh8J}bZ&Zs(_d%LLmL18V05OAzo-a*gu zX68i#iJf%Upcicc^wH9gopq@JNCCzOE&(=XAgednU9nU8IJnVNP{#q}q$tjwsD?uY z!k`lsf3$l93jO+GEy6AHW z4h6M51mxK;u8m@Ik@J0Z&SHpUc7IIV>H8zG!d6I}mHX+ryi__@MBf&-_dF({vOi73 z{&s|CF0*}aWkXu5)ANILjLhXSV@H&1P-f9%N&`cs2&5Ydz45ZFXNx&NlfKXDn%uikypb}(!cX&q-avkt;Ao4e${ll&4Y#c6WT7sh|_GB zGnK`VZJFDmhb{BZ6KkDJIybEpWlCc9i>DLn@|Bk$o*;u=;@>N{A*d*0+X`i>ZmWBa zNpZDvuu2S&da5!uM1O)XN5h+J znrGfO-hSWd5f0Nz0&^_fAC4hLZ#%(GPI1Kzl$cL;2%U-apDI1E^*mbde7x+EmpU!q zNTyc5`|JRa=Oum48VN@kwn@<7NfV|TR_EvtsHx>TEnVP)2vkb!Ou!1R#60O=ZVzL1 zxExAIx;@CYY!2>7#+}8wSv+7tz zu0_d3(LL9o%(f1(rmA&$R@crb4Y*{SWe;C51-Zb?@1@wIV25JStJ69dJYO+){vteFYK501ZEI9aa`f3|x_dP016=?Dc zW5b~qL)SQJGdZkYrLmgpE0qj97k@hArXo4oEO(ez8Hrmcoh=eV)j&}Y1@;UB_A`3y z6UDrK3BJ61EGyzd-dSuQ2i&&)n$VwxaC{W)Vvccl5`Y^@22jTP_Yl)o+j}}r;Wh?Q zZ_q6V3jmvQ_eavT1Ev6z=egw(px~xOIw%ua>4=MGMzrh-yqtErQn;%loWw~TtpEY| z{I}%5t`Sxrri{q2>tT`Rll0L<1=+VJsE65X;g=v2(Gi5}X+7R(bLuyjj;gijRYKz+HB!yBwQCxE_{IA=hgmT=V>GHx z?GamU@#DQdYK4eNZN+)>*yk*_85F&2cz--&KII=`rWe`uN%PXmeKcnth2HJM8z2W*shV~$=6t_%MjRk85I3RAn z4ct1kyAplj2?h1+S&Re4*GfDG_(cwp-X?TudcWlwB`lOAlOLbE@0sEUWpnM3>3qe6dQe-Tm`B z=$bcJsXb`@Z8HY~gZ{2xqOHbQ|AfZk(#&Y@P4mK8H8L97s^YZuI=fk z^QF*Bg^8n%v#{;^K8OL`$hOG%1nZpJEHypH7Q(Hti4Yk-aa)oR1hoW;3o~=$3C&FA z`D-u~`>f6AxMr@KzH7wK3M0VrD2E?Tuv}aBgK21=c;EBoFSdApv^rC=>E{b~_ol9T zQNhbQ90Bu~U>E+X{h0#L!->fj4)R=;QKeoI)T7Q6M~6D@e3@pOg6+7+g$X#>pn_k!%+T38ku#n1A4KWRhpc?P48JdHWK)1+cuCn1KxSEsU zsXot_&Yk*5!87P0FEeizm^9!Bq(1A}`PHB*8lW{xnynMyItYjMj-)biXG92CUe~}w z-56Hv>J!i$UkdT?7<7|FvJ17c2F34&KnqQj{ZlS9_kr!fD9mUd1+4BAAHUXtP?=Tf z?1YcVLX6pIy9zSmNe7?F3UHNdecIerBTmqUoPQ0vbt7N^*WD1H)|PuvJi?TqmnwCO z!vLLcCh;@!@2XN)o!TN^m1ynK5(~xJD9}eBkAdWqb*8H3X00fD^NVHjllkI|UA)Q# zHe-5dadGf$c_?iSAnzdX&V1jEgmSa>MKEhL)!eapMsXHUvCgr zwmhAyQ$5NM^kkpfF2ST}En@vPo7A@z2!g4%`%BvVK~H9oV-OM`XUBr>e3nNOomsd% zder63MkLzh-H{W6AIN{*NvHh#yAjlT>=u%0m*eAi4_*m7t1st+k?A>7$!7-W1iY>i z!@RW0jhL9FW;^aR3cCC4CLSCwA^;T~!Oq-i!FW}fxnmd&Z zqBt7~^rt<+Ngr*}$;{pMslK9>*}T5?eh`&jd1RUM%YFfWBnn3toF>WI{OYZXh7XQD z9_^#O^d7hHIR1nMUGlk0hyRjwk}DKVxpAWs;S!Vrj!9*NPi6}+CziEWe58H^o_D`= zjmJ7^?9YBTgD)A-QBkMSSA_u&zu6n=Rh`#m4C7OP71$R4qlnbcdI}U}*3Fn@+`c`a zzkRdmk`Nx(>>nV~w_vIVRnXiVRgPvj;u#90ez!ng{j6^7w)GPpG+S;%Wo0%R*h>gl zrjmIth%lo(+t_=%-fX)XX6|B`ex!bzeCq~(I4YKgxH8P%e^9ZPO}&kOG)xL&;2SOH z9lBrGwgG%ecwB3;PB>vV?SW&12|rt?{mR4u_Xu+^$lGsIYKXN{DloFXtI^Mi=tybb z>pC`ksne`lrlxs!@Yd)#Al2MEA4^RAo&*1L)B}#D#7?1PSu&X^QcqgchAe{op5AoJ z1`5VJ6kQZ8*dz#(UQerZLq(V+jqi-dc#t77TB$*bvRRJsPaIo5n^1PE@Zq3F0N6@z zI5wl|0Q+9(blB9-pe^P*Y&sFA_vRV{TAz~g1c4oM85g@tjedUdq^M$QT}ZX-B0FEaBQ zzk;XGoAiuqabSLcwAcW16;S+^Dblc~h#9|>`eu*1dq4JPMcO=>9lAs^KjS5Rak(F? z2CluKO8sv`-RXp&ZqXi?pHjLx$oTW}6)2AftXrRvV0N-FxzR$7%~q&ELo&GUdxo~f z@}?lO9(BH`K+bi&H3Y#KCUW0Nr(SudZ(ADk^`vUC$)?J76*tI$=~c3DB8e{@X7VrG zr-)pC#_`G5DFcN?58*VTRM>hWJBYl2T%!T_zoI7V{^cqAFoN;ASl_tdVbApor4kW+ zHoGjIUI}-Wd;K05>v5W(DPab#k!=-hl;oJ})2n$(WBqbE^8{=1 z*J-^rvSsX)emB*{ihLBOYkHpA-{Y ztP|0Fa`G4VKFVh>kF@VL5AlvBF;;~4k{C#?I@?7g5}4MRJP5}$%_$1pYX^I!tC8E7z&C)W2^=D5+0d0BKUyv2NVtvbz9-FN0}qSw-1w) zHhn(%XuSR&I|*srCsbW0!Gw=KPhZHed;alRrfvr4C_z<>`wplNNF6NH5~xwPDr&kW z%&^9|CJ&_y;(lrRur8Y(^^Gi0#OQ)Bz<@HyO@%V1!TQpl_07xsP~@u}a$dJqwIH|^ z`>qr5gS~Ba5Bvx6s+I4o^2(AM@yU_dbks(xvl2E=v>OCHJ(|F)SGWvg;tjPYjm$?RCYcg7vtm+DO6N%`zv66^Tn1z;TOZ>Nf zmjV`uu)`q_ksp%QEHo?LiyqykAapFdDv)_Yr>{I%h5kZ$%F#^8$Ge7q#*x%^07*B&+4M=5#X72r822xZRAPa zideJRaD54S%k%-Zhmz39TWHn;!?j%jrjS$vxz-yf)fY~ZBbn4Vn559(4-wz&d8=WX zAHHuXXHm0xTkBoKA=)y{4G^eb2>PMrmS|$ZezqH;FjSHb_lhTS(Ie&^exK8 zf7AhnZs7&Zx;}%QUX7vZ7ItF~S0-W906&yo{VxsjGcyeh+*DPFOTEkT+f;Dp{(iR= zGG0n3WS~Wui8-s=2msD!9qswJQ0C-syykNc^|SEBaSaBw;g`ugh+&L%BPKTyG=o6P z*Nn@xHWAu*8ydu#)>RBDnWU5HO_v@f7;9Q(($;$vsV{mR?y2zhHLcf0wlLrUzCzt3 z*BVgAe}x}$u;dqaUurl~;{WS&CA1J#CYT7sk>8c|T7l4Pub&#z+-|^K%8nsCYzR>H zTC|8SKd2f`RLobh1_N9xPYu`t-x=KDv_`4KEFMK*o&Z@>*CJDj2mzwl?IWB6wM_la zK0;!E$^wTAWkd~86DA}W!eme*pG~dALV!kyEffA5BM}l&ruU4Ee3*b%k_r!sC>YG^ zLV~!6KIDMvy7_FPB}K@0p5gWDE#c7*nfs$}|3&VffAWvzfD#C6;QG0t!OPCBe8ltn z*?#x!p*I!gexiOf9NPfm{`r-EOyFOB@gI-!!Gh)ATG-PVK4sYcr}2OCW&3~x)j=D( zJ>xaC_A?6=3wG1MWr0yR zg#P~~t>*$2;lJ%T*Xsa<2XYSS|61w4KEgxH z!T#&>|8)`&|JPwKh{1twN67za>Ho2a2>PIcw%>tK& zN$ZgLugBJp`sem`uFrlQ_66Xu-0ic~_=?}=WV$qX{;fLHbF(Pia=rdQf2n;GmPxOj zfsp$t0hA)xIl90C;P@xwY$*P5j>UeomDc|YS!Q^@?ruRmF7pf!WR2=%1|QB2s|tbR z$)Y&HwSGKRWdFkV7RSq3ao}bvd9PE|Eead?I(I&N@wm_+aynJk;oIW?RfEKSY`E_g)Pn@VN=&zA?)1C<5de|ZkOh~i&f zr}kYqkh!i_G?hZG%iRzQ3gDM9cd8a@H(8JIGt9r;pHlI6cG|x`(*jT=ODJ%rX$*+IkN4?ahp#Pir za0Q?l7<9(w4@o~-Y94gBY$fI7goJy4^0=rNRvYg9GFvWbxj%j=^t?SeV*7O-y?cz? zFmX70K(D|#o&?Ir8D3veHrb8Nrw{$jW8!e+W?a(HvTc;d(CS5RdA{3iqamV*T6>lDxF@@XT5 zw!28Lx3dB_TWNBE>iyWRuurS~JzTk*6jGFSaRAg{*U(RG??dzd7EYi$hW=A0zw>FX zI7%78eV+zcfBE+EGWI7@yFfj9QmI;yU-NDW29MvUyg)Wy4F5;T-Boe7h@Uk)sD>a` z82V27mpn@9{oe16%h5!OT5xb=9_Qbbq1*1=?+7?_Io42)nlu_DRWab#cKNSy0{TQ9 z#YzKB{TqDb8ek*)qYDtDX z*tNr9_FxKrQ%-x(|0UN?I9`U&q-m zPh~fEgd<*Mk4_C(G<8jDS@>^J8;#l=Ht^Vdqbzt;6#!jV{Q99RB!~mfhOj#rStbPu z!}4BlCta#j+P?X2*s)p*SqVq4R+$7t?2T&e)MO9J^R1|bEs{y6EvvSbPiCsP&RT8Q zOw?|&G24tU9#1%#I||g?-+7X!><1M*Jj`uv>DRitX{?->E(Wv0Bj&4D7;q(wmnlG<;{Qy?dR-+M&_ zTuunk`^A|dT*q$bmr>w`K@F8-4ba*!+DXLBxAuDew<54zikh>K>HU7zgS^~gQNvE)Dsyk+EX$qM$DMs(q##U@ud{Fq-W4W-mRz z#9KYlurdjeLE+cSt2k=_=B`lWsX}n%EA_gpeQ5phHy3L)VDWJ6cAphnFLo~Z-o7gs zPl0gzOZ+5crMg!{ebBK50ElKbyb40e6p6FUJOOI_jXBP17q+z#t^0! zvW;tQh8g$Y?i-`LUsViMZ>->?kR|@GIvi&wMK!|dc}rz`jpoQN%%$G#`VYc>IMHAm z(C*$dlEnSE;4&aQ*F{mM6QzL#rK`hJD$(k?9)JDh<0xpvQx|w4Bo3=hIxKqMm0vi^ z^6cFLp9EY~2tK+D6|`%*EDlN0YiSosgYzMcLjIh0=*JGMATZK0Q}jOj;lE*USjCyG^1#CA)qPHU11mQYS^_u$OVD zA-XtJa#^H61sPmuAt;Rr6b~p%QiU|usNmGG3qv=%trsO@$VE;uv!`7a@9*|eiSQ6G zjW>{#Occ2H*FzCs=1tm~DG~~%+tcq4NxPpR{L+Ucj|aSLiJzkb@UWm2r~+Rz;<_L5 zDfEIWcVj3n(qVTI%5OT}Q1<^ucR}&Pd9Pc%Lr8CvNb2lLN-g!HFVVp3kxWuqfTWr) z-2Ozo^ym4^_F_Q&J%^Z(nDtaVo<(;qhq zG{+v}sMV2IYMp}V;O!5`E1#auXCzyl&*-gIofa<8KDC+@YfUB?BJuyw!VR-DM?-gb z!NbF&w@R_7R;g)jkaIg-QXY&ZF6yE)kxP&&(je)M+s9g$t@46 zV%tu@Fv1#DAdA)bpw_RHrvD%IzyMkGeRSe3Vwlf&sJA+KN4{sT z8Mc-PLipog{iew`VFVAy&^0Jv=QB!+`$KDiT&B`yg`%vz0lg4CYf^Vy;aCl47o*v1 zK@tjTcJ~~6ypxver?92!F@iIAG`qKuxux6hLXeU*Mg~QBfAcD@b5wM0Y}&YDSL1+DSDfgdZLS!y3AKgp5kND z$uLGW#z#b|S}d+c6Hl9JPq1Z(hjSzbq^Rh&!k2o_X!uk#xws;u1<4^`2~fEx6qVA> zk}0`Z%TQnRcI)(~#A8*R-k$Ad%!+0sR~?gGKAvCo++AsdLX4vqg0oK&?y#U<3yfdJ{>SS=F!Q^5VjE$&`oyx z@<*|z3NxL@b~};xg{4(($>&XCey>il5fxv@`{<^o>rDGA9-)V=m`F~vn4Ob^lWNXWEN_1#OryrIZ9HvZ_TEXK140FFP$5d zFXHF1{#zG`=*ExIGm3BbE}J&alEtFb*_0j~PqP;!T;3m9{R#Syb86ei4IZ~QQ{sg* zwwmrCj#aoaxd!b7!h*t{yRshq(0(z)+d)5IxI72 zTq71W6$asIgn+?uu9`aEA{Ay#aO=>q=e&sDVE@6XeYUyQH~df@1` z=BRLvIOFJ@ghS6T)hjpMNYbM>5+ba>6u1`kqra1=uu*x4(ec190-NG%kMg=)U6>g{ z_dc{eKI#EJu}`vV-lDBc+>}k1!r+h@Rj)(mzG&`y1%aEK;8H_-ZOod;Q3gEKHc)d~ zKOGbuzhM4I|NM^Re4NrC?5NH+bMtmRw%)Y$^-XchJ@4#uOSEFmId&?|lc9k~iAy?> zrf8%Qp&TX44$RGS5%#uS>f z;&$asLZn?=Z(#dDLthZ1S=zw+)Z(XBhnHtW&=A>%@-dii)@t^ss|$b4pDz_N!W+%qCiuIy3!8u+#)+r$^4$NG;v6E(0dyz z*QDtZdaMA+ec1XJRO%x$*yC#TN}vxIZT9S$)=iNl_b-BZ6iuxhVqf4Bjx#hY5g zd&NUn%AF#1-7HTngrEhVWNq?QdiU9`5chH_DV&+H6KqwOPV@PS(WC%WpiSXdtOsmA^tdkPEF2Z+Bo&mH zlHt|9%Z#_-=bAi}6`_1jnC!YE3>D$KxWs{_1G*95oE1c)aP}Hm1uko}p z@cX>BMFJl$wC(i_22FIX$PMzr$ZQ5LcPFGOhQyhSu8X;fHSb!R?56cL7yTo3`o=yh z0@H4BCb&kcEm|QzA*E^q{x|Ih9+z~3&XG9k_>VV~?G#AoykXF78a$GY2yt@ZC4nnF z2ERJ|tQGvpl_wP(xEvODvO6$l55=R!QXmh0p0blhHXamxiE3xal3TtVDatoQahN?X zchmDWmE9FW$QlJ7X%0$tL5^naagaGe!`YkO$&cf#35RLh*BG=P8tf+wyIe<#Q{z*U zEG0WB5UJ}`c;!=y%Lyg6!0a{q&UwvE<>3|M6g6~l7ka9tXH5BK(HFfFUT9t`@Y&oB z+~Rtx)tX|>t^u|Ex`l**n3o;Fmu`-<@`Hd@VV#J(B|Pv*p;u?>NHRv>GR7Zubqbbu9n!I% z0NB;Ru|wNc4@f7A8u!lMzYWCI+DsoGt@r;+#%dJ2@#qa##Fn6OFnu@Kx7e z4Qdp&cxb5GV47+cwU6qTR=pKljy?*lX5Mkv=)i#2Cv%sdv@f&2LJP3`dRl7$SdlV z8BN4Ts@wVmqe?503Jr9*lvtX)a2zZ1!JCoV1k>Z0kr}9>-R1v~vc-IAWQUmn*4CHzr z?j8-~{T?l-;g*vdTE=(t_Bn)2LDc&KWf^0P>svzvAtIN-s|zYs&07-{Q+2g|S%$VG zfq(z9ON~jaPkb_4S^?u`k~7kBhkd$hj|L%^#qCBV1r7eT3M%MO@w~meTM~>J5#j*; z8U|AZTlZ(a#p!@K#!gkGQ=c}<=zY+?$0dpHu^o=1tYgsbloE@=FK&~EGEV=+cG3xJ z(L-3%R6@Y%e%|bOJTC(2;xM_tm`vXFmCQ`3c=lbbN|RdOi%%GxLI!NLS$20Inpz*_ zl-}*`aQ03bs6k5J`KA{j7B`cO=En|sxq*-!pWjPW6g{(D_j&zYGMlOfb&;hY zG{gp|fYA$c&2MjPp-df3aPe*c^OV*;(mU}1Gqppm>HVz%!_c?0$RXNr29L%eZ9f5{ zBV1xk>BMAoFr3X|>3an*N7XcutNO1KKUdV<_nH!Y`)P5{E10pkUo8cQ3^$b;B{ruj zg6=Bg&&{ypl8BSMU-gf9T)H;CG1H|vg~4IAoy;&CuG47JZa`U$DRg?CdCR$Jt7 zAnVtfYSFa1qclq{PvyadDY7G2%w)ZV^#hUwJlNb6xSuayr?}|UZD2>4WgSjFCZviI zL%%YOS~3CiB@jn7kVe5Hw0FP>K-&_gtv^nx@nYp-!>cBQ8X;8okYE*usJ-qMT|%o3 zvk;S?{9)4l?v(k$@M|Lqx5l&p5pYpqdN0kI%qg`Ik&oolYZVdw|jRkD82H*q-Sqc`^f7Vbh2*QDKw2~gtxof4q1f}!gQb6Xjt?iZ=<0m3$`>E6hGdm2dX4|0 z(aWLHw_;fZ$N4T2&|hqvRVdbBP=pX$_qY~B)Ks!%!}z6$gS>xW44N+A(L21%+S_?a za07pa5F7bydiuP;*)quP@7~@LTV~;&q2^2jH`{E+c)tG~ZR!&fu_LDH(}X@)|9POF z?X7>Jwkj}{63FFT>-j1vq9Y&$@}pOUzme)c4DC)RzTRKb=_4PTZ4m9mFZOu7p&0QD zOMyzp<=HF-e{OhLEMK#>y5^|#iUxS)S#}#|90m9j4TEeaC+dixJJaFv6AVWW8JlYK zR97Ud<46HB5&@CPobv81Q2qAI9kpE6EcXv{6?t!cP6Yd5m7C!N#XmkAYB;PB4&N-F zH7wIL#jt^Fx`(^G0VM2=!j{FCJpP1Z(5$Su-vP-o84dDcGV8QRs|>N=eNDa%Zu(QS zsL#P|_#Yj2dH49Wiiga6WW-IMBK&8yNr%)_6r;qc{WdQ@cR= z7rgbgr@83U?iF2$fjRFt0P8(h&00L?y;EgUJBTj`7XKvZ7Jt|)@Lx8|;5TUh1>}HI z!`B-=!|E^Uv`*`F7`*;i_gdRjE9|{IhFUqDCsZ+Ld(g?`qTbXmek@+R)!MEosxm2% z8qS7`_zJ;&0^e*}qB38eH-ORQA%1tOKhv90+tNP@BL{0fV3bQ|WH1LHT9|%t#=YFJVmcq1*Gp&7Bug zg2Izw$6Wo#F*DOFANiQxAx}9b09<$T--0~NS}zI1ZkgQt;t5G%)?+-~=0t>a4A^{#zeMfoyu2Bu{A1cvvlCo5b( z8PxD~Zs-W`!{p}q(*9c~j@jq4hKi#yW(XUy>9E1y+zNO53f zMGD67jxk4p;u;8?IQz^zH@gmw6#!EipYHucDbm_sYu{(~QnEp8e}L~CEtRDJ6S+?J z@yABE6WM-0u1AxV<9QAHe4Sh-gvb^XW=V%x5qw`ur^O#Bq9v$Ae7 z=pQhP@FcpaOUASaiNo|p{s~}vF|`RLj;^dU{7wzoL(GIH%TpC|Gxzj|xXG3^dx{tw zhX#bApj!xvJnr~?p(hh0U4agQ#|pa^93%^|S|h~A34oKmeXuVXKDTO>=a^;T=q@2PK^;l*b6C zo-tXkHF1h0IR~$B(P)K~STg`DLhYrrlcLR6)PpnWTCs*b*B99?v(GjnuNqNCf)eaR zmmla?`#CN<(CRA=eTgM|{#&z3y_pj*uL5FzB;quzTYd zNAleWdYSsQT`uDu777KFQ&>7g=%!@qzvabW9=d1J!(dZ`xXYRdep~jJyJ%Jg!Ohe=^r3(_Qh&r z%n7?7Bv1f8eOZH9xrhT6T&uuo-OnQ0i!3pP)-JCa_uJ#qxC_X4OLB;|-J6E+$2t0#m zxJ43xwKeU1bwkP?inOATF$5A+CU;{JC?+UbwwOr?ZG@syr6pNXE|D-SdL+SgdVz7} zCKv!a3mE>wAET|+a9L0bn=OJj0$w|Lu?DpN7(}Y3Ko9?_LsIecCBQE6`CXlIK*+1s zRF3qHdOnaxLZ?N|r?Y(3RqW4n+OOmiiX7IHG9o~sVaK{XRG8mv!>_Tbv1rM$=vxsv zhnPVphq2|J#D~_u#Ehs_G9xB4^{d_de*5?hs(;?AWx1PZAS^y4Tac1>59&^(I^WGq zR>Jm_>P~DFj=L#+Z|Q*lr@gm|imTb$g>lye3El+vV8JyI+}%A89D)RCXe0!82^QSl zEw~1P1eXRH3+}FcI{W?hw|8>)U;g8qn;zY3^r&gIYSnz!oKK}qkJDJ7Xip*oXF|^D zoRs-)-&DXA-qni*EfK_CH3JT$ViaX#>#)ZeMkQP85E>^EaIuQ$; zJ$+5|xEmj$7rCq71^SGo$&Yb_^?WunMVpXIz8%4_bzk!5&->tMT4stDiO$j^!lYfX z<5{RFL6n#(vaNk%X16&HRK#*@hr{_vfVQ)QH9YsQTxKgQ%3dT0Bv9^PwuKQ>;;vnY z06wfcoH2>50Lw|=P?FQ;sr8W+K~#`>48eoNltwC5d|Lq|cg5L_>y`eTenqncy!pAN zcRD5pD9J$1`mn3hCmyVr0eLU5GnW-GlEL#<0`{_b69eIys}Ro!M|5}auODeI2URlg zYM=Y{r#g#!(X!eM;6lwr7|CN%yr{J0?7lCA?D!{_Ne&vL^7QN zC4djcZdVYl&>P}DGuY|8_AtCjgO#E29WPe`u58QM^1<{|3_04o{={b!g0+Ys@I_Q} z8UF4-B3bzjZN@lTDyT`l`-N>0Ifq7TFu`XQ8WJSUY;Mr;W7j>YQ)r;1rL^FA9Xa!N zdbiK&7U21{Qh@1`+d`gi?BuA@)xi?SH6Fq35d~65NG>R>jWdpkWI?`y!D3gek0ssO zwP3PNq}EEAeS6K=Hiji)Y*h~Me0w}o{ir)OkqHqY#M(U}NT%{lbVv=>{ML`X(un&q zzN6t5Oh5ICZ>Zpej4jbX-%c&&yo&&`?$q-pmvL+tl`P#_uYy>(YL5?AACOt7DJ?r+ z6(FU@dZ{H_7|%H(4Yfr0sc#;VFfXY&7NsMM^V)q;X*~EwJChSDn?)bT^HHp*-a%dU zY*UtrYVJGTn?a`(s>0(qi~_KhfCL?q1^zq(Gdk()7_uHu2NxlwIQrH5c42ksIi0Gu z(D#Qh-Xz{`T@?CuU8H%`60Gb9ctQ_F%?aJ(ZJMAPHm=ZbTkN{tXAXe5q{APGWyIDU(nJH?L%+T$l_-_ssoh4-Hs9&g7-hO`X zN`f|l8vz}eIQY3vC7?AHUR}ClP~CnXl>`uCicdYV{TS%O(_gEP3FmjlehS(V05$f5F*YgA2>t8 zg?0T}Np!bj43p2(l?|i%{9c~r{}X-b83Dh*l;;U4 zqwjGi6estBSi?>LNc>3c{Avh~h7k7?bmNMm4Ua+~oyXxUPLtI{`|-5J`5lD3v0@xM z%b)O5ko*x?;PNK$Q$)`=hf$+?TGL*3^36$5%?;*$_s%E*=$*I#>_z6wsAF*|S)9VF zFodX2RgW&`%K$tLv{-$^4quX2rrOL_DO;wdbz3aja+iBay~sgdn6tbpVO0SqP<)%2 z+LBu63#{04EaI?*I%_rQ`>IYh>~YhcTa{@EO-6)sc~c`o}G?3}w*)ktM-Df&@C<%f|7V7^@zF zGGo5da-z|Y;70Ohb75{K)0pz8J3<`gZqK*0NG$q?XVw-f;L_IxH*scDC}Lfyen=>mA#r(vee~buk)!RCacr0z8AFbaD38qXl6<<2 z`d*o5MLA#~^a4T)KNn-dv0mq|7%;&O_s9T+K1Fq*4c(5ip9YhGt_y_L1BpAZi8hpV zhd7l!C;79P6TVvM!pCCn$FIvqBic~Zs%1!^YpasaG4si%B0zgAYu$I*&l0=^Z%)7Yax{2HcD_YV$;9;xHiZgZFAdlA8_3-6#qg^b4lj`I!zSi z1gDs*Zm+xLPm<*_wV8|>t#HCP250tVNep(x)r@vht=w?m>1NC_@x^pxqgB50siLWp zRE6$D#4Ec#rIeIk-uDMjmzhM+cUi!fh}GQPiw*nK;{4z5u+wQ(Dl-Isq9TDc%0xe* z=o63hJ`;5WTdolqfIU-}@YT`K2B;ds8I&`V#gd_6jKf=agOFrcA%F~wf}=7Joyxe? z%|%NzL%_!v1^b{v5|IQkU&gie7;A4$6-h|Y^&*&yaMlT&dx|fMA9=`R|B~T3L-cgu zv#s;jnObe~P+4=!Oly&1=Nuo@B&H045@F{cf1PC3R0|(orCZ|@TZ_5=IznW)8pS<1ni-+9@h}It@ z=i0xTxb?03HXAm7R71Sxyb`~(B`jdpby8%K7#UCE{*3kH-F(moUaqja_h&0*Qjb-- zxQxXQC5+W+zVGfvgxZ+Csf9}|)nw;L==vh$@t~>ODfLqq|XZP)oSw@ z(H(fKTBVBJ+sPCUOF5SCFaZc6_)B(fOjpN}PRr%!j^ zn$ZjlYa?m>TEG4>An2@S6$0$2ITTvvt$0_TLaeJy+2lc5&B3=JiY%G@`0L(bvJHr? zS9^!Fj&~!Q6-}r0P)Bs}U>RApM<{I77i`4k{jpSKr5N<&3H@jy4git85Hx7CSM&P%n1B%^>~$vfk$@0Man_>{UwHr$ z(=6h1`4n%^;fyD5L(C~mKP+YYnzsZ-{yE|=Hx0V$@uw*;ke_LMO7Y2SOXkSn=7~tE zX&#S8ZcK8`S7t1LyzPR|!T>SziNPl#duUg3*iY90TEkEjiUkXd>kyOY(#!-oQw zIC5vLY~>UQ93S(tzV*}ud9~}lI~}YnHkk8#58Px1WO?)ENP#n~K!CRIOrCA9f=|G61Q;##ep^jwqJTHb)XAMH!Rj zY&T+7-Ls=BEniooK5GsC5Es+my?cs<@UHt1WvWOCF=vhI3_FgLw{%xBlY<`>9))6^ z*G&Mx7W@2RYeZ*ewMHwV5Y08w7#Z2IK?+$?QWcKFq$5}2RunNJTYJ>AM*-F<;)fmF zCz1@KM9l7v<=;_N>kbpv89Vr0*2cc(hr<3){e;>hY~vMLLg36HYh)Zx>n)t@Q*97X zRaNyiUO7Rq;i28V?QO_YwXp*leRr+RDHn|C21L08-w&gg)>wo?9wJ3D(yuYE;1Xa4 zx!@a_XI-{az{wl+oS+M}+Ksvdbxpx?-cAHvBwy$efJmZ+i;fBf2PmcBxA<)o2rE5} zo?57T$B8jOjObK8mgJBHs|U?AI0%X;97qQ3;yZyNUmvkbN%Ig+2vma#10j@+k3QRn zrDp>3C`49Y4nQwIVVbX9ij$u{5wj;kO+Iim(HAO6p1+IQ8j9O+mXi+wd>ZXeEF*}1 zzV)g8y~cjvH_Ox9VS3u-tU)=A!VK|b#wJOHB<2F&m0a}lc&FXR#5IzG6!u8%T*dLO zP`z)$?ng8cUD&67u!7@Y-*x%NyM`_)o5EW3eLaLFER6+;S-2=nSn5fj5~Db*D{HlF zY_6$^U2mXn1@*Go*hVvYPbgd=`(QFOJGqfQ#Oj7Prt$|@&ZqQ@IC5DcSThdt!W z;jzSUpL-(BM;W0$t+>lgO0ZMD_CvLKBrcd?K+dPwdGHHmI2no0JJbG-rOxO?y3Af} z6TstoyGTJ2W%TS9n`mxiV3=;7b+BE4%s_tp40mezs<-j1g9t-EW}yagpgHoCyfLhv z4A$L^QNShNEnK4IqDnqEZv2gHHF~MCjo_w-El0#0n_xHv#pOA<)xur^m*0SB)MwRP zRYAsA^5HTe`Rmx%pJBj;SxOM1eqpys;*4PLrLyeqDNLRbU6y44hj_PHvZ&O}t1PUBfD1g+NgS!0O&ofZd4?ss{9(+X5 zCGIEJMc?p@=3XVO9ardueNt2>uciRvLa2}j3qXPMg&hVndb4f}E)8Hj4qIGr{ejNE zISxf3q&{qhblk#?X})7SK9Mm4M~Iv@_RakJZ2Ln2uA}|oP_kHqW&GUXh&F7bu19_& z*vad>FTXMi6@P2~z$feZ(o~>jA3jEnXuXd8jK>^bMZs^1-SgzEeAutEdoMxT(3ytVz%XYL1{Ngb$q!pIrgUd*th1$_AkA(}pL$_-IQn z_kM0>!Ws1Hg%Kg#S?Pxzk^ZN-2MeJJ10hk%q+l7&n9#5UNj8~a>AN}MxHJ#(sNqio zM^tw7Of9vp{sy4Aso32vx0jWC<G;i`mrUK)Jz_pWu7`K z>$|t;u~+vnJX`GTb}ByL0|*$l^x|z1;N~VedNvNvHWYggK z1HSSwaNMIm3Au|A8h0G=AjHaHuM&;i**+*ny!+|AFqyD!Z^*olliz1WMc*`{2jzGh zbT9GD=)_>W0h+%~?P@&h#mrQ!8=67ssj_J|dORNlX9P6DXNh197^*~OrAtKwZ)NM& znAa3d;3_;TGsH5d5G)k0p@d&DqCy9A8B20d1joa%+eb=#vXU>#8PwyhX*S;lusw{w=J&gdqLs$^ zc-RoVwPm-49YrMq{{1~zKQ`c@SnA_!-@_2SX>JV{o+DgL1olb!xR2iTlD#w3?HCY^ zUe{hO(~&%Grt0G42&^O4h1!j9dy=3HwCV?cJcP*Usv8e3#y2Gv`e#jCbI7Nc&$pZ|(6`!$%Jh&y&XdJUU*% zjx8Kr8lJRb;kA8)rQBPePZQk9mb~3Vg%*T3;?PZuv1^VXXMLe zsy9;@`P-N5@9yS!bk4ZB&m^Yc=)b#!o52DtWNVwqp~zfx>lk!w0a)uCaqci&JLR=wvE20 zQErnF2;U^GS~q;(l*0O0M6rcVL>S82BzAbrnB|HAkA#O-D(*Wqkam@_{iyBE{@iq_ zQPaS!y!N#t_#lgWKTl<*S?!I{>Nvt;zCLVS65Z2j}L?G`g&f-tnyB_y{rtMH`(OZdh|jQRZ}*(Lx_ zm3DNIx1J9Xd|>S?&2&*80zP5s;W!T9O?F>09b4CEQ+VViRcgGTs~7@WA`qUh-*k~| ziWk0`v#1l%=phIIWbR?Ap5UVLnFor~GM(#v1ph&Ucj zjK!-`+5XYcKaCNw?30bv@zQw`LrV8ok%37#B0N%b8614z#_(t_o;V!L9pVs6f-kq- zGHGYvh`YnXHx4TMmEjdS_#$xjUaXb-xAH(u<===q%9tyu-A1Rex@KKEP!<` z+1-TxY~5;Hlb5%x6!_4gYHl95`W>(-Ui|9!_~Nn{)uU#z9%M;x|7G281z<-uc&6QU z4-S9-t`&2%?0X45LIQeU3+m3$DI_#_U+Q<7nnjV$xKBBS?Cvr!+&SPZODiO^T}M27 zXK7jLCCP2%Ze`NKhAZ%o$M`7`Eb+*3t-c2Qk3|ga;@iqaBaZ@$HCFqt+ixcA_WYN% zS|4gxp5jY5I&33wk2DUydFn&xJ}Qs^``em+%Q6=KKhha75fnKYG26u{{vE%3;-RtcKM!KIZ3>h) ziwP+l7EmarD~-|yS{w{%Gv5S+x$1tG8W|H}PGjqGWd!il78$TYC|^54VZ2_hh&NfuQVGyQQH_OAkZ zaZvWO4bzMNsQRyO0TcKDaXdkww9?-uGV%tT6=lLQS*m%tP}ldSimb{MrLMpGqqhs6#h#1 zz*f$go^LGgMgLaWN+a8?2gj@}RGwErIJ|B3j+BmZ|gDq!NdZl{3Od56l+l|Z0Gh40vkWQ*7* z>24;Ed)VRH=gSydhYMsG2tlgyw}3?a`j@bhK;G@T@dhClg-?aBE=b-68<+63em?X? zZpPx~F22Dzt{c$+{Zb9>i7G~qXvn&^Q{dyBx{M~ zzr=nWO}+WhXgg4gMMy}vyH%E6qL5HZ$8xdi=>)FrML@b0tkY1?3Kcx}n^hsF0C`MdosUZt7@RZ+K{Ex(j~p{SX8`JoM&0p3jhjuK_}Ubyj*Qtdz6FEa>iP z#1WaqrLD07wzza98vOTy0J*`mUrjyYM%|mPEMccmR=YimqpR$jBdg`ztZ>(_vdXgqK`Ph-h^L}%w6~+N{2#zZ(JdOIjnW5^}Oa3O=U@OfshSGY}k(gAb zHMbHtSEi%mb9L|&b`JN0H7937VtP7@&f5-YTm431EnZtqu^#77Kx6LKLd&3EGj_A3 z@23WAr(YdG+@-H1TwTMtxVhcs)pHG7j^Cib(UKj8o;7X7C{av=q?c5I*<0; z++589dAbVS8hPx!!BV}tLR8T!6IkzC?&UsTshJ_Le-L`1XJ(e8t>0E#TMKOGv!CBw zB7Xk-@OY_VU#3D z$ar$e;$a4&%J=|lD1=%-21N2;>^kuB5T5^<6lefd*MR9vk~>?8+R{b-5oA?Q7eGh5nA)mu+eFhiGM6Liya^x9fpuqSi?`_m)n$K*Ng0gTEW8QS-|9}rvwmdOdXsy8 z`M6F;D&n1SHP3^+Jy|lfOM2fHSE|_(AF{#)UJ~Iy>M#$wjf}+duNe99g?ImX|8!-+ zlwduJ$0&S49uVlT=tgBjBIc2Jsnbux?&W=knT%X(kayg^A3Dze?`_XNH#E{4QyuT* zj(UiA(lDXB_V(hvV#s=b@OZs<*x6Cu_X~Zo zQDnx?#WKQ)UMO8uz(SPCwg0e1MFA%ZitXZ>;%`$LFlgaqcG=DadC(>NsxThCj8B34Q$P z^vCuhz!byMdV4|Ae?;p$7mA^CwrvuPiDB}lmrhf}&!j_p5kwy*``!$7p=WHe&id&{ z6|40|3fkoy4cia?$SEyw5{Ya-PCAdj744&9&2&jL4YTEXj)w|qvIy#%052;R^@shA zSK56^AKmAS-#aa`?3!F%ecR^{WDyaWDuRrgG!Nlca&yL^#(2ks zMT|=0E*x1sw?oS8f({=;j!SQds5pd+6byn+QYMobv-t{`0|Vl@+`fRn9trDAPJ*a@b*Pzfi9d ziWDy;2(H`O<^z$~A+AkUjE1Ywv^QmIolZ6y^pvR=*~T|h8r~lkHI*`6X~XK;$5F+i zn{OBy(s&&6nG&cjyT1q>kvdGVdEA%5rj;4+;*?y;Z_mtKxcyP>Xp`XYm5`#1EAeYw zL2_7BASF3}y@y|W;~SO4o@+UDm)A!XmDuSXy0hNSvjR6QGprxr5%^wp=BsdsoKMpl z&F>qoeuW(wcT=Ueu#l{zx@AhnqMz15R$OGz2=d80LOFwegubcJt22JAY#SyLjGS3r z$y{5l&zh+ygKp13j;?)Up%&< zHgqBIsEA(0rtvw!%4x^`Oe5%sigg$kjWe|8CKIt6$&4MtMug&!Sxs8L*Y0|j=_Tlj zy%olHXZr2c7nM|GWf~4jAnG90ML3-_!AmWtsY)X|wnlsSCYx-bj$4lo{|~FfM5Jzr z@2&?{t*Dy&Z(y&0OStVRpDvnKE`#Q-JgS7A1g;gb#-N{1EqD%RwzbAqs>knkG@uJP z{z);cPEL@7M>kZ@r#gSXqaXw&hnzl*__X_grgZvOwwun6wa0Yfm0DBQyv=uQf~bOq z;uha)Egir+jYNqROeazP=UJ9kV_BU;)|kRi^GM4g53gJJF4!TE#o0;+JF<`d$8`v< z=Vuq)sCAEtAullj&HG@uRV6xSJL+b^mJjR(Jyv+-Moq>Bti6yEix#$nkNs8m`w0Rc zfIiw4`em91>C~L3+t>$r4xBQ?w9XOC?{tX z?W%5*3UwDB3Q`R7MX$8MY=xfZ6`l(jceE|__ahwbx@-m+dsWAiEqPBD4+?@+)}Caq zbhPiU93EbJ!4`L4=cSIru8ZCwrB4O=#!$*S9Ukzrje zWof)+`%7M!Kv0gEA!4U8sLdLD6IQC-o<^iE-lg=}*{T(pc-!wO$E3Kh5;9uyEueeB zXmS2U$>ixrH`T9kwcd3dCarJLmG;YkWx>w{zlMMFoZhLCZfwt7TCWUm|0L=LN0K=E z6e&5J6j3elK}pO>2ZNkyiKQXayV?6j(0b?L^QU5>_ZWCPt4 z_7f|*Enqq7prklJY6-@gq*0yPy<%;bE+Q;M&~V7h$gipYD}m{%!Dv*yXE{B z$8>LFbuK17tvqe5$CD>PUYkpfNJq|X#N;Dug>N^1{kpqEjc$mv@M6FIGp$^s zjZHSn-rhde{Az_wAY{+hM$bveTl^u@yhtU43Jos=7;M( Date: Wed, 18 May 2022 16:54:15 +0200 Subject: [PATCH 012/150] [Exploratory View] Breakdown column issue (#132328) --- .../configurations/lens_attributes.test.ts | 4 ++-- .../configurations/lens_attributes.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) 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 e2b85fdccc537..54fccb2f62132 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 @@ -505,6 +505,7 @@ describe('Lens Attribute', () => { lnsAttr = new LensAttributes([layerConfig1]); lnsAttr.getBreakdownColumn({ + layerConfig: layerConfig1, sourceField: USER_AGENT_NAME, layerId: 'layer0', indexPattern: mockDataView, @@ -544,8 +545,7 @@ describe('Lens Attribute', () => { params: { missingBucket: false, orderBy: { - columnId: 'y-axis-column-layer0', - type: 'column', + type: 'alphabetical', }, orderDirection: 'desc', otherBucket: true, 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 cce0a0a3bc5f6..08d371494f9bc 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 @@ -146,14 +146,20 @@ export class LensAttributes { layerId, labels, indexPattern, + layerConfig, }: { sourceField: string; layerId: string; labels: Record; indexPattern: DataView; + layerConfig: LayerConfig; }): TermsIndexPatternColumn { const fieldMeta = indexPattern.getFieldByName(sourceField); + const { sourceField: yAxisSourceField } = layerConfig.seriesConfig.yAxisColumns[0]; + + const isFormulaColumn = yAxisSourceField === RECORDS_PERCENTAGE_FIELD; + return { sourceField, label: `Top values of ${labels[sourceField]}`, @@ -162,7 +168,9 @@ export class LensAttributes { scale: 'ordinal', isBucketed: true, params: { - orderBy: { type: 'column', columnId: `y-axis-column-${layerId}` }, + orderBy: isFormulaColumn + ? { type: 'alphabetical' } + : { type: 'column', columnId: `y-axis-column-${layerId}` }, size: 10, orderDirection: 'desc', otherBucket: true, @@ -393,6 +401,7 @@ export class LensAttributes { if (xAxisColumn?.sourceField === USE_BREAK_DOWN_COLUMN) { return this.getBreakdownColumn({ layerId, + layerConfig, indexPattern: layerConfig.indexPattern, sourceField: layerConfig.breakdown || layerConfig.seriesConfig.breakdownFields[0], labels: layerConfig.seriesConfig.labels, @@ -722,6 +731,7 @@ export class LensAttributes { sourceField: breakdown, indexPattern: layerConfig.indexPattern, labels: layerConfig.seriesConfig.labels, + layerConfig, }), } : {}), From 465c419902209e6058889c0bab2cd46a1b6a9b95 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 18 May 2022 18:00:31 +0300 Subject: [PATCH 013/150] [Visualize] Renders no data component if there is no ES data or dataview (#132223) * [Visualize] Renders no data component if there is no ES data or dataview * Fix types * Adds a functional test * Fix FTs * Fix * Fix no data test * Changes on the dashboard save test * Add a loader before the data being fetched * Add check for default dataview * A small nit * Address PR comments --- src/plugins/visualizations/kibana.json | 3 +- src/plugins/visualizations/public/mocks.ts | 2 + src/plugins/visualizations/public/plugin.ts | 7 +- .../public/visualize_app/app.scss | 7 ++ .../public/visualize_app/app.tsx | 89 ++++++++++++++++++- .../public/visualize_app/types.ts | 3 + src/plugins/visualizations/tsconfig.json | 1 + .../apps/dashboard/group4/dashboard_save.ts | 2 + .../apps/visualize/group1/_no_data.ts | 67 ++++++++++++++ .../functional/apps/visualize/group1/index.ts | 2 +- .../apps/maps/group4/visualize_create_menu.js | 22 +++-- 11 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 test/functional/apps/visualize/group1/_no_data.ts diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index 2588a3e9854e6..c468662fb1431 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -14,7 +14,8 @@ "savedObjects", "screenshotMode", "presentationUtil", - "dataViews" + "dataViews", + "dataViewEditor" ], "optionalPlugins": ["home", "share", "usageCollection", "spaces", "savedObjectsTaggingOss"], "requiredBundles": ["kibanaUtils", "discover", "kibanaReact"], diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 35cc96daf4f7d..96f4d48f12837 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -13,6 +13,7 @@ import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { indexPatternEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks'; import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/public/mocks'; import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks'; @@ -58,6 +59,7 @@ const createInstance = async () => { plugin.start(coreMock.createStart(), { data: dataPluginMock.createStartContract(), dataViews: dataViewPluginMocks.createStartContract(), + dataViewEditor: indexPatternEditorPluginMock.createStartContract(), expressions: expressionsPluginMock.createStartContract(), inspector: inspectorPluginMock.createStartContract(), uiActions: uiActionsPluginMock.createStartContract(), diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index 7cba4ef19b254..40c408605b7b8 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -53,6 +53,7 @@ import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import type { ScreenshotModePluginStart } from '@kbn/screenshot-mode-plugin/public'; import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import type { TypesSetup, TypesStart } from './vis_types'; import type { VisualizeServices } from './visualize_app/types'; import { visualizeEditorTrigger } from './triggers'; @@ -118,6 +119,7 @@ export interface VisualizationsSetupDeps { export interface VisualizationsStartDeps { data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; + dataViewEditor: DataViewEditorStart; expressions: ExpressionsStart; embeddable: EmbeddableStart; inspector: InspectorStart; @@ -239,9 +241,6 @@ export class VisualizationsPlugin // make sure the index pattern list is up to date pluginsStart.dataViews.clearCache(); - // make sure a default index pattern exists - // if not, the page will be redirected to management and visualize won't be rendered - await pluginsStart.dataViews.ensureDefaultDataView(); appMounted(); @@ -269,6 +268,8 @@ export class VisualizationsPlugin pluginInitializerContext: this.initializerContext, chrome: coreStart.chrome, data: pluginsStart.data, + core: coreStart, + dataViewEditor: pluginsStart.dataViewEditor, dataViews: pluginsStart.dataViews, localStorage: new Storage(localStorage), navigation: pluginsStart.navigation, diff --git a/src/plugins/visualizations/public/visualize_app/app.scss b/src/plugins/visualizations/public/visualize_app/app.scss index f7f68fbc2c359..c22ba129dbf50 100644 --- a/src/plugins/visualizations/public/visualize_app/app.scss +++ b/src/plugins/visualizations/public/visualize_app/app.scss @@ -10,3 +10,10 @@ flex-direction: column; flex-grow: 1; } + +.visAppLoadingWrapper { + align-items: center; + justify-content: center; + display: flex; + flex-grow: 1; +} diff --git a/src/plugins/visualizations/public/visualize_app/app.tsx b/src/plugins/visualizations/public/visualize_app/app.tsx index 156cc9b99b16e..66cbac5dcc4e0 100644 --- a/src/plugins/visualizations/public/visualize_app/app.tsx +++ b/src/plugins/visualizations/public/visualize_app/app.tsx @@ -7,12 +7,18 @@ */ import './app.scss'; -import React, { useEffect } from 'react'; +import React, { useEffect, useCallback, useState } from 'react'; import { Route, Switch, useLocation } from 'react-router-dom'; - -import { AppMountParameters } from '@kbn/core/public'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { AppMountParameters, CoreStart } from '@kbn/core/public'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import { syncQueryStateWithUrl } from '@kbn/data-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { + AnalyticsNoDataPageKibanaProvider, + AnalyticsNoDataPage, +} from '@kbn/shared-ux-page-analytics-no-data'; +import type { DataViewsContract } from '@kbn/data-views-plugin/public'; import { VisualizeServices } from './types'; import { VisualizeEditor, @@ -26,14 +32,49 @@ export interface VisualizeAppProps { onAppLeave: AppMountParameters['onAppLeave']; } +interface NoDataComponentProps { + core: CoreStart; + dataViews: DataViewsContract; + dataViewEditor: DataViewEditorStart; + onDataViewCreated: (dataView: unknown) => void; +} + +const NoDataComponent = ({ + core, + dataViews, + dataViewEditor, + onDataViewCreated, +}: NoDataComponentProps) => { + const analyticsServices = { + coreStart: core, + dataViews, + dataViewEditor, + }; + return ( + + ; + + ); +}; + export const VisualizeApp = ({ onAppLeave }: VisualizeAppProps) => { const { services: { - data: { query }, + data: { query, dataViews }, + core, kbnUrlStateStorage, + dataViewEditor, }, } = useKibana(); const { pathname } = useLocation(); + const [showNoDataPage, setShowNoDataPage] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + const onDataViewCreated = useCallback((dataView: unknown) => { + if (dataView) { + setShowNoDataPage(false); + } + }, []); useEffect(() => { // syncs `_g` portion of url with query services @@ -45,6 +86,46 @@ export const VisualizeApp = ({ onAppLeave }: VisualizeAppProps) => { // so the global state is always preserved }, [query, kbnUrlStateStorage, pathname]); + useEffect(() => { + const checkESOrDataViewExist = async () => { + // check if there is any data view or data source + const hasUserDataView = await dataViews.hasData.hasUserDataView().catch(() => false); + const hasEsData = await dataViews.hasData.hasESData().catch(() => false); + if (!hasUserDataView || !hasEsData) { + setShowNoDataPage(true); + } + // Adding this check as TSVB asks for the default dataview on initialization + const defaultDataView = await dataViews.getDefaultDataView(); + if (!defaultDataView) { + setShowNoDataPage(true); + } + setIsLoading(false); + }; + + // call the function + checkESOrDataViewExist(); + }, [dataViews]); + + if (isLoading) { + return ( +

+ +
+ ); + } + + // Visualize app should return the noData component if there is no data view or data source + if (showNoDataPage) { + return ( + + ); + } + return ( diff --git a/src/plugins/visualizations/public/visualize_app/types.ts b/src/plugins/visualizations/public/visualize_app/types.ts index caa39d8bf9308..a940063067e89 100644 --- a/src/plugins/visualizations/public/visualize_app/types.ts +++ b/src/plugins/visualizations/public/visualize_app/types.ts @@ -25,6 +25,7 @@ import type { IKbnUrlStateStorage, ReduxLikeStateContainer, } from '@kbn/kibana-utils-plugin/public'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import type { NavigationPublicPluginStart as NavigationStart } from '@kbn/navigation-plugin/public'; import type { Filter } from '@kbn/es-query'; @@ -85,7 +86,9 @@ export interface VisualizeServices extends CoreStart { stateTransferService: EmbeddableStateTransfer; embeddable: EmbeddableStart; history: History; + dataViewEditor: DataViewEditorStart; kbnUrlStateStorage: IKbnUrlStateStorage; + core: CoreStart; urlForwarding: UrlForwardingStart; pluginInitializerContext: PluginInitializerContext; chrome: ChromeStart; diff --git a/src/plugins/visualizations/tsconfig.json b/src/plugins/visualizations/tsconfig.json index ce38bbf55ebdf..9a9cb97d63764 100644 --- a/src/plugins/visualizations/tsconfig.json +++ b/src/plugins/visualizations/tsconfig.json @@ -30,6 +30,7 @@ { "path": "../navigation/tsconfig.json" }, { "path": "../home/tsconfig.json" }, { "path": "../share/tsconfig.json" }, + { "path": "../data_view_editor/tsconfig.json" }, { "path": "../presentation_util/tsconfig.json" }, { "path": "../screenshot_mode/tsconfig.json" }, { "path": "../../../x-pack/plugins/spaces/tsconfig.json" } diff --git a/test/functional/apps/dashboard/group4/dashboard_save.ts b/test/functional/apps/dashboard/group4/dashboard_save.ts index f20817c65d25d..4272d95fd68d4 100644 --- a/test/functional/apps/dashboard/group4/dashboard_save.ts +++ b/test/functional/apps/dashboard/group4/dashboard_save.ts @@ -13,6 +13,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const listingTable = getService('listingTable'); const testSubjects = getService('testSubjects'); const dashboardAddPanel = getService('dashboardAddPanel'); + const esArchiver = getService('esArchiver'); describe('dashboard save', function describeIndexTests() { this.tags('includeFirefox'); @@ -125,6 +126,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('Does not show dashboard save modal when on quick save', async function () { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.clickNewDashboard(); await PageObjects.dashboard.saveDashboard('test quick save'); diff --git a/test/functional/apps/visualize/group1/_no_data.ts b/test/functional/apps/visualize/group1/_no_data.ts new file mode 100644 index 0000000000000..9b86ea3aae675 --- /dev/null +++ b/test/functional/apps/visualize/group1/_no_data.ts @@ -0,0 +1,67 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['visualize', 'header', 'common']); + const esArchiver = getService('esArchiver'); + const find = getService('find'); + const kibanaServer = getService('kibanaServer'); + + const createDataView = async (dataViewName: string) => { + await testSubjects.setValue('createIndexPatternNameInput', dataViewName, { + clearWithKeyboard: true, + typeCharByChar: true, + }); + await testSubjects.click('saveIndexPatternButton'); + }; + + describe('no data in visualize', function () { + it('should show the integrations component if there is no data', async () => { + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + await esArchiver.unload('test/functional/fixtures/es_archiver/long_window_logstash'); + await kibanaServer.savedObjects.clean({ types: ['search', 'index-pattern'] }); + await PageObjects.common.navigateToApp('visualize'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + const addIntegrations = await testSubjects.find('kbnOverviewAddIntegrations'); + await addIntegrations.click(); + await PageObjects.common.waitUntilUrlIncludes('integrations/browse'); + }); + + it('should show the no dataview component if no dataviews exist', async function () { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/long_window_logstash'); + await kibanaServer.savedObjects.clean({ types: ['search', 'index-pattern'] }); + await PageObjects.common.navigateToApp('visualize'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const button = await testSubjects.find('createDataViewButtonFlyout'); + button.click(); + await retry.waitForWithTimeout('index pattern editor form to be visible', 15000, async () => { + return await (await find.byClassName('indexPatternEditor__form')).isDisplayed(); + }); + + const dataViewToCreate = 'logstash'; + await createDataView(dataViewToCreate); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await retry.waitForWithTimeout( + 'data view selector to include a newly created dataview', + 5000, + async () => { + const addNewVizButton = await testSubjects.exists('newItemButton'); + expect(addNewVizButton).to.be(true); + return addNewVizButton; + } + ); + }); + }); +} diff --git a/test/functional/apps/visualize/group1/index.ts b/test/functional/apps/visualize/group1/index.ts index fa3379b632cc1..aee4595d8f0a0 100644 --- a/test/functional/apps/visualize/group1/index.ts +++ b/test/functional/apps/visualize/group1/index.ts @@ -22,11 +22,11 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/long_window_logstash'); }); - loadTestFile(require.resolve('./_embedding_chart')); loadTestFile(require.resolve('./_data_table')); loadTestFile(require.resolve('./_data_table_nontimeindex')); loadTestFile(require.resolve('./_data_table_notimeindex_filters')); loadTestFile(require.resolve('./_chart_types')); + loadTestFile(require.resolve('./_no_data')); }); } diff --git a/x-pack/test/functional/apps/maps/group4/visualize_create_menu.js b/x-pack/test/functional/apps/maps/group4/visualize_create_menu.js index 0c2154fe52a73..f91bd55452fa6 100644 --- a/x-pack/test/functional/apps/maps/group4/visualize_create_menu.js +++ b/x-pack/test/functional/apps/maps/group4/visualize_create_menu.js @@ -16,9 +16,12 @@ export default function ({ getService, getPageObjects }) { describe('maps visualize alias', () => { describe('with write permission', () => { before(async () => { - await security.testUser.setRoles(['global_maps_all', 'global_visualize_all'], { - skipBrowserRefresh: true, - }); + await security.testUser.setRoles( + ['global_maps_all', 'global_visualize_all', 'test_logstash_reader'], + { + skipBrowserRefresh: true, + } + ); await PageObjects.visualize.navigateToNewVisualization(); }); @@ -38,9 +41,12 @@ export default function ({ getService, getPageObjects }) { describe('without write permission', () => { before(async () => { - await security.testUser.setRoles(['global_maps_read', 'global_visualize_all'], { - skipBrowserRefresh: true, - }); + await security.testUser.setRoles( + ['global_maps_read', 'global_visualize_all', 'test_logstash_reader'], + { + skipBrowserRefresh: true, + } + ); await PageObjects.visualize.navigateToNewVisualization(); }); @@ -58,7 +64,9 @@ export default function ({ getService, getPageObjects }) { describe('aggregion based visualizations', () => { before(async () => { - await security.testUser.setRoles(['global_visualize_all'], { skipBrowserRefresh: true }); + await security.testUser.setRoles(['global_visualize_all', 'test_logstash_reader'], { + skipBrowserRefresh: true, + }); await PageObjects.visualize.navigateToNewAggBasedVisualization(); }); From cae61c2fba1db54e319ae6f142693de311a0eb3b Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 18 May 2022 10:02:41 -0500 Subject: [PATCH 014/150] skip flaky jest suite (#132360) --- .../user_alerts_table/user_alerts_table.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.test.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.test.tsx index 74c266243dc56..8e0b7656fda8e 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.test.tsx @@ -47,7 +47,8 @@ const renderComponent = () => ); -describe('UserAlertsTable', () => { +// FLAKY: https://github.com/elastic/kibana/issues/132360 +describe.skip('UserAlertsTable', () => { beforeEach(() => { jest.clearAllMocks(); }); From 74b73ad8f10f5506a59131b80b81806476421538 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 18 May 2022 10:05:45 -0500 Subject: [PATCH 015/150] skip flaky jest suite (#132398) --- .../pages/endpoint_hosts/view/components/search_bar.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.test.tsx index 246f99e55c480..31247bce600b9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.test.tsx @@ -18,7 +18,8 @@ import { fireEvent } from '@testing-library/dom'; import { uiQueryParams } from '../../store/selectors'; import { EndpointIndexUIQueryParams } from '../../types'; -describe('when rendering the endpoint list `AdminSearchBar`', () => { +// FLAKY: https://github.com/elastic/kibana/issues/132398 +describe.skip('when rendering the endpoint list `AdminSearchBar`', () => { let render: ( urlParams?: EndpointIndexUIQueryParams ) => Promise>; From afe71c76f388437f2760739c799c420713526767 Mon Sep 17 00:00:00 2001 From: Kyle Pollich Date: Wed, 18 May 2022 11:15:48 -0400 Subject: [PATCH 016/150] Add clear all button to tag filter dropdown (#132433) --- .../components/search_and_filter_bar.tsx | 61 +++++++++++++------ 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx index 9f164d4aff13c..7772ebb1e1379 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx @@ -13,12 +13,15 @@ import { EuiFilterSelectItem, EuiFlexGroup, EuiFlexItem, + EuiHorizontalRule, + EuiIcon, EuiPopover, EuiPortal, EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import styled from 'styled-components'; import type { Agent, AgentPolicy } from '../../../../types'; import { AgentEnrollmentFlyout, SearchBar } from '../../../../components'; @@ -62,6 +65,10 @@ const statusFilters = [ }, ]; +const ClearAllTagsFilterItem = styled(EuiFilterSelectItem)` + padding: ${(props) => props.theme.eui.paddingSizes.s}; +`; + export const SearchAndFilterBar: React.FunctionComponent<{ agentPolicies: AgentPolicy[]; draftKuery: string; @@ -222,27 +229,45 @@ export const SearchAndFilterBar: React.FunctionComponent<{ panelPaddingSize="none" >
- {tags.map((tag, index) => ( - + {tags.map((tag, index) => ( + { + if (selectedTags.includes(tag)) { + removeTagsFilter(tag); + } else { + addTagsFilter(tag); + } + }} + > + {tag.length > MAX_TAG_DISPLAY_LENGTH ? ( + + {truncateTag(tag)} + + ) : ( + tag + )} + + ))} + + + + { - if (selectedTags.includes(tag)) { - removeTagsFilter(tag); - } else { - addTagsFilter(tag); - } + onSelectedTagsChange([]); }} > - {tag.length > MAX_TAG_DISPLAY_LENGTH ? ( - - {truncateTag(tag)} - - ) : ( - tag - )} - - ))} + + + + + Clear all + + +
Date: Wed, 18 May 2022 18:13:39 +0200 Subject: [PATCH 017/150] [Discover] Add close button to field popover using Document explorer (#131899) * Add close button to field popover * Redesign `Copy to clipboard` button when showing JSON Co-authored-by: Julia Rechkunova --- .../discover_grid/discover_grid.tsx | 35 +- .../discover_grid_cell_actions.test.tsx | 78 ++-- .../discover_grid_cell_actions.tsx | 59 ++- .../discover_grid/discover_grid_context.tsx | 1 + .../discover_grid_document_selection.test.tsx | 1 + .../discover_grid_expand_button.test.tsx | 1 + .../get_render_cell_value.test.tsx | 342 +++++++++++++----- .../discover_grid/get_render_cell_value.tsx | 68 +++- .../json_code_editor.test.tsx.snap | 1 + .../json_code_editor/json_code_editor.tsx | 26 +- .../json_code_editor_common.tsx | 58 +-- .../discover/public/utils/format_value.ts | 9 +- 12 files changed, 483 insertions(+), 196 deletions(-) diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx index da9892f343d70..64cbab5c1511b 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx @@ -6,12 +6,11 @@ * Side Public License, v 1. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState, useRef } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import './discover_grid.scss'; import { EuiDataGridSorting, - EuiDataGridProps, EuiDataGrid, EuiScreenReaderOnly, EuiSpacer, @@ -19,6 +18,7 @@ import { htmlIdGenerator, EuiLoadingSpinner, EuiIcon, + EuiDataGridRefProps, } from '@elastic/eui'; import type { DataView } from '@kbn/data-views-plugin/public'; import { flattenHit } from '@kbn/data-plugin/public'; @@ -165,9 +165,7 @@ export interface DiscoverGridProps { onUpdateRowHeight?: (rowHeight: number) => void; } -export const EuiDataGridMemoized = React.memo((props: EuiDataGridProps) => { - return ; -}); +export const EuiDataGridMemoized = React.memo(EuiDataGrid); const CONTROL_COLUMN_IDS_DEFAULT = ['openDetails', 'select']; @@ -199,6 +197,7 @@ export const DiscoverGrid = ({ rowHeightState, onUpdateRowHeight, }: DiscoverGridProps) => { + const dataGridRef = useRef(null); const services = useDiscoverServices(); const [selectedDocs, setSelectedDocs] = useState([]); const [isFilterActive, setIsFilterActive] = useState(false); @@ -232,6 +231,12 @@ export const DiscoverGrid = ({ return rowsFiltered; }, [rows, usedSelectedDocs, isFilterActive]); + const displayedRowsFlattened = useMemo(() => { + return displayedRows.map((hit) => { + return flattenHit(hit, indexPattern, { includeIgnoredValues: true }); + }); + }, [displayedRows, indexPattern]); + /** * Pagination */ @@ -290,16 +295,20 @@ export const DiscoverGrid = ({ getRenderCellValueFn( indexPattern, displayedRows, - displayedRows - ? displayedRows.map((hit) => - flattenHit(hit, indexPattern, { includeIgnoredValues: true }) - ) - : [], + displayedRowsFlattened, useNewFieldsApi, fieldsToShow, - services.uiSettings.get(MAX_DOC_FIELDS_DISPLAYED) + services.uiSettings.get(MAX_DOC_FIELDS_DISPLAYED), + () => dataGridRef.current?.closeCellPopover() ), - [indexPattern, displayedRows, useNewFieldsApi, fieldsToShow, services.uiSettings] + [ + indexPattern, + displayedRowsFlattened, + displayedRows, + useNewFieldsApi, + fieldsToShow, + services.uiSettings, + ] ); /** @@ -432,6 +441,7 @@ export const DiscoverGrid = ({ expanded: expandedDoc, setExpanded: setExpandedDoc, rows: displayedRows, + rowsFlattened: displayedRowsFlattened, onFilter, indexPattern, isDarkMode: services.uiSettings.get('theme:darkMode'), @@ -463,6 +473,7 @@ export const DiscoverGrid = ({ onColumnResize={onResize} pagination={paginationObj} renderCellValue={renderCellValue} + ref={dataGridRef} rowCount={rowCount} schemaDetectors={schemaDetectors} sorting={sorting as EuiDataGridSorting} diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.test.tsx index 9a75a74396ff0..5ce0befcf9305 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.test.tsx @@ -5,17 +5,50 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +const mockCopyToClipboard = jest.fn(); +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + copyToClipboard: (value: string) => mockCopyToClipboard(value), + }; +}); + +jest.mock('../../utils/use_discover_services', () => { + const services = { + toastNotifications: { + addInfo: jest.fn(), + }, + }; + const originalModule = jest.requireActual('../../utils/use_discover_services'); + return { + ...originalModule, + useDiscoverServices: () => services, + }; +}); import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; -import { FilterInBtn, FilterOutBtn, buildCellActions } from './discover_grid_cell_actions'; +import { FilterInBtn, FilterOutBtn, buildCellActions, CopyBtn } from './discover_grid_cell_actions'; import { DiscoverGridContext } from './discover_grid_context'; - +import { EuiButton } from '@elastic/eui'; import { indexPatternMock } from '../../__mocks__/index_pattern'; import { esHits } from '../../__mocks__/es_hits'; -import { EuiButton } from '@elastic/eui'; import { DataViewField } from '@kbn/data-views-plugin/public'; +import { flattenHit } from '@kbn/data-plugin/common'; + +const contextMock = { + expanded: undefined, + setExpanded: jest.fn(), + rows: esHits, + rowsFlattened: esHits.map((hit) => flattenHit(hit, indexPatternMock)), + onFilter: jest.fn(), + indexPattern: indexPatternMock, + isDarkMode: false, + selectedDocs: [], + setSelectedDocs: jest.fn(), +}; describe('Discover cell actions ', function () { it('should not show cell actions for unfilterable fields', async () => { @@ -23,17 +56,6 @@ describe('Discover cell actions ', function () { }); it('triggers filter function when FilterInBtn is clicked', async () => { - const contextMock = { - expanded: undefined, - setExpanded: jest.fn(), - rows: esHits, - onFilter: jest.fn(), - indexPattern: indexPatternMock, - isDarkMode: false, - selectedDocs: [], - setSelectedDocs: jest.fn(), - }; - const component = mountWithIntl( { - const contextMock = { - expanded: undefined, - setExpanded: jest.fn(), - rows: esHits, - onFilter: jest.fn(), - indexPattern: indexPatternMock, - isDarkMode: false, - selectedDocs: [], - setSelectedDocs: jest.fn(), - }; - const component = mountWithIntl( { + const component = mountWithIntl( + + } + rowIndex={1} + colIndex={1} + columnId="extension" + isExpanded={false} + /> + + ); + const button = findTestSubject(component, 'copyClipboardButton'); + await button.simulate('click'); + expect(mockCopyToClipboard).toHaveBeenCalledWith('jpg'); + }); }); diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.tsx index 318e1719c08f8..df07478dae5c6 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.tsx @@ -7,11 +7,12 @@ */ import React, { useContext } from 'react'; -import { EuiDataGridColumnCellActionProps } from '@elastic/eui'; +import { copyToClipboard, EuiDataGridColumnCellActionProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DataViewField } from '@kbn/data-views-plugin/public'; -import { flattenHit } from '@kbn/data-plugin/public'; import { DiscoverGridContext, GridContext } from './discover_grid_context'; +import { useDiscoverServices } from '../../utils/use_discover_services'; +import { formatFieldValue } from '../../utils/format_value'; function onFilterCell( context: GridContext, @@ -19,12 +20,12 @@ function onFilterCell( columnId: EuiDataGridColumnCellActionProps['columnId'], mode: '+' | '-' ) { - const row = context.rows[rowIndex]; - const flattened = flattenHit(row, context.indexPattern); + const row = context.rowsFlattened[rowIndex]; + const value = String(row[columnId]); const field = context.indexPattern.fields.getByName(columnId); - if (flattened && field) { - context.onFilter(field, flattened[columnId], mode); + if (value && field) { + context.onFilter(field, value, mode); } } @@ -84,8 +85,52 @@ export const FilterOutBtn = ({ ); }; +export const CopyBtn = ({ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps) => { + const { indexPattern: dataView, rowsFlattened, rows } = useContext(DiscoverGridContext); + const { fieldFormats, toastNotifications } = useDiscoverServices(); + + const buttonTitle = i18n.translate('discover.grid.copyClipboardButtonTitle', { + defaultMessage: 'Copy value of {column}', + values: { column: columnId }, + }); + + return ( + { + const rowFlattened = rowsFlattened[rowIndex]; + const field = dataView.fields.getByName(columnId); + const value = rowFlattened[columnId]; + + const valueFormatted = + field?.type === '_source' + ? JSON.stringify(rowFlattened, null, 2) + : formatFieldValue(value, rows[rowIndex], fieldFormats, dataView, field, 'text'); + copyToClipboard(valueFormatted); + const infoTitle = i18n.translate('discover.grid.copyClipboardToastTitle', { + defaultMessage: 'Copied value of {column} to clipboard.', + values: { column: columnId }, + }); + + toastNotifications.addInfo({ + title: infoTitle, + }); + }} + iconType="copyClipboard" + aria-label={buttonTitle} + title={buttonTitle} + data-test-subj="copyClipboardButton" + > + {i18n.translate('discover.grid.copyClipboardButton', { + defaultMessage: 'Copy to clipboard', + })} + + ); +}; + export function buildCellActions(field: DataViewField) { - if (!field.filterable) { + if (field?.type === '_source') { + return [CopyBtn]; + } else if (!field.filterable) { return undefined; } diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_context.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_context.tsx index 41d58cf213336..f1b21dabab86e 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_context.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_context.tsx @@ -15,6 +15,7 @@ export interface GridContext { expanded?: ElasticSearchHit; setExpanded: (hit?: ElasticSearchHit) => void; rows: ElasticSearchHit[]; + rowsFlattened: Array>; onFilter: DocViewFilterFn; indexPattern: DataView; isDarkMode: boolean; diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.test.tsx index d416372ac183f..f1d8ab9fcb86d 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.test.tsx @@ -21,6 +21,7 @@ const baseContextMock = { expanded: undefined, setExpanded: jest.fn(), rows: esHits, + rowsFlattened: esHits, onFilter: jest.fn(), indexPattern: indexPatternMock, isDarkMode: false, diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.test.tsx index 27ee307d746eb..903d0bc4bedcd 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.test.tsx @@ -18,6 +18,7 @@ const baseContextMock = { expanded: undefined, setExpanded: jest.fn(), rows: esHits, + rowsFlattened: esHits, onFilter: jest.fn(), indexPattern: indexPatternMock, isDarkMode: false, diff --git a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx index 53e5c23cb47d5..62b37225372dc 100644 --- a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx @@ -8,24 +8,28 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; import { getRenderCellValueFn } from './get_render_cell_value'; import { indexPatternMock } from '../../__mocks__/index_pattern'; import { flattenHit } from '@kbn/data-plugin/public'; import { ElasticSearchHit } from '../../types'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; + +const mockServices = { + uiSettings: { + get: (key: string) => key === 'discover:maxDocFieldsDisplayed' && 200, + }, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => (value ? value : '-') })), + }, +}; jest.mock('../../utils/use_discover_services', () => { - const services = { - uiSettings: { - get: (key: string) => key === 'discover:maxDocFieldsDisplayed' && 200, - }, - fieldFormats: { - getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => (value ? value : '-') })), - }, - }; const originalModule = jest.requireActual('../../utils/use_discover_services'); return { ...originalModule, - useDiscoverServices: () => services, + useDiscoverServices: () => mockServices, }; }); @@ -79,7 +83,8 @@ describe('Discover grid cell rendering', function () { rowsSource.map(flatten), false, [], - 100 + 100, + jest.fn() ); const component = shallow( ); expect(component.html()).toMatchInlineSnapshot( - `"100"` + `"
100
"` ); }); it('renders bytes column correctly using fields when details is true', () => { + const closePopoverMockFn = jest.fn(); const DiscoverGridCellValue = getRenderCellValueFn( indexPatternMock, rowsFields, rowsFields.map(flatten), false, [], - 100 + 100, + closePopoverMockFn ); - const component = shallow( + const component = mountWithIntl( ); expect(component.html()).toMatchInlineSnapshot( - `"100"` + `"
100
"` ); + findTestSubject(component, 'docTableClosePopover').simulate('click'); + expect(closePopoverMockFn).toHaveBeenCalledTimes(1); }); it('renders _source column correctly', () => { @@ -154,7 +164,8 @@ describe('Discover grid cell rendering', function () { rowsSource.map(flatten), false, ['extension', 'bytes'], - 100 + 100, + jest.fn() ); const component = shallow( ); expect(component).toMatchInlineSnapshot(` - + + + + + + + + + + + + `); }); @@ -271,7 +313,8 @@ describe('Discover grid cell rendering', function () { rowsFields.map(flatten), true, ['extension', 'bytes'], - 100 + 100, + jest.fn() ); const component = shallow( ); expect(component).toMatchInlineSnapshot(` - + + + + + + + + + + + + `); }); @@ -476,7 +551,8 @@ describe('Discover grid cell rendering', function () { rowsFieldsWithTopLevelObject.map(flatten), true, ['object.value', 'extension', 'bytes'], - 100 + 100, + jest.fn() ); const component = shallow( { + const closePopoverMockFn = jest.fn(); const DiscoverGridCellValue = getRenderCellValueFn( indexPatternMock, rowsFieldsWithTopLevelObject, rowsFieldsWithTopLevelObject.map(flatten), true, [], - 100 + 100, + closePopoverMockFn ); const component = shallow( ); expect(component).toMatchInlineSnapshot(` - + + + + + + + + + + + + `); }); + it('renders a functional close button when CodeEditor is rendered', () => { + const closePopoverMockFn = jest.fn(); + const DiscoverGridCellValue = getRenderCellValueFn( + indexPatternMock, + rowsFieldsWithTopLevelObject, + rowsFieldsWithTopLevelObject.map(flatten), + true, + [], + 100, + closePopoverMockFn + ); + const component = mountWithIntl( + + + + ); + const gridSelectionBtn = findTestSubject(component, 'docTableClosePopover'); + gridSelectionBtn.simulate('click'); + expect(closePopoverMockFn).toHaveBeenCalledTimes(1); + }); + it('does not collect subfields when the the column is unmapped but part of fields response', () => { (indexPatternMock.getFieldByName as jest.Mock).mockReturnValueOnce(undefined); const DiscoverGridCellValue = getRenderCellValueFn( @@ -594,7 +732,8 @@ describe('Discover grid cell rendering', function () { rowsFieldsWithTopLevelObject.map(flatten), true, [], - 100 + 100, + jest.fn() ); const component = shallow( ); expect(componentWithDetails).toMatchInlineSnapshot(` - + + + + + + + + `); }); }); diff --git a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx index b6a63d47b7a0f..4175ff1bdd7b5 100644 --- a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx +++ b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx @@ -8,6 +8,7 @@ import React, { Fragment, useContext, useEffect, useMemo } from 'react'; import classnames from 'classnames'; +import { i18n } from '@kbn/i18n'; import { euiLightVars as themeLight, euiDarkVars as themeDark } from '@kbn/ui-theme'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import { @@ -15,6 +16,9 @@ import { EuiDescriptionList, EuiDescriptionListTitle, EuiDescriptionListDescription, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { DiscoverGridContext } from './discover_grid_context'; @@ -36,7 +40,8 @@ export const getRenderCellValueFn = rowsFlattened: Array>, useNewFieldsApi: boolean, fieldsToShow: string[], - maxDocFieldsDisplayed: number + maxDocFieldsDisplayed: number, + closePopover: () => void ) => ({ rowIndex, columnId, isDetails, setCellProps }: EuiDataGridCellValueElementProps) => { const { uiSettings, fieldFormats } = useDiscoverServices(); @@ -93,6 +98,7 @@ export const getRenderCellValueFn = dataView, useTopLevelObjectColumns, fieldFormats, + closePopover, }); } @@ -147,6 +153,13 @@ function getInnerColumns(fields: Record, columnId: string) { ); } +function getJSON(columnId: string, rowRaw: ElasticSearchHit, useTopLevelObjectColumns: boolean) { + const json = useTopLevelObjectColumns + ? getInnerColumns(rowRaw.fields as Record, columnId) + : rowRaw; + return json as Record; +} + /** * Helper function for the cell popover */ @@ -158,6 +171,7 @@ function renderPopoverContent({ dataView, useTopLevelObjectColumns, fieldFormats, + closePopover, }: { rowRaw: ElasticSearchHit; rowFlattened: Record; @@ -166,25 +180,53 @@ function renderPopoverContent({ dataView: DataView; useTopLevelObjectColumns: boolean; fieldFormats: FieldFormatsStart; + closePopover: () => void; }) { + const closeButton = ( + + ); if (useTopLevelObjectColumns || field?.type === '_source') { - const json = useTopLevelObjectColumns - ? getInnerColumns(rowRaw.fields as Record, columnId) - : rowRaw; return ( - } width={defaultMonacoEditorWidth} /> + + + + {closeButton} + + + + + + ); } return ( - + + + + + {closeButton} + ); } /** diff --git a/src/plugins/discover/public/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap b/src/plugins/discover/public/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap index 31dd6347218b5..7af546298e0d8 100644 --- a/src/plugins/discover/public/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap +++ b/src/plugins/discover/public/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap @@ -2,6 +2,7 @@ exports[`returns the \`JsonCodeEditor\` component 1`] = ` ; width?: string | number; + height?: string | number; hasLineNumbers?: boolean; } -export const JsonCodeEditor = ({ json, width, hasLineNumbers }: JsonCodeEditorProps) => { +export const JsonCodeEditor = ({ json, width, height, hasLineNumbers }: JsonCodeEditorProps) => { const jsonValue = JSON.stringify(json, null, 2); - // setting editor height based on lines height and count to stretch and fit its content - const setEditorCalculatedHeight = useCallback((editor) => { - const editorElement = editor.getDomNode(); - - if (!editorElement) { - return; - } - - const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight); - const lineCount = editor.getModel()?.getLineCount() || 1; - const height = editor.getTopForLineNumber(lineCount + 1) + lineHeight; - - editorElement.style.height = `${height}px`; - editor.layout(); - }, []); - return ( void 0} + hideCopyButton={true} /> ); }; diff --git a/src/plugins/discover/public/components/json_code_editor/json_code_editor_common.tsx b/src/plugins/discover/public/components/json_code_editor/json_code_editor_common.tsx index 5f6faa8ac0e9d..777240fe2f5bb 100644 --- a/src/plugins/discover/public/components/json_code_editor/json_code_editor_common.tsx +++ b/src/plugins/discover/public/components/json_code_editor/json_code_editor_common.tsx @@ -27,6 +27,7 @@ interface JsonCodeEditorCommonProps { width?: string | number; height?: string | number; hasLineNumbers?: boolean; + hideCopyButton?: boolean; } export const JsonCodeEditorCommon = ({ @@ -35,10 +36,40 @@ export const JsonCodeEditorCommon = ({ height, hasLineNumbers, onEditorDidMount, + hideCopyButton, }: JsonCodeEditorCommonProps) => { if (jsonValue === '') { return null; } + const codeEditor = ( + + ); + if (hideCopyButton) { + return codeEditor; + } return ( @@ -53,32 +84,7 @@ export const JsonCodeEditorCommon = ({ - - - + {codeEditor} ); }; diff --git a/src/plugins/discover/public/utils/format_value.ts b/src/plugins/discover/public/utils/format_value.ts index 74331e946682e..b7ee9af7f6873 100644 --- a/src/plugins/discover/public/utils/format_value.ts +++ b/src/plugins/discover/public/utils/format_value.ts @@ -10,6 +10,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { KBN_FIELD_TYPES } from '@kbn/data-plugin/public'; import { DataView, DataViewField } from '@kbn/data-views-plugin/public'; +import { FieldFormatsContentType } from '@kbn/field-formats-plugin/common/types'; /** * Formats the value of a specific field using the appropriate field formatter if available @@ -26,16 +27,18 @@ export function formatFieldValue( hit: estypes.SearchHit, fieldFormats: FieldFormatsStart, dataView?: DataView, - field?: DataViewField + field?: DataViewField, + contentType?: FieldFormatsContentType | undefined ): string { + const usedContentType = contentType ?? 'html'; if (!dataView || !field) { // If either no field is available or no data view, we'll use the default // string formatter to format that field. return fieldFormats .getDefaultInstance(KBN_FIELD_TYPES.STRING) - .convert(value, 'html', { hit, field }); + .convert(value, usedContentType, { hit, field }); } // If we have a data view and field we use that fields field formatter - return dataView.getFormatterForField(field).convert(value, 'html', { hit, field }); + return dataView.getFormatterForField(field).convert(value, usedContentType, { hit, field }); } From e4a365a2983f78b0a30680bf1e0d05efc7d31c5c Mon Sep 17 00:00:00 2001 From: Faisal Kanout Date: Wed, 18 May 2022 19:20:01 +0300 Subject: [PATCH 018/150] [AO] - Add functional tests for the new Rules page (#129349) * WIP * Add permissions tests * Clean up * Clean up * Add create rule flyout test * Add rule creating and check rules table * Update wording * Enable tests * Add rules table tests * disable "only" * Add enabled/disabled test case * fix style * Fix failed test * Code review * Update permission * Remove unwanted file * Update rule_add.tsx * Update rules_page.ts * Update to fix conflicts * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * Fix tests * Fix failing tests * Use and the data test subj for ui triggersAction UI * remove unsed service Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/rule_stats/rule_stats.tsx | 2 +- .../prompts/no_permission_prompt.tsx | 1 + .../pages/rules/components/rules_table.tsx | 2 +- .../public/pages/rules/index.tsx | 1 + .../components/rule_status_dropdown.tsx | 2 + .../services/observability/alerts/common.ts | 10 + .../services/observability/alerts/index.ts | 3 + .../observability/alerts/rules_page.ts | 32 ++++ .../apps/observability/alerts/rules_page.ts | 177 ++++++++++++++++++ .../apps/observability/index.ts | 1 + 10 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 x-pack/test/functional/services/observability/alerts/rules_page.ts create mode 100644 x-pack/test/observability_functional/apps/observability/alerts/rules_page.ts diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.tsx b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.tsx index 62c520c7b7442..b346e9ad28b88 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.tsx @@ -147,7 +147,7 @@ export const renderRuleStats = ( snoozedStatsComponent, errorStatsComponent, , - + {i18n.translate('xpack.observability.alerts.manageRulesButtonLabel', { defaultMessage: 'Manage Rules', })} diff --git a/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx index 7201e0cc45d16..b32952bbc18d4 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx @@ -13,6 +13,7 @@ export function NoPermissionPrompt() { return ( +
<> diff --git a/x-pack/plugins/observability/public/pages/rules/index.tsx b/x-pack/plugins/observability/public/pages/rules/index.tsx index 4ab0790cf5bd4..231e6c97cc29f 100644 --- a/x-pack/plugins/observability/public/pages/rules/index.tsx +++ b/x-pack/plugins/observability/public/pages/rules/index.tsx @@ -234,6 +234,7 @@ function RulesPage() { field: 'enabled', name: STATUS_COLUMN_TITLE, sortable: true, + 'data-test-subj': 'rulesTableCell-ContextStatus', render: (_enabled: boolean, item: RuleTableItem) => { return triggersActionsUi.getRuleStatusDropdown({ rule: item, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx index 40658ae282e16..90a42bd4fe21c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx @@ -292,11 +292,13 @@ const RuleStatusMenu: React.FunctionComponent = ({ name: ENABLED, icon: isEnabled && !isSnoozed ? 'check' : 'empty', onClick: enableRule, + 'data-test-subj': 'statusDropdownEnabledItem', }, { name: DISABLED, icon: !isEnabled ? 'check' : 'empty', onClick: disableRule, + 'data-test-subj': 'statusDropdownDisabledItem', }, { name: snoozeButtonTitle, diff --git a/x-pack/test/functional/services/observability/alerts/common.ts b/x-pack/test/functional/services/observability/alerts/common.ts index 83a8ed009452f..8b7d15e96cb26 100644 --- a/x-pack/test/functional/services/observability/alerts/common.ts +++ b/x-pack/test/functional/services/observability/alerts/common.ts @@ -48,6 +48,15 @@ export function ObservabilityAlertsCommonProvider({ ); }; + const navigateToRulesPage = async () => { + return await pageObjects.common.navigateToUrlWithBrowserHistory( + 'observability', + '/alerts/rules', + '?', + { ensureCurrentUrl: false } + ); + }; + const navigateWithoutFilter = async () => { return await pageObjects.common.navigateToUrlWithBrowserHistory( 'observability', @@ -326,5 +335,6 @@ export function ObservabilityAlertsCommonProvider({ viewRuleDetailsLinkClick, getAlertsFlyoutViewRuleDetailsLinkOrFail, getRuleStatValue, + navigateToRulesPage, }; } diff --git a/x-pack/test/functional/services/observability/alerts/index.ts b/x-pack/test/functional/services/observability/alerts/index.ts index 096eaff9cf7c8..a617fdab808a6 100644 --- a/x-pack/test/functional/services/observability/alerts/index.ts +++ b/x-pack/test/functional/services/observability/alerts/index.ts @@ -8,6 +8,7 @@ import { ObservabilityAlertsPaginationProvider } from './pagination'; import { ObservabilityAlertsCommonProvider } from './common'; import { ObservabilityAlertsAddToCaseProvider } from './add_to_case'; +import { ObservabilityAlertsRulesProvider } from './rules_page'; import { FtrProviderContext } from '../../../ftr_provider_context'; @@ -15,10 +16,12 @@ export function ObservabilityAlertsProvider(context: FtrProviderContext) { const common = ObservabilityAlertsCommonProvider(context); const pagination = ObservabilityAlertsPaginationProvider(context); const addToCase = ObservabilityAlertsAddToCaseProvider(context); + const rulesPage = ObservabilityAlertsRulesProvider(context); return { common, pagination, addToCase, + rulesPage, }; } diff --git a/x-pack/test/functional/services/observability/alerts/rules_page.ts b/x-pack/test/functional/services/observability/alerts/rules_page.ts new file mode 100644 index 0000000000000..ff8e21c943725 --- /dev/null +++ b/x-pack/test/functional/services/observability/alerts/rules_page.ts @@ -0,0 +1,32 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export function ObservabilityAlertsRulesProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + + const getManageRulesPageHref = async () => { + const manageRulesPageButton = await testSubjects.find('manageRulesPageButton'); + return manageRulesPageButton.getAttribute('href'); + }; + + const clickCreateRuleButton = async () => { + const createRuleButton = await testSubjects.find('createRuleButton'); + return createRuleButton.click(); + }; + + const clickRuleStatusDropDownMenu = async () => testSubjects.click('statusDropdown'); + + const clickDisableFromDropDownMenu = async () => testSubjects.click('statusDropdownDisabledItem'); + + return { + getManageRulesPageHref, + clickCreateRuleButton, + clickRuleStatusDropDownMenu, + clickDisableFromDropDownMenu, + }; +} diff --git a/x-pack/test/observability_functional/apps/observability/alerts/rules_page.ts b/x-pack/test/observability_functional/apps/observability/alerts/rules_page.ts new file mode 100644 index 0000000000000..8b6c9c2ee6745 --- /dev/null +++ b/x-pack/test/observability_functional/apps/observability/alerts/rules_page.ts @@ -0,0 +1,177 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const supertest = getService('supertest'); + const find = getService('find'); + const retry = getService('retry'); + const RULE_ENDPOINT = '/api/alerting/rule'; + + async function createRule(rule: any): Promise { + const ruleResponse = await supertest.post(RULE_ENDPOINT).set('kbn-xsrf', 'foo').send(rule); + expect(ruleResponse.status).to.eql(200); + return ruleResponse.body.id; + } + async function deleteRuleById(ruleId: string) { + const ruleResponse = await supertest + .delete(`${RULE_ENDPOINT}/${ruleId}`) + .set('kbn-xsrf', 'foo'); + expect(ruleResponse.status).to.eql(204); + return true; + } + + const getRulesList = async (tableRows: any[]) => { + const rows = []; + for (const euiTableRow of tableRows) { + const $ = await euiTableRow.parseDomContent(); + rows.push({ + name: $.findTestSubjects('rulesTableCell-name').find('a').text(), + enabled: $.findTestSubjects('rulesTableCell-ContextStatus').find('button').attr('title'), + }); + } + return rows; + }; + + describe('Observability Rules page', function () { + this.tags('includeFirefox'); + + const observability = getService('observability'); + + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); + await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'); + await observability.alerts.common.navigateWithoutFilter(); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts'); + await esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs'); + }); + + describe('Feature flag', () => { + // Related to the config inside x-pack/test/observability_functional/with_rac_write.config.ts + it('Link point to O11y Rules pages by default or when "xpack.observability.unsafe.rules.enabled: true"', async () => { + const manageRulesPageHref = await observability.alerts.rulesPage.getManageRulesPageHref(); + expect(new URL(manageRulesPageHref).pathname).equal('/app/observability/alerts/rules'); + }); + }); + + describe('Create rule button', () => { + it('Show Create Rule flyout when Create Rule button is clicked', async () => { + await observability.alerts.common.navigateToRulesPage(); + await retry.waitFor( + 'Create Rule button is visible', + async () => await testSubjects.exists('createRuleButton') + ); + await observability.alerts.rulesPage.clickCreateRuleButton(); + await retry.waitFor( + 'Create Rule flyout is visible', + async () => await testSubjects.exists('addRuleFlyoutTitle') + ); + }); + }); + + describe('Rules table', () => { + let uptimeRuleId: string; + let logThresholdRuleId: string; + before(async () => { + const uptimeRule = { + params: { + search: '', + numTimes: 5, + timerangeUnit: 'm', + timerangeCount: 15, + shouldCheckStatus: true, + shouldCheckAvailability: true, + availability: { range: 30, rangeUnit: 'd', threshold: '99' }, + }, + consumer: 'alerts', + schedule: { interval: '1m' }, + tags: [], + name: 'uptime', + rule_type_id: 'xpack.uptime.alerts.monitorStatus', + notify_when: 'onActionGroupChange', + actions: [], + }; + const logThresholdRule = { + params: { + timeSize: 5, + timeUnit: 'm', + count: { value: 75, comparator: 'more than' }, + criteria: [{ field: 'log.level', comparator: 'equals', value: 'error' }], + }, + consumer: 'alerts', + schedule: { interval: '1m' }, + tags: [], + name: 'error-log', + rule_type_id: 'logs.alert.document.count', + notify_when: 'onActionGroupChange', + actions: [], + }; + uptimeRuleId = await createRule(uptimeRule); + logThresholdRuleId = await createRule(logThresholdRule); + await observability.alerts.common.navigateToRulesPage(); + }); + after(async () => { + await deleteRuleById(uptimeRuleId); + await deleteRuleById(logThresholdRuleId); + }); + + it('shows the rules table ', async () => { + await testSubjects.existOrFail('rulesList'); + await testSubjects.waitForDeleted('centerJustifiedSpinner'); + const tableRows = await find.allByCssSelector('.euiTableRow'); + const rows = await getRulesList(tableRows); + expect(rows.length).to.be(2); + expect(rows[0].name).to.be('error-log'); + expect(rows[0].enabled).to.be('Enabled'); + expect(rows[1].name).to.be('uptime'); + expect(rows[1].enabled).to.be('Enabled'); + }); + + it('changes the rule status to "disabled"', async () => { + await testSubjects.existOrFail('rulesList'); + await observability.alerts.rulesPage.clickRuleStatusDropDownMenu(); + await observability.alerts.rulesPage.clickDisableFromDropDownMenu(); + await retry.waitFor('The rule to be disabled', async () => { + const tableRows = await find.allByCssSelector('.euiTableRow'); + const rows = await getRulesList(tableRows); + expect(rows[0].enabled).to.be('Disabled'); + return true; + }); + }); + }); + + describe('User permissions', () => { + it('shows the Create Rule button when user has permissions', async () => { + await observability.alerts.common.navigateToRulesPage(); + await retry.waitFor( + 'Create rule button', + async () => await testSubjects.exists('createRuleButton') + ); + }); + + it(`shows the no permission prompt when the user has no permissions`, async () => { + await observability.users.setTestUserRole( + observability.users.defineBasicObservabilityRole({ + logs: ['read'], + }) + ); + await observability.alerts.common.navigateToRulesPage(); + await retry.waitFor( + 'No permissions prompt', + async () => await testSubjects.exists('noPermissionPrompt') + ); + }); + }); + }); +}; diff --git a/x-pack/test/observability_functional/apps/observability/index.ts b/x-pack/test/observability_functional/apps/observability/index.ts index d60f93f1285ad..ec1f2e089e732 100644 --- a/x-pack/test/observability_functional/apps/observability/index.ts +++ b/x-pack/test/observability_functional/apps/observability/index.ts @@ -19,5 +19,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./alerts/table_storage')); loadTestFile(require.resolve('./exploratory_view')); loadTestFile(require.resolve('./feature_controls')); + loadTestFile(require.resolve('./alerts/rules_page')); }); } From fa7df7983c4516c216ed5c55ee447cee89039590 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Wed, 18 May 2022 18:37:44 +0200 Subject: [PATCH 019/150] Nav unified show timeline (#131811) * Update useShowTimeline to work with new grouped navigation * Fix bundle size * Fix broken unit tests * Please code review * Fix create rules deepLink * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../security_solution/common/constants.ts | 4 +- .../public/app/deep_links/index.ts | 11 ++ .../public/app/translations.ts | 4 + .../security_solution/public/cases/links.ts | 2 + .../link_to/redirect_to_detection_engine.tsx | 2 - .../navigation/breadcrumbs/index.ts | 4 +- .../common/components/url_state/helpers.ts | 3 +- .../public/common/links/app_links.ts | 2 + .../public/common/links/links.test.ts | 4 +- .../public/common/links/links.ts | 5 +- .../public/common/links/types.ts | 1 + .../utils/timeline/use_show_timeline.test.tsx | 114 +++++++++++++----- .../utils/timeline/use_show_timeline.tsx | 25 +++- .../load_empty_prompt.test.tsx | 6 +- .../pre_packaged_rules/load_empty_prompt.tsx | 22 +--- .../detection_engine/rules/create/index.tsx | 2 +- .../detection_engine/rules/index.test.tsx | 20 ++- .../pages/detection_engine/rules/index.tsx | 24 +--- .../public/management/links.ts | 23 +++- .../public/overview/links.ts | 3 + 20 files changed, 195 insertions(+), 86 deletions(-) diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 17da07280a7f0..f8c159241d00e 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -108,6 +108,7 @@ export enum SecurityPageName { overview = 'overview', policies = 'policy', rules = 'rules', + rulesCreate = 'rules-create', timelines = 'timelines', timelinesTemplates = 'timelines-templates', trustedApps = 'trusted_apps', @@ -119,7 +120,7 @@ export enum SecurityPageName { sessions = 'sessions', usersEvents = 'users-events', usersExternalAlerts = 'users-external_alerts', - threatHuntingLanding = 'threat-hunting', + threatHuntingLanding = 'threat_hunting', dashboardsLanding = 'dashboards', } @@ -134,6 +135,7 @@ export const DETECTION_RESPONSE_PATH = '/detection_response' as const; export const DETECTIONS_PATH = '/detections' as const; export const ALERTS_PATH = '/alerts' as const; export const RULES_PATH = '/rules' as const; +export const RULES_CREATE_PATH = `${RULES_PATH}/create` as const; export const EXCEPTIONS_PATH = '/exceptions' as const; export const HOSTS_PATH = '/hosts' as const; export const USERS_PATH = '/users' as const; diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index 8d8871305b034..550ec608a76cb 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -35,6 +35,7 @@ import { GETTING_STARTED, THREAT_HUNTING, DASHBOARDS, + CREATE_NEW_RULE, } from '../translations'; import { OVERVIEW_PATH, @@ -59,6 +60,7 @@ import { THREAT_HUNTING_PATH, DASHBOARDS_PATH, MANAGE_PATH, + RULES_CREATE_PATH, } from '../../../common/constants'; import { ExperimentalFeatures } from '../../../common/experimental_features'; @@ -183,6 +185,15 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [ }), ], searchable: true, + deepLinks: [ + { + id: SecurityPageName.rulesCreate, + title: CREATE_NEW_RULE, + path: RULES_CREATE_PATH, + navLinkStatus: AppNavLinkStatus.hidden, + searchable: false, + }, + ], }, { id: SecurityPageName.exceptions, diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index aa7eaa83685db..9857e7160a209 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -110,6 +110,10 @@ export const BLOCKLIST = i18n.translate('xpack.securitySolution.navigation.block defaultMessage: 'Blocklist', }); +export const CREATE_NEW_RULE = i18n.translate('xpack.securitySolution.navigation.newRuleTitle', { + defaultMessage: 'Create new rule', +}); + export const GO_TO_DOCUMENTATION = i18n.translate( 'xpack.securitySolution.goToDocumentationButton', { diff --git a/x-pack/plugins/security_solution/public/cases/links.ts b/x-pack/plugins/security_solution/public/cases/links.ts index 3765dfadc8fcc..9ed7a1f3980a6 100644 --- a/x-pack/plugins/security_solution/public/cases/links.ts +++ b/x-pack/plugins/security_solution/public/cases/links.ts @@ -21,9 +21,11 @@ export const getCasesLinkItems = (): LinkItem => { [SecurityPageName.caseConfigure]: { features: [FEATURE.casesCrud], licenseType: 'gold', + hideTimeline: true, }, [SecurityPageName.caseCreate]: { features: [FEATURE.casesCrud], + hideTimeline: true, }, }, }); diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_detection_engine.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_detection_engine.tsx index b66d923cf0a15..7a6ddbec9e88b 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_detection_engine.tsx @@ -11,8 +11,6 @@ export const getDetectionEngineUrl = (search?: string) => `${appendSearch(search export const getRulesUrl = (search?: string) => `${appendSearch(search)}`; -export const getCreateRuleUrl = (search?: string) => `/create${appendSearch(search)}`; - export const getRuleDetailsUrl = (detailName: string, search?: string) => `/id/${detailName}${appendSearch(search)}`; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts index 5020e910dfaa6..3c2e103c0dfd3 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts @@ -83,7 +83,9 @@ const isAdminRoutes = (spyState: RouteSpyState): spyState is AdministrationRoute spyState != null && spyState.pageName === SecurityPageName.administration; const isRulesRoutes = (spyState: RouteSpyState): spyState is AdministrationRouteSpyState => - spyState != null && spyState.pageName === SecurityPageName.rules; + spyState != null && + (spyState.pageName === SecurityPageName.rules || + spyState.pageName === SecurityPageName.rulesCreate); // eslint-disable-next-line complexity export const getBreadcrumbsForRoute = ( diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts index 4430c8f030122..71b6852943ebf 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts @@ -30,6 +30,7 @@ import { SourcererScopeName, SourcererUrlState } from '../../store/sourcerer/mod export const isDetectionsPages = (pageName: string) => pageName === SecurityPageName.alerts || pageName === SecurityPageName.rules || + pageName === SecurityPageName.rulesCreate || pageName === SecurityPageName.exceptions; export const decodeRisonUrlState = (value: string | undefined): T | null => { @@ -103,7 +104,7 @@ export const getUrlType = (pageName: string): UrlStateType => { return 'network'; } else if (pageName === SecurityPageName.alerts) { return 'alerts'; - } else if (pageName === SecurityPageName.rules) { + } else if (pageName === SecurityPageName.rules || pageName === SecurityPageName.rulesCreate) { return 'rules'; } else if (pageName === SecurityPageName.exceptions) { return 'exceptions'; diff --git a/x-pack/plugins/security_solution/public/common/links/app_links.ts b/x-pack/plugins/security_solution/public/common/links/app_links.ts index 4a972bd5deb1f..1a78444012334 100644 --- a/x-pack/plugins/security_solution/public/common/links/app_links.ts +++ b/x-pack/plugins/security_solution/public/common/links/app_links.ts @@ -33,6 +33,8 @@ export const appLinks: Readonly = Object.freeze([ }), ], links: [hostsLinks, networkLinks, usersLinks], + skipUrlState: true, + hideTimeline: true, }, timelinesLinks, getCasesLinkItems(), diff --git a/x-pack/plugins/security_solution/public/common/links/links.test.ts b/x-pack/plugins/security_solution/public/common/links/links.test.ts index b86b05f48607d..b68ae3d863de3 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.test.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.test.ts @@ -98,9 +98,11 @@ const threatHuntingLinkInfo = { features: ['siem.show'], globalNavEnabled: false, globalSearchKeywords: ['Threat hunting'], - id: 'threat-hunting', + id: 'threat_hunting', path: '/threat_hunting', title: 'Threat Hunting', + hideTimeline: true, + skipUrlState: true, }; const hostsLinkInfo = { diff --git a/x-pack/plugins/security_solution/public/common/links/links.ts b/x-pack/plugins/security_solution/public/common/links/links.ts index af9357a122a1e..57965bdeba0c0 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.ts @@ -155,7 +155,6 @@ const getNormalizedLinks = ( * Normalized indexed version of the global `links` array, referencing the parent by id, instead of having nested links children */ const normalizedLinks: Readonly = Object.freeze(getNormalizedLinks(appLinks)); - /** * Returns the `NormalizedLink` from a link id parameter. * The object reference is frozen to make sure it is not mutated by the caller. @@ -193,3 +192,7 @@ export const getAncestorLinksInfo = (id: SecurityPageName): LinkInfo[] => { export const needsUrlState = (id: SecurityPageName): boolean => { return !getNormalizedLink(id).skipUrlState; }; + +export const getLinksWithHiddenTimeline = (): LinkInfo[] => { + return Object.values(normalizedLinks).filter((link) => link.hideTimeline); +}; diff --git a/x-pack/plugins/security_solution/public/common/links/types.ts b/x-pack/plugins/security_solution/public/common/links/types.ts index 320c38d1d229b..bfa87851306ff 100644 --- a/x-pack/plugins/security_solution/public/common/links/types.ts +++ b/x-pack/plugins/security_solution/public/common/links/types.ts @@ -58,6 +58,7 @@ export interface LinkItem { links?: LinkItem[]; path: string; skipUrlState?: boolean; // defaults to false + hideTimeline?: boolean; // defaults to false title: string; } diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx index 18e4af5886064..33a9f3a37a42f 100644 --- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx @@ -17,40 +17,96 @@ jest.mock('react-router-dom', () => { }; }); +const mockedUseIsGroupedNavigationEnabled = jest.fn(); + +jest.mock('../../components/navigation/helpers', () => ({ + useIsGroupedNavigationEnabled: () => mockedUseIsGroupedNavigationEnabled(), +})); + describe('use show timeline', () => { - it('shows timeline for routes on default', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); - await waitForNextUpdate(); - const showTimeline = result.current; - expect(showTimeline).toEqual([true]); + describe('useIsGroupedNavigationEnabled false', () => { + beforeAll(() => { + mockedUseIsGroupedNavigationEnabled.mockReturnValue(false); }); - }); - it('hides timeline for blacklist routes', async () => { - mockUseLocation.mockReturnValueOnce({ pathname: '/rules/create' }); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); - await waitForNextUpdate(); - const showTimeline = result.current; - expect(showTimeline).toEqual([false]); + + it('shows timeline for routes on default', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); + await waitForNextUpdate(); + const showTimeline = result.current; + expect(showTimeline).toEqual([true]); + }); }); - }); - it('shows timeline for partial blacklist routes', async () => { - mockUseLocation.mockReturnValueOnce({ pathname: '/rules' }); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); - await waitForNextUpdate(); - const showTimeline = result.current; - expect(showTimeline).toEqual([true]); + + it('hides timeline for blacklist routes', async () => { + mockUseLocation.mockReturnValueOnce({ pathname: '/rules/create' }); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); + await waitForNextUpdate(); + const showTimeline = result.current; + expect(showTimeline).toEqual([false]); + }); + }); + it('shows timeline for partial blacklist routes', async () => { + mockUseLocation.mockReturnValueOnce({ pathname: '/rules' }); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); + await waitForNextUpdate(); + const showTimeline = result.current; + expect(showTimeline).toEqual([true]); + }); + }); + it('hides timeline for sub blacklist routes', async () => { + mockUseLocation.mockReturnValueOnce({ pathname: '/administration/policy' }); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); + await waitForNextUpdate(); + const showTimeline = result.current; + expect(showTimeline).toEqual([false]); + }); }); }); - it('hides timeline for sub blacklist routes', async () => { - mockUseLocation.mockReturnValueOnce({ pathname: '/administration/policy' }); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); - await waitForNextUpdate(); - const showTimeline = result.current; - expect(showTimeline).toEqual([false]); + + describe('useIsGroupedNavigationEnabled true', () => { + beforeAll(() => { + mockedUseIsGroupedNavigationEnabled.mockReturnValue(true); + }); + + it('shows timeline for routes on default', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); + await waitForNextUpdate(); + const showTimeline = result.current; + expect(showTimeline).toEqual([true]); + }); + }); + + it('hides timeline for blacklist routes', async () => { + mockUseLocation.mockReturnValueOnce({ pathname: '/rules/create' }); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); + await waitForNextUpdate(); + const showTimeline = result.current; + expect(showTimeline).toEqual([false]); + }); + }); + it('shows timeline for partial blacklist routes', async () => { + mockUseLocation.mockReturnValueOnce({ pathname: '/rules' }); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); + await waitForNextUpdate(); + const showTimeline = result.current; + expect(showTimeline).toEqual([true]); + }); + }); + it('hides timeline for sub blacklist routes', async () => { + mockUseLocation.mockReturnValueOnce({ pathname: '/administration/policy' }); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); + await waitForNextUpdate(); + const showTimeline = result.current; + expect(showTimeline).toEqual([false]); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx index 3378b13f8cb73..bb9eb075d735f 100644 --- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx @@ -8,7 +8,10 @@ import { useState, useEffect } from 'react'; import { matchPath, useLocation } from 'react-router-dom'; -const HIDDEN_TIMELINE_ROUTES: readonly string[] = [ +import { getLinksWithHiddenTimeline } from '../../links'; +import { useIsGroupedNavigationEnabled } from '../../components/navigation/helpers'; + +const DEPRECATED_HIDDEN_TIMELINE_ROUTES: readonly string[] = [ `/cases/configure`, '/administration', '/rules/create', @@ -18,17 +21,27 @@ const HIDDEN_TIMELINE_ROUTES: readonly string[] = [ '/manage', ]; -const isHiddenTimelinePath = (currentPath: string): boolean => { - return !!HIDDEN_TIMELINE_ROUTES.find((route) => matchPath(currentPath, route)); +const isTimelineHidden = (currentPath: string, isGroupedNavigationEnabled: boolean): boolean => { + const groupLinksWithHiddenTimelinePaths = getLinksWithHiddenTimeline().map((l) => l.path); + + const hiddenTimelineRoutes = isGroupedNavigationEnabled + ? groupLinksWithHiddenTimelinePaths + : DEPRECATED_HIDDEN_TIMELINE_ROUTES; + + return !!hiddenTimelineRoutes.find((route) => matchPath(currentPath, route)); }; export const useShowTimeline = () => { + const isGroupedNavigationEnabled = useIsGroupedNavigationEnabled(); const { pathname } = useLocation(); - const [showTimeline, setShowTimeline] = useState(!isHiddenTimelinePath(pathname)); + + const [showTimeline, setShowTimeline] = useState( + !isTimelineHidden(pathname, isGroupedNavigationEnabled) + ); useEffect(() => { - setShowTimeline(!isHiddenTimelinePath(pathname)); - }, [pathname]); + setShowTimeline(!isTimelineHidden(pathname, isGroupedNavigationEnabled)); + }, [pathname, isGroupedNavigationEnabled]); return [showTimeline]; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx index 0595fd96d1377..8228dc4e22274 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx @@ -161,9 +161,9 @@ describe('LoadPrebuiltRulesAndTemplatesButton', () => { await waitFor(() => { wrapper.update(); - expect( - wrapper.find('[data-test-subj="load-prebuilt-rules"] button').props().disabled - ).toEqual(true); + expect(wrapper.find('button[data-test-subj="load-prebuilt-rules"]').props().disabled).toEqual( + true + ); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx index 281ef8c0f62ac..2d7551f1634c6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx @@ -9,14 +9,11 @@ import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { memo, useCallback, useMemo } from 'react'; import styled from 'styled-components'; -import { getCreateRuleUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; import * as i18n from './translations'; -import { LinkButton } from '../../../../common/components/links'; +import { SecuritySolutionLinkButton } from '../../../../common/components/links'; import { SecurityPageName } from '../../../../app/types'; -import { useFormatUrl } from '../../../../common/components/link_to'; import { usePrePackagedRules } from '../../../containers/detection_engine/rules'; import { useUserData } from '../../user_info'; -import { useNavigateTo } from '../../../../common/lib/kibana/hooks'; const EmptyPrompt = styled(EuiEmptyPrompt)` align-self: center; /* Corrects horizontal centering in IE11 */ @@ -38,16 +35,6 @@ const PrePackagedRulesPromptComponent: React.FC = ( const handlePreBuiltCreation = useCallback(() => { createPrePackagedRules(); }, [createPrePackagedRules]); - const { formatUrl } = useFormatUrl(SecurityPageName.rules); - const { navigateTo } = useNavigateTo(); - - const goToCreateRule = useCallback( - (ev) => { - ev.preventDefault(); - navigateTo({ deepLinkId: SecurityPageName.rules, path: getCreateRuleUrl() }); - }, - [navigateTo] - ); const [{ isSignalIndexExists, isAuthenticated, hasEncryptionKey, canUserCRUD, hasIndexWrite }] = useUserData(); @@ -80,14 +67,13 @@ const PrePackagedRulesPromptComponent: React.FC = ( {loadPrebuiltRulesAndTemplatesButton} - {i18n.CREATE_RULE_ACTION} - + } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index c7043f3725fcf..c37cba0e2b57f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -439,7 +439,7 @@ const CreateRulePageComponent: React.FC = () => { - + ); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx index 05867a9830ad1..93d0e73c3017f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx @@ -33,7 +33,25 @@ jest.mock('../../../containers/detection_engine/rules/use_find_rules_query'); jest.mock('../../../../common/components/link_to'); jest.mock('../../../components/user_info'); -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/lib/kibana', () => { + const actual = jest.requireActual('../../../../common/lib/kibana'); + return { + ...actual, + + useKibana: () => ({ + services: { + ...actual.useKibana().services, + application: { + navigateToApp: jest.fn(), + }, + }, + }), + useNavigation: () => ({ + navigateTo: jest.fn(), + }), + }; +}); + jest.mock('../../../../common/components/toasters', () => { const actual = jest.requireActual('../../../../common/components/toasters'); return { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx index 10d82bd4ba075..9281dbde77c2a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx @@ -10,10 +10,7 @@ import React, { useCallback, useMemo } from 'react'; import { usePrePackagedRules, importRules } from '../../../containers/detection_engine/rules'; import { useListsConfig } from '../../../containers/detection_engine/lists/use_lists_config'; -import { - getDetectionEngineUrl, - getCreateRuleUrl, -} from '../../../../common/components/link_to/redirect_to_detection_engine'; +import { getDetectionEngineUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; import { SecuritySolutionPageWrapper } from '../../../../common/components/page_wrapper'; import { SpyRoute } from '../../../../common/utils/route/spy_routes'; @@ -30,8 +27,7 @@ import { } from './helpers'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../app/types'; -import { LinkButton } from '../../../../common/components/links'; -import { useFormatUrl } from '../../../../common/components/link_to'; +import { SecuritySolutionLinkButton } from '../../../../common/components/links'; import { NeedAdminForUpdateRulesCallOut } from '../../../components/callouts/need_admin_for_update_callout'; import { MlJobCompatibilityCallout } from '../../../components/callouts/ml_job_compatibility_callout'; import { MissingPrivilegesCallOut } from '../../../components/callouts/missing_privileges_callout'; @@ -96,7 +92,6 @@ const RulesPageComponent: React.FC = () => { timelinesNotInstalled, timelinesNotUpdated ); - const { formatUrl } = useFormatUrl(SecurityPageName.rules); const handleCreatePrePackagedRules = useCallback(async () => { if (createPrePackagedRules != null) { @@ -113,14 +108,6 @@ const RulesPageComponent: React.FC = () => { } }, [refetchPrePackagedRulesStatus]); - const goToNewRule = useCallback( - (ev) => { - ev.preventDefault(); - navigateToApp(APP_UI_ID, { deepLinkId: SecurityPageName.rules, path: getCreateRuleUrl() }); - }, - [navigateToApp] - ); - const loadPrebuiltRulesAndTemplatesButton = useMemo( () => getLoadPrebuiltRulesAndTemplatesButton({ @@ -212,16 +199,15 @@ const RulesPageComponent: React.FC = () => { - {i18n.ADD_NEW_RULE} - + diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index 54c0b3f0d8dd2..ee60274cbb83d 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -12,14 +12,16 @@ import { EVENT_FILTERS_PATH, EXCEPTIONS_PATH, HOST_ISOLATION_EXCEPTIONS_PATH, - MANAGEMENT_PATH, + MANAGE_PATH, POLICIES_PATH, + RULES_CREATE_PATH, RULES_PATH, SecurityPageName, TRUSTED_APPS_PATH, } from '../../common/constants'; import { BLOCKLIST, + CREATE_NEW_RULE, ENDPOINTS, EVENT_FILTERS, EXCEPTIONS, @@ -44,8 +46,9 @@ import { IconTrustedApplications } from './icons/trusted_applications'; export const links: LinkItem = { id: SecurityPageName.administration, title: MANAGE, - path: MANAGEMENT_PATH, + path: MANAGE_PATH, skipUrlState: true, + hideTimeline: true, globalNavEnabled: false, features: [FEATURE.general], globalSearchKeywords: [ @@ -71,6 +74,16 @@ export const links: LinkItem = { }), ], globalSearchEnabled: true, + links: [ + { + id: SecurityPageName.rulesCreate, + title: CREATE_NEW_RULE, + path: RULES_CREATE_PATH, + globalNavEnabled: false, + skipUrlState: true, + hideTimeline: true, + }, + ], }, { id: SecurityPageName.exceptions, @@ -99,6 +112,7 @@ export const links: LinkItem = { globalNavOrder: 9006, path: ENDPOINTS_PATH, skipUrlState: true, + hideTimeline: true, }, { id: SecurityPageName.policies, @@ -110,6 +124,7 @@ export const links: LinkItem = { landingIcon: IconEndpointPolicies, path: POLICIES_PATH, skipUrlState: true, + hideTimeline: true, experimentalKey: 'policyListEnabled', }, { @@ -125,6 +140,7 @@ export const links: LinkItem = { landingIcon: IconTrustedApplications, path: TRUSTED_APPS_PATH, skipUrlState: true, + hideTimeline: true, }, { id: SecurityPageName.eventFilters, @@ -135,6 +151,7 @@ export const links: LinkItem = { landingIcon: IconEventFilters, path: EVENT_FILTERS_PATH, skipUrlState: true, + hideTimeline: true, }, { id: SecurityPageName.hostIsolationExceptions, @@ -145,6 +162,7 @@ export const links: LinkItem = { landingIcon: IconHostIsolation, path: HOST_ISOLATION_EXCEPTIONS_PATH, skipUrlState: true, + hideTimeline: true, }, { id: SecurityPageName.blocklist, @@ -155,6 +173,7 @@ export const links: LinkItem = { landingIcon: IconBlocklist, path: BLOCKLIST_PATH, skipUrlState: true, + hideTimeline: true, }, ], }; diff --git a/x-pack/plugins/security_solution/public/overview/links.ts b/x-pack/plugins/security_solution/public/overview/links.ts index d09c23a6cfc62..9fd06b523347f 100644 --- a/x-pack/plugins/security_solution/public/overview/links.ts +++ b/x-pack/plugins/security_solution/public/overview/links.ts @@ -48,6 +48,7 @@ export const gettingStartedLinks: LinkItem = { }), ], skipUrlState: true, + hideTimeline: true, }; export const detectionResponseLinks: LinkItem = { @@ -81,4 +82,6 @@ export const dashboardsLandingLinks: LinkItem = { }), ], links: [overviewLinks, detectionResponseLinks], + skipUrlState: true, + hideTimeline: true, }; From 64689c0f9e8b5b27883f7f84ef72cf8323b0d3b0 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Wed, 18 May 2022 12:07:54 -0500 Subject: [PATCH 020/150] [RAM] Add data model for scheduled and recurring snoozes (#131019) * [RAM] Add data model for scheduled and recurring snoozes * Update migration tests * Make snoozeIndefinitely required * Fix remaining muteAlls * Replace snoozeEndTime with snoozeSchedule * Fix typecheck * Fix typecheck * Revert muteAll => snoozeIndefinitely rename * Revert more snoozeIndefinitely refs * Revert README * Restore updated taskrunner test * Fix RuleStatusDropdown test * Add timeZone to SO * Update timezone usage * Implement RRule * Fix task runner test * Add rrule types * Push snoozeEndTime from server and fix unsnooze * Fix Jest Tests 5 * Fix rulestatusdropdown test * Fix jest tests 1 * Fix snooze_end_time refs in functional tests * Fix snooze API integration tests * Move isRuleSnoozed to server * Update x-pack/plugins/alerting/server/lib/is_rule_snoozed.ts Co-authored-by: Patrick Mueller * Require timeZone in rulesnooze * Add automatic isSnoozedUntil savedobject flag * Check isSnoozedUntil against now * Fix jest * Fix typecheck * Fix jest * Fix snoozedUntil date parsing * Fix rewriterule * Add error handling for RRule * Fix re-snoozing * Add comments to rulesnoozetype * Restructure data model to use rRule for everything * Fix functional tests * Fix jest * Fix functional tests * Fix functional tests * Fix functional tests * Clarify isRuleSnoozed Co-authored-by: Patrick Mueller --- package.json | 3 + x-pack/plugins/alerting/common/index.ts | 1 + x-pack/plugins/alerting/common/rule.ts | 6 +- .../alerting/common/rule_snooze_type.ts | 35 ++ x-pack/plugins/alerting/server/lib/index.ts | 1 + .../server/lib/is_rule_snoozed.test.ts | 319 ++++++++++++++++++ .../alerting/server/lib/is_rule_snoozed.ts | 63 ++++ .../alerting/server/routes/create_rule.ts | 2 + .../alerting/server/routes/get_rule.ts | 9 +- .../server/routes/lib/rewrite_rule.ts | 7 +- .../alerting/server/routes/update_rule.ts | 4 + .../alerting/server/rules_client.mock.ts | 1 + .../server/rules_client/rules_client.ts | 84 ++++- .../rules_client/tests/aggregate.test.ts | 4 +- .../server/rules_client/tests/create.test.ts | 52 ++- .../rules_client/tests/mute_all.test.ts | 2 +- .../rules_client/tests/unmute_all.test.ts | 2 +- .../alerting/server/saved_objects/index.ts | 6 +- .../alerting/server/saved_objects/mappings.ts | 68 +++- .../server/saved_objects/migrations.test.ts | 22 ++ .../server/saved_objects/migrations.ts | 31 +- .../server/task_runner/task_runner.test.ts | 39 ++- .../server/task_runner/task_runner.ts | 35 +- x-pack/plugins/alerting/server/types.ts | 4 +- .../rule_status_dropdown_sandbox.tsx | 12 +- .../lib/rule_api/aggregate.test.ts | 6 +- .../lib/rule_api/common_transformations.ts | 6 +- .../lib/rule_api/map_filters_to_kql.test.ts | 12 +- .../lib/rule_api/map_filters_to_kql.ts | 2 +- .../application/lib/rule_api/rules.test.ts | 6 +- .../components/rule_status_dropdown.test.tsx | 10 +- .../components/rule_status_dropdown.tsx | 24 +- .../group1/tests/alerting/create.ts | 1 + .../group1/tests/alerting/find.ts | 10 +- .../group1/tests/alerting/get.ts | 2 +- .../group2/tests/alerting/mute_all.ts | 8 +- .../group2/tests/alerting/snooze.ts | 38 ++- .../group2/tests/alerting/unmute_all.ts | 8 +- .../group2/tests/alerting/unsnooze.ts | 8 +- .../group2/tests/alerting/update.ts | 5 + .../spaces_only/tests/alerting/create.ts | 3 + .../spaces_only/tests/alerting/find.ts | 6 +- .../spaces_only/tests/alerting/get.ts | 4 +- .../spaces_only/tests/alerting/mute_all.ts | 4 +- .../spaces_only/tests/alerting/snooze.ts | 9 +- .../spaces_only/tests/alerting/unmute_all.ts | 4 +- .../spaces_only/tests/alerting/update.ts | 2 + yarn.lock | 32 +- 48 files changed, 878 insertions(+), 144 deletions(-) create mode 100644 x-pack/plugins/alerting/common/rule_snooze_type.ts create mode 100644 x-pack/plugins/alerting/server/lib/is_rule_snoozed.test.ts create mode 100644 x-pack/plugins/alerting/server/lib/is_rule_snoozed.ts diff --git a/package.json b/package.json index 52a681a39b4d0..2d3009b7b7099 100644 --- a/package.json +++ b/package.json @@ -220,6 +220,7 @@ "@types/mapbox__vector-tile": "1.3.0", "@types/moment-duration-format": "^2.2.3", "@types/react-is": "^16.7.1", + "@types/rrule": "^2.2.9", "JSONStream": "1.3.5", "abort-controller": "^3.0.0", "antlr4ts": "^0.5.0-alpha.3", @@ -309,6 +310,7 @@ "loader-utils": "^1.2.3", "lodash": "^4.17.21", "lru-cache": "^4.1.5", + "luxon": "^2.3.2", "lz-string": "^1.4.4", "mapbox-gl-draw-rectangle-mode": "1.0.4", "maplibre-gl": "2.1.9", @@ -405,6 +407,7 @@ "reselect": "^4.0.0", "resize-observer-polyfill": "^1.5.1", "rison-node": "1.0.2", + "rrule": "2.6.4", "rxjs": "^7.5.5", "safe-squel": "^5.12.5", "seedrandom": "^3.0.5", diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index f056ad7e0e4b7..eeb3db0be0066 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -21,6 +21,7 @@ export * from './disabled_action_groups'; export * from './rule_notify_when_type'; export * from './parse_duration'; export * from './execution_log_types'; +export * from './rule_snooze_type'; export interface AlertingFrameworkHealth { isSufficientlySecure: boolean; diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index 4509a004c6e58..f690e1b603359 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -12,6 +12,7 @@ import { // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '@kbn/core/server'; import { RuleNotifyWhenType } from './rule_notify_when_type'; +import { RuleSnooze } from './rule_snooze_type'; export type RuleTypeState = Record; export type RuleTypeParams = Record; @@ -104,12 +105,13 @@ export interface Rule { apiKey: string | null; apiKeyOwner: string | null; throttle: string | null; - notifyWhen: RuleNotifyWhenType | null; muteAll: boolean; + notifyWhen: RuleNotifyWhenType | null; mutedInstanceIds: string[]; executionStatus: RuleExecutionStatus; monitoring?: RuleMonitoring; - snoozeEndTime?: Date | null; // Remove ? when this parameter is made available in the public API + snoozeSchedule?: RuleSnooze; // Remove ? when this parameter is made available in the public API + isSnoozedUntil?: Date | null; } export type SanitizedRule = Omit, 'apiKey'>; diff --git a/x-pack/plugins/alerting/common/rule_snooze_type.ts b/x-pack/plugins/alerting/common/rule_snooze_type.ts new file mode 100644 index 0000000000000..405cbef357242 --- /dev/null +++ b/x-pack/plugins/alerting/common/rule_snooze_type.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { WeekdayStr } from 'rrule'; + +export type RuleSnooze = Array<{ + duration: number; + rRule: Partial & Pick; + // For scheduled/recurring snoozes, `id` uniquely identifies them so that they can be displayed, modified, and deleted individually + id?: string; +}>; + +// An iCal RRULE to define a recurrence schedule, see https://github.com/jakubroztocil/rrule for the spec +export interface RRuleRecord { + dtstart: string; + tzid: string; + freq?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + until?: string; + count?: number; + interval?: number; + wkst?: WeekdayStr; + byweekday?: Array; + bymonth?: number[]; + bysetpos?: number[]; + bymonthday: number; + byyearday: number[]; + byweekno: number[]; + byhour: number[]; + byminute: number[]; + bysecond: number[]; +} diff --git a/x-pack/plugins/alerting/server/lib/index.ts b/x-pack/plugins/alerting/server/lib/index.ts index 31528c0d50683..4c0d4a00b05de 100644 --- a/x-pack/plugins/alerting/server/lib/index.ts +++ b/x-pack/plugins/alerting/server/lib/index.ts @@ -27,4 +27,5 @@ export { } from './rule_execution_status'; export { getRecoveredAlerts } from './get_recovered_alerts'; export { createWrappedScopedClusterClientFactory } from './wrap_scoped_cluster_client'; +export { isRuleSnoozed, getRuleSnoozeEndTime } from './is_rule_snoozed'; export { convertRuleIdsToKueryNode } from './convert_rule_ids_to_kuery_node'; diff --git a/x-pack/plugins/alerting/server/lib/is_rule_snoozed.test.ts b/x-pack/plugins/alerting/server/lib/is_rule_snoozed.test.ts new file mode 100644 index 0000000000000..14ad981a5e903 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/is_rule_snoozed.test.ts @@ -0,0 +1,319 @@ +/* + * 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 sinon from 'sinon'; +import { RRule } from 'rrule'; +import { isRuleSnoozed } from './is_rule_snoozed'; +import { RRuleRecord } from '../types'; + +const DATE_9999 = '9999-12-31T12:34:56.789Z'; +const DATE_1970 = '1970-01-01T00:00:00.000Z'; +const DATE_2019 = '2019-01-01T00:00:00.000Z'; +const DATE_2019_PLUS_6_HOURS = '2019-01-01T06:00:00.000Z'; +const DATE_2020 = '2020-01-01T00:00:00.000Z'; +const DATE_2020_MINUS_1_HOUR = '2019-12-31T23:00:00.000Z'; +const DATE_2020_MINUS_1_MONTH = '2019-12-01T00:00:00.000Z'; + +const NOW = DATE_2020; + +let fakeTimer: sinon.SinonFakeTimers; + +describe('isRuleSnoozed', () => { + beforeAll(() => { + fakeTimer = sinon.useFakeTimers(new Date(NOW)); + }); + + afterAll(() => fakeTimer.restore()); + + test('returns false when snooze has not yet started', () => { + const snoozeSchedule = [ + { + duration: 100000000, + rRule: { + dtstart: DATE_9999, + tzid: 'UTC', + count: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule, muteAll: false })).toBe(false); + }); + + test('returns true when snooze has started', () => { + const snoozeSchedule = [ + { + duration: 100000000, + rRule: { + dtstart: NOW, + tzid: 'UTC', + count: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule, muteAll: false })).toBe(true); + }); + + test('returns false when snooze has ended', () => { + const snoozeSchedule = [ + { + duration: 100000000, + + rRule: { + dtstart: DATE_2019, + tzid: 'UTC', + count: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule, muteAll: false })).toBe(false); + }); + + test('returns true when snooze is indefinite', () => { + const snoozeSchedule = [ + { + duration: 100000000, + + rRule: { + dtstart: DATE_9999, + tzid: 'UTC', + count: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule, muteAll: true })).toBe(true); + }); + + test('returns as expected for an indefinitely recurring snooze', () => { + const snoozeScheduleA = [ + { + duration: 60 * 1000, + rRule: { + dtstart: DATE_2019, + tzid: 'UTC', + freq: RRule.DAILY, + interval: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleA, muteAll: false })).toBe(true); + const snoozeScheduleB = [ + { + duration: 60 * 1000, + rRule: { + dtstart: DATE_2019_PLUS_6_HOURS, + tzid: 'UTC', + freq: RRule.DAILY, + interval: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleB, muteAll: false })).toBe(false); + const snoozeScheduleC = [ + { + duration: 60 * 1000, + rRule: { + dtstart: DATE_2020_MINUS_1_HOUR, + tzid: 'UTC', + freq: RRule.HOURLY, + interval: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleC, muteAll: false })).toBe(true); + }); + + test('returns as expected for a recurring snooze with limited occurrences', () => { + const snoozeScheduleA = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.HOURLY, + interval: 1, + tzid: 'UTC', + count: 8761, + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleA, muteAll: false })).toBe(true); + const snoozeScheduleB = [ + { + duration: 60 * 1000, + + rRule: { + freq: RRule.HOURLY, + interval: 1, + tzid: 'UTC', + count: 25, + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleB, muteAll: false })).toBe(false); + const snoozeScheduleC = [ + { + duration: 60 * 1000, + + rRule: { + freq: RRule.YEARLY, + interval: 1, + tzid: 'UTC', + count: 60, + dtstart: DATE_1970, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleC, muteAll: false })).toBe(true); + }); + + test('returns as expected for a recurring snooze with an end date', () => { + const snoozeScheduleA = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.HOURLY, + interval: 1, + tzid: 'UTC', + until: DATE_9999, + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleA, muteAll: false })).toBe(true); + const snoozeScheduleB = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.HOURLY, + interval: 1, + tzid: 'UTC', + until: DATE_2020_MINUS_1_HOUR, + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleB, muteAll: false })).toBe(false); + }); + + test('returns as expected for a recurring snooze on a day of the week', () => { + const snoozeScheduleA = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.WEEKLY, + interval: 1, + tzid: 'UTC', + byweekday: ['MO', 'WE', 'FR'], // Jan 1 2020 was a Wednesday + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleA, muteAll: false })).toBe(true); + const snoozeScheduleB = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.WEEKLY, + interval: 1, + tzid: 'UTC', + byweekday: ['TU', 'TH', 'SA', 'SU'], + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleB, muteAll: false })).toBe(false); + const snoozeScheduleC = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.WEEKLY, + interval: 1, + tzid: 'UTC', + byweekday: ['MO', 'WE', 'FR'], + count: 12, + dtstart: DATE_2020_MINUS_1_MONTH, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleC, muteAll: false })).toBe(false); + const snoozeScheduleD = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.WEEKLY, + interval: 1, + tzid: 'UTC', + byweekday: ['MO', 'WE', 'FR'], + count: 15, + dtstart: DATE_2020_MINUS_1_MONTH, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleD, muteAll: false })).toBe(true); + }); + + test('returns as expected for a recurring snooze on an nth day of the week of a month', () => { + const snoozeScheduleA = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.MONTHLY, + interval: 1, + tzid: 'UTC', + byweekday: ['+1WE'], // Jan 1 2020 was the first Wednesday of the month + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleA, muteAll: false })).toBe(true); + const snoozeScheduleB = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.MONTHLY, + interval: 1, + tzid: 'UTC', + byweekday: ['+2WE'], + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleB, muteAll: false })).toBe(false); + }); + + test('using a timezone, returns as expected for a recurring snooze on a day of the week', () => { + const snoozeScheduleA = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.WEEKLY, + interval: 1, + byweekday: ['WE'], + tzid: 'Asia/Taipei', + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleA, muteAll: false })).toBe(false); + const snoozeScheduleB = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.WEEKLY, + interval: 1, + byweekday: ['WE'], + byhour: [0], + byminute: [0], + tzid: 'UTC', + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleB, muteAll: false })).toBe(true); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/is_rule_snoozed.ts b/x-pack/plugins/alerting/server/lib/is_rule_snoozed.ts new file mode 100644 index 0000000000000..7ae4b99e4df75 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/is_rule_snoozed.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RRule, ByWeekday, Weekday, rrulestr } from 'rrule'; +import { SanitizedRule, RuleTypeParams } from '../../common/rule'; + +type RuleSnoozeProps = Pick, 'muteAll' | 'snoozeSchedule'>; + +export function getRuleSnoozeEndTime(rule: RuleSnoozeProps): Date | null { + if (rule.snoozeSchedule == null) { + return null; + } + + const now = Date.now(); + for (const snooze of rule.snoozeSchedule) { + const { duration, rRule } = snooze; + const startTimeMS = Date.parse(rRule.dtstart); + const initialEndTime = startTimeMS + duration; + // If now is during the first occurrence of the snooze + + if (now >= startTimeMS && now < initialEndTime) return new Date(initialEndTime); + + // Check to see if now is during a recurrence of the snooze + if (rRule) { + try { + const rRuleOptions = { + ...rRule, + dtstart: new Date(rRule.dtstart), + until: rRule.until ? new Date(rRule.until) : null, + wkst: rRule.wkst ? Weekday.fromStr(rRule.wkst) : null, + byweekday: rRule.byweekday ? parseByWeekday(rRule.byweekday) : null, + }; + + const recurrenceRule = new RRule(rRuleOptions); + const lastOccurrence = recurrenceRule.before(new Date(now), true); + if (!lastOccurrence) continue; + const lastOccurrenceEndTime = lastOccurrence.getTime() + duration; + if (now < lastOccurrenceEndTime) return new Date(lastOccurrenceEndTime); + } catch (e) { + throw new Error(`Failed to process RRule ${rRule}: ${e}`); + } + } + } + + return null; +} + +export function isRuleSnoozed(rule: RuleSnoozeProps) { + if (rule.muteAll) { + return true; + } + return Boolean(getRuleSnoozeEndTime(rule)); +} + +function parseByWeekday(byweekday: Array): ByWeekday[] { + const rRuleString = `RRULE:BYDAY=${byweekday.join(',')}`; + const parsedRRule = rrulestr(rRuleString); + return parsedRRule.origOptions.byweekday as ByWeekday[]; +} diff --git a/x-pack/plugins/alerting/server/routes/create_rule.ts b/x-pack/plugins/alerting/server/routes/create_rule.ts index cf044c94f2529..442162ae21cbb 100644 --- a/x-pack/plugins/alerting/server/routes/create_rule.ts +++ b/x-pack/plugins/alerting/server/routes/create_rule.ts @@ -67,12 +67,14 @@ const rewriteBodyRes: RewriteResponseCase> = ({ notifyWhen, muteAll, mutedInstanceIds, + snoozeSchedule, executionStatus: { lastExecutionDate, lastDuration, ...executionStatus }, ...rest }) => ({ ...rest, rule_type_id: alertTypeId, scheduled_task_id: scheduledTaskId, + snooze_schedule: snoozeSchedule, created_by: createdBy, updated_by: updatedBy, created_at: createdAt, diff --git a/x-pack/plugins/alerting/server/routes/get_rule.ts b/x-pack/plugins/alerting/server/routes/get_rule.ts index f4414b0364dcb..c735d68f83bbe 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule.ts @@ -35,7 +35,8 @@ const rewriteBodyRes: RewriteResponseCase> = ({ executionStatus, actions, scheduledTaskId, - snoozeEndTime, + snoozeSchedule, + isSnoozedUntil, ...rest }) => ({ ...rest, @@ -46,10 +47,10 @@ const rewriteBodyRes: RewriteResponseCase> = ({ updated_at: updatedAt, api_key_owner: apiKeyOwner, notify_when: notifyWhen, - mute_all: muteAll, muted_alert_ids: mutedInstanceIds, - // Remove this object spread boolean check after snoozeEndTime is added to the public API - ...(snoozeEndTime !== undefined ? { snooze_end_time: snoozeEndTime } : {}), + mute_all: muteAll, + ...(isSnoozedUntil !== undefined ? { is_snoozed_until: isSnoozedUntil } : {}), + snooze_schedule: snoozeSchedule, scheduled_task_id: scheduledTaskId, execution_status: executionStatus && { ...omit(executionStatus, 'lastExecutionDate', 'lastDuration'), diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts index 537d42bbc4f47..162177d695e0a 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts @@ -21,7 +21,8 @@ export const rewriteRule = ({ executionStatus, actions, scheduledTaskId, - snoozeEndTime, + snoozeSchedule, + isSnoozedUntil, ...rest }: SanitizedRule) => ({ ...rest, @@ -35,8 +36,8 @@ export const rewriteRule = ({ mute_all: muteAll, muted_alert_ids: mutedInstanceIds, scheduled_task_id: scheduledTaskId, - // Remove this object spread boolean check after snoozeEndTime is added to the public API - ...(snoozeEndTime !== undefined ? { snooze_end_time: snoozeEndTime } : {}), + snooze_schedule: snoozeSchedule, + ...(isSnoozedUntil != null ? { is_snoozed_until: isSnoozedUntil } : {}), execution_status: executionStatus && { ...omit(executionStatus, 'lastExecutionDate', 'lastDuration'), last_execution_date: executionStatus.lastExecutionDate, diff --git a/x-pack/plugins/alerting/server/routes/update_rule.ts b/x-pack/plugins/alerting/server/routes/update_rule.ts index d2130e1f33541..1faddd66c8f0e 100644 --- a/x-pack/plugins/alerting/server/routes/update_rule.ts +++ b/x-pack/plugins/alerting/server/routes/update_rule.ts @@ -70,12 +70,16 @@ const rewriteBodyRes: RewriteResponseCase> = ({ muteAll, mutedInstanceIds, executionStatus, + snoozeSchedule, + isSnoozedUntil, ...rest }) => ({ ...rest, api_key_owner: apiKeyOwner, created_by: createdBy, updated_by: updatedBy, + snooze_schedule: snoozeSchedule, + ...(isSnoozedUntil ? { is_snoozed_until: isSnoozedUntil } : {}), ...(alertTypeId ? { rule_type_id: alertTypeId } : {}), ...(scheduledTaskId ? { scheduled_task_id: scheduledTaskId } : {}), ...(createdAt ? { created_at: createdAt } : {}), diff --git a/x-pack/plugins/alerting/server/rules_client.mock.ts b/x-pack/plugins/alerting/server/rules_client.mock.ts index 302824221ded8..44914e3e3bce8 100644 --- a/x-pack/plugins/alerting/server/rules_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client.mock.ts @@ -35,6 +35,7 @@ const createRulesClientMock = () => { bulkEdit: jest.fn(), snooze: jest.fn(), unsnooze: jest.fn(), + updateSnoozedUntilTime: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index ec01c2c15abf4..4e248412eae15 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -54,6 +54,7 @@ import { RuleWithLegacyId, SanitizedRuleWithLegacyId, PartialRuleWithLegacyId, + RuleSnooze, RawAlertInstance as RawAlert, } from '../types'; import { @@ -62,6 +63,7 @@ import { getRuleNotifyWhenType, validateMutatedRuleTypeParams, convertRuleIdsToKueryNode, + getRuleSnoozeEndTime, } from '../lib'; import { taskInstanceToAlertTaskInstance } from '../task_runner/alert_task_instance'; import { RegistryRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; @@ -310,7 +312,8 @@ export interface CreateOptions { | 'mutedInstanceIds' | 'actions' | 'executionStatus' - | 'snoozeEndTime' + | 'snoozeSchedule' + | 'isSnoozedUntil' > & { actions: NormalizedAlertAction[] }; options?: { id?: string; @@ -391,7 +394,7 @@ export class RulesClient { private readonly fieldsToExcludeFromPublicApi: Array = [ 'monitoring', 'mapped_params', - 'snoozeEndTime', + 'snoozeSchedule', ]; constructor({ @@ -504,7 +507,8 @@ export class RulesClient { updatedBy: username, createdAt: new Date(createTime).toISOString(), updatedAt: new Date(createTime).toISOString(), - snoozeEndTime: null, + isSnoozedUntil: null, + snoozeSchedule: [], params: updatedParams as RawRule['params'], muteAll: false, mutedInstanceIds: [], @@ -1018,7 +1022,7 @@ export class RulesClient { }, snoozed: { date_range: { - field: 'alert.attributes.snoozeEndTime', + field: 'alert.attributes.snoozeSchedule.rRule.dtstart', format: 'strict_date_time', ranges: [{ from: 'now' }], }, @@ -2120,8 +2124,21 @@ export class RulesClient { // If snoozeEndTime is -1, instead mute all const newAttrs = snoozeEndTime === -1 - ? { muteAll: true, snoozeEndTime: null } - : { snoozeEndTime: new Date(snoozeEndTime).toISOString(), muteAll: false }; + ? { + muteAll: true, + snoozeSchedule: clearUnscheduledSnooze(attributes), + } + : { + snoozeSchedule: clearUnscheduledSnooze(attributes).concat({ + duration: Date.parse(snoozeEndTime) - Date.now(), + rRule: { + dtstart: new Date().toISOString(), + tzid: 'UTC', + count: 1, + }, + }), + muteAll: false, + }; const updateAttributes = this.updateMeta({ ...newAttrs, @@ -2135,7 +2152,7 @@ export class RulesClient { id, updateAttributes, updateOptions - ); + ).then(() => this.updateSnoozedUntilTime({ id })); } public async unsnooze({ id }: { id: string }): Promise { @@ -2185,7 +2202,7 @@ export class RulesClient { this.ruleTypeRegistry.ensureRuleTypeEnabled(attributes.alertTypeId); const updateAttributes = this.updateMeta({ - snoozeEndTime: null, + snoozeSchedule: clearUnscheduledSnooze(attributes), muteAll: false, updatedBy: await this.getUserName(), updatedAt: new Date().toISOString(), @@ -2200,6 +2217,30 @@ export class RulesClient { ); } + public async updateSnoozedUntilTime({ id }: { id: string }): Promise { + const { attributes, version } = await this.unsecuredSavedObjectsClient.get( + 'alert', + id + ); + + const isSnoozedUntil = getRuleSnoozeEndTime(attributes); + if (!isSnoozedUntil) return; + + const updateAttributes = this.updateMeta({ + isSnoozedUntil: isSnoozedUntil.toISOString(), + updatedBy: await this.getUserName(), + updatedAt: new Date().toISOString(), + }); + const updateOptions = { version }; + + await partiallyUpdateAlert( + this.unsecuredSavedObjectsClient, + id, + updateAttributes, + updateOptions + ); + } + public async muteAll({ id }: { id: string }): Promise { return await retryIfConflicts( this.logger, @@ -2249,7 +2290,7 @@ export class RulesClient { const updateAttributes = this.updateMeta({ muteAll: true, mutedInstanceIds: [], - snoozeEndTime: null, + snoozeSchedule: clearUnscheduledSnooze(attributes), updatedBy: await this.getUserName(), updatedAt: new Date().toISOString(), }); @@ -2312,7 +2353,7 @@ export class RulesClient { const updateAttributes = this.updateMeta({ muteAll: false, mutedInstanceIds: [], - snoozeEndTime: null, + snoozeSchedule: clearUnscheduledSnooze(attributes), updatedBy: await this.getUserName(), updatedAt: new Date().toISOString(), }); @@ -2560,15 +2601,23 @@ export class RulesClient { executionStatus, schedule, actions, - snoozeEndTime, + snoozeSchedule, + isSnoozedUntil, ...partialRawRule }: Partial, references: SavedObjectReference[] | undefined, includeLegacyId: boolean = false, excludeFromPublicApi: boolean = false ): PartialRule | PartialRuleWithLegacyId { - const snoozeEndTimeDate = snoozeEndTime != null ? new Date(snoozeEndTime) : snoozeEndTime; - const includeSnoozeEndTime = snoozeEndTimeDate !== undefined && !excludeFromPublicApi; + const snoozeScheduleDates = snoozeSchedule?.map((s) => ({ + ...s, + rRule: { + ...s.rRule, + dtstart: new Date(s.rRule.dtstart), + ...(s.rRule.until ? { until: new Date(s.rRule.until) } : {}), + }, + })); + const includeSnoozeSchedule = snoozeSchedule !== undefined; const rule = { id, notifyWhen, @@ -2578,9 +2627,10 @@ export class RulesClient { schedule: schedule as IntervalSchedule, actions: actions ? this.injectReferencesIntoActions(id, actions, references || []) : [], params: this.injectReferencesIntoParams(id, ruleType, params, references || []) as Params, - ...(includeSnoozeEndTime ? { snoozeEndTime: snoozeEndTimeDate } : {}), + ...(includeSnoozeSchedule ? { snoozeSchedule: snoozeScheduleDates } : {}), ...(updatedAt ? { updatedAt: new Date(updatedAt) } : {}), ...(createdAt ? { createdAt: new Date(createdAt) } : {}), + ...(isSnoozedUntil ? { isSnoozedUntil: new Date(isSnoozedUntil) } : {}), ...(scheduledTaskId ? { scheduledTaskId } : {}), ...(executionStatus ? { executionStatus: ruleExecutionStatusFromRaw(this.logger, id, executionStatus) } @@ -2795,3 +2845,9 @@ function parseDate(dateString: string | undefined, propertyName: string, default return parsedDate; } + +function clearUnscheduledSnooze(attributes: { snoozeSchedule?: RuleSnooze }) { + return attributes.snoozeSchedule + ? attributes.snoozeSchedule.filter((s) => typeof s.id !== 'undefined') + : []; +} diff --git a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts index 1a3d203162bd6..bc1c8d276aedd 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts @@ -203,7 +203,7 @@ describe('aggregate()', () => { }, snoozed: { date_range: { - field: 'alert.attributes.snoozeEndTime', + field: 'alert.attributes.snoozeSchedule.rRule.dtstart', format: 'strict_date_time', ranges: [{ from: 'now' }], }, @@ -240,7 +240,7 @@ describe('aggregate()', () => { }, snoozed: { date_range: { - field: 'alert.attributes.snoozeEndTime', + field: 'alert.attributes.snoozeSchedule.rRule.dtstart', format: 'strict_date_time', ranges: [{ from: 'now' }], }, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index 8e24b7c183262..f5c839c5006fd 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -300,7 +300,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], actions: [ { @@ -376,6 +376,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -412,6 +413,7 @@ describe('create()', () => { "status": "pending", "warning": null, }, + "isSnoozedUntil": null, "legacyId": null, "meta": Object { "versionApiKeyLastmodified": "v8.0.0", @@ -434,7 +436,7 @@ describe('create()', () => { "schedule": Object { "interval": "1m", }, - "snoozeEndTime": null, + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -506,7 +508,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], actions: [ { @@ -566,7 +568,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], actions: [ { @@ -618,6 +620,7 @@ describe('create()', () => { "status": "pending", "warning": null, }, + "isSnoozedUntil": null, "legacyId": "123", "meta": Object { "versionApiKeyLastmodified": "v7.10.0", @@ -640,7 +643,7 @@ describe('create()', () => { "schedule": Object { "interval": "1m", }, - "snoozeEndTime": null, + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -1044,6 +1047,7 @@ describe('create()', () => { createdAt: '2019-02-12T21:01:22.479Z', createdBy: 'elastic', enabled: true, + isSnoozedUntil: null, legacyId: null, executionStatus: { error: null, @@ -1054,7 +1058,7 @@ describe('create()', () => { monitoring: getDefaultRuleMonitoring(), meta: { versionApiKeyLastmodified: kibanaVersion }, muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], name: 'abc', notifyWhen: 'onActiveAlert', @@ -1243,6 +1247,7 @@ describe('create()', () => { createdAt: '2019-02-12T21:01:22.479Z', createdBy: 'elastic', enabled: true, + isSnoozedUntil: null, legacyId: null, executionStatus: { error: null, @@ -1253,7 +1258,7 @@ describe('create()', () => { monitoring: getDefaultRuleMonitoring(), meta: { versionApiKeyLastmodified: kibanaVersion }, muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], name: 'abc', notifyWhen: 'onActiveAlert', @@ -1407,6 +1412,7 @@ describe('create()', () => { alertTypeId: '123', apiKey: null, apiKeyOwner: null, + isSnoozedUntil: null, legacyId: null, consumer: 'bar', createdAt: '2019-02-12T21:01:22.479Z', @@ -1421,7 +1427,7 @@ describe('create()', () => { monitoring: getDefaultRuleMonitoring(), meta: { versionApiKeyLastmodified: kibanaVersion }, muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], name: 'abc', notifyWhen: 'onActiveAlert', @@ -1530,7 +1536,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], notifyWhen: 'onActionGroupChange', actions: [ @@ -1571,6 +1577,7 @@ describe('create()', () => { alertTypeId: '123', consumer: 'bar', name: 'abc', + isSnoozedUntil: null, legacyId: null, params: { bar: true }, apiKey: null, @@ -1587,7 +1594,7 @@ describe('create()', () => { throttle: '10m', notifyWhen: 'onActionGroupChange', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], tags: ['foo'], executionStatus: { @@ -1638,6 +1645,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -1662,7 +1670,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], notifyWhen: 'onThrottleInterval', actions: [ @@ -1700,6 +1708,7 @@ describe('create()', () => { params: { foo: true }, }, ], + isSnoozedUntil: null, legacyId: null, alertTypeId: '123', consumer: 'bar', @@ -1719,7 +1728,7 @@ describe('create()', () => { throttle: '10m', notifyWhen: 'onThrottleInterval', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], tags: ['foo'], executionStatus: { @@ -1770,6 +1779,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -1794,7 +1804,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], notifyWhen: 'onActiveAlert', actions: [ @@ -1832,6 +1842,7 @@ describe('create()', () => { params: { foo: true }, }, ], + isSnoozedUntil: null, legacyId: null, alertTypeId: '123', consumer: 'bar', @@ -1851,7 +1862,7 @@ describe('create()', () => { throttle: null, notifyWhen: 'onActiveAlert', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], tags: ['foo'], executionStatus: { @@ -1902,6 +1913,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -1935,7 +1947,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], actions: [ { @@ -1993,13 +2005,14 @@ describe('create()', () => { ], apiKeyOwner: null, apiKey: null, + isSnoozedUntil: null, legacyId: null, createdBy: 'elastic', updatedBy: 'elastic', createdAt: '2019-02-12T21:01:22.479Z', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], executionStatus: { status: 'pending', @@ -2066,6 +2079,7 @@ describe('create()', () => { "interval": "10s", }, "scheduledTaskId": "task-123", + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -2345,6 +2359,7 @@ describe('create()', () => { alertTypeId: '123', consumer: 'bar', name: 'abc', + isSnoozedUntil: null, legacyId: null, params: { bar: true }, apiKey: Buffer.from('123:abc').toString('base64'), @@ -2361,7 +2376,7 @@ describe('create()', () => { throttle: null, notifyWhen: 'onActiveAlert', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], tags: ['foo'], executionStatus: { @@ -2444,6 +2459,7 @@ describe('create()', () => { params: { foo: true }, }, ], + isSnoozedUntil: null, legacyId: null, alertTypeId: '123', consumer: 'bar', @@ -2463,7 +2479,7 @@ describe('create()', () => { throttle: null, notifyWhen: 'onActiveAlert', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], tags: ['foo'], executionStatus: { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts index 7f8ae28a20c6e..e2625be88482c 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts @@ -82,7 +82,7 @@ describe('muteAll()', () => { { muteAll: true, mutedInstanceIds: [], - snoozeEndTime: null, + snoozeSchedule: [], updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', }, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts index cf063eea07862..f5d4cb372f867 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts @@ -82,7 +82,7 @@ describe('unmuteAll()', () => { { muteAll: false, mutedInstanceIds: [], - snoozeEndTime: null, + snoozeSchedule: [], updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', }, diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 6566fee15d4a8..f4f23cced722c 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -30,7 +30,8 @@ export const AlertAttributesExcludedFromAAD = [ 'updatedAt', 'executionStatus', 'monitoring', - 'snoozeEndTime', + 'snoozeSchedule', + 'isSnoozedUntil', ]; // useful for Pick which is a @@ -45,7 +46,8 @@ export type AlertAttributesExcludedFromAADType = | 'updatedAt' | 'executionStatus' | 'monitoring' - | 'snoozeEndTime'; + | 'snoozeSchedule' + | 'isSnoozedUntil'; export function setupSavedObjects( savedObjects: SavedObjectsServiceSetup, diff --git a/x-pack/plugins/alerting/server/saved_objects/mappings.ts b/x-pack/plugins/alerting/server/saved_objects/mappings.ts index 5e2803222ecba..31ad40117a7ec 100644 --- a/x-pack/plugins/alerting/server/saved_objects/mappings.ts +++ b/x-pack/plugins/alerting/server/saved_objects/mappings.ts @@ -185,7 +185,73 @@ export const alertMappings: SavedObjectsTypeMappingDefinition = { }, }, }, - snoozeEndTime: { + snoozeSchedule: { + type: 'nested', + properties: { + id: { + type: 'keyword', + }, + duration: { + type: 'long', + }, + rRule: { + type: 'nested', + properties: { + freq: { + type: 'keyword', + }, + dtstart: { + type: 'date', + format: 'strict_date_time', + }, + tzid: { + type: 'keyword', + }, + until: { + type: 'date', + format: 'strict_date_time', + }, + count: { + type: 'long', + }, + interval: { + type: 'long', + }, + wkst: { + type: 'keyword', + }, + byweekday: { + type: 'keyword', + }, + bymonth: { + type: 'short', + }, + bysetpos: { + type: 'long', + }, + bymonthday: { + type: 'short', + }, + byyearday: { + type: 'short', + }, + byweekno: { + type: 'short', + }, + byhour: { + type: 'long', + }, + byminute: { + type: 'long', + }, + bysecond: { + type: 'long', + }, + }, + }, + }, + }, + isSnoozedUntil: { type: 'date', format: 'strict_date_time', }, diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts index c83d0a95dfdcb..bbf93f85450cb 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import sinon from 'sinon'; import uuid from 'uuid'; import { getMigrations, isAnyActionSupportIncidents } from './migrations'; import { RawRule } from '../types'; @@ -2318,6 +2319,27 @@ describe('successful migrations', () => { }); describe('8.3.0', () => { + test('migrates snoozed rules to the new data model', () => { + const fakeTimer = sinon.useFakeTimers(); + const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.3.0' + ]; + const mutedAlert = getMockData( + { + snoozeEndTime: '1970-01-02T00:00:00.000Z', + }, + true + ); + const migratedMutedAlert830 = migration830(mutedAlert, migrationContext); + + expect(migratedMutedAlert830.attributes.snoozeSchedule.length).toEqual(1); + expect(migratedMutedAlert830.attributes.snoozeSchedule[0].rRule.dtstart).toEqual( + '1970-01-01T00:00:00.000Z' + ); + expect(migratedMutedAlert830.attributes.snoozeSchedule[0].duration).toEqual(86400000); + fakeTimer.restore(); + }); + test('migrates es_query alert params', () => { const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ '8.3.0' diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index b3f8d873d8ef0..ddae200ae8fa6 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -7,6 +7,8 @@ import { isRuleType, ruleTypeMappings } from '@kbn/securitysolution-rules'; import { isString } from 'lodash/fp'; +import { omit } from 'lodash'; +import moment from 'moment-timezone'; import { gte } from 'semver'; import { LogMeta, @@ -164,7 +166,7 @@ export function getMigrations( const migrationRules830 = createEsoMigration( encryptedSavedObjects, (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => true, - pipeMigrations(addSearchType, removeInternalTags) + pipeMigrations(addSearchType, removeInternalTags, convertSnoozes) ); return mergeSavedObjectMigrationMaps( @@ -888,6 +890,33 @@ function addMappedParams( return doc; } +function convertSnoozes( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + const { + attributes: { snoozeEndTime }, + } = doc; + + return { + ...doc, + attributes: { + ...(omit(doc.attributes, ['snoozeEndTime']) as RawRule), + snoozeSchedule: snoozeEndTime + ? [ + { + duration: Date.parse(snoozeEndTime as string) - Date.now(), + rRule: { + dtstart: new Date().toISOString(), + tzid: moment.tz.guess(), + count: 1, + }, + }, + ] + : [], + }, + }; +} + function getCorrespondingAction( actions: SavedObjectAttribute, connectorRef: string diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 7d95f63f3c43c..f3d2c7039585b 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -470,17 +470,42 @@ describe('Task Runner', () => { const snoozeTestParams: SnoozeTestParams[] = [ [false, null, false], [false, undefined, false], - [false, DATE_1970, false], - [false, DATE_9999, true], + // Stringify the snooze schedules for better failure reporting + [ + false, + JSON.stringify([ + { rRule: { dtstart: DATE_9999, tzid: 'UTC', count: 1 }, duration: 100000000 }, + ]), + false, + ], + [ + false, + JSON.stringify([ + { rRule: { dtstart: DATE_1970, tzid: 'UTC', count: 1 }, duration: 100000000 }, + ]), + true, + ], [true, null, true], [true, undefined, true], - [true, DATE_1970, true], - [true, DATE_9999, true], + [ + true, + JSON.stringify([ + { rRule: { dtstart: DATE_9999, tzid: 'UTC', count: 1 }, duration: 100000000 }, + ]), + true, + ], + [ + true, + JSON.stringify([ + { rRule: { dtstart: DATE_1970, tzid: 'UTC', count: 1 }, duration: 100000000 }, + ]), + true, + ], ]; test.each(snoozeTestParams)( - 'snoozing works as expected with muteAll: %s; snoozeEndTime: %s', - async (muteAll, snoozeEndTime, shouldBeSnoozed) => { + 'snoozing works as expected with muteAll: %s; snoozeSchedule: %s', + async (muteAll, snoozeSchedule, shouldBeSnoozed) => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); ruleType.executor.mockImplementation( @@ -507,7 +532,7 @@ describe('Task Runner', () => { rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, muteAll, - snoozeEndTime: snoozeEndTime != null ? new Date(snoozeEndTime) : snoozeEndTime, + snoozeSchedule: snoozeSchedule != null ? JSON.parse(snoozeSchedule) : [], }); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 6cd6b73b9539e..525c252b40b66 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -25,6 +25,7 @@ import { getRecoveredAlerts, ruleExecutionStatusToRaw, validateRuleTypeParams, + isRuleSnoozed, } from '../lib'; import { Rule, @@ -247,18 +248,6 @@ export class TaskRunner< } } - private isRuleSnoozed(rule: SanitizedRule): boolean { - if (rule.muteAll) { - return true; - } - - if (rule.snoozeEndTime == null) { - return false; - } - - return Date.now() < rule.snoozeEndTime.getTime(); - } - private shouldLogAndScheduleActionsForAlerts() { // if execution hasn't been cancelled, return true if (!this.cancelled) { @@ -477,7 +466,10 @@ export class TaskRunner< }); } - const ruleIsSnoozed = this.isRuleSnoozed(rule); + const ruleIsSnoozed = isRuleSnoozed(rule); + if (ruleIsSnoozed) { + this.markRuleAsSnoozed(rule.id); + } if (!ruleIsSnoozed && this.shouldLogAndScheduleActionsForAlerts()) { const mutedAlertIdsSet = new Set(mutedInstanceIds); @@ -580,6 +572,23 @@ export class TaskRunner< return this.executeRule(fakeRequest, rule, validatedParams, executionHandler, spaceId); } + private async markRuleAsSnoozed(id: string) { + let apiKey: string | null; + + const { + params: { alertId: ruleId, spaceId }, + } = this.taskInstance; + try { + const decryptedAttributes = await this.getDecryptedAttributes(ruleId, spaceId); + apiKey = decryptedAttributes.apiKey; + } catch (err) { + throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Decrypt, err); + } + const fakeRequest = this.getFakeKibanaRequest(spaceId, apiKey); + const rulesClient = this.context.getRulesClientWithRequest(fakeRequest); + await rulesClient.updateSnoozedUntilTime({ id }); + } + private async loadRuleAttributesAndRun(): Promise> { const { params: { alertId: ruleId, spaceId }, diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 1c453df386e24..7b1725e42bd5e 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -40,6 +40,7 @@ import { SanitizedRuleConfig, RuleMonitoring, MappedParams, + RuleSnooze, } from '../common'; export type WithoutQueryAndParams = Pick>; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; @@ -249,7 +250,8 @@ export interface RawRule extends SavedObjectAttributes { meta?: RuleMeta; executionStatus: RawRuleExecutionStatus; monitoring?: RuleMonitoring; - snoozeEndTime?: string | null; // Remove ? when this parameter is made available in the public API + snoozeSchedule?: RuleSnooze; // Remove ? when this parameter is made available in the public API + isSnoozedUntil?: string | null; } export interface AlertingPlugin { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_status_dropdown_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_status_dropdown_sandbox.tsx index 46b7fed8e14d4..e17721930858d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_status_dropdown_sandbox.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_status_dropdown_sandbox.tsx @@ -10,33 +10,33 @@ import { getRuleStatusDropdownLazy } from '../../../common/get_rule_status_dropd export const RuleStatusDropdownSandbox: React.FC<{}> = () => { const [enabled, setEnabled] = useState(true); - const [snoozeEndTime, setSnoozeEndTime] = useState(null); + const [isSnoozedUntil, setIsSnoozedUntil] = useState(null); const [muteAll, setMuteAll] = useState(false); return getRuleStatusDropdownLazy({ rule: { enabled, - snoozeEndTime, + isSnoozedUntil, muteAll, }, enableRule: async () => { setEnabled(true); setMuteAll(false); - setSnoozeEndTime(null); + setIsSnoozedUntil(null); }, disableRule: async () => setEnabled(false), snoozeRule: async (time) => { if (time === -1) { - setSnoozeEndTime(null); + setIsSnoozedUntil(null); setMuteAll(true); } else { - setSnoozeEndTime(new Date(time)); + setIsSnoozedUntil(new Date(time)); setMuteAll(false); } }, unsnoozeRule: async () => { setMuteAll(false); - setSnoozeEndTime(null); + setIsSnoozedUntil(null); }, onRuleChanged: () => {}, isEditable: true, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.test.ts index 5377e4269f46e..104f0507aef8e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.test.ts @@ -243,7 +243,7 @@ describe('loadRuleAggregations', () => { Object { "query": Object { "default_search_operator": "AND", - "filter": "alert.attributes.enabled:(true) and not (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)", + "filter": "alert.attributes.enabled:(true) and not (alert.attributes.muteAll:true OR alert.attributes.isSnoozedUntil > now)", "search": undefined, "search_fields": undefined, }, @@ -262,7 +262,7 @@ describe('loadRuleAggregations', () => { Object { "query": Object { "default_search_operator": "AND", - "filter": "alert.attributes.enabled:(true) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)", + "filter": "alert.attributes.enabled:(true) or (alert.attributes.muteAll:true OR alert.attributes.isSnoozedUntil > now)", "search": undefined, "search_fields": undefined, }, @@ -281,7 +281,7 @@ describe('loadRuleAggregations', () => { Object { "query": Object { "default_search_operator": "AND", - "filter": "alert.attributes.enabled:(true) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)", + "filter": "alert.attributes.enabled:(true) or (alert.attributes.muteAll:true OR alert.attributes.isSnoozedUntil > now)", "search": undefined, "search_fields": undefined, }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts index 67838f4f84881..5648aa30820c2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts @@ -43,7 +43,8 @@ export const transformRule: RewriteRequestCase = ({ scheduled_task_id: scheduledTaskId, execution_status: executionStatus, actions: actions, - snooze_end_time: snoozeEndTime, + snooze_schedule: snoozeSchedule, + is_snoozed_until: isSnoozedUntil, ...rest }: any) => ({ ruleTypeId, @@ -55,12 +56,13 @@ export const transformRule: RewriteRequestCase = ({ notifyWhen, muteAll, mutedInstanceIds, - snoozeEndTime, + snoozeSchedule, executionStatus: executionStatus ? transformExecutionStatus(executionStatus) : undefined, actions: actions ? actions.map((action: AsApiContract) => transformAction(action)) : [], scheduledTaskId, + isSnoozedUntil, ...rest, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.test.ts index f67a27ef5409c..8d744c84d6f77 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.test.ts @@ -46,7 +46,7 @@ describe('mapFiltersToKql', () => { ruleStatusesFilter: ['enabled'], }) ).toEqual([ - 'alert.attributes.enabled:(true) and not (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)', + 'alert.attributes.enabled:(true) and not (alert.attributes.muteAll:true OR alert.attributes.isSnoozedUntil > now)', ]); expect( @@ -54,21 +54,21 @@ describe('mapFiltersToKql', () => { ruleStatusesFilter: ['disabled'], }) ).toEqual([ - 'alert.attributes.enabled:(false) and not (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)', + 'alert.attributes.enabled:(false) and not (alert.attributes.muteAll:true OR alert.attributes.isSnoozedUntil > now)', ]); expect( mapFiltersToKql({ ruleStatusesFilter: ['snoozed'], }) - ).toEqual(['(alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)']); + ).toEqual(['(alert.attributes.muteAll:true OR alert.attributes.isSnoozedUntil > now)']); expect( mapFiltersToKql({ ruleStatusesFilter: ['enabled', 'snoozed'], }) ).toEqual([ - 'alert.attributes.enabled:(true) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)', + 'alert.attributes.enabled:(true) or (alert.attributes.muteAll:true OR alert.attributes.isSnoozedUntil > now)', ]); expect( @@ -76,7 +76,7 @@ describe('mapFiltersToKql', () => { ruleStatusesFilter: ['disabled', 'snoozed'], }) ).toEqual([ - 'alert.attributes.enabled:(false) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)', + 'alert.attributes.enabled:(false) or (alert.attributes.muteAll:true OR alert.attributes.isSnoozedUntil > now)', ]); expect( @@ -84,7 +84,7 @@ describe('mapFiltersToKql', () => { ruleStatusesFilter: ['enabled', 'disabled', 'snoozed'], }) ).toEqual([ - 'alert.attributes.enabled:(true or false) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)', + 'alert.attributes.enabled:(true or false) or (alert.attributes.muteAll:true OR alert.attributes.isSnoozedUntil > now)', ]); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.ts index ff2a49e3a5e45..6629024e3eb11 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.ts @@ -57,7 +57,7 @@ export const mapFiltersToKql = ({ if (ruleStatusesFilter && ruleStatusesFilter.length) { const enablementFilter = getEnablementFilter(ruleStatusesFilter); - const snoozedFilter = `(alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)`; + const snoozedFilter = `(alert.attributes.muteAll:true OR alert.attributes.isSnoozedUntil > now)`; const hasEnablement = ruleStatusesFilter.includes('enabled') || ruleStatusesFilter.includes('disabled'); const hasSnoozed = ruleStatusesFilter.includes('snoozed'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.test.ts index 2a20c9d9469f5..e06ee24464d78 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.test.ts @@ -266,7 +266,7 @@ describe('loadRules', () => { Object { "query": Object { "default_search_operator": "AND", - "filter": "alert.attributes.enabled:(true) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)", + "filter": "alert.attributes.enabled:(true) or (alert.attributes.muteAll:true OR alert.attributes.isSnoozedUntil > now)", "page": 1, "per_page": 10, "search": undefined, @@ -295,7 +295,7 @@ describe('loadRules', () => { Object { "query": Object { "default_search_operator": "AND", - "filter": "alert.attributes.enabled:(false) and not (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)", + "filter": "alert.attributes.enabled:(false) and not (alert.attributes.muteAll:true OR alert.attributes.isSnoozedUntil > now)", "page": 1, "per_page": 10, "search": undefined, @@ -324,7 +324,7 @@ describe('loadRules', () => { Object { "query": Object { "default_search_operator": "AND", - "filter": "alert.attributes.enabled:(true or false) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)", + "filter": "alert.attributes.enabled:(true or false) or (alert.attributes.muteAll:true OR alert.attributes.isSnoozedUntil > now)", "page": 1, "per_page": 10, "search": undefined, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.test.tsx index 15086518124b4..b2ea5e9a78aae 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.test.tsx @@ -10,7 +10,7 @@ import { mountWithIntl } from '@kbn/test-jest-helpers'; import { RuleStatusDropdown, ComponentOpts } from './rule_status_dropdown'; const NOW_STRING = '2020-03-01T00:00:00.000Z'; -const SNOOZE_END_TIME = new Date('2020-03-04T00:00:00.000Z'); +const SNOOZE_UNTIL = new Date('2020-03-04T00:00:00.000Z'); describe('RuleStatusDropdown', () => { const enableRule = jest.fn(); @@ -51,7 +51,7 @@ describe('RuleStatusDropdown', () => { notifyWhen: null, index: 0, updatedAt: new Date('2020-08-20T19:23:38Z'), - snoozeEndTime: null, + snoozeSchedule: [], } as ComponentOpts['rule'], onRuleChanged: jest.fn(), }; @@ -86,7 +86,7 @@ describe('RuleStatusDropdown', () => { const wrapper = mountWithIntl( ); expect(wrapper.find('[data-test-subj="statusDropdown"]').first().props().title).toBe('Snoozed'); @@ -108,7 +108,7 @@ describe('RuleStatusDropdown', () => { test('renders status control as disabled when rule is snoozed but also disabled', () => { const wrapper = mountWithIntl( ); expect(wrapper.find('[data-test-subj="statusDropdown"]').first().props().title).toBe( @@ -121,7 +121,7 @@ describe('RuleStatusDropdown', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx index 90a42bd4fe21c..7c6a71e893f96 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx @@ -36,7 +36,7 @@ import { Rule } from '../../../../types'; type SnoozeUnit = 'm' | 'h' | 'd' | 'w' | 'M'; const SNOOZE_END_TIME_FORMAT = 'LL @ LT'; -type DropdownRuleRecord = Pick; +type DropdownRuleRecord = Pick; export interface ComponentOpts { rule: DropdownRuleRecord; @@ -74,6 +74,11 @@ const usePreviousSnoozeInterval: (p?: string | null) => [string | null, (n: stri return [previousSnoozeInterval, storeAndSetPreviousSnoozeInterval]; }; +const isRuleSnoozed = (rule: { isSnoozedUntil?: Date | null; muteAll: boolean }) => + Boolean( + (rule.isSnoozedUntil && new Date(rule.isSnoozedUntil).getTime() > Date.now()) || rule.muteAll + ); + export const RuleStatusDropdown: React.FunctionComponent = ({ rule, onRuleChanged, @@ -158,11 +163,13 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ isEnabled && isSnoozed ? ( - {rule.muteAll ? INDEFINITELY : moment(rule.snoozeEndTime).fromNow(true)} + {rule.muteAll ? INDEFINITELY : moment(new Date(rule.isSnoozedUntil!)).fromNow(true)} ) : null; @@ -215,7 +222,7 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ onChangeSnooze={onChangeSnooze} isEnabled={isEnabled} isSnoozed={isSnoozed} - snoozeEndTime={rule.snoozeEndTime} + snoozeEndTime={rule.isSnoozedUntil} previousSnoozeInterval={previousSnoozeInterval} /> @@ -476,15 +483,6 @@ const SnoozePanel: React.FunctionComponent = ({ ); }; -const isRuleSnoozed = (rule: DropdownRuleRecord) => { - const { snoozeEndTime, muteAll } = rule; - if (muteAll) return true; - if (!snoozeEndTime) { - return false; - } - return moment(Date.now()).isBefore(snoozeEndTime); -}; - const futureTimeToInterval = (time?: Date | null) => { if (!time) return; const relativeTime = moment(time).locale('en').fromNow(true); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/create.ts index e601c6ee15ec7..2d3829f42a678 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/create.ts @@ -115,6 +115,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) { created_by: user.username, schedule: { interval: '1m' }, scheduled_task_id: response.body.scheduled_task_id, + snooze_schedule: response.body.snooze_schedule, created_at: response.body.created_at, updated_at: response.body.updated_at, throttle: '1m', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/find.ts index 20a5e82d303fe..177e51ab78eea 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/find.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/find.ts @@ -73,6 +73,7 @@ const findTestUtils = ( params: {}, created_by: 'elastic', scheduled_task_id: match.scheduled_task_id, + snooze_schedule: match.snooze_schedule, created_at: match.created_at, updated_at: match.updated_at, throttle: '1m', @@ -82,9 +83,7 @@ const findTestUtils = ( mute_all: false, muted_alert_ids: [], execution_status: match.execution_status, - ...(describeType === 'internal' - ? { monitoring: match.monitoring, snooze_end_time: match.snooze_end_time } - : {}), + ...(describeType === 'internal' ? { monitoring: match.monitoring } : {}), }); expect(Date.parse(match.created_at)).to.be.greaterThan(0); expect(Date.parse(match.updated_at)).to.be.greaterThan(0); @@ -283,9 +282,8 @@ const findTestUtils = ( created_at: match.created_at, updated_at: match.updated_at, execution_status: match.execution_status, - ...(describeType === 'internal' - ? { monitoring: match.monitoring, snooze_end_time: match.snooze_end_time } - : {}), + snooze_schedule: match.snooze_schedule, + ...(describeType === 'internal' ? { monitoring: match.monitoring } : {}), }); expect(Date.parse(match.created_at)).to.be.greaterThan(0); expect(Date.parse(match.updated_at)).to.be.greaterThan(0); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/get.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/get.ts index 48559aa35ac3c..c2c94af19b209 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/get.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/get.ts @@ -72,6 +72,7 @@ const getTestUtils = ( params: {}, created_by: 'elastic', scheduled_task_id: response.body.scheduled_task_id, + snooze_schedule: response.body.snooze_schedule, updated_at: response.body.updated_at, created_at: response.body.created_at, throttle: '1m', @@ -84,7 +85,6 @@ const getTestUtils = ( ...(describeType === 'internal' ? { monitoring: response.body.monitoring, - snooze_end_time: response.body.snooze_end_time, } : {}), }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/mute_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/mute_all.ts index 5a4c792463b62..f0ce5962de368 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/mute_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/mute_all.ts @@ -99,7 +99,7 @@ export default function createMuteAlertTests({ getService }: FtrProviderContext) .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(true); - expect(updatedAlert.snooze_end_time).to.eql(undefined); + expect(updatedAlert.snooze_schedule.length).to.eql(0); // Ensure AAD isn't broken await checkAAD({ supertest, @@ -156,7 +156,7 @@ export default function createMuteAlertTests({ getService }: FtrProviderContext) .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(true); - expect(updatedAlert.snooze_end_time).to.eql(undefined); + expect(updatedAlert.snooze_schedule.length).to.eql(0); // Ensure AAD isn't broken await checkAAD({ supertest, @@ -224,7 +224,7 @@ export default function createMuteAlertTests({ getService }: FtrProviderContext) .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(true); - expect(updatedAlert.snooze_end_time).to.eql(undefined); + expect(updatedAlert.snooze_schedule.length).to.eql(0); // Ensure AAD isn't broken await checkAAD({ supertest, @@ -292,7 +292,7 @@ export default function createMuteAlertTests({ getService }: FtrProviderContext) .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(true); - expect(updatedAlert.snooze_end_time).to.eql(undefined); + expect(updatedAlert.snooze_schedule.length).to.eql(0); // Ensure AAD isn't broken await checkAAD({ supertest, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/snooze.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/snooze.ts index 553e090498f00..0ca1ce4bf1eb7 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/snooze.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/snooze.ts @@ -97,12 +97,19 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); + const now = Date.now(); const { body: updatedAlert } = await supertestWithoutAuth .get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`) .set('kbn-xsrf', 'foo') .auth(user.username, user.password) .expect(200); - expect(updatedAlert.snooze_end_time).to.eql(FUTURE_SNOOZE_TIME); + expect(updatedAlert.snooze_schedule.length).to.eql(1); + // Due to latency, test to make sure the returned rRule.dtstart is within 10 seconds of the current time + const { rRule, duration } = updatedAlert.snooze_schedule[0]; + expect(Math.abs(Date.parse(rRule.dtstart) - now) < 10000).to.be(true); + expect(Math.abs(duration - (Date.parse(FUTURE_SNOOZE_TIME) - now)) < 10000).to.be( + true + ); expect(updatedAlert.mute_all).to.eql(false); // Ensure AAD isn't broken await checkAAD({ @@ -156,12 +163,19 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); + const now = Date.now(); const { body: updatedAlert } = await supertestWithoutAuth .get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`) .set('kbn-xsrf', 'foo') .auth(user.username, user.password) .expect(200); - expect(updatedAlert.snooze_end_time).to.eql(FUTURE_SNOOZE_TIME); + expect(updatedAlert.snooze_schedule.length).to.eql(1); + // Due to latency, test to make sure the returned rRule.dtstart is within 10 seconds of the current time + const { rRule, duration } = updatedAlert.snooze_schedule[0]; + expect(Math.abs(Date.parse(rRule.dtstart) - now) < 10000).to.be(true); + expect(Math.abs(duration - (Date.parse(FUTURE_SNOOZE_TIME) - now)) < 10000).to.be( + true + ); expect(updatedAlert.mute_all).to.eql(false); // Ensure AAD isn't broken await checkAAD({ @@ -226,12 +240,19 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); + const now = Date.now(); const { body: updatedAlert } = await supertestWithoutAuth .get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`) .set('kbn-xsrf', 'foo') .auth(user.username, user.password) .expect(200); - expect(updatedAlert.snooze_end_time).to.eql(FUTURE_SNOOZE_TIME); + expect(updatedAlert.snooze_schedule.length).to.eql(1); + // Due to latency, test to make sure the returned rRule.dtstart is within 10 seconds of the current time + const { rRule, duration } = updatedAlert.snooze_schedule[0]; + expect(Math.abs(Date.parse(rRule.dtstart) - now) < 10000).to.be(true); + expect(Math.abs(duration - (Date.parse(FUTURE_SNOOZE_TIME) - now)) < 10000).to.be( + true + ); expect(updatedAlert.mute_all).to.eql(false); // Ensure AAD isn't broken await checkAAD({ @@ -296,12 +317,19 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); + const now = Date.now(); const { body: updatedAlert } = await supertestWithoutAuth .get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`) .set('kbn-xsrf', 'foo') .auth(user.username, user.password) .expect(200); - expect(updatedAlert.snooze_end_time).to.eql(FUTURE_SNOOZE_TIME); + expect(updatedAlert.snooze_schedule.length).to.eql(1); + // Due to latency, test to make sure the returned rRule.dtstart is within 10 seconds of the current time + const { rRule, duration } = updatedAlert.snooze_schedule[0]; + expect(Math.abs(Date.parse(rRule.dtstart) - now) < 10000).to.be(true); + expect(Math.abs(duration - (Date.parse(FUTURE_SNOOZE_TIME) - now)) < 10000).to.be( + true + ); expect(updatedAlert.mute_all).to.eql(false); // Ensure AAD isn't broken await checkAAD({ @@ -383,7 +411,7 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext .set('kbn-xsrf', 'foo') .auth(user.username, user.password) .expect(200); - expect(updatedAlert.snooze_end_time).to.eql(null); + expect(updatedAlert.snooze_schedule).to.eql([]); expect(updatedAlert.mute_all).to.eql(true); // Ensure AAD isn't broken await checkAAD({ diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/unmute_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/unmute_all.ts index dde198f54f771..9c918b3225f9e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/unmute_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/unmute_all.ts @@ -104,7 +104,7 @@ export default function createUnmuteAlertTests({ getService }: FtrProviderContex .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(false); - expect(updatedAlert.snooze_end_time).to.eql(undefined); + expect(updatedAlert.snooze_schedule.length).to.eql(0); // Ensure AAD isn't broken await checkAAD({ supertest, @@ -166,7 +166,7 @@ export default function createUnmuteAlertTests({ getService }: FtrProviderContex .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(false); - expect(updatedAlert.snooze_end_time).to.eql(undefined); + expect(updatedAlert.snooze_schedule.length).to.eql(0); // Ensure AAD isn't broken await checkAAD({ supertest, @@ -239,7 +239,7 @@ export default function createUnmuteAlertTests({ getService }: FtrProviderContex .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(false); - expect(updatedAlert.snooze_end_time).to.eql(undefined); + expect(updatedAlert.snooze_schedule.length).to.eql(0); // Ensure AAD isn't broken await checkAAD({ supertest, @@ -312,7 +312,7 @@ export default function createUnmuteAlertTests({ getService }: FtrProviderContex .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(false); - expect(updatedAlert.snooze_end_time).to.eql(undefined); + expect(updatedAlert.snooze_schedule.length).to.eql(0); // Ensure AAD isn't broken await checkAAD({ supertest, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/unsnooze.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/unsnooze.ts index c868654235c21..8b6a8aa2c6c45 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/unsnooze.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/unsnooze.ts @@ -98,7 +98,7 @@ export default function createUnsnoozeRuleTests({ getService }: FtrProviderConte .set('kbn-xsrf', 'foo') .auth(user.username, user.password) .expect(200); - expect(updatedAlert.snooze_end_time).to.eql(null); + expect(updatedAlert.snooze_schedule).to.eql(null); expect(updatedAlert.mute_all).to.eql(false); // Ensure AAD isn't broken await checkAAD({ @@ -155,7 +155,7 @@ export default function createUnsnoozeRuleTests({ getService }: FtrProviderConte .set('kbn-xsrf', 'foo') .auth(user.username, user.password) .expect(200); - expect(updatedAlert.snooze_end_time).to.eql(null); + expect(updatedAlert.snooze_schedule).to.eql(null); expect(updatedAlert.mute_all).to.eql(false); // Ensure AAD isn't broken await checkAAD({ @@ -223,7 +223,7 @@ export default function createUnsnoozeRuleTests({ getService }: FtrProviderConte .set('kbn-xsrf', 'foo') .auth(user.username, user.password) .expect(200); - expect(updatedAlert.snooze_end_time).to.eql(null); + expect(updatedAlert.snooze_schedule).to.eql(null); expect(updatedAlert.mute_all).to.eql(false); // Ensure AAD isn't broken await checkAAD({ @@ -291,7 +291,7 @@ export default function createUnsnoozeRuleTests({ getService }: FtrProviderConte .set('kbn-xsrf', 'foo') .auth(user.username, user.password) .expect(200); - expect(updatedAlert.snooze_end_time).to.eql(null); + expect(updatedAlert.snooze_schedule).to.eql(null); expect(updatedAlert.mute_all).to.eql(false); // Ensure AAD isn't broken await checkAAD({ diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts index c49fa62c606b6..d28b81f479b11 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts @@ -129,6 +129,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { }, ], scheduled_task_id: createdAlert.scheduled_task_id, + snooze_schedule: createdAlert.snooze_schedule, created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, @@ -213,6 +214,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { mute_all: false, muted_alert_ids: [], scheduled_task_id: createdAlert.scheduled_task_id, + snooze_schedule: createdAlert.snooze_schedule, created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, @@ -308,6 +310,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { mute_all: false, muted_alert_ids: [], scheduled_task_id: createdAlert.scheduled_task_id, + snooze_schedule: createdAlert.snooze_schedule, created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, @@ -403,6 +406,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { mute_all: false, muted_alert_ids: [], scheduled_task_id: createdAlert.scheduled_task_id, + snooze_schedule: createdAlert.snooze_schedule, created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, @@ -496,6 +500,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { mute_all: false, muted_alert_ids: [], scheduled_task_id: createdAlert.scheduled_task_id, + snooze_schedule: createdAlert.snooze_schedule, created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts index 143d845d074c4..a33f7fc5a1a2c 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts @@ -85,6 +85,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) { created_by: null, schedule: { interval: '1m' }, scheduled_task_id: response.body.scheduled_task_id, + snooze_schedule: response.body.snooze_schedule, updated_by: null, api_key_owner: null, throttle: '1m', @@ -180,6 +181,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) { created_by: null, schedule: { interval: '1m' }, scheduled_task_id: response.body.scheduled_task_id, + snooze_schedule: response.body.snooze_schedule, updated_by: null, api_key_owner: null, throttle: '1m', @@ -475,6 +477,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) { createdBy: null, schedule: { interval: '1m' }, scheduledTaskId: response.body.scheduledTaskId, + snoozeSchedule: response.body.snoozeSchedule, updatedBy: null, apiKeyOwner: null, throttle: '1m', diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts index a1b0f5c7eeb14..021a2be1ebb5d 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts @@ -64,6 +64,7 @@ const findTestUtils = ( created_by: null, api_key_owner: null, scheduled_task_id: match.scheduled_task_id, + snooze_schedule: match.snooze_schedule, updated_by: null, throttle: '1m', notify_when: 'onThrottleInterval', @@ -72,9 +73,7 @@ const findTestUtils = ( created_at: match.created_at, updated_at: match.updated_at, execution_status: match.execution_status, - ...(describeType === 'internal' - ? { monitoring: match.monitoring, snooze_end_time: match.snooze_end_time } - : {}), + ...(describeType === 'internal' ? { monitoring: match.monitoring } : {}), }); expect(Date.parse(match.created_at)).to.be.greaterThan(0); expect(Date.parse(match.updated_at)).to.be.greaterThan(0); @@ -296,6 +295,7 @@ export default function createFindTests({ getService }: FtrProviderContext) { createdBy: null, apiKeyOwner: null, scheduledTaskId: match.scheduledTaskId, + snoozeSchedule: match.snoozeSchedule, updatedBy: null, throttle: '1m', notifyWhen: 'onThrottleInterval', diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts index 58c68def04372..ee993c425fa38 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts @@ -45,6 +45,7 @@ const getTestUtils = ( params: {}, created_by: null, scheduled_task_id: response.body.scheduled_task_id, + snooze_schedule: response.body.snooze_schedule, updated_by: null, api_key_owner: null, throttle: '1m', @@ -55,7 +56,7 @@ const getTestUtils = ( updated_at: response.body.updated_at, execution_status: response.body.execution_status, ...(describeType === 'internal' - ? { monitoring: response.body.monitoring, snooze_end_time: response.body.snooze_end_time } + ? { monitoring: response.body.monitoring, snooze_schedule: response.body.snooze_schedule } : {}), }); expect(Date.parse(response.body.created_at)).to.be.greaterThan(0); @@ -136,6 +137,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { params: {}, createdBy: null, scheduledTaskId: response.body.scheduledTaskId, + snoozeSchedule: response.body.snoozeSchedule, updatedBy: null, apiKeyOwner: null, throttle: '1m', diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mute_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mute_all.ts index 53517b191bab6..a56b95ed09219 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mute_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mute_all.ts @@ -41,7 +41,7 @@ export default function createMuteTests({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .expect(200); expect(updatedAlert.mute_all).to.eql(true); - expect(updatedAlert.snooze_end_time).to.eql(undefined); + expect(updatedAlert.snooze_schedule.length).to.eql(0); // Ensure AAD isn't broken await checkAAD({ supertest: supertestWithoutAuth, @@ -70,7 +70,7 @@ export default function createMuteTests({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .expect(200); expect(updatedAlert.mute_all).to.eql(true); - expect(updatedAlert.snooze_end_time).to.eql(undefined); + expect(updatedAlert.snooze_schedule.length).to.eql(0); // Ensure AAD isn't broken await checkAAD({ diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/snooze.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/snooze.ts index 5be5b59a15248..80cfa5a105467 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/snooze.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/snooze.ts @@ -70,11 +70,16 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); + const now = Date.now(); const { body: updatedAlert } = await supertestWithoutAuth .get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${createdRule.id}`) .set('kbn-xsrf', 'foo') .expect(200); - expect(updatedAlert.snooze_end_time).to.eql(FUTURE_SNOOZE_TIME); + expect(updatedAlert.snooze_schedule.length).to.eql(1); + // Due to latency, test to make sure the returned rRule.dtstart is within 10 seconds of the current time + const { rRule, duration } = updatedAlert.snooze_schedule[0]; + expect(Math.abs(Date.parse(rRule.dtstart) - now) < 10000).to.be(true); + expect(Math.abs(duration - (Date.parse(FUTURE_SNOOZE_TIME) - now)) < 10000).to.be(true); expect(updatedAlert.mute_all).to.eql(false); // Ensure AAD isn't broken await checkAAD({ @@ -126,7 +131,7 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext .get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${createdRule.id}`) .set('kbn-xsrf', 'foo') .expect(200); - expect(updatedAlert.snooze_end_time).to.eql(null); + expect(updatedAlert.snooze_schedule).to.eql([]); expect(updatedAlert.mute_all).to.eql(true); // Ensure AAD isn't broken await checkAAD({ diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/unmute_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/unmute_all.ts index 782df6d86d542..62ff63052f841 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/unmute_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/unmute_all.ts @@ -42,7 +42,7 @@ export default function createUnmuteTests({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .expect(200); expect(updatedAlert.mute_all).to.eql(false); - expect(updatedAlert.snooze_end_time).to.eql(undefined); + expect(updatedAlert.snooze_schedule.length).to.eql(0); // Ensure AAD isn't broken await checkAAD({ @@ -76,7 +76,7 @@ export default function createUnmuteTests({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .expect(200); expect(updatedAlert.mute_all).to.eql(false); - expect(updatedAlert.snooze_end_time).to.eql(undefined); + expect(updatedAlert.snooze_schedule.length).to.eql(0); // Ensure AAD isn't broken await checkAAD({ diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts index c5a9c93d45e81..c431654f0fd20 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts @@ -60,6 +60,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { muted_alert_ids: [], notify_when: 'onThrottleInterval', scheduled_task_id: createdAlert.scheduled_task_id, + snooze_schedule: createdAlert.snooze_schedule, created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, @@ -160,6 +161,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { mutedInstanceIds: [], notifyWhen: 'onThrottleInterval', scheduledTaskId: createdAlert.scheduled_task_id, + snoozeSchedule: createdAlert.snooze_schedule, createdAt: response.body.createdAt, updatedAt: response.body.updatedAt, executionStatus: response.body.executionStatus, diff --git a/yarn.lock b/yarn.lock index ebfce5de26090..30f73d40cd149 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7006,6 +7006,13 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== +"@types/rrule@^2.2.9": + version "2.2.9" + resolved "https://registry.yarnpkg.com/@types/rrule/-/rrule-2.2.9.tgz#b25222b5057b9a9e6eea28ce9e94673a957c960f" + integrity sha512-OWTezBoGwsL2nn9SFbLbiTrAic1hpxAIRqeF8QDB84iW6KBEAHM6Oj9T2BEokgeIDgT1q73sfD0gI1S2yElSFA== + dependencies: + rrule "*" + "@types/seedrandom@>=2.0.0 <4.0.0": version "2.4.28" resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-2.4.28.tgz#9ce8fa048c1e8c85cb71d7fe4d704e000226036f" @@ -19469,11 +19476,16 @@ lru-queue@0.1: dependencies: es5-ext "~0.10.2" -luxon@^1.25.0: +luxon@^1.21.3, luxon@^1.25.0: version "1.28.0" resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.28.0.tgz#e7f96daad3938c06a62de0fb027115d251251fbf" integrity sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ== +luxon@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-2.3.2.tgz#5f2f3002b8c39b60a7b7ad24b2a85d90dc5db49c" + integrity sha512-MlAQQVMFhGk4WUA6gpfsy0QycnKP0+NlCBJRVRNPxxSIbjrCbQ65nrpJD3FVyJNZLuJ0uoqL57ye6BmDYgHaSw== + lz-string@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" @@ -25234,6 +25246,24 @@ rollup@^0.25.8: minimist "^1.2.0" source-map-support "^0.3.2" +rrule@*: + version "2.6.9" + resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.6.9.tgz#8ee4ee261451e84852741f92ded769245580744a" + integrity sha512-PE4ErZDMfAcRnc1B35bZgPGS9mbn7Z9bKDgk6+XgrIwvBjeWk7JVEYsqKwHYTrDGzsHPtZTpaon8IyeKzAhj5w== + dependencies: + tslib "^1.10.0" + optionalDependencies: + luxon "^1.21.3" + +rrule@2.6.4: + version "2.6.4" + resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.6.4.tgz#7f4f31fda12bc7249bb176c891109a9bc448e035" + integrity sha512-sLdnh4lmjUqq8liFiOUXD5kWp/FcnbDLPwq5YAc/RrN6120XOPb86Ae5zxF7ttBVq8O3LxjjORMEit1baluahA== + dependencies: + tslib "^1.10.0" + optionalDependencies: + luxon "^1.21.3" + rst-selector-parser@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz#81b230ea2fcc6066c89e3472de794285d9b03d91" From 7d8aae5f8a7f91a55960d7ae814253af22b71605 Mon Sep 17 00:00:00 2001 From: Jeramy Soucy Date: Wed, 18 May 2022 19:27:40 +0200 Subject: [PATCH 021/150] Deprecate Anonymous Authentication Credentials (#131636) * Adds deprecation warnings for apiKey and elasticsearch_anonymous_user credentials of anonymous authentication providers. Adds telemetry for usage of anonymous authentication credential type. * Update x-pack/plugins/security/server/config_deprecations.ts Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> * Update x-pack/plugins/security/server/config_deprecations.ts Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> * Update x-pack/plugins/security/server/config_deprecations.ts Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> * Updated all docs to remove deprecated anon auth features, fixed doc link logic and typos. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> --- docs/settings/security-settings.asciidoc | 20 +- .../security/authentication/index.asciidoc | 45 +--- .../server/config_deprecations.test.ts | 88 ++++++++ .../security/server/config_deprecations.ts | 59 +++++- .../security_usage_collector.test.ts | 195 ++++++++++++++++++ .../security_usage_collector.ts | 28 +++ .../schema/xpack_plugins.json | 6 + 7 files changed, 378 insertions(+), 63 deletions(-) diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 787efa64f0775..6f7ada651ad3a 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -112,34 +112,20 @@ In addition to <.credentials {ess-icon}:: -Credentials that {kib} should use internally to authenticate anonymous requests to {es}. Possible values are: username and password, API key, or the constant `elasticsearch_anonymous_user` if you want to leverage {ref}/anonymous-access.html[{es} anonymous access]. +Credentials that {kib} should use internally to authenticate anonymous requests to {es}. + For example: + [source,yaml] ---------------------------------------- -# Username and password credentials xpack.security.authc.providers.anonymous.anonymous1: credentials: username: "anonymous_service_account" password: "anonymous_service_account_password" - -# API key (concatenated and base64-encoded) -xpack.security.authc.providers.anonymous.anonymous1: - credentials: - apiKey: "VnVhQ2ZHY0JDZGJrUW0tZTVhT3g6dWkybHAyYXhUTm1zeWFrdzl0dk5udw==" - -# API key (as returned from Elasticsearch API) -xpack.security.authc.providers.anonymous.anonymous1: - credentials: - apiKey.id: "VuaCfGcBCdbkQm-e5aOx" - apiKey.key: "ui2lp2axTNmsyakw9tvNnw" - -# Elasticsearch anonymous access -xpack.security.authc.providers.anonymous.anonymous1: - credentials: "elasticsearch_anonymous_user" ---------------------------------------- +For more information, refer to <>. + [float] [[http-authentication-settings]] ==== HTTP authentication settings diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index 446de62326f8e..007d1af017df3 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -332,13 +332,11 @@ Anyone with access to the network {kib} is exposed to will be able to access {ki Anonymous authentication gives users access to {kib} without requiring them to provide credentials. This can be useful if you want your users to skip the login step when you embed dashboards in another application or set up a demo {kib} instance in your internal network, while still keeping other security features intact. -To enable anonymous authentication in {kib}, you must decide what credentials the anonymous service account {kib} should use internally to authenticate anonymous requests. +To enable anonymous authentication in {kib}, you must specify the credentials the anonymous service account {kib} should use internally to authenticate anonymous requests. NOTE: You can configure only one anonymous authentication provider per {kib} instance. -There are three ways to specify these credentials: - -If you have a user who can authenticate to {es} using username and password, for instance from the Native or LDAP security realms, you can also use these credentials to impersonate the anonymous users. Here is how your `kibana.yml` might look if you use username and password credentials: +You must have a user account that can authenticate to {es} using a username and password, for instance from the Native or LDAP security realms, so that you can use these credentials to impersonate the anonymous users. Here is how your `kibana.yml` might look: [source,yaml] ----------------------------------------------- @@ -350,45 +348,6 @@ xpack.security.authc.providers: password: "anonymous_service_account_password" ----------------------------------------------- -If using username and password credentials isn't desired or feasible, then you can create a dedicated <> for the anonymous service account. In this case, your `kibana.yml` might look like this: - -[source,yaml] ------------------------------------------------ -xpack.security.authc.providers: - anonymous.anonymous1: - order: 0 - credentials: - apiKey: "VnVhQ2ZHY0JDZGJrUW0tZTVhT3g6dWkybHAyYXhUTm1zeWFrdzl0dk5udw==" ------------------------------------------------ - -The previous configuration snippet uses an API key string that is the result of base64-encoding of the `id` and `api_key` fields returned from the {es} API, joined by a colon. You can also specify these fields separately, and {kib} will do the concatenation and base64-encoding for you: - -[source,yaml] ------------------------------------------------ -xpack.security.authc.providers: - anonymous.anonymous1: - order: 0 - credentials: - apiKey.id: "VuaCfGcBCdbkQm-e5aOx" - apiKey.key: "ui2lp2axTNmsyakw9tvNnw" ------------------------------------------------ - -It's also possible to use {kib} anonymous access in conjunction with the {es} anonymous access. - -Prior to configuring {kib}, ensure that anonymous access is enabled and properly configured in {es}. See {ref}/anonymous-access.html[Enabling anonymous access] for more information. - -Here is how your `kibana.yml` might look like if you want to use {es} anonymous access to impersonate anonymous users in {kib}: - -[source,yaml] ------------------------------------------------ -xpack.security.authc.providers: - anonymous.anonymous1: - order: 0 - credentials: "elasticsearch_anonymous_user" <1> ------------------------------------------------ - -<1> The `elasticsearch_anonymous_user` is a special constant that indicates you want to use the {es} anonymous user. - [float] ===== Anonymous access and other types of authentication diff --git a/x-pack/plugins/security/server/config_deprecations.test.ts b/x-pack/plugins/security/server/config_deprecations.test.ts index 5bd4bf0fa3f52..bed0f49fa1b59 100644 --- a/x-pack/plugins/security/server/config_deprecations.test.ts +++ b/x-pack/plugins/security/server/config_deprecations.test.ts @@ -295,4 +295,92 @@ describe('Config Deprecations', () => { ] `); }); + + it(`warns that 'xpack.security.authc.providers.anonymous..credentials.apiKey' is deprecated`, () => { + const config = { + xpack: { + security: { + authc: { + providers: { + anonymous: { + anonymous1: { + credentials: { + apiKey: 'VnVhQ2ZHY0JDZGJrUW0tZTVhT3g6dWkybHAyYXhUTm1zeWFrdzl0dk5udw==', + }, + }, + }, + }, + }, + }, + }, + }; + const { messages, configPaths } = applyConfigDeprecations(cloneDeep(config)); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Support for apiKey is being removed from the 'anonymous' authentication provider. Use username and password credentials.", + ] + `); + + expect(configPaths).toEqual([ + 'xpack.security.authc.providers.anonymous.anonymous1.credentials.apiKey', + ]); + }); + + it(`warns that 'xpack.security.authc.providers.anonymous..credentials.apiKey' with id and key is deprecated`, () => { + const config = { + xpack: { + security: { + authc: { + providers: { + anonymous: { + anonymous1: { + credentials: { + apiKey: { id: 'VuaCfGcBCdbkQm-e5aOx', key: 'ui2lp2axTNmsyakw9tvNnw' }, + }, + }, + }, + }, + }, + }, + }, + }; + const { messages, configPaths } = applyConfigDeprecations(cloneDeep(config)); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Support for apiKey is being removed from the 'anonymous' authentication provider. Use username and password credentials.", + ] + `); + + expect(configPaths).toEqual([ + 'xpack.security.authc.providers.anonymous.anonymous1.credentials.apiKey', + ]); + }); + + it(`warns that 'xpack.security.authc.providers.anonymous..credentials' of 'elasticsearch_anonymous_user' is deprecated`, () => { + const config = { + xpack: { + security: { + authc: { + providers: { + anonymous: { + anonymous1: { + credentials: 'elasticsearch_anonymous_user', + }, + }, + }, + }, + }, + }, + }; + const { messages, configPaths } = applyConfigDeprecations(cloneDeep(config)); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Support for 'elasticsearch_anonymous_user' is being removed from the 'anonymous' authentication provider. Use username and password credentials.", + ] + `); + + expect(configPaths).toEqual([ + 'xpack.security.authc.providers.anonymous.anonymous1.credentials', + ]); + }); }); diff --git a/x-pack/plugins/security/server/config_deprecations.ts b/x-pack/plugins/security/server/config_deprecations.ts index b4625c521e036..262a2f885779b 100644 --- a/x-pack/plugins/security/server/config_deprecations.ts +++ b/x-pack/plugins/security/server/config_deprecations.ts @@ -35,7 +35,7 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ (settings, _fromPath, addDeprecation, { branch }) => { if (Array.isArray(settings?.xpack?.security?.authc?.providers)) { // TODO: remove when docs support "main" - const docsBranch = branch === 'main' ? 'master' : 'main'; + const docsBranch = branch === 'main' ? 'master' : 'branch'; addDeprecation({ configPath: 'xpack.security.authc.providers', title: i18n.translate('xpack.security.deprecations.authcProvidersTitle', { @@ -62,7 +62,7 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ }, (settings, _fromPath, addDeprecation, { branch }) => { // TODO: remove when docs support "main" - const docsBranch = branch === 'main' ? 'master' : 'main'; + const docsBranch = branch === 'main' ? 'master' : 'branch'; const hasProviderType = (providerType: string) => { const providers = settings?.xpack?.security?.authc?.providers; @@ -106,7 +106,7 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ }, (settings, _fromPath, addDeprecation, { branch }) => { // TODO: remove when docs support "main" - const docsBranch = branch === 'main' ? 'master' : 'main'; + const docsBranch = branch === 'main' ? 'master' : 'branch'; const samlProviders = (settings?.xpack?.security?.authc?.providers?.saml ?? {}) as Record< string, any @@ -138,4 +138,57 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ }); } }, + (settings, _fromPath, addDeprecation, { branch }) => { + // TODO: remove when docs support "main" + const docsBranch = branch === 'main' ? 'master' : 'branch'; + const anonProviders = (settings?.xpack?.security?.authc?.providers?.anonymous ?? {}) as Record< + string, + any + >; + + const credTypeElasticsearchAnonUser = 'elasticsearch_anonymous_user'; + const credTypeApiKey = 'apiKey'; + + for (const provider of Object.entries(anonProviders)) { + if ( + !!provider[1].credentials.apiKey || + provider[1].credentials === credTypeElasticsearchAnonUser + ) { + const isApiKey: boolean = !!provider[1].credentials.apiKey; + addDeprecation({ + configPath: `xpack.security.authc.providers.anonymous.${provider[0]}.credentials${ + isApiKey ? '.apiKey' : '' + }`, + title: i18n.translate( + 'xpack.security.deprecations.anonymousApiKeyOrElasticsearchAnonUserTitle', + { + values: { + credType: isApiKey ? `${credTypeApiKey}` : `'${credTypeElasticsearchAnonUser}'`, + }, + defaultMessage: `Use of {credType} for "xpack.security.authc.providers.anonymous.credentials" is deprecated.`, + } + ), + message: i18n.translate( + 'xpack.security.deprecations.anonymousApiKeyOrElasticsearchAnonUserMessage', + { + values: { + credType: isApiKey ? `${credTypeApiKey}` : `'${credTypeElasticsearchAnonUser}'`, + }, + defaultMessage: `Support for {credType} is being removed from the 'anonymous' authentication provider. Use username and password credentials.`, + } + ), + level: 'warning', + documentationUrl: `https://www.elastic.co/guide/en/kibana/${docsBranch}/kibana-authentication.html#anonymous-authentication`, + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.security.deprecations.anonAuthCredentials.manualSteps1', { + defaultMessage: + 'Change the anonymous authentication provider to use username and password credentials.', + }), + ], + }, + }); + } + } + }, ]; diff --git a/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts b/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts index 3b6e28765f69f..15a0713d80326 100644 --- a/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts +++ b/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts @@ -49,6 +49,7 @@ describe('Security UsageCollector', () => { sessionIdleTimeoutInMinutes: 480, sessionLifespanInMinutes: 43200, sessionCleanupInMinutes: 60, + anonymousCredentialType: undefined, }; describe('initialization', () => { @@ -109,6 +110,7 @@ describe('Security UsageCollector', () => { sessionIdleTimeoutInMinutes: 0, sessionLifespanInMinutes: 0, sessionCleanupInMinutes: 0, + anonymousCredentialType: undefined, }); }); @@ -465,4 +467,197 @@ describe('Security UsageCollector', () => { }); }); }); + + describe('anonymous auth credentials', () => { + it('reports anonymous credential of apiKey with id and key as api_key', async () => { + const config = createSecurityConfig( + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 1, + credentials: { + apiKey: { id: 'VuaCfGcBCdbkQm-e5aOx', key: 'ui2lp2axTNmsyakw9tvNnw' }, + }, + }, + }, + }, + }, + }) + ); + const usageCollection = usageCollectionPluginMock.createSetupContract(); + const license = createSecurityLicense({ isLicenseAvailable: true, allowAuditLogging: false }); + registerSecurityUsageCollector({ usageCollection, config, license }); + + const usage = await usageCollection + .getCollectorByType('security') + ?.fetch(collectorFetchContext); + + expect(usage).toEqual({ + ...DEFAULT_USAGE, + enabledAuthProviders: ['anonymous'], + anonymousCredentialType: 'api_key', + }); + }); + + it('reports anonymous credential of apiKey as api_key', async () => { + const config = createSecurityConfig( + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 1, + credentials: { + apiKey: 'VnVhQ2ZHY0JDZGJrUW0tZTVhT3g6dWkybHAyYXhUTm1zeWFrdzl0dk5udw==', + }, + }, + }, + }, + }, + }) + ); + const usageCollection = usageCollectionPluginMock.createSetupContract(); + const license = createSecurityLicense({ isLicenseAvailable: true, allowAuditLogging: false }); + registerSecurityUsageCollector({ usageCollection, config, license }); + + const usage = await usageCollection + .getCollectorByType('security') + ?.fetch(collectorFetchContext); + + expect(usage).toEqual({ + ...DEFAULT_USAGE, + enabledAuthProviders: ['anonymous'], + anonymousCredentialType: 'api_key', + }); + }); + + it(`reports anonymous credential of 'elasticsearch_anonymous_user' as elasticsearch_anonymous_user`, async () => { + const config = createSecurityConfig( + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 1, + credentials: 'elasticsearch_anonymous_user', + }, + }, + }, + }, + }) + ); + const usageCollection = usageCollectionPluginMock.createSetupContract(); + const license = createSecurityLicense({ isLicenseAvailable: true, allowAuditLogging: false }); + registerSecurityUsageCollector({ usageCollection, config, license }); + + const usage = await usageCollection + .getCollectorByType('security') + ?.fetch(collectorFetchContext); + + expect(usage).toEqual({ + ...DEFAULT_USAGE, + enabledAuthProviders: ['anonymous'], + anonymousCredentialType: 'elasticsearch_anonymous_user', + }); + }); + + it('reports anonymous credential of username and password as usernanme_password', async () => { + const config = createSecurityConfig( + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 1, + credentials: { + username: 'anonymous_service_account', + password: 'anonymous_service_account_password', + }, + }, + }, + }, + }, + }) + ); + const usageCollection = usageCollectionPluginMock.createSetupContract(); + const license = createSecurityLicense({ isLicenseAvailable: true, allowAuditLogging: false }); + registerSecurityUsageCollector({ usageCollection, config, license }); + + const usage = await usageCollection + .getCollectorByType('security') + ?.fetch(collectorFetchContext); + + expect(usage).toEqual({ + ...DEFAULT_USAGE, + enabledAuthProviders: ['anonymous'], + anonymousCredentialType: 'username_password', + }); + }); + + it('reports lack of anonymous credential as undefined', async () => { + const config = createSecurityConfig(ConfigSchema.validate({})); + const usageCollection = usageCollectionPluginMock.createSetupContract(); + const license = createSecurityLicense({ isLicenseAvailable: true, allowAuditLogging: false }); + registerSecurityUsageCollector({ usageCollection, config, license }); + + const usage = await usageCollection + .getCollectorByType('security') + ?.fetch(collectorFetchContext); + + expect(usage).toEqual({ + ...DEFAULT_USAGE, + enabledAuthProviders: ['basic'], + anonymousCredentialType: undefined, + }); + }); + + it('reports the enabled anonymous credential of username and password as usernanme_password', async () => { + const config = createSecurityConfig( + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 1, + enabled: false, + credentials: { + apiKey: 'VnVhQ2ZHY0JDZGJrUW0tZTVhT3g6dWkybHAyYXhUTm1zeWFrdzl0dk5udw==', + }, + }, + anonymous2: { + order: 2, + credentials: { + username: 'anonymous_service_account', + password: 'anonymous_service_account_password', + }, + }, + anonymous3: { + order: 3, + enabled: false, + credentials: { + apiKey: { id: 'VuaCfGcBCdbkQm-e5aOx', key: 'ui2lp2axTNmsyakw9tvNnw' }, + }, + }, + }, + }, + }, + }) + ); + const usageCollection = usageCollectionPluginMock.createSetupContract(); + const license = createSecurityLicense({ isLicenseAvailable: true, allowAuditLogging: false }); + registerSecurityUsageCollector({ usageCollection, config, license }); + + const usage = await usageCollection + .getCollectorByType('security') + ?.fetch(collectorFetchContext); + + expect(usage).toEqual({ + ...DEFAULT_USAGE, + enabledAuthProviders: ['anonymous'], + anonymousCredentialType: 'username_password', + }); + }); + }); }); diff --git a/x-pack/plugins/security/server/usage_collector/security_usage_collector.ts b/x-pack/plugins/security/server/usage_collector/security_usage_collector.ts index 0b1ef3a3d1f39..4050e70bbcfed 100644 --- a/x-pack/plugins/security/server/usage_collector/security_usage_collector.ts +++ b/x-pack/plugins/security/server/usage_collector/security_usage_collector.ts @@ -20,6 +20,7 @@ interface Usage { sessionIdleTimeoutInMinutes: number; sessionLifespanInMinutes: number; sessionCleanupInMinutes: number; + anonymousCredentialType: string | undefined; } interface Deps { @@ -122,6 +123,13 @@ export function registerSecurityUsageCollector({ usageCollection, config, licens 'The session cleanup interval that is configured, in minutes (0 if disabled).', }, }, + anonymousCredentialType: { + type: 'keyword', + _meta: { + description: + 'The credential type that is configured for the anonymous authentication provider.', + }, + }, }, fetch: () => { const { allowRbac, allowAccessAgreement, allowAuditLogging } = license.getFeatures(); @@ -136,6 +144,7 @@ export function registerSecurityUsageCollector({ usageCollection, config, licens sessionIdleTimeoutInMinutes: 0, sessionLifespanInMinutes: 0, sessionCleanupInMinutes: 0, + anonymousCredentialType: undefined, }; } @@ -163,6 +172,24 @@ export function registerSecurityUsageCollector({ usageCollection, config, licens const sessionLifespanInMinutes = sessionExpirations.lifespan?.asMinutes() ?? 0; const sessionCleanupInMinutes = config.session.cleanupInterval?.asMinutes() ?? 0; + const anonProviders = config.authc.providers.anonymous ?? ({} as Record); + const foundProvider = Object.entries(anonProviders).find( + ([_, provider]) => !!provider.credentials && provider.enabled + ); + + const credElasticAnonUser = 'elasticsearch_anonymous_user'; + const credApiKey = 'api_key'; + const credUsernamePassword = 'username_password'; + + let anonymousCredentialType; + if (foundProvider) { + if (!!foundProvider[1].credentials.apiKey) anonymousCredentialType = credApiKey; + else if (foundProvider[1].credentials === credElasticAnonUser) + anonymousCredentialType = credElasticAnonUser; + else if (!!foundProvider[1].credentials.username && !!foundProvider[1].credentials.password) + anonymousCredentialType = credUsernamePassword; + } + return { auditLoggingEnabled, loginSelectorEnabled, @@ -173,6 +200,7 @@ export function registerSecurityUsageCollector({ usageCollection, config, licens sessionIdleTimeoutInMinutes, sessionLifespanInMinutes, sessionCleanupInMinutes, + anonymousCredentialType, }; }, }); diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index aac8d2e40f650..68051c047f230 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -12561,6 +12561,12 @@ "_meta": { "description": "The session cleanup interval that is configured, in minutes (0 if disabled)." } + }, + "anonymousCredentialType": { + "type": "keyword", + "_meta": { + "description": "The credential type that is configured for the anonymous authentication provider." + } } } }, From 9f9a24a06d79f98b3a8f893a3b604b32bb6c5c9d Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 18 May 2022 12:05:29 -0600 Subject: [PATCH 022/150] [maps] update vector tile search API integration tests for fixed polygon orientation (#132447) --- .../apis/maps/get_grid_tile.js | 19 +++++++++---------- .../api_integration/apis/maps/get_tile.js | 7 +++---- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/x-pack/test/api_integration/apis/maps/get_grid_tile.js b/x-pack/test/api_integration/apis/maps/get_grid_tile.js index 36e4d678093a7..46fdda09ec476 100644 --- a/x-pack/test/api_integration/apis/maps/get_grid_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_grid_tile.js @@ -12,8 +12,7 @@ import expect from '@kbn/expect'; export default function ({ getService }) { const supertest = getService('supertest'); - // Failing: See https://github.com/elastic/kibana/issues/132372 - describe.skip('getGridTile', () => { + describe('getGridTile', () => { const URL = `/api/maps/mvt/getGridTile/3/2/3.pbf\ ?geometryFieldName=geo.coordinates\ &index=logstash-*\ @@ -110,9 +109,9 @@ export default function ({ getService }) { expect(gridFeature.loadGeometry()).to.eql([ [ { x: 80, y: 672 }, - { x: 96, y: 672 }, - { x: 96, y: 656 }, { x: 80, y: 656 }, + { x: 96, y: 656 }, + { x: 96, y: 672 }, { x: 80, y: 672 }, ], ]); @@ -143,11 +142,11 @@ export default function ({ getService }) { expect(gridFeature.loadGeometry()).to.eql([ [ { x: 102, y: 669 }, - { x: 99, y: 659 }, - { x: 89, y: 657 }, - { x: 83, y: 664 }, - { x: 86, y: 674 }, { x: 96, y: 676 }, + { x: 86, y: 674 }, + { x: 83, y: 664 }, + { x: 89, y: 657 }, + { x: 99, y: 659 }, { x: 102, y: 669 }, ], ]); @@ -186,9 +185,9 @@ export default function ({ getService }) { expect(metadataFeature.loadGeometry()).to.eql([ [ { x: 0, y: 4096 }, - { x: 4096, y: 4096 }, - { x: 4096, y: 0 }, { x: 0, y: 0 }, + { x: 4096, y: 0 }, + { x: 4096, y: 4096 }, { x: 0, y: 4096 }, ], ]); diff --git a/x-pack/test/api_integration/apis/maps/get_tile.js b/x-pack/test/api_integration/apis/maps/get_tile.js index d8754f8c0b0c6..09b8bf1d8b862 100644 --- a/x-pack/test/api_integration/apis/maps/get_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_tile.js @@ -21,8 +21,7 @@ function findFeature(layer, callbackFn) { export default function ({ getService }) { const supertest = getService('supertest'); - // Failing: See https://github.com/elastic/kibana/issues/132368 - describe.skip('getTile', () => { + describe('getTile', () => { it('should return ES vector tile containing documents and metadata', async () => { const resp = await supertest .get( @@ -78,9 +77,9 @@ export default function ({ getService }) { expect(metadataFeature.loadGeometry()).to.eql([ [ { x: 44, y: 2382 }, - { x: 550, y: 2382 }, - { x: 550, y: 1913 }, { x: 44, y: 1913 }, + { x: 550, y: 1913 }, + { x: 550, y: 2382 }, { x: 44, y: 2382 }, ], ]); From 03617b48227b0ed762d02219b63a0f5c6ef2951b Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 18 May 2022 12:06:02 -0600 Subject: [PATCH 023/150] [Maps] fix Cannot open Lens editor in airgapped environment (#132429) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../layer_template.tsx | 13 ++++-- .../ems_tms_source/tile_service_select.tsx | 42 +++++++++++-------- .../public/components/ems_file_select.tsx | 13 +++++- .../public/ems_autosuggest/ems_autosuggest.ts | 9 +++- .../choropleth_chart/expression_renderer.tsx | 10 ++++- .../public/lens/choropleth_chart/setup.ts | 11 ++++- 6 files changed, 71 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/layer_template.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/layer_template.tsx index 7e40f37dce26f..4edf85bc922d1 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/layer_template.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/layer_template.tsx @@ -121,10 +121,15 @@ export class LayerTemplate extends Component { }; _loadEmsFileFields = async () => { - const emsFileLayers = await getEmsFileLayers(); - const emsFileLayer = emsFileLayers.find((fileLayer: FileLayer) => { - return fileLayer.getId() === this.state.leftEmsFileId; - }); + let emsFileLayer: FileLayer | undefined; + try { + const emsFileLayers = await getEmsFileLayers(); + emsFileLayer = emsFileLayers.find((fileLayer: FileLayer) => { + return fileLayer.getId() === this.state.leftEmsFileId; + }); + } catch (error) { + // ignore error, lack of EMS file layers will be surfaced in EMS file select + } if (!this._isMounted || !emsFileLayer) { return; diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.tsx b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.tsx index c2f86a2cdb161..09c6a0bf313b0 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.tsx @@ -45,25 +45,31 @@ export class TileServiceSelect extends Component { } _loadTmsOptions = async () => { - const emsTMSServices = await getEmsTmsServices(); - - if (!this._isMounted) { - return; + try { + const emsTMSServices = await getEmsTmsServices(); + + if (!this._isMounted) { + return; + } + + const emsTmsOptions = emsTMSServices.map((tmsService) => { + return { + value: tmsService.getId(), + text: tmsService.getDisplayName() ? tmsService.getDisplayName() : tmsService.getId(), + }; + }); + emsTmsOptions.unshift({ + value: AUTO_SELECT, + text: i18n.translate('xpack.maps.source.emsTile.autoLabel', { + defaultMessage: 'Autoselect based on Kibana theme', + }), + }); + this.setState({ emsTmsOptions, hasLoaded: true }); + } catch (error) { + if (this._isMounted) { + this.setState({ emsTmsOptions: [], hasLoaded: true }); + } } - - const emsTmsOptions = emsTMSServices.map((tmsService) => { - return { - value: tmsService.getId(), - text: tmsService.getDisplayName() ? tmsService.getDisplayName() : tmsService.getId(), - }; - }); - emsTmsOptions.unshift({ - value: AUTO_SELECT, - text: i18n.translate('xpack.maps.source.emsTile.autoLabel', { - defaultMessage: 'Autoselect based on Kibana theme', - }), - }); - this.setState({ emsTmsOptions, hasLoaded: true }); }; _onChange = (e: ChangeEvent) => { diff --git a/x-pack/plugins/maps/public/components/ems_file_select.tsx b/x-pack/plugins/maps/public/components/ems_file_select.tsx index 694e3f6413059..f2a409b8629b0 100644 --- a/x-pack/plugins/maps/public/components/ems_file_select.tsx +++ b/x-pack/plugins/maps/public/components/ems_file_select.tsx @@ -33,7 +33,18 @@ export class EMSFileSelect extends Component { }; _loadFileOptions = async () => { - const fileLayers: FileLayer[] = await getEmsFileLayers(); + let fileLayers: FileLayer[] = []; + try { + fileLayers = await getEmsFileLayers(); + } catch (error) { + if (this._isMounted) { + this.setState({ + hasLoadedOptions: true, + emsFileOptions: [], + }); + } + } + const options = fileLayers.map((fileLayer) => { return { value: fileLayer.getId(), diff --git a/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.ts b/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.ts index b88305cae0e92..4ade37658fd13 100644 --- a/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.ts +++ b/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.ts @@ -33,8 +33,13 @@ interface FileLayerFieldShim { export async function suggestEMSTermJoinConfig( sampleValuesConfig: SampleValuesConfig ): Promise { - const fileLayers = await getEmsFileLayers(); - return emsAutoSuggest(sampleValuesConfig, fileLayers); + try { + const fileLayers = await getEmsFileLayers(); + return emsAutoSuggest(sampleValuesConfig, fileLayers); + } catch (error) { + // can not return suggestions since EMS is not available. + return null; + } } export function emsAutoSuggest( diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_renderer.tsx b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_renderer.tsx index 4fc96d7625504..b2705ea5f0492 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_renderer.tsx +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_renderer.tsx @@ -10,6 +10,7 @@ import ReactDOM from 'react-dom'; import type { IInterpreterRenderHandlers } from '@kbn/expressions-plugin/public'; import type { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; import type { CoreSetup, CoreStart } from '@kbn/core/public'; +import type { FileLayer } from '@elastic/ems-client'; import type { MapsPluginStartDependencies } from '../../plugin'; import type { ChoroplethChartProps } from './types'; import type { MapEmbeddableInput, MapEmbeddableOutput } from '../../embeddable'; @@ -40,12 +41,19 @@ export function getExpressionRenderer(coreSetup: CoreSetup, domNode, diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/setup.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/setup.ts index 626030e72a576..3e1525353d1b5 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/setup.ts +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/setup.ts @@ -8,6 +8,7 @@ import type { ExpressionsSetup } from '@kbn/expressions-plugin/public'; import type { CoreSetup, CoreStart } from '@kbn/core/public'; import type { LensPublicSetup } from '@kbn/lens-plugin/public'; +import type { FileLayer } from '@elastic/ems-client'; import type { MapsPluginStartDependencies } from '../../plugin'; import { getExpressionFunction } from './expression_function'; import { getExpressionRenderer } from './expression_renderer'; @@ -28,9 +29,17 @@ export function setupLensChoroplethChart( await coreSetup.getStartServices(); const { getEmsFileLayers } = await import('../../util'); const { getVisualization } = await import('./visualization'); + + let emsFileLayers: FileLayer[] = []; + try { + emsFileLayers = await getEmsFileLayers(); + } catch (error) { + // ignore error, lack of EMS file layers will be surfaced in dimension editor + } + return getVisualization({ theme: coreStart.theme, - emsFileLayers: await getEmsFileLayers(), + emsFileLayers, paletteService: await plugins.charts.palettes.getPalettes(), }); }); From 6cf8ebfdcc9f3e8a5ce9ea87da08bf44d7c3e357 Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Wed, 18 May 2022 20:07:30 +0200 Subject: [PATCH 024/150] [Fleet] Lazy load package icons in integrations grid (#132455) --- x-pack/plugins/fleet/public/components/package_icon.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/public/components/package_icon.tsx b/x-pack/plugins/fleet/public/components/package_icon.tsx index 9e7b54673c9d9..7d106852324fe 100644 --- a/x-pack/plugins/fleet/public/components/package_icon.tsx +++ b/x-pack/plugins/fleet/public/components/package_icon.tsx @@ -16,7 +16,8 @@ export const PackageIcon: React.FunctionComponent< UsePackageIconType & Omit > = ({ packageName, integrationName, version, icons, tryApi, ...euiIconProps }) => { const iconType = usePackageIconType({ packageName, integrationName, version, icons, tryApi }); - return ; + // @ts-expect-error loading="lazy" is not supported by EuiIcon + return ; }; export const CardIcon: React.FunctionComponent> = ( @@ -26,7 +27,8 @@ export const CardIcon: React.FunctionComponent; } else if (icons && icons.length === 1 && icons[0].type === 'svg') { - return ; + // @ts-expect-error loading="lazy" is not supported by EuiIcon + return ; } else { return ; } From dac92b2b84fea6b731f0f1ef53c90a8454704097 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Wed, 18 May 2022 15:21:47 -0400 Subject: [PATCH 025/150] [CI] Move PR skippable changes to pr-bot config (#132461) --- .buildkite/pull_requests.json | 20 +++++++++++++++- .../pipelines/pull_request/pipeline.js | 13 ++++------ .../pull_request/skippable_pr_matchers.js | 24 ------------------- 3 files changed, 24 insertions(+), 33 deletions(-) delete mode 100644 .buildkite/scripts/pipelines/pull_request/skippable_pr_matchers.js diff --git a/.buildkite/pull_requests.json b/.buildkite/pull_requests.json index d54f637b8f6d1..e0e7454127733 100644 --- a/.buildkite/pull_requests.json +++ b/.buildkite/pull_requests.json @@ -16,7 +16,25 @@ "trigger_comment_regex": "^(?:(?:buildkite\\W+)?(?:build|test)\\W+(?:this|it))", "always_trigger_comment_regex": "^(?:(?:buildkite\\W+)?(?:build|test)\\W+(?:this|it))", "skip_ci_labels": ["skip-ci", "jenkins-ci"], - "skip_target_branches": ["6.8", "7.11", "7.12"] + "skip_target_branches": ["6.8", "7.11", "7.12"], + "skip_ci_on_only_changed": [ + "^docs/", + "^rfcs/", + "^.ci/.+\\.yml$", + "^.ci/es-snapshots/", + "^.ci/pipeline-library/", + "^.ci/Jenkinsfile_[^/]+$", + "^\\.github/", + "\\.md$", + "^\\.backportrc\\.json$", + "^nav-kibana-dev\\.docnav\\.json$", + "^src/dev/prs/kibana_qa_pr_list\\.json$", + "^\\.buildkite/pull_requests\\.json$" + ], + "always_require_ci_on_changed": [ + "^docs/developer/plugin-list.asciidoc$", + "/plugins/[^/]+/readme\\.(md|asciidoc)$" + ] } ] } diff --git a/.buildkite/scripts/pipelines/pull_request/pipeline.js b/.buildkite/scripts/pipelines/pull_request/pipeline.js index 6a4610284e400..c9f42dae1a776 100644 --- a/.buildkite/scripts/pipelines/pull_request/pipeline.js +++ b/.buildkite/scripts/pipelines/pull_request/pipeline.js @@ -9,14 +9,11 @@ const execSync = require('child_process').execSync; const fs = require('fs'); const { areChangesSkippable, doAnyChangesMatch } = require('kibana-buildkite-library'); -const { SKIPPABLE_PR_MATCHERS } = require('./skippable_pr_matchers'); - -const REQUIRED_PATHS = [ - // this file is auto-generated and changes to it need to be validated with CI - /^docs\/developer\/plugin-list.asciidoc$/, - // don't skip CI on prs with changes to plugin readme files /i is for case-insensitive matching - /\/plugins\/[^\/]+\/readme\.(md|asciidoc)$/i, -]; +const prConfigs = require('../../../pull_requests.json'); +const prConfig = prConfigs.jobs.find((job) => job.pipelineSlug === 'kibana-pull-request'); + +const REQUIRED_PATHS = prConfig.always_require_ci_on_changed.map((r) => new RegExp(r, 'i')); +const SKIPPABLE_PR_MATCHERS = prConfig.skip_ci_on_only_changed.map((r) => new RegExp(r, 'i')); const getPipeline = (filename, removeSteps = true) => { const str = fs.readFileSync(filename).toString(); diff --git a/.buildkite/scripts/pipelines/pull_request/skippable_pr_matchers.js b/.buildkite/scripts/pipelines/pull_request/skippable_pr_matchers.js deleted file mode 100644 index 2a36e66e11cd6..0000000000000 --- a/.buildkite/scripts/pipelines/pull_request/skippable_pr_matchers.js +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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. - */ - -module.exports = { - SKIPPABLE_PR_MATCHERS: [ - /^docs\//, - /^rfcs\//, - /^.ci\/.+\.yml$/, - /^.ci\/es-snapshots\//, - /^.ci\/pipeline-library\//, - /^.ci\/Jenkinsfile_[^\/]+$/, - /^\.github\//, - /\.md$/, - /^\.backportrc\.json$/, - /^nav-kibana-dev\.docnav\.json$/, - /^src\/dev\/prs\/kibana_qa_pr_list\.json$/, - /^\.buildkite\/scripts\/pipelines\/pull_request\/skippable_pr_matchers\.js$/, - ], -}; From 16e3caf4f44de4255b46c95aee5158659db4b64b Mon Sep 17 00:00:00 2001 From: Caroline Horn <549577+cchaos@users.noreply.github.com> Date: Wed, 18 May 2022 16:18:14 -0400 Subject: [PATCH 026/150] [Unified Search] Add refresh button text back in when window is very large (#132375) --- .../query_string_input/query_bar_top_row.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx index 5427c61b485df..0ad4756e9177b 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx @@ -8,7 +8,7 @@ import dateMath from '@kbn/datemath'; import classNames from 'classnames'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import deepEqual from 'fast-deep-equal'; import useObservable from 'react-use/lib/useObservable'; import type { Filter } from '@kbn/es-query'; @@ -126,6 +126,20 @@ const SharingMetaFields = React.memo(function SharingMetaFields({ export const QueryBarTopRow = React.memo( function QueryBarTopRow(props: QueryBarTopRowProps) { const isMobile = useIsWithinBreakpoints(['xs', 's']); + const [isXXLarge, setIsXXLarge] = useState(false); + + useEffect(() => { + function handleResize() { + setIsXXLarge(window.innerWidth >= 1440); + } + + window.removeEventListener('resize', handleResize); + window.addEventListener('resize', handleResize); + handleResize(); + + return () => window.removeEventListener('resize', handleResize); + }, []); + const { showQueryInput = true, showDatePicker = true, @@ -367,7 +381,7 @@ export const QueryBarTopRow = React.memo( Date: Wed, 18 May 2022 15:58:18 -0500 Subject: [PATCH 027/150] [es snapshots] Skip cloud build errors (#132469) --- .../scripts/steps/es_snapshots/build.sh | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/.buildkite/scripts/steps/es_snapshots/build.sh b/.buildkite/scripts/steps/es_snapshots/build.sh index cdc1750e59bfc..370ae275aa758 100755 --- a/.buildkite/scripts/steps/es_snapshots/build.sh +++ b/.buildkite/scripts/steps/es_snapshots/build.sh @@ -69,7 +69,6 @@ echo "--- Build Elasticsearch" :distribution:archives:darwin-aarch64-tar:assemble \ :distribution:archives:darwin-tar:assemble \ :distribution:docker:docker-export:assemble \ - :distribution:docker:cloud-docker-export:assemble \ :distribution:archives:linux-aarch64-tar:assemble \ :distribution:archives:linux-tar:assemble \ :distribution:archives:windows-zip:assemble \ @@ -86,19 +85,26 @@ docker images "docker.elastic.co/elasticsearch/elasticsearch" --format "{{.Tag}} docker images "docker.elastic.co/elasticsearch/elasticsearch" --format "{{.Tag}}" | xargs -n1 bash -c 'docker save docker.elastic.co/elasticsearch/elasticsearch:${0} | gzip > ../es-build/elasticsearch-${0}-docker-image.tar.gz' echo "--- Create kibana-ci docker cloud image archives" -ES_CLOUD_ID=$(docker images "docker.elastic.co/elasticsearch-ci/elasticsearch-cloud" --format "{{.ID}}") -ES_CLOUD_VERSION=$(docker images "docker.elastic.co/elasticsearch-ci/elasticsearch-cloud" --format "{{.Tag}}") -KIBANA_ES_CLOUD_VERSION="$ES_CLOUD_VERSION-$ELASTICSEARCH_GIT_COMMIT" -KIBANA_ES_CLOUD_IMAGE="docker.elastic.co/kibana-ci/elasticsearch-cloud:$KIBANA_ES_CLOUD_VERSION" - -docker tag "$ES_CLOUD_ID" "$KIBANA_ES_CLOUD_IMAGE" - -echo "$KIBANA_DOCKER_PASSWORD" | docker login -u "$KIBANA_DOCKER_USERNAME" --password-stdin docker.elastic.co -trap 'docker logout docker.elastic.co' EXIT -docker image push "$KIBANA_ES_CLOUD_IMAGE" - -export ELASTICSEARCH_CLOUD_IMAGE="$KIBANA_ES_CLOUD_IMAGE" -export ELASTICSEARCH_CLOUD_IMAGE_CHECKSUM="$(docker images "$KIBANA_ES_CLOUD_IMAGE" --format "{{.Digest}}")" +# Ignore build failures. This docker image downloads metricbeat and filebeat. +# When we bump versions, these dependencies may not exist yet, but we don't want to +# block the rest of the snapshot promotion process +set +e +./gradlew :distribution:docker:cloud-docker-export:assemble && { + ES_CLOUD_ID=$(docker images "docker.elastic.co/elasticsearch-ci/elasticsearch-cloud" --format "{{.ID}}") + ES_CLOUD_VERSION=$(docker images "docker.elastic.co/elasticsearch-ci/elasticsearch-cloud" --format "{{.Tag}}") + KIBANA_ES_CLOUD_VERSION="$ES_CLOUD_VERSION-$ELASTICSEARCH_GIT_COMMIT" + KIBANA_ES_CLOUD_IMAGE="docker.elastic.co/kibana-ci/elasticsearch-cloud:$KIBANA_ES_CLOUD_VERSION" + echo $ES_CLOUD_ID $ES_CLOUD_VERSION $KIBANA_ES_CLOUD_VERSION $KIBANA_ES_CLOUD_IMAGE + docker tag "$ES_CLOUD_ID" "$KIBANA_ES_CLOUD_IMAGE" + + echo "$KIBANA_DOCKER_PASSWORD" | docker login -u "$KIBANA_DOCKER_USERNAME" --password-stdin docker.elastic.co + trap 'docker logout docker.elastic.co' EXIT + docker image push "$KIBANA_ES_CLOUD_IMAGE" + + export ELASTICSEARCH_CLOUD_IMAGE="$KIBANA_ES_CLOUD_IMAGE" + export ELASTICSEARCH_CLOUD_IMAGE_CHECKSUM="$(docker images "$KIBANA_ES_CLOUD_IMAGE" --format "{{.Digest}}")" +} +set -e echo "--- Create checksums for snapshot files" cd "$destination" From 1343ef3f7cff5ae4584549ffa66a6f0c01fe0539 Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Wed, 18 May 2022 23:12:35 +0200 Subject: [PATCH 028/150] [SharedUX] Add loading indicator to NoDataPage (#132272) * [SharedUX] Add loading indicator to NoDataPage * Rename hasFinishedLoading > isLoading * Change EuiLoadingSpinner > EuiLoadingElastic * Update packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx Co-authored-by: Caroline Horn <549577+cchaos@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Caroline Horn <549577+cchaos@users.noreply.github.com> --- .../kibana_no_data_page.stories.tsx | 23 ++++++++++++++++- .../empty_state/kibana_no_data_page.test.tsx | 25 +++++++++++++++++++ .../src/empty_state/kibana_no_data_page.tsx | 11 +++++++- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.stories.tsx b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.stories.tsx index 552ffa555377d..f544f21c35387 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.stories.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.stories.tsx @@ -39,8 +39,9 @@ type Params = Pick & DataServiceFactoryCon export const PureComponent = (params: Params) => { const { solution, logo, hasESData, hasUserDataView } = params; + const serviceParams = { hasESData, hasUserDataView, hasDataViews: false }; - const services = servicesFactory(serviceParams); + const services = servicesFactory({ ...serviceParams, hasESData, hasUserDataView }); return ( { ); }; +export const PureComponentLoadingState = () => { + const dataCheck = () => new Promise((resolve, reject) => {}); + const services = { + ...servicesFactory({ hasESData: false, hasUserDataView: false, hasDataViews: false }), + data: { + hasESData: dataCheck, + hasUserDataView: dataCheck, + hasDataView: dataCheck, + }, + }; + return ( + + + + ); +}; + PureComponent.argTypes = { solution: { control: 'text', diff --git a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx index 82fbd222b3640..4f565e55ef52c 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { EuiLoadingElastic } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { SharedUxServicesProvider, mockServicesFactory } from '@kbn/shared-ux-services'; @@ -68,4 +69,28 @@ describe('Kibana No Data Page', () => { expect(component.find(NoDataViews).length).toBe(1); expect(component.find(NoDataConfigPage).length).toBe(0); }); + + test('renders loading indicator', async () => { + const dataCheck = () => new Promise((resolve, reject) => {}); + const services = { + ...mockServicesFactory(), + data: { + hasESData: dataCheck, + hasUserDataView: dataCheck, + hasDataView: dataCheck, + }, + }; + const component = mountWithIntl( + + + + ); + + await act(() => new Promise(setImmediate)); + component.update(); + + expect(component.find(EuiLoadingElastic).length).toBe(1); + expect(component.find(NoDataViews).length).toBe(0); + expect(component.find(NoDataConfigPage).length).toBe(0); + }); }); diff --git a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx index 2e54d0d9f6a67..89ba915c07cfd 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx @@ -6,6 +6,7 @@ * Side Public License, v 1. */ import React, { useEffect, useState } from 'react'; +import { EuiLoadingElastic } from '@elastic/eui'; import { useData } from '@kbn/shared-ux-services'; import { NoDataConfigPage, NoDataPageProps } from '../page_template'; import { NoDataViews } from './no_data_views'; @@ -17,6 +18,7 @@ export interface Props { export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: Props) => { const { hasESData, hasUserDataView } = useData(); + const [isLoading, setIsLoading] = useState(true); const [dataExists, setDataExists] = useState(false); const [hasUserDataViews, setHasUserDataViews] = useState(false); @@ -24,12 +26,19 @@ export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: Props) => const checkData = async () => { setDataExists(await hasESData()); setHasUserDataViews(await hasUserDataView()); + setIsLoading(false); }; // TODO: add error handling // https://github.com/elastic/kibana/issues/130913 - checkData().catch(() => {}); + checkData().catch(() => { + setIsLoading(false); + }); }, [hasESData, hasUserDataView]); + if (isLoading) { + return ; + } + if (!dataExists) { return ; } From 1e3c90a40b12019088c2b39ce98163569325919b Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 18 May 2022 17:15:00 -0400 Subject: [PATCH 029/150] Update troubleshooting.mdx (#132475) --- dev_docs/getting_started/troubleshooting.mdx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/dev_docs/getting_started/troubleshooting.mdx b/dev_docs/getting_started/troubleshooting.mdx index e0adfbad86a84..db52830bbae4f 100644 --- a/dev_docs/getting_started/troubleshooting.mdx +++ b/dev_docs/getting_started/troubleshooting.mdx @@ -26,3 +26,17 @@ git clean -fdxn -e /config -e /.vscode # review the files which will be deleted, consider adding some more excludes (-e) # re-run without the dry-run (-n) flag to actually delete the files ``` + +### search.check_ccs_compatibility error + +If you run into an error that says something like: + +``` +[class org.elasticsearch.action.search.SearchRequest] is not compatible version 8.1.0 and the 'search.check_ccs_compatibility' setting is enabled. +``` + +it means you are using a new Elasticsearch feature that will not work in a CCS environment because the feature does not exist in older versions. If you are working on an experimental feature and are okay with this limitation, you will have to move the failing test into a special test suite that does not use this setting to get ci to pass. Take this path cautiously. If you do not remember to move the test back into the default test suite when the feature is GA'ed, it will not have proper CCS test coverage. + +We added this test coverage in version `8.1` because we accidentally broke core Kibana features (for example, when Discover started using the new fields parameter) for our CCS users. CCS is not a corner case and (excluding certain experimental features) Kibana should always work for our CCS users. This setting is our way of ensuring test coverage. + +Please reach out to the [Kibana Operations team](https://github.com/orgs/elastic/teams/kibana-operations) if you have further questions. From 11bff753341c102ef8aa80ae432af54f33474b99 Mon Sep 17 00:00:00 2001 From: mgiota Date: Wed, 18 May 2022 23:18:33 +0200 Subject: [PATCH 030/150] correct apm rule type id (#132476) --- x-pack/plugins/observability/public/pages/rules/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/observability/public/pages/rules/config.ts b/x-pack/plugins/observability/public/pages/rules/config.ts index 8c39acb75976d..4e7b9e83d5ab1 100644 --- a/x-pack/plugins/observability/public/pages/rules/config.ts +++ b/x-pack/plugins/observability/public/pages/rules/config.ts @@ -49,8 +49,8 @@ export const OBSERVABILITY_RULE_TYPES = [ 'xpack.uptime.alerts.durationAnomaly', 'apm.error_rate', 'apm.transaction_error_rate', + 'apm.anomaly', 'apm.transaction_duration', - 'apm.transaction_duration_anomaly', 'metrics.alert.inventory.threshold', 'metrics.alert.threshold', 'logs.alert.document.count', From 9b361b0a3683b7828ee13e094eeca882f15a0047 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Wed, 18 May 2022 14:41:27 -0700 Subject: [PATCH 031/150] [Controls] Improved validation for Range Slider (#131421) * Ignores validation in range slider UI * Check if range filter results in no data before applying filter * Ignores validation in range slider UI * Check if range filter results in no data before applying filter * Trigger range slider control update when ignoreValidation setting changes * No longer disable range slider num fields when no data available * Fix functional test * Only add ticks and levels if popover is open * Simplify query for validation check Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../range_slider/range_slider.component.tsx | 14 +- .../range_slider/range_slider_embeddable.tsx | 186 ++++++++++++------ .../range_slider/range_slider_popover.tsx | 66 +++---- .../range_slider/range_slider_strings.ts | 6 +- .../controls/public/services/kibana/data.ts | 4 +- .../controls/range_slider.ts | 8 +- 6 files changed, 174 insertions(+), 110 deletions(-) diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx index 259b6bd7f66a1..54b53f25da89f 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx @@ -20,6 +20,7 @@ import './range_slider.scss'; interface Props { componentStateSubject: BehaviorSubject; + ignoreValidation: boolean; } // Availableoptions and loading state is controled by the embeddable, but is not considered embeddable input. export interface RangeSliderComponentState { @@ -28,9 +29,10 @@ export interface RangeSliderComponentState { min: string; max: string; loading: boolean; + isInvalid?: boolean; } -export const RangeSliderComponent: FC = ({ componentStateSubject }) => { +export const RangeSliderComponent: FC = ({ componentStateSubject, ignoreValidation }) => { // Redux embeddable Context to get state from Embeddable input const { useEmbeddableDispatch, @@ -40,10 +42,11 @@ export const RangeSliderComponent: FC = ({ componentStateSubject }) => { const dispatch = useEmbeddableDispatch(); // useStateObservable to get component state from Embeddable - const { loading, min, max, fieldFormatter } = useStateObservable( - componentStateSubject, - componentStateSubject.getValue() - ); + const { loading, min, max, fieldFormatter, isInvalid } = + useStateObservable( + componentStateSubject, + componentStateSubject.getValue() + ); const { value, id, title } = useEmbeddableSelector((state) => state); @@ -64,6 +67,7 @@ export const RangeSliderComponent: FC = ({ componentStateSubject }) => { value={value ?? ['', '']} onChange={onChangeComplete} fieldFormatter={fieldFormatter} + isInvalid={!ignoreValidation && isInvalid} /> ); }; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx index 1ad34fd361ac6..d7e1984b7c54c 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx @@ -12,12 +12,14 @@ import { buildRangeFilter, COMPARE_ALL_OPTIONS, RangeFilterParams, + Filter, + Query, } from '@kbn/es-query'; import React from 'react'; import ReactDOM from 'react-dom'; import { get, isEqual } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { Subscription, BehaviorSubject } from 'rxjs'; +import { Subscription, BehaviorSubject, lastValueFrom } from 'rxjs'; import { debounceTime, distinctUntilChanged, skip, map } from 'rxjs/operators'; import { @@ -59,6 +61,7 @@ interface RangeSliderDataFetchProps { dataViewId: string; query?: ControlInput['query']; filters?: ControlInput['filters']; + validate?: boolean; } const fieldMissingError = (fieldName: string) => @@ -99,6 +102,7 @@ export class RangeSliderEmbeddable extends Embeddable value, + isInvalid: false, }; this.updateComponentState(this.componentState); @@ -111,7 +115,7 @@ export class RangeSliderEmbeddable extends Embeddable { + this.runRangeSliderQuery().then(async () => { if (initialValue) { this.setInitializationFinished(); } @@ -122,6 +126,7 @@ export class RangeSliderEmbeddable extends Embeddable { const dataFetchPipe = this.getInput$().pipe( map((newInput) => ({ + validate: !Boolean(newInput.ignoreParentSettings?.ignoreValidations), lastReloadRequestTime: newInput.lastReloadRequestTime, dataViewId: newInput.dataViewId, fieldName: newInput.fieldName, @@ -134,7 +139,7 @@ export class RangeSliderEmbeddable extends Embeddable { - const aggBody: any = {}; - if (field) { - if (field.scripted) { - aggBody.script = { - source: field.script, - lang: field.lang, - }; - } else { - aggBody.field = field.name; - } - } - - return { - maxAgg: { - max: aggBody, - }, - minAgg: { - min: aggBody, - }, - }; - }; - - private fetchMinMax = async () => { + private runRangeSliderQuery = async () => { this.updateComponentState({ loading: true }); this.updateOutput({ loading: true }); const { dataView, field } = await this.getCurrentDataViewAndField(); @@ -220,7 +202,7 @@ export class RangeSliderEmbeddable extends Embeddable { const searchSource = await this.dataService.searchSource.create(); searchSource.setField('size', 0); searchSource.setField('index', dataView); - const aggs = this.minMaxAgg(field); - searchSource.setField('aggs', aggs); - searchSource.setField('filter', filters); - if (!ignoreParentSettings?.ignoreQuery) { + if (query) { searchSource.setField('query', query); } - const resp = await searchSource.fetch$().toPromise(); + const aggBody: any = {}; + + if (field) { + if (field.scripted) { + aggBody.script = { + source: field.script, + lang: field.lang, + }; + } else { + aggBody.field = field.name; + } + } + + const aggs = { + maxAgg: { + max: aggBody, + }, + minAgg: { + min: aggBody, + }, + }; + + searchSource.setField('aggs', aggs); + + const resp = await lastValueFrom(searchSource.fetch$()); const min = get(resp, 'rawResponse.aggregations.minAgg.value', ''); const max = get(resp, 'rawResponse.aggregations.maxAgg.value', ''); - this.updateComponentState({ - min: `${min ?? ''}`, - max: `${max ?? ''}`, - }); - - // build filter with new min/max - await this.buildFilter(); + return { min, max }; }; private buildFilter = async () => { - const { value: [selectedMin, selectedMax] = ['', ''], ignoreParentSettings } = this.getInput(); + const { + value: [selectedMin, selectedMax] = ['', ''], + query, + timeRange, + filters = [], + ignoreParentSettings, + } = this.getInput(); + const availableMin = this.componentState.min; const availableMax = this.componentState.max; @@ -271,22 +302,14 @@ export class RangeSliderEmbeddable extends Embeddable parseFloat(selectedMax); - const isLowerSelectionOutOfRange = - hasLowerSelection && parseFloat(selectedMin) > parseFloat(availableMax); - const isUpperSelectionOutOfRange = - hasUpperSelection && parseFloat(selectedMax) < parseFloat(availableMin); - const isSelectionOutOfRange = - (!ignoreParentSettings?.ignoreValidations && hasData && isLowerSelectionOutOfRange) || - isUpperSelectionOutOfRange; + const { dataView, field } = await this.getCurrentDataViewAndField(); - if (!hasData || !hasEitherSelection || hasInvalidSelection || isSelectionOutOfRange) { - this.updateComponentState({ loading: false }); + if (!hasData || !hasEitherSelection) { + this.updateComponentState({ + loading: false, + isInvalid: !ignoreParentSettings?.ignoreValidations && hasEitherSelection, + }); this.updateOutput({ filters: [], dataViews: [dataView], loading: false }); return; } @@ -307,12 +330,52 @@ export class RangeSliderEmbeddable extends Embeddable { - this.fetchMinMax(); + this.runRangeSliderQuery(); }; public destroy = () => { @@ -327,7 +390,14 @@ export class RangeSliderEmbeddable extends Embeddable - + , node ); diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx index 1bb7501f7104f..fce3dbdfe7009 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx @@ -23,8 +23,11 @@ import { import { RangeSliderStrings } from './range_slider_strings'; import { RangeValue } from './types'; +const INVALID_CLASS = 'rangeSliderAnchor__fieldNumber--invalid'; + export interface Props { id: string; + isInvalid?: boolean; isLoading?: boolean; min: string; max: string; @@ -36,6 +39,7 @@ export interface Props { export const RangeSliderPopover: FC = ({ id, + isInvalid, isLoading, min, max, @@ -52,6 +56,13 @@ export const RangeSliderPopover: FC = ({ let helpText = ''; const hasAvailableRange = min !== '' && max !== ''; + + if (!hasAvailableRange) { + helpText = RangeSliderStrings.popover.getNoAvailableDataHelpText(); + } else if (isInvalid) { + helpText = RangeSliderStrings.popover.getNoDataHelpText(); + } + const hasLowerBoundSelection = value[0] !== ''; const hasUpperBoundSelection = value[1] !== ''; @@ -60,23 +71,10 @@ export const RangeSliderPopover: FC = ({ const minValue = parseFloat(min); const maxValue = parseFloat(max); - if (!hasAvailableRange) { - helpText = 'There is no data to display. Adjust the time range and filters.'; - } - // EuiDualRange can only handle integers as min/max const roundedMin = hasAvailableRange ? Math.floor(minValue) : minValue; const roundedMax = hasAvailableRange ? Math.ceil(maxValue) : maxValue; - const isLowerSelectionInvalid = hasLowerBoundSelection && lowerBoundValue > roundedMax; - const isUpperSelectionInvalid = hasUpperBoundSelection && upperBoundValue < roundedMin; - const isSelectionInvalid = - hasAvailableRange && (isLowerSelectionInvalid || isUpperSelectionInvalid); - - if (isSelectionInvalid) { - helpText = RangeSliderStrings.popover.getNoDataHelpText(); - } - if (lowerBoundValue > upperBoundValue) { errorMessage = RangeSliderStrings.errors.getUpperLessThanLowerErrorMessage(); } @@ -89,7 +87,7 @@ export const RangeSliderPopover: FC = ({ const ticks = []; const levels = []; - if (hasAvailableRange) { + if (hasAvailableRange && isPopoverOpen) { ticks.push({ value: rangeSliderMin, label: fieldFormatter(String(rangeSliderMin)) }); ticks.push({ value: rangeSliderMax, label: fieldFormatter(String(rangeSliderMax)) }); levels.push({ min: roundedMin, max: roundedMax, color: 'success' }); @@ -127,17 +125,15 @@ export const RangeSliderPopover: FC = ({ controlOnly fullWidth className={`rangeSliderAnchor__fieldNumber ${ - hasLowerBoundSelection && isSelectionInvalid - ? 'rangeSliderAnchor__fieldNumber--invalid' - : '' + hasLowerBoundSelection && isInvalid ? INVALID_CLASS : '' }`} value={hasLowerBoundSelection ? lowerBoundValue : ''} onChange={(event) => { onChange([event.target.value, isNaN(upperBoundValue) ? '' : String(upperBoundValue)]); }} - disabled={!hasAvailableRange || isLoading} + disabled={isLoading} placeholder={`${hasAvailableRange ? roundedMin : ''}`} - isInvalid={isLowerSelectionInvalid} + isInvalid={isInvalid} data-test-subj="rangeSlider__lowerBoundFieldNumber" /> @@ -151,17 +147,15 @@ export const RangeSliderPopover: FC = ({ controlOnly fullWidth className={`rangeSliderAnchor__fieldNumber ${ - hasUpperBoundSelection && isSelectionInvalid - ? 'rangeSliderAnchor__fieldNumber--invalid' - : '' + hasUpperBoundSelection && isInvalid ? INVALID_CLASS : '' }`} value={hasUpperBoundSelection ? upperBoundValue : ''} onChange={(event) => { onChange([isNaN(lowerBoundValue) ? '' : String(lowerBoundValue), event.target.value]); }} - disabled={!hasAvailableRange || isLoading} + disabled={isLoading} placeholder={`${hasAvailableRange ? roundedMax : ''}`} - isInvalid={isUpperSelectionInvalid} + isInvalid={isInvalid} data-test-subj="rangeSlider__upperBoundFieldNumber" /> @@ -234,19 +228,17 @@ export const RangeSliderPopover: FC = ({ {errorMessage || helpText} - {hasAvailableRange ? ( - - - onChange(['', ''])} - aria-label={RangeSliderStrings.popover.getClearRangeButtonTitle()} - data-test-subj="rangeSlider__clearRangeButton" - /> - - - ) : null} + + + onChange(['', ''])} + aria-label={RangeSliderStrings.popover.getClearRangeButtonTitle()} + data-test-subj="rangeSlider__clearRangeButton" + /> + + ); diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts b/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts index a901f79ba20f5..53d614fd54a2e 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts @@ -42,7 +42,11 @@ export const RangeSliderStrings = { }), getNoDataHelpText: () => i18n.translate('controls.rangeSlider.popover.noDataHelpText', { - defaultMessage: 'Selected range is outside of available data. No filter was applied.', + defaultMessage: 'Selected range resulted in no data. No filter was applied.', + }), + getNoAvailableDataHelpText: () => + i18n.translate('controls.rangeSlider.popover.noAvailableDataHelpText', { + defaultMessage: 'There is no data to display. Adjust the time range and filters.', }), }, errors: { diff --git a/src/plugins/controls/public/services/kibana/data.ts b/src/plugins/controls/public/services/kibana/data.ts index 29a96a98c7e76..0dc702542633b 100644 --- a/src/plugins/controls/public/services/kibana/data.ts +++ b/src/plugins/controls/public/services/kibana/data.ts @@ -8,7 +8,7 @@ import { DataViewField } from '@kbn/data-views-plugin/common'; import { get } from 'lodash'; -import { from } from 'rxjs'; +import { from, lastValueFrom } from 'rxjs'; import { KibanaPluginServiceFactory } from '@kbn/presentation-util-plugin/public'; import { ControlsDataService } from '../data'; import { ControlsPluginStartDeps } from '../../types'; @@ -78,7 +78,7 @@ export const dataServiceFactory: DataServiceFactory = ({ startPlugins }) => { searchSource.setField('filter', ignoreParentSettings?.ignoreFilters ? [] : filters); searchSource.setField('query', ignoreParentSettings?.ignoreQuery ? undefined : query); - const resp = await searchSource.fetch$().toPromise(); + const resp = await lastValueFrom(searchSource.fetch$()); const min = get(resp, 'rawResponse.aggregations.minAgg.value', undefined); const max = get(resp, 'rawResponse.aggregations.maxAgg.value', undefined); diff --git a/test/functional/apps/dashboard_elements/controls/range_slider.ts b/test/functional/apps/dashboard_elements/controls/range_slider.ts index b2d07e7a49489..a4b84206bde84 100644 --- a/test/functional/apps/dashboard_elements/controls/range_slider.ts +++ b/test/functional/apps/dashboard_elements/controls/range_slider.ts @@ -206,7 +206,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.rangeSliderSetUpperBound(firstId, '400'); }); - it('disables inputs when no data available', async () => { + it('disables range slider when no data available', async () => { await dashboardControls.createControl({ controlType: RANGE_SLIDER_CONTROL, dataViewTitle: 'logstash-*', @@ -214,12 +214,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { width: 'small', }); const secondId = (await dashboardControls.getAllControlIds())[1]; - expect( - await dashboardControls.rangeSliderGetLowerBoundAttribute(secondId, 'disabled') - ).to.be('true'); - expect( - await dashboardControls.rangeSliderGetUpperBoundAttribute(secondId, 'disabled') - ).to.be('true'); await dashboardControls.rangeSliderOpenPopover(secondId); await dashboardControls.rangeSliderPopoverAssertOpen(); expect( From 8930324da2159547bac1e13ad852552f1825343d Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Wed, 18 May 2022 15:19:47 -0700 Subject: [PATCH 032/150] try to unskip maps auto_fit_to_bounds test (#132373) * try to unskip maps auto_fit_to_bounds test * final check --- .../test/functional/apps/maps/group1/auto_fit_to_bounds.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/x-pack/test/functional/apps/maps/group1/auto_fit_to_bounds.js b/x-pack/test/functional/apps/maps/group1/auto_fit_to_bounds.js index 2d5813e81c214..1fe78ec17f1ce 100644 --- a/x-pack/test/functional/apps/maps/group1/auto_fit_to_bounds.js +++ b/x-pack/test/functional/apps/maps/group1/auto_fit_to_bounds.js @@ -11,8 +11,7 @@ export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps']); const security = getService('security'); - // FLAKY: https://github.com/elastic/kibana/issues/129467 - describe.skip('auto fit map to bounds', () => { + describe('auto fit map to bounds', () => { describe('initial location', () => { before(async () => { await security.testUser.setRoles(['global_maps_all', 'test_logstash_reader']); @@ -30,7 +29,7 @@ export default function ({ getPageObjects, getService }) { expect(hits).to.equal('6'); const { lat, lon } = await PageObjects.maps.getView(); - expect(Math.round(lat)).to.equal(41); + expect(Math.round(lat)).to.be.within(41, 43); expect(Math.round(lon)).to.equal(-99); }); }); From 27d96702ffe6965fd92795673876ea192450ff62 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 18 May 2022 23:24:19 +0100 Subject: [PATCH 033/150] docs(NA): adding @kbn/ambient-ui-types into ops docs (#132482) * docs(NA): adding @kbn/ambient-ui-types into ops docs * docs(NA): wording update --- dev_docs/operations/operations_landing.mdx | 1 + nav-kibana-dev.docnav.json | 3 ++- packages/kbn-ambient-ui-types/README.mdx | 13 +++++++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/dev_docs/operations/operations_landing.mdx b/dev_docs/operations/operations_landing.mdx index 31e996086dd0b..85676d179074b 100644 --- a/dev_docs/operations/operations_landing.mdx +++ b/dev_docs/operations/operations_landing.mdx @@ -43,5 +43,6 @@ layout: landing { pageId: "kibDevDocsToolingLog" }, { pageId: "kibDevDocsOpsJestSerializers"}, { pageId: "kibDevDocsOpsExpect" }, + { pageId: "kibDevDocsOpsAmbientUiTypes"}, ]} /> \ No newline at end of file diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index 6075889f47889..f565026115a84 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -198,7 +198,8 @@ "items": [ { "id": "kibDevDocsToolingLog" }, { "id": "kibDevDocsOpsJestSerializers"}, - { "id": "kibDevDocsOpsExpect" } + { "id": "kibDevDocsOpsExpect" }, + { "id": "kibDevDocsOpsAmbientUiTypes" } ] } ] diff --git a/packages/kbn-ambient-ui-types/README.mdx b/packages/kbn-ambient-ui-types/README.mdx index d63d8567afe07..dbff6fb8e18a2 100644 --- a/packages/kbn-ambient-ui-types/README.mdx +++ b/packages/kbn-ambient-ui-types/README.mdx @@ -1,7 +1,15 @@ -# @kbn/ambient-ui-types +--- +id: kibDevDocsOpsAmbientUiTypes +slug: /kibana-dev-docs/ops/ambient-ui-types +title: "@kbn/ambient-ui-types" +description: A package holding ambient type definitions for files +date: 2022-05-18 +tags: ['kibana', 'dev', 'contributor', 'operations', 'ambient', 'ui', 'types'] +--- -This is a package of Typescript types for files that might get imported by Webpack and therefore need definitions. +This package holds ambient typescript definitions for files with extensions like `.html, .png, .svg, .mdx` that might get imported by Webpack and therefore needed. +## Plugins These types will automatically be included for plugins. ## Packages @@ -9,4 +17,5 @@ These types will automatically be included for plugins. To include these types in a package: - add `"//packages/kbn-ambient-ui-types"` to the `RUNTIME_DEPS` portion of the `BUILD.bazel` file. +- add `"//packages/kbn-ambient-ui-types:npm_module_types"` to the `TYPES_DEPS` portion of the `BUILD.bazel` file. - add `"@kbn/ambient-ui-types"` to the `types` portion of the `tsconfig.json` file. \ No newline at end of file From 9ebb269f13630ace0db03cedca31c733d3dc5ea7 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 19 May 2022 00:57:21 +0200 Subject: [PATCH 034/150] Allow default arguments to yarn es to be overwritten. (#130864) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Spencer --- packages/kbn-es/src/cluster.js | 42 ++++++++++++------- .../src/integration_tests/cluster.test.js | 34 ++++++++++++++- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index eecaef06be453..5c410523d70ca 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -325,29 +325,41 @@ exports.Cluster = class Cluster { this._log.info(chalk.bold('Starting')); this._log.indent(4); - const esArgs = [ - 'action.destructive_requires_name=true', - 'ingest.geoip.downloader.enabled=false', - 'search.check_ccs_compatibility=true', - 'cluster.routing.allocation.disk.threshold_enabled=false', - ].concat(options.esArgs || []); + const esArgs = new Map([ + ['action.destructive_requires_name', 'true'], + ['cluster.routing.allocation.disk.threshold_enabled', 'false'], + ['ingest.geoip.downloader.enabled', 'false'], + ['search.check_ccs_compatibility', 'true'], + ]); + + // options.esArgs overrides the default esArg values + for (const arg of [].concat(options.esArgs || [])) { + const [key, ...value] = arg.split('='); + esArgs.set(key.trim(), value.join('=').trim()); + } // Add to esArgs if ssl is enabled if (this._ssl) { - esArgs.push('xpack.security.http.ssl.enabled=true'); - - // Include default keystore settings only if keystore isn't configured. - if (!esArgs.some((arg) => arg.startsWith('xpack.security.http.ssl.keystore'))) { - esArgs.push(`xpack.security.http.ssl.keystore.path=${ES_NOPASSWORD_P12_PATH}`); - esArgs.push(`xpack.security.http.ssl.keystore.type=PKCS12`); + esArgs.set('xpack.security.http.ssl.enabled', 'true'); + // Include default keystore settings only if ssl isn't disabled by esArgs and keystore isn't configured. + if (!esArgs.get('xpack.security.http.ssl.keystore.path')) { // We are explicitly using ES_NOPASSWORD_P12_PATH instead of ES_P12_PATH + ES_P12_PASSWORD. The reasoning for this is that setting // the keystore password using environment variables causes Elasticsearch to emit deprecation warnings. + esArgs.set(`xpack.security.http.ssl.keystore.path`, ES_NOPASSWORD_P12_PATH); + esArgs.set(`xpack.security.http.ssl.keystore.type`, `PKCS12`); } } - const args = parseSettings(extractConfigFiles(esArgs, installPath, { log: this._log }), { - filter: SettingsFilter.NonSecureOnly, - }).reduce( + const args = parseSettings( + extractConfigFiles( + Array.from(esArgs).map((e) => e.join('=')), + installPath, + { log: this._log } + ), + { + filter: SettingsFilter.NonSecureOnly, + } + ).reduce( (acc, [settingName, settingValue]) => acc.concat(['-E', `${settingName}=${settingValue}`]), [] ); diff --git a/packages/kbn-es/src/integration_tests/cluster.test.js b/packages/kbn-es/src/integration_tests/cluster.test.js index 250bc9ac883b3..1a871667bd7a9 100644 --- a/packages/kbn-es/src/integration_tests/cluster.test.js +++ b/packages/kbn-es/src/integration_tests/cluster.test.js @@ -304,9 +304,41 @@ describe('#start(installPath)', () => { Array [ Array [ "action.destructive_requires_name=true", + "cluster.routing.allocation.disk.threshold_enabled=false", "ingest.geoip.downloader.enabled=false", "search.check_ccs_compatibility=true", + ], + undefined, + Object { + "log": , + }, + ], + ] + `); + }); + + it(`allows overriding search.check_ccs_compatibility`, async () => { + mockEsBin({ start: true }); + + extractConfigFiles.mockReturnValueOnce([]); + + const cluster = new Cluster({ + log, + ssl: false, + }); + + await cluster.start(undefined, { + esArgs: ['search.check_ccs_compatibility=false'], + }); + + expect(extractConfigFiles.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Array [ + "action.destructive_requires_name=true", "cluster.routing.allocation.disk.threshold_enabled=false", + "ingest.geoip.downloader.enabled=false", + "search.check_ccs_compatibility=false", ], undefined, Object { @@ -384,9 +416,9 @@ describe('#run()', () => { Array [ Array [ "action.destructive_requires_name=true", + "cluster.routing.allocation.disk.threshold_enabled=false", "ingest.geoip.downloader.enabled=false", "search.check_ccs_compatibility=true", - "cluster.routing.allocation.disk.threshold_enabled=false", ], undefined, Object { From 912979a8cc2105d31f9ff93e6ebfb4325439b754 Mon Sep 17 00:00:00 2001 From: Faisal Kanout Date: Thu, 19 May 2022 02:15:04 +0300 Subject: [PATCH 035/150] Add Execution history table to rule details page (#132245) --- .../public/pages/rule_details/index.tsx | 18 ++++++++++++++++-- .../triggers_actions_ui/public/index.ts | 1 + 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index ce7049bd61056..9cce5bfb99c92 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -23,6 +23,7 @@ import { EuiHorizontalRule, EuiTabbedContent, EuiEmptyPrompt, + EuiLoadingSpinner, } from '@elastic/eui'; import { @@ -33,9 +34,11 @@ import { deleteRules, useLoadRuleTypes, RuleType, + RuleEventLogListProps, } from '@kbn/triggers-actions-ui-plugin/public'; // TODO: use a Delete modal from triggersActionUI when it's sharable import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common'; + import { DeleteModalConfirmation } from '../rules/components/delete_modal_confirmation'; import { CenterJustifiedSpinner } from '../rules/components/center_justified_spinner'; import { getHealthColor, OBSERVABILITY_SOLUTIONS } from '../rules/config'; @@ -63,7 +66,12 @@ import { export function RuleDetailsPage() { const { http, - triggersActionsUi: { ruleTypeRegistry, getRuleStatusDropdown, getEditAlertFlyout }, + triggersActionsUi: { + ruleTypeRegistry, + getRuleStatusDropdown, + getEditAlertFlyout, + getRuleEventLogList, + }, application: { capabilities, navigateToUrl }, notifications: { toasts }, } = useKibana().services; @@ -163,7 +171,13 @@ export function RuleDetailsPage() { defaultMessage: 'Execution history', }), 'data-test-subj': 'eventLogListTab', - content: Execution history, + content: rule ? ( + getRuleEventLogList({ + rule, + } as RuleEventLogListProps) + ) : ( + + ), }, { id: ALERT_LIST_TAB, diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index f14b5482fd6fd..001f63bc6cc6f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -36,6 +36,7 @@ export type { RuleSummary, AlertStatus, AlertsTableConfigurationRegistryContract, + RuleEventLogListProps, } from './types'; export { From b29c645bdad0fa3fa66826dfedeb56d947ec9654 Mon Sep 17 00:00:00 2001 From: John Dorlus Date: Wed, 18 May 2022 19:29:30 -0400 Subject: [PATCH 036/150] Fix for Console Test (#129276) * Added some wait conditions to ensure that the comma is present before trying to make assertion. * Added check to verify inner html. * Switched wait to retry. * Fixed duplicate declaration. * Fixed PR per nits. --- test/functional/apps/console/_autocomplete.ts | 23 ++++++++++++++++--- test/functional/page_objects/console_page.ts | 16 +++++++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/test/functional/apps/console/_autocomplete.ts b/test/functional/apps/console/_autocomplete.ts index 7bf872373c6c7..85be77d9522a7 100644 --- a/test/functional/apps/console/_autocomplete.ts +++ b/test/functional/apps/console/_autocomplete.ts @@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); const PageObjects = getPageObjects(['common', 'console']); + const find = getService('find'); describe('console autocomplete feature', function describeIndexTests() { this.tags('includeFirefox'); @@ -34,14 +35,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(PageObjects.console.isAutocompleteVisible()).to.be.eql(true); }); - // FLAKY: https://github.com/elastic/kibana/issues/126414 - describe.skip('with a missing comma in query', () => { + describe('with a missing comma in query', () => { const LINE_NUMBER = 4; beforeEach(async () => { await PageObjects.console.clearTextArea(); await PageObjects.console.enterRequest(); await PageObjects.console.pressEnter(); }); + it('should add a comma after previous non empty line', async () => { await PageObjects.console.enterText(`{\n\t"query": {\n\t\t"match": {}`); await PageObjects.console.pressEnter(); @@ -49,7 +50,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.console.pressEnter(); await PageObjects.console.promptAutocomplete(); await PageObjects.console.pressEnter(); - + await retry.try(async () => { + let conApp = await find.byCssSelector('.conApp'); + const firstInnerHtml = await conApp.getAttribute('innerHTML'); + await PageObjects.common.sleep(500); + conApp = await find.byCssSelector('.conApp'); + const secondInnerHtml = await conApp.getAttribute('innerHTML'); + return firstInnerHtml === secondInnerHtml; + }); + const textAreaString = await PageObjects.console.getAllVisibleText(); + log.debug('Text Area String Value==================\n'); + log.debug(textAreaString); + expect(textAreaString).to.contain(','); const text = await PageObjects.console.getVisibleTextAt(LINE_NUMBER); const lastChar = text.charAt(text.length - 1); expect(lastChar).to.be.eql(','); @@ -61,6 +73,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.console.promptAutocomplete(); await PageObjects.console.pressEnter(); + await retry.waitForWithTimeout('text area to contain comma', 25000, async () => { + const textAreaString = await PageObjects.console.getAllVisibleText(); + return textAreaString.includes(','); + }); + const text = await PageObjects.console.getVisibleTextAt(LINE_NUMBER); const lastChar = text.charAt(text.length - 1); expect(lastChar).to.be.eql(','); diff --git a/test/functional/page_objects/console_page.ts b/test/functional/page_objects/console_page.ts index 7aaf842f28d14..218a1077d63ef 100644 --- a/test/functional/page_objects/console_page.ts +++ b/test/functional/page_objects/console_page.ts @@ -119,10 +119,22 @@ export class ConsolePageObject extends FtrService { return await this.testSubjects.find('console-textarea'); } - public async getVisibleTextAt(lineIndex: number) { + public async getAllTextLines() { const editor = await this.getEditor(); - const lines = await editor.findAllByClassName('ace_line_group'); + return await editor.findAllByClassName('ace_line_group'); + } + public async getAllVisibleText() { + let textString = ''; + const textLineElements = await this.getAllTextLines(); + for (let i = 0; i < textLineElements.length; i++) { + textString = textString.concat(await textLineElements[i].getVisibleText()); + } + return textString; + } + + public async getVisibleTextAt(lineIndex: number) { + const lines = await this.getAllTextLines(); if (lines.length < lineIndex) { throw new Error(`No line with index: ${lineIndex}`); } From d8a62589b3e1ed4dfe9090be39fa551afb92a1ba Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 19 May 2022 00:51:07 +0100 Subject: [PATCH 037/150] docs(NA): adding @kbn/ambient-storybook-types into ops docs (#132483) --- dev_docs/operations/operations_landing.mdx | 1 + nav-kibana-dev.docnav.json | 3 ++- packages/kbn-ambient-storybook-types/README.md | 3 --- .../kbn-ambient-storybook-types/README.mdx | 18 ++++++++++++++++++ 4 files changed, 21 insertions(+), 4 deletions(-) delete mode 100644 packages/kbn-ambient-storybook-types/README.md create mode 100644 packages/kbn-ambient-storybook-types/README.mdx diff --git a/dev_docs/operations/operations_landing.mdx b/dev_docs/operations/operations_landing.mdx index 85676d179074b..cda44a96fe4dd 100644 --- a/dev_docs/operations/operations_landing.mdx +++ b/dev_docs/operations/operations_landing.mdx @@ -43,6 +43,7 @@ layout: landing { pageId: "kibDevDocsToolingLog" }, { pageId: "kibDevDocsOpsJestSerializers"}, { pageId: "kibDevDocsOpsExpect" }, + { pageId: "kibDevDocsOpsAmbientStorybookTypes" }, { pageId: "kibDevDocsOpsAmbientUiTypes"}, ]} /> \ No newline at end of file diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index f565026115a84..4704430ba94b6 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -197,8 +197,9 @@ "label": "Utilities", "items": [ { "id": "kibDevDocsToolingLog" }, - { "id": "kibDevDocsOpsJestSerializers"}, + { "id": "kibDevDocsOpsJestSerializers" }, { "id": "kibDevDocsOpsExpect" }, + { "id": "kibDevDocsOpsAmbientStorybookTypes" }, { "id": "kibDevDocsOpsAmbientUiTypes" } ] } diff --git a/packages/kbn-ambient-storybook-types/README.md b/packages/kbn-ambient-storybook-types/README.md deleted file mode 100644 index 865cf8d522d1b..0000000000000 --- a/packages/kbn-ambient-storybook-types/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @kbn/ambient-storybook-types - -Ambient types needed to use storybook. \ No newline at end of file diff --git a/packages/kbn-ambient-storybook-types/README.mdx b/packages/kbn-ambient-storybook-types/README.mdx new file mode 100644 index 0000000000000..f0db9b552d6ee --- /dev/null +++ b/packages/kbn-ambient-storybook-types/README.mdx @@ -0,0 +1,18 @@ +--- +id: kibDevDocsOpsAmbientStorybookTypes +slug: /kibana-dev-docs/ops/ambient-storybook-types +title: "@kbn/ambient-storybook-types" +description: A package holding ambient type definitions for storybooks +date: 2022-05-18 +tags: ['kibana', 'dev', 'contributor', 'operations', 'ambient', 'storybook', 'types'] +--- + +This package holds ambient typescript definitions needed to use storybooks. + +## Packages + +To include these types in a package: + +- add `"//packages/kbn-ambient-storybook-types"` to the `RUNTIME_DEPS` portion of the `BUILD.bazel` file. +- add `"//packages/kbn-ambient-storybook-types:npm_module_types"` to the `TYPES_DEPS` portion of the `BUILD.bazel` file. +- add `"@kbn/ambient-storybook-types"` to the `types` portion of the `tsconfig.json` file. From 5ecde4b053d77f86f05f1b04c8417c6a5e4c5d92 Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Thu, 19 May 2022 09:21:50 +0200 Subject: [PATCH 038/150] [Osquery] Add multiline query support (#131224) --- .../utils/build_query/remove_multilines.ts | 9 + .../plugins/osquery/public/editor/index.tsx | 4 +- .../packs/pack_queries_status_table.tsx | 267 ++++++++++++------ .../queries/ecs_mapping_editor_field.tsx | 5 +- .../server/routes/pack/create_pack_route.ts | 4 +- .../server/routes/pack/update_pack_route.ts | 4 +- .../osquery/server/routes/pack/utils.test.ts | 57 ++++ .../osquery/server/routes/pack/utils.ts | 11 +- .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - x-pack/test/api_integration/apis/index.ts | 1 + .../api_integration/apis/osquery/index.js | 12 + .../api_integration/apis/osquery/packs.ts | 152 ++++++++++ 14 files changed, 428 insertions(+), 107 deletions(-) create mode 100644 x-pack/plugins/osquery/common/utils/build_query/remove_multilines.ts create mode 100644 x-pack/plugins/osquery/server/routes/pack/utils.test.ts create mode 100644 x-pack/test/api_integration/apis/osquery/index.js create mode 100644 x-pack/test/api_integration/apis/osquery/packs.ts diff --git a/x-pack/plugins/osquery/common/utils/build_query/remove_multilines.ts b/x-pack/plugins/osquery/common/utils/build_query/remove_multilines.ts new file mode 100644 index 0000000000000..66208a0c7524d --- /dev/null +++ b/x-pack/plugins/osquery/common/utils/build_query/remove_multilines.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const removeMultilines = (query: string): string => + query.replaceAll('\n', ' ').replaceAll(/ +/g, ' '); diff --git a/x-pack/plugins/osquery/public/editor/index.tsx b/x-pack/plugins/osquery/public/editor/index.tsx index 191fe1e7ea548..9718e80926d06 100644 --- a/x-pack/plugins/osquery/public/editor/index.tsx +++ b/x-pack/plugins/osquery/public/editor/index.tsx @@ -35,9 +35,7 @@ const OsqueryEditorComponent: React.FC = ({ }) => { const [editorValue, setEditorValue] = useState(defaultValue ?? ''); - useDebounce(() => onChange(editorValue.replaceAll('\n', ' ').replaceAll(' ', ' ')), 500, [ - editorValue, - ]); + useDebounce(() => onChange(editorValue), 500, [editorValue]); useEffect(() => setEditorValue(defaultValue), [defaultValue]); diff --git a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx index 70282ab0819fd..3aa345f986493 100644 --- a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx +++ b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx @@ -21,7 +21,7 @@ import { EuiPanel, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage, FormattedDate, FormattedTime, FormattedRelative } from '@kbn/i18n-react'; +import { FormattedDate, FormattedTime, FormattedRelative } from '@kbn/i18n-react'; import moment from 'moment-timezone'; import type { @@ -32,6 +32,7 @@ import type { } from '@kbn/lens-plugin/public'; import { DOCUMENT_FIELD_NAME as RECORDS_FIELD } from '@kbn/lens-plugin/common/constants'; import { FilterStateStore, DataView } from '@kbn/data-plugin/common'; +import { removeMultilines } from '../../common/utils/build_query/remove_multilines'; import { useKibana } from '../common/lib/kibana'; import { OsqueryManagerPackagePolicyInputStream } from '../../common/types'; import { ScheduledQueryErrorsTable } from './scheduled_query_errors_table'; @@ -384,6 +385,13 @@ const ScheduledQueryExpandedContent = React.memo = ({ actionId, - queryId, interval, logsDataView, - toggleErrors, - expanded, }) => { const { data: lastResultsData, isLoading } = usePackQueryLastResults({ actionId, @@ -406,22 +411,11 @@ const ScheduledQueryLastResults: React.FC = ({ logsDataView, }); - const { data: errorsData, isLoading: errorsLoading } = usePackQueryErrors({ - actionId, - interval, - logsDataView, - }); - - const handleErrorsToggle = useCallback( - () => toggleErrors({ queryId, interval }), - [queryId, interval, toggleErrors] - ); - - if (isLoading || errorsLoading) { + if (isLoading) { return ; } - if (!lastResultsData && !errorsData?.total) { + if (!lastResultsData) { return <>{'-'}; } @@ -448,73 +442,115 @@ const ScheduledQueryLastResults: React.FC = ({ '-' )} - - - - - {lastResultsData?.docCount ?? 0} - - - - - - - + + ); +}; - - - - - {lastResultsData?.uniqueAgentsCount ?? 0} - - - - - - +const DocsColumnResults: React.FC = ({ + actionId, + interval, + logsDataView, +}) => { + const { data: lastResultsData, isLoading } = usePackQueryLastResults({ + actionId, + interval, + logsDataView, + }); + if (isLoading) { + return ; + } + + if (!lastResultsData) { + return <>{'-'}; + } + + return ( + + + + {lastResultsData?.docCount ?? 0} + + + ); +}; - - - - - {errorsData?.total ?? 0} - - - - - {' '} - - - - - - - +const AgentsColumnResults: React.FC = ({ + actionId, + interval, + logsDataView, +}) => { + const { data: lastResultsData, isLoading } = usePackQueryLastResults({ + actionId, + interval, + logsDataView, + }); + if (isLoading) { + return ; + } + + if (!lastResultsData) { + return <>{'-'}; + } + + return ( + + + + {lastResultsData?.uniqueAgentsCount ?? 0} + ); }; +const ErrorsColumnResults: React.FC = ({ + actionId, + interval, + queryId, + toggleErrors, + expanded, + logsDataView, +}) => { + const handleErrorsToggle = useCallback( + () => toggleErrors({ queryId, interval }), + [toggleErrors, queryId, interval] + ); + + const { data: errorsData, isLoading: errorsLoading } = usePackQueryErrors({ + actionId, + interval, + logsDataView, + }); + if (errorsLoading) { + return ; + } + + if (!errorsData?.total) { + return <>{'-'}; + } + + return ( + + + + + {errorsData?.total ?? 0} + + + + + + + + + ); +}; + const getPackActionId = (actionId: string, packName: string) => `pack_${packName}_${actionId}`; interface PackViewInActionProps { @@ -625,14 +661,18 @@ const PackQueriesStatusTableComponent: React.FC = ( fetchLogsDataView(); }, [dataViews]); - const renderQueryColumn = useCallback( - (query: string) => ( - - {query} - - ), - [] - ); + const renderQueryColumn = useCallback((query: string, item) => { + const singleLine = removeMultilines(query); + const content = singleLine.length > 55 ? `${singleLine.substring(0, 55)}...` : singleLine; + + return ( + {query}}> + + {content} + + + ); + }, []); const toggleErrors = useCallback( ({ queryId, interval }: { queryId: string; interval: number }) => { @@ -658,14 +698,44 @@ const PackQueriesStatusTableComponent: React.FC = ( (item) => ( + ), + [packName, logsDataView] + ); + const renderDocsColumn = useCallback( + (item) => ( + + ), + [logsDataView, packName] + ); + const renderAgentsColumn = useCallback( + (item) => ( + + ), + [logsDataView, packName] + ); + const renderErrorsColumn = useCallback( + (item) => ( + ), - [itemIdToExpandedRowMap, packName, toggleErrors, logsDataView] + [itemIdToExpandedRowMap, logsDataView, packName, toggleErrors] ); const renderDiscoverResultsAction = useCallback( @@ -705,6 +775,7 @@ const PackQueriesStatusTableComponent: React.FC = ( defaultMessage: 'ID', }), width: '15%', + truncateText: true, }, { field: 'interval', @@ -719,13 +790,32 @@ const PackQueriesStatusTableComponent: React.FC = ( defaultMessage: 'Query', }), render: renderQueryColumn, - width: '20%', + width: '40%', }, { name: i18n.translate('xpack.osquery.pack.queriesTable.lastResultsColumnTitle', { defaultMessage: 'Last results', }), render: renderLastResultsColumn, + width: '12%', + }, + { + name: i18n.translate('xpack.osquery.pack.queriesTable.docsResultsColumnTitle', { + defaultMessage: 'Docs', + }), + render: renderDocsColumn, + }, + { + name: i18n.translate('xpack.osquery.pack.queriesTable.agentsResultsColumnTitle', { + defaultMessage: 'Agents', + }), + render: renderAgentsColumn, + }, + { + name: i18n.translate('xpack.osquery.pack.queriesTable.errorsResultsColumnTitle', { + defaultMessage: 'Errors', + }), + render: renderErrorsColumn, }, { name: i18n.translate('xpack.osquery.pack.queriesTable.viewResultsColumnTitle', { @@ -745,6 +835,9 @@ const PackQueriesStatusTableComponent: React.FC = ( [ renderQueryColumn, renderLastResultsColumn, + renderDocsColumn, + renderAgentsColumn, + renderErrorsColumn, renderDiscoverResultsAction, renderLensResultsAction, ] diff --git a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx index 5aaab625d3ef5..15dca629821b2 100644 --- a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx +++ b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx @@ -59,6 +59,7 @@ import { FormArrayField, } from '../../shared_imports'; import { OsqueryIcon } from '../../components/osquery_icon'; +import { removeMultilines } from '../../../common/utils/build_query/remove_multilines'; export const CommonUseField = getUseField({ component: Field }); @@ -773,11 +774,13 @@ export const ECSMappingEditorField = React.memo( return; } + const oneLineQuery = removeMultilines(query); + // eslint-disable-next-line @typescript-eslint/no-explicit-any let ast: Record | undefined; try { - ast = sqliteParser(query)?.statement?.[0]; + ast = sqliteParser(oneLineQuery)?.statement?.[0]; } catch (e) { return; } diff --git a/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts b/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts index bf34152078582..67ae97b9af5cd 100644 --- a/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts +++ b/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts @@ -19,7 +19,7 @@ import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; import { OSQUERY_INTEGRATION_NAME } from '../../../common'; import { PLUGIN_ID } from '../../../common'; import { packSavedObjectType } from '../../../common/types'; -import { convertPackQueriesToSO } from './utils'; +import { convertPackQueriesToSO, convertSOQueriesToPack } from './utils'; import { getInternalSavedObjectsClient } from '../../usage/collector'; export const createPackRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { @@ -138,7 +138,7 @@ export const createPackRoute = (router: IRouter, osqueryContext: OsqueryAppConte } set(draft, `inputs[0].config.osquery.value.packs.${packSO.attributes.name}`, { - queries, + queries: convertSOQueriesToPack(queries, { removeMultiLines: true }), }); return draft; diff --git a/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts b/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts index 82d880c70fbd6..cb79165f3dca1 100644 --- a/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts +++ b/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts @@ -282,7 +282,9 @@ export const updatePackRoute = (router: IRouter, osqueryContext: OsqueryAppConte draft, `inputs[0].config.osquery.value.packs.${updatedPackSO.attributes.name}`, { - queries: updatedPackSO.attributes.queries, + queries: convertSOQueriesToPack(updatedPackSO.attributes.queries, { + removeMultiLines: true, + }), } ); diff --git a/x-pack/plugins/osquery/server/routes/pack/utils.test.ts b/x-pack/plugins/osquery/server/routes/pack/utils.test.ts new file mode 100644 index 0000000000000..97905fde6bf02 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/pack/utils.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { convertSOQueriesToPack } from './utils'; + +const getTestQueries = (additionalFields?: Record, packName = 'default') => ({ + [packName]: { + ...additionalFields, + query: + 'select u.username,\n' + + ' p.pid,\n' + + ' p.name,\n' + + ' pos.local_address,\n' + + ' pos.local_port,\n' + + ' p.path,\n' + + ' p.cmdline,\n' + + ' pos.remote_address,\n' + + ' pos.remote_port\n' + + 'from processes as p\n' + + 'join users as u\n' + + ' on u.uid=p.uid\n' + + 'join process_open_sockets as pos\n' + + ' on pos.pid=p.pid\n' + + "where pos.remote_port !='0'\n" + + 'limit 1000;', + interval: 3600, + }, +}); + +const oneLiner = { + default: { + ecs_mapping: {}, + interval: 3600, + query: `select u.username, p.pid, p.name, pos.local_address, pos.local_port, p.path, p.cmdline, pos.remote_address, pos.remote_port from processes as p join users as u on u.uid=p.uid join process_open_sockets as pos on pos.pid=p.pid where pos.remote_port !='0' limit 1000;`, + }, +}; + +describe('Pack utils', () => { + describe('convertSOQueriesToPack', () => { + test('converts to pack with empty ecs_mapping', () => { + const convertedQueries = convertSOQueriesToPack(getTestQueries()); + expect(convertedQueries).toStrictEqual(getTestQueries({ ecs_mapping: {} })); + }); + test('converts to pack with converting query to single line', () => { + const convertedQueries = convertSOQueriesToPack(getTestQueries(), { removeMultiLines: true }); + expect(convertedQueries).toStrictEqual(oneLiner); + }); + test('converts to object with pack names after query.id', () => { + const convertedQueries = convertSOQueriesToPack(getTestQueries({ id: 'testId' })); + expect(convertedQueries).toStrictEqual(getTestQueries({ ecs_mapping: {} }, 'testId')); + }); + }); +}); diff --git a/x-pack/plugins/osquery/server/routes/pack/utils.ts b/x-pack/plugins/osquery/server/routes/pack/utils.ts index 9edb750263209..84466a6ce4ad1 100644 --- a/x-pack/plugins/osquery/server/routes/pack/utils.ts +++ b/x-pack/plugins/osquery/server/routes/pack/utils.ts @@ -6,6 +6,7 @@ */ import { pick, reduce } from 'lodash'; +import { removeMultilines } from '../../../common/utils/build_query/remove_multilines'; import { convertECSMappingToArray, convertECSMappingToObject } from '../utils'; // @ts-expect-error update types @@ -27,13 +28,15 @@ export const convertPackQueriesToSO = (queries) => ); // @ts-expect-error update types -export const convertSOQueriesToPack = (queries) => +export const convertSOQueriesToPack = (queries, options?: { removeMultiLines?: boolean }) => reduce( queries, // eslint-disable-next-line @typescript-eslint/naming-convention - (acc, { id: queryId, ecs_mapping, ...query }) => { - acc[queryId] = { - ...query, + (acc, { id: queryId, ecs_mapping, query, ...rest }, key) => { + const index = queryId ? queryId : key; + acc[index] = { + ...rest, + query: options?.removeMultiLines ? removeMultilines(query) : query, ecs_mapping: convertECSMappingToObject(ecs_mapping), }; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 258ea8b4bdeea..a9d350146c0d9 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -21991,9 +21991,6 @@ "xpack.osquery.packUploader.unsupportedFileTypeText": "Le type de fichier {fileType} n'est pas pris en charge, veuillez charger le fichier config {supportedFileTypes}", "xpack.osquery.permissionDeniedErrorMessage": "Vous n'êtes pas autorisé à accéder à cette page.", "xpack.osquery.permissionDeniedErrorTitle": "Autorisation refusée", - "xpack.osquery.queriesStatusTable.agentsLabelText": "{count, plural, one {Agent} other {Agents}}", - "xpack.osquery.queriesStatusTable.documentLabelText": "{count, plural, one {Document} other {Documents}}", - "xpack.osquery.queriesStatusTable.errorsLabelText": "{count, plural, one {Erreur} other {Erreurs}}", "xpack.osquery.queriesTable.osqueryVersionAllLabel": "TOUS", "xpack.osquery.queryFlyoutForm.addFormTitle": "Attacher la recherche suivante", "xpack.osquery.queryFlyoutForm.cancelButtonLabel": "Annuler", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 30b0d5ad9a48b..89813c1104606 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -22129,9 +22129,6 @@ "xpack.osquery.packUploader.unsupportedFileTypeText": "ファイルタイプ{fileType}はサポートされていません。{supportedFileTypes}構成ファイルをアップロードしてください", "xpack.osquery.permissionDeniedErrorMessage": "このページへのアクセスが許可されていません。", "xpack.osquery.permissionDeniedErrorTitle": "パーミッションが拒否されました", - "xpack.osquery.queriesStatusTable.agentsLabelText": "{count, plural, other {エージェント}}", - "xpack.osquery.queriesStatusTable.documentLabelText": "{count, plural, other {ドキュメント}}", - "xpack.osquery.queriesStatusTable.errorsLabelText": "{count, plural, other {エラー}}", "xpack.osquery.queriesTable.osqueryVersionAllLabel": "すべて", "xpack.osquery.queryFlyoutForm.addFormTitle": "次のクエリを関連付ける", "xpack.osquery.queryFlyoutForm.cancelButtonLabel": "キャンセル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index cff374e41bf98..a9278d13031f4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -22160,9 +22160,6 @@ "xpack.osquery.packUploader.unsupportedFileTypeText": "文件类型 {fileType} 不受支持,请上传 {supportedFileTypes} 配置文件", "xpack.osquery.permissionDeniedErrorMessage": "您无权访问此页面。", "xpack.osquery.permissionDeniedErrorTitle": "权限被拒绝", - "xpack.osquery.queriesStatusTable.agentsLabelText": "{count, plural, other {代理}}", - "xpack.osquery.queriesStatusTable.documentLabelText": "{count, plural, other {文档}}", - "xpack.osquery.queriesStatusTable.errorsLabelText": "{count, plural, other {错误}}", "xpack.osquery.queriesTable.osqueryVersionAllLabel": "全部", "xpack.osquery.queryFlyoutForm.addFormTitle": "附加下一个查询", "xpack.osquery.queryFlyoutForm.cancelButtonLabel": "取消", diff --git a/x-pack/test/api_integration/apis/index.ts b/x-pack/test/api_integration/apis/index.ts index b3566ff30aea2..6bec2ebe80a13 100644 --- a/x-pack/test/api_integration/apis/index.ts +++ b/x-pack/test/api_integration/apis/index.ts @@ -35,5 +35,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./ml')); loadTestFile(require.resolve('./watcher')); loadTestFile(require.resolve('./logs_ui')); + loadTestFile(require.resolve('./osquery')); }); } diff --git a/x-pack/test/api_integration/apis/osquery/index.js b/x-pack/test/api_integration/apis/osquery/index.js new file mode 100644 index 0000000000000..afe684aa9bd68 --- /dev/null +++ b/x-pack/test/api_integration/apis/osquery/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export default function ({ loadTestFile }) { + describe('Osquery Endpoints', () => { + loadTestFile(require.resolve('./packs')); + }); +} diff --git a/x-pack/test/api_integration/apis/osquery/packs.ts b/x-pack/test/api_integration/apis/osquery/packs.ts new file mode 100644 index 0000000000000..543c01ac92c41 --- /dev/null +++ b/x-pack/test/api_integration/apis/osquery/packs.ts @@ -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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +const getDefaultPack = ({ policyIds = [] }: { policyIds?: string[] }) => ({ + name: 'TestPack', + description: 'TestPack Description', + enabled: true, + policy_ids: policyIds, + queries: { + testQuery: { + query: multiLineQuery, + interval: 600, + platform: 'windows', + version: '1', + }, + }, +}); + +const singleLineQuery = + "select u.username, p.pid, p.name, pos.local_address, pos.local_port, p.path, p.cmdline, pos.remote_address, pos.remote_port from processes as p join users as u on u.uid=p.uid join process_open_sockets as pos on pos.pid=p.pid where pos.remote_port !='0' limit 1000;"; +const multiLineQuery = `select u.username, + p.pid, + p.name, + pos.local_address, + pos.local_port, + p.path, + p.cmdline, + pos.remote_address, + pos.remote_port +from processes as p +join users as u + on u.uid=p.uid +join process_open_sockets as pos + on pos.pid=p.pid +where pos.remote_port !='0' +limit 1000;`; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Packs', () => { + let packId: string = ''; + let hostedPolicy: Record; + let packagePolicyId: string; + before(async () => { + await getService('esArchiver').load('x-pack/test/functional/es_archives/empty_kibana'); + await getService('esArchiver').load( + 'x-pack/test/functional/es_archives/fleet/empty_fleet_server' + ); + }); + after(async () => { + await getService('esArchiver').unload('x-pack/test/functional/es_archives/empty_kibana'); + await getService('esArchiver').unload( + 'x-pack/test/functional/es_archives/fleet/empty_fleet_server' + ); + }); + + after(async function () { + await supertest + .post(`/api/fleet/agent_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ agentPolicyId: hostedPolicy.id }); + }); + + it('create route should return 200 and multi line query, but single line query in packs config', async () => { + const { + body: { item: agentPolicy }, + } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Hosted policy from ${Date.now()}`, + namespace: 'default', + }); + hostedPolicy = agentPolicy; + + const { + body: { item: packagePolicy }, + } = await supertest + .post('/api/fleet/package_policies') + .set('kbn-xsrf', 'true') + .send({ + enabled: true, + package: { + name: 'osquery_manager', + version: '1.2.1', + title: 'test', + }, + inputs: [], + namespace: 'default', + output_id: '', + policy_id: hostedPolicy.id, + name: 'TEST', + description: '123', + id: '123', + }); + packagePolicyId = packagePolicy.id; + + const createPackResponse = await supertest + .post('/internal/osquery/packs') + .set('kbn-xsrf', 'true') + .send(getDefaultPack({ policyIds: [hostedPolicy.id] })); + + packId = createPackResponse.body.id; + expect(createPackResponse.status).to.be(200); + + const pack = await supertest.get('/internal/osquery/packs/' + packId).set('kbn-xsrf', 'true'); + + expect(pack.status).to.be(200); + expect(pack.body.queries.testQuery.query).to.be(multiLineQuery); + + const { + body: { + item: { inputs }, + }, + } = await supertest.get(`/api/fleet/package_policies/${packagePolicyId}`); + + expect(inputs[0].config.osquery.value.packs.TestPack.queries.testQuery.query).to.be( + singleLineQuery + ); + }); + + it('update route should return 200 and multi line query, but single line query in packs config', async () => { + const updatePackResponse = await supertest + .put('/internal/osquery/packs/' + packId) + .set('kbn-xsrf', 'true') + .send(getDefaultPack({ policyIds: [hostedPolicy.id] })); + + expect(updatePackResponse.status).to.be(200); + expect(updatePackResponse.body.id).to.be(packId); + const pack = await supertest.get('/internal/osquery/packs/' + packId).set('kbn-xsrf', 'true'); + + expect(pack.body.queries.testQuery.query).to.be(multiLineQuery); + const { + body: { + item: { inputs }, + }, + } = await supertest.get(`/api/fleet/package_policies/${packagePolicyId}`); + + expect(inputs[0].config.osquery.value.packs.TestPack.queries.testQuery.query).to.be( + singleLineQuery + ); + }); + }); +} From fdf2086eb0caace2092ea9a1cdb1066979d678fc Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> Date: Thu, 19 May 2022 10:24:55 +0300 Subject: [PATCH 039/150] [Discover] Cancel long running requests in Discover alert (#130077) * [Discover] improve long running requests for search source within alert rule * [Discover] add tests * [Discover] fix linting * [Discover] fix unit test * [Discover] add getMetrics test * [Discover] fix unit test * [Discover] merge search clients metrics * [Discover] wrap searchSourceClient * [Discover] add unit tests * [Discover] replace searchSourceUtils with searchSourceClient in tests * [Discover] apply suggestions --- x-pack/plugins/alerting/server/lib/types.ts | 15 ++ .../server/lib/wrap_scoped_cluster_client.ts | 11 +- .../lib/wrap_search_source_client.test.ts | 157 ++++++++++++++++ .../server/lib/wrap_search_source_client.ts | 174 ++++++++++++++++++ x-pack/plugins/alerting/server/mocks.ts | 9 +- .../server/task_runner/task_runner.ts | 32 +++- x-pack/plugins/alerting/server/types.ts | 8 +- .../utils/create_lifecycle_rule_type.test.ts | 2 +- .../server/utils/rule_executor_test_utils.ts | 9 +- .../routes/rules/preview_rules_route.ts | 8 +- .../utils/wrap_search_source_client.test.ts | 108 +++++++++++ .../rules/utils/wrap_search_source_client.ts | 120 ++++++++++++ .../server/alert_types/es_query/executor.ts | 5 +- .../es_query/lib/fetch_search_source_query.ts | 6 +- 14 files changed, 622 insertions(+), 42 deletions(-) create mode 100644 x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts create mode 100644 x-pack/plugins/alerting/server/lib/wrap_search_source_client.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.ts diff --git a/x-pack/plugins/alerting/server/lib/types.ts b/x-pack/plugins/alerting/server/lib/types.ts index 701ac32e6974e..173ba1119a72a 100644 --- a/x-pack/plugins/alerting/server/lib/types.ts +++ b/x-pack/plugins/alerting/server/lib/types.ts @@ -7,6 +7,9 @@ import * as t from 'io-ts'; import { either } from 'fp-ts/lib/Either'; +import { Rule } from '../types'; +import { RuleRunMetrics } from './rule_run_metrics_store'; + // represents a Date from an ISO string export const DateFromString = new t.Type( 'DateFromString', @@ -24,3 +27,15 @@ export const DateFromString = new t.Type( ), (valueToEncode) => valueToEncode.toISOString() ); + +export type RuleInfo = Pick & { spaceId: string }; + +export interface LogSearchMetricsOpts { + esSearchDuration: number; + totalSearchDuration: number; +} + +export type SearchMetrics = Pick< + RuleRunMetrics, + 'numSearches' | 'totalSearchDurationMs' | 'esSearchDurationMs' +>; diff --git a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts index 28c5301e9a8b9..e1156d177116c 100644 --- a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts +++ b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts @@ -20,15 +20,8 @@ import type { SearchRequest as SearchRequestWithBody, AggregationsAggregate, } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { IScopedClusterClient, ElasticsearchClient, Logger } from '@kbn/core/server'; -import { Rule } from '../types'; -import { RuleRunMetrics } from './rule_run_metrics_store'; - -type RuleInfo = Pick & { spaceId: string }; -type SearchMetrics = Pick< - RuleRunMetrics, - 'numSearches' | 'totalSearchDurationMs' | 'esSearchDurationMs' ->; +import type { IScopedClusterClient, ElasticsearchClient, Logger } from '@kbn/core/server'; +import { SearchMetrics, RuleInfo } from './types'; interface WrapScopedClusterClientFactoryOpts { scopedClusterClient: IScopedClusterClient; diff --git a/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts b/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts new file mode 100644 index 0000000000000..9c10e619e3ebb --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts @@ -0,0 +1,157 @@ +/* + * 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 { loggingSystemMock } from '@kbn/core/server/mocks'; +import { ISearchStartSearchSource } from '@kbn/data-plugin/common'; +import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; +import { of, throwError } from 'rxjs'; +import { wrapSearchSourceClient } from './wrap_search_source_client'; + +const logger = loggingSystemMock.create().get(); + +const rule = { + name: 'test-rule', + alertTypeId: '.test-rule-type', + id: 'abcdefg', + spaceId: 'my-space', +}; + +const createSearchSourceClientMock = () => { + const searchSourceMock = createSearchSourceMock(); + searchSourceMock.fetch$ = jest.fn().mockImplementation(() => of({ rawResponse: { took: 5 } })); + + return { + searchSourceMock, + searchSourceClientMock: { + create: jest.fn().mockReturnValue(searchSourceMock), + createEmpty: jest.fn().mockReturnValue(searchSourceMock), + } as unknown as ISearchStartSearchSource, + }; +}; + +describe('wrapSearchSourceClient', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('searches with provided abort controller', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + + const { searchSourceClient } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.createEmpty(); + await wrappedSearchSource.fetch(); + + expect(searchSourceMock.fetch$).toHaveBeenCalledWith({ + abortSignal: abortController.signal, + }); + }); + + test('uses search options when specified', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + + const { searchSourceClient } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.create(); + await wrappedSearchSource.fetch({ isStored: true }); + + expect(searchSourceMock.fetch$).toHaveBeenCalledWith({ + isStored: true, + abortSignal: abortController.signal, + }); + }); + + test('keeps track of number of queries', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + searchSourceMock.fetch$ = jest + .fn() + .mockImplementation(() => of({ rawResponse: { took: 333 } })); + + const { searchSourceClient, getMetrics } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.create(); + await wrappedSearchSource.fetch(); + await wrappedSearchSource.fetch(); + await wrappedSearchSource.fetch(); + + expect(searchSourceMock.fetch$).toHaveBeenCalledWith({ + abortSignal: abortController.signal, + }); + + const stats = getMetrics(); + expect(stats.numSearches).toEqual(3); + expect(stats.esSearchDurationMs).toEqual(999); + + expect(logger.debug).toHaveBeenCalledWith( + `executing query for rule .test-rule-type:abcdefg in space my-space - with options {}` + ); + }); + + test('re-throws error when search throws error', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + searchSourceMock.fetch$ = jest + .fn() + .mockReturnValue(throwError(new Error('something went wrong!'))); + + const { searchSourceClient } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.create(); + const fetch = wrappedSearchSource.fetch(); + + await expect(fetch).rejects.toThrowErrorMatchingInlineSnapshot('"something went wrong!"'); + }); + + test('throws error when search throws abort error', async () => { + const abortController = new AbortController(); + abortController.abort(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + searchSourceMock.fetch$ = jest + .fn() + .mockReturnValue(throwError(new Error('Request has been aborted by the user'))); + + const { searchSourceClient } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.create(); + const fetch = wrappedSearchSource.fetch(); + + await expect(fetch).rejects.toThrowErrorMatchingInlineSnapshot( + '"Search has been aborted due to cancelled execution"' + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/wrap_search_source_client.ts b/x-pack/plugins/alerting/server/lib/wrap_search_source_client.ts new file mode 100644 index 0000000000000..442f0c3e292bf --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/wrap_search_source_client.ts @@ -0,0 +1,174 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import { + ISearchOptions, + ISearchSource, + ISearchStartSearchSource, + SearchSource, + SerializedSearchSourceFields, +} from '@kbn/data-plugin/common'; +import { catchError, tap, throwError } from 'rxjs'; +import { LogSearchMetricsOpts, RuleInfo, SearchMetrics } from './types'; + +interface Props { + logger: Logger; + rule: RuleInfo; + abortController: AbortController; + searchSourceClient: ISearchStartSearchSource; +} + +interface WrapParams { + logger: Logger; + rule: RuleInfo; + abortController: AbortController; + pureSearchSource: T; + logMetrics: (metrics: LogSearchMetricsOpts) => void; +} + +export function wrapSearchSourceClient({ + logger, + rule, + abortController, + searchSourceClient: pureSearchSourceClient, +}: Props) { + let numSearches: number = 0; + let esSearchDurationMs: number = 0; + let totalSearchDurationMs: number = 0; + + function logMetrics(metrics: LogSearchMetricsOpts) { + numSearches++; + esSearchDurationMs += metrics.esSearchDuration; + totalSearchDurationMs += metrics.totalSearchDuration; + } + + const wrapParams = { + logMetrics, + logger, + rule, + abortController, + }; + + const wrappedSearchSourceClient: ISearchStartSearchSource = Object.create(pureSearchSourceClient); + + wrappedSearchSourceClient.createEmpty = () => { + const pureSearchSource = pureSearchSourceClient.createEmpty(); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource, + }); + }; + + wrappedSearchSourceClient.create = async (fields?: SerializedSearchSourceFields) => { + const pureSearchSource = await pureSearchSourceClient.create(fields); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource, + }); + }; + + return { + searchSourceClient: wrappedSearchSourceClient, + getMetrics: (): SearchMetrics => ({ + esSearchDurationMs, + totalSearchDurationMs, + numSearches, + }), + }; +} + +function wrapSearchSource({ + pureSearchSource, + ...wrapParams +}: WrapParams): T { + const wrappedSearchSource = Object.create(pureSearchSource); + + wrappedSearchSource.createChild = wrapCreateChild({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.createCopy = wrapCreateCopy({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.create = wrapCreate({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.fetch$ = wrapFetch$({ ...wrapParams, pureSearchSource }); + + return wrappedSearchSource; +} + +function wrapCreate({ pureSearchSource, ...wrapParams }: WrapParams) { + return function () { + const pureCreatedSearchSource = pureSearchSource.create(); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureCreatedSearchSource, + }); + }; +} + +function wrapCreateChild({ pureSearchSource, ...wrapParams }: WrapParams) { + return function (options?: {}) { + const pureSearchSourceChild = pureSearchSource.createChild(options); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureSearchSourceChild, + }); + }; +} + +function wrapCreateCopy({ pureSearchSource, ...wrapParams }: WrapParams) { + return function () { + const pureSearchSourceChild = pureSearchSource.createCopy(); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureSearchSourceChild, + }) as SearchSource; + }; +} + +function wrapFetch$({ + logger, + rule, + abortController, + pureSearchSource, + logMetrics, +}: WrapParams) { + return (options?: ISearchOptions) => { + const searchOptions = options ?? {}; + const start = Date.now(); + + logger.debug( + `executing query for rule ${rule.alertTypeId}:${rule.id} in space ${ + rule.spaceId + } - with options ${JSON.stringify(searchOptions)}` + ); + + return pureSearchSource + .fetch$({ + ...searchOptions, + abortSignal: abortController.signal, + }) + .pipe( + catchError((error) => { + if (abortController.signal.aborted) { + return throwError( + () => new Error('Search has been aborted due to cancelled execution') + ); + } + return throwError(() => error); + }), + tap((result) => { + const durationMs = Date.now() - start; + logMetrics({ + esSearchDuration: result.rawResponse.took ?? 0, + totalSearchDuration: durationMs, + }); + }) + ); + }; +} diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index f7525c2c5f570..fd554783111d2 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -9,9 +9,8 @@ import { elasticsearchServiceMock, savedObjectsClientMock, uiSettingsServiceMock, - httpServerMock, } from '@kbn/core/server/mocks'; -import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; +import { searchSourceCommonMock } from '@kbn/data-plugin/common/search/search_source/mocks'; import { rulesClientMock } from './rules_client.mock'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { Alert, AlertFactoryDoneUtils } from './alert'; @@ -113,11 +112,7 @@ const createRuleExecutorServicesMock = < shouldWriteAlerts: () => true, shouldStopExecution: () => true, search: createAbortableSearchServiceMock(), - searchSourceClient: Promise.resolve( - dataPluginMock - .createStartContract() - .search.searchSource.asScoped(httpServerMock.createKibanaRequest()) - ), + searchSourceClient: searchSourceCommonMock, }; }; export type RuleExecutorServicesMock = ReturnType; diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 525c252b40b66..bd83b269ce10d 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -17,7 +17,6 @@ import { TaskRunnerContext } from './task_runner_factory'; import { createExecutionHandler, ExecutionHandler } from './create_execution_handler'; import { Alert, createAlertFactory } from '../alert'; import { - createWrappedScopedClusterClientFactory, ElasticsearchError, ErrorWithReason, executionStatusFromError, @@ -69,9 +68,12 @@ import { RuleRunResult, RuleTaskStateAndMetrics, } from './types'; +import { createWrappedScopedClusterClientFactory } from '../lib/wrap_scoped_cluster_client'; import { IExecutionStatusAndMetrics } from '../lib/rule_execution_status'; import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { wrapSearchSourceClient } from '../lib/wrap_search_source_client'; import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; +import { SearchMetrics } from '../lib/types'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; @@ -337,9 +339,7 @@ export class TaskRunner< const ruleLabel = `${this.ruleType.id}:${ruleId}: '${name}'`; - const scopedClusterClient = this.context.elasticsearch.client.asScoped(fakeRequest); - const wrappedScopedClusterClient = createWrappedScopedClusterClientFactory({ - scopedClusterClient, + const wrappedClientOptions = { rule: { name: rule.name, alertTypeId: rule.alertTypeId, @@ -348,6 +348,16 @@ export class TaskRunner< }, logger: this.logger, abortController: this.searchAbortController, + }; + const scopedClusterClient = this.context.elasticsearch.client.asScoped(fakeRequest); + const wrappedScopedClusterClient = createWrappedScopedClusterClientFactory({ + ...wrappedClientOptions, + scopedClusterClient, + }); + const searchSourceClient = await this.context.data.search.searchSource.asScoped(fakeRequest); + const wrappedSearchSourceClient = wrapSearchSourceClient({ + ...wrappedClientOptions, + searchSourceClient, }); let updatedRuleTypeState: void | Record; @@ -371,9 +381,9 @@ export class TaskRunner< executionId: this.executionId, services: { savedObjectsClient, + searchSourceClient: wrappedSearchSourceClient.searchSourceClient, uiSettingsClient: this.context.uiSettings.asScopedToClient(savedObjectsClient), scopedClusterClient: wrappedScopedClusterClient.client(), - searchSourceClient: this.context.data.search.searchSource.asScoped(fakeRequest), alertFactory: createAlertFactory< InstanceState, InstanceContext, @@ -426,9 +436,19 @@ export class TaskRunner< this.alertingEventLogger.setExecutionSucceeded(`rule executed: ${ruleLabel}`); + const scopedClusterClientMetrics = wrappedScopedClusterClient.getMetrics(); + const searchSourceClientMetrics = wrappedSearchSourceClient.getMetrics(); + const searchMetrics: SearchMetrics = { + numSearches: scopedClusterClientMetrics.numSearches + searchSourceClientMetrics.numSearches, + totalSearchDurationMs: + scopedClusterClientMetrics.totalSearchDurationMs + + searchSourceClientMetrics.totalSearchDurationMs, + esSearchDurationMs: + scopedClusterClientMetrics.esSearchDurationMs + + searchSourceClientMetrics.esSearchDurationMs, + }; const ruleRunMetricsStore = new RuleRunMetricsStore(); - const searchMetrics = wrappedScopedClusterClient.getMetrics(); ruleRunMetricsStore.setNumSearches(searchMetrics.numSearches); ruleRunMetricsStore.setTotalSearchDurationMs(searchMetrics.totalSearchDurationMs); ruleRunMetricsStore.setEsSearchDurationMs(searchMetrics.esSearchDurationMs); diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 7b1725e42bd5e..b7e06aa602f27 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -10,13 +10,15 @@ import type { CustomRequestHandlerContext, SavedObjectReference, IUiSettingsClient, +} from '@kbn/core/server'; +import { ISearchStartSearchSource } from '@kbn/data-plugin/common'; +import { LicenseType } from '@kbn/licensing-plugin/server'; +import { IScopedClusterClient, SavedObjectAttributes, SavedObjectsClientContract, } from '@kbn/core/server'; import type { PublicMethodsOf } from '@kbn/utility-types'; -import { ISearchStartSearchSource } from '@kbn/data-plugin/common'; -import { LicenseType } from '@kbn/licensing-plugin/server'; import { AlertFactoryDoneUtils, PublicAlert } from './alert'; import { RuleTypeRegistry as OrigruleTypeRegistry } from './rule_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; @@ -72,7 +74,7 @@ export interface RuleExecutorServices< InstanceContext extends AlertInstanceContext = AlertInstanceContext, ActionGroupIds extends string = never > { - searchSourceClient: Promise; + searchSourceClient: ISearchStartSearchSource; savedObjectsClient: SavedObjectsClientContract; uiSettingsClient: IUiSettingsClient; scopedClusterClient: IScopedClusterClient; diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index 7894478aedf22..9387a9ce8c0ed 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -118,7 +118,7 @@ function createRule(shouldWriteAlerts: boolean = true) { shouldWriteAlerts: () => shouldWriteAlerts, shouldStopExecution: () => false, search: {} as any, - searchSourceClient: Promise.resolve({} as ISearchStartSearchSource), + searchSourceClient: {} as ISearchStartSearchSource, }, spaceId: 'spaceId', state, diff --git a/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts b/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts index 05c069d80ed3e..b2c25973f7cc4 100644 --- a/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts +++ b/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts @@ -7,7 +7,6 @@ import { elasticsearchServiceMock, savedObjectsClientMock, - httpServerMock, uiSettingsServiceMock, } from '@kbn/core/server/mocks'; import { @@ -18,7 +17,7 @@ import { RuleTypeState, } from '@kbn/alerting-plugin/server'; import { alertsMock } from '@kbn/alerting-plugin/server/mocks'; -import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; +import { searchSourceCommonMock } from '@kbn/data-plugin/common/search/search_source/mocks'; export const createDefaultAlertExecutorOptions = < Params extends RuleTypeParams = never, @@ -77,11 +76,7 @@ export const createDefaultAlertExecutorOptions = < scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), shouldWriteAlerts: () => shouldWriteAlerts, shouldStopExecution: () => false, - searchSourceClient: Promise.resolve( - dataPluginMock - .createStartContract() - .search.searchSource.asScoped(httpServerMock.createKibanaRequest()) - ), + searchSourceClient: searchSourceCommonMock, }, state, updatedBy: null, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts index 00fc13315ff36..de60e82e336ef 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts @@ -54,6 +54,7 @@ import { import { createSecurityRuleTypeWrapper } from '../../rule_types/create_security_rule_type_wrapper'; import { RULE_PREVIEW_INVOCATION_COUNT } from '../../../../../common/detection_engine/constants'; import { RuleExecutionContext, StatusChangeArgs } from '../../rule_execution_log'; +import { wrapSearchSourceClient } from './utils/wrap_search_source_client'; const PREVIEW_TIMEOUT_SECONDS = 60; @@ -86,7 +87,7 @@ export const previewRulesRoute = async ( } try { const [, { data, security: securityService }] = await getStartServices(); - const searchSourceClient = data.search.searchSource.asScoped(request); + const searchSourceClient = await data.search.searchSource.asScoped(request); const savedObjectsClient = coreContext.savedObjects.client; const siemClient = (await context.securitySolution).getAppClient(); @@ -242,7 +243,10 @@ export const previewRulesRoute = async ( abortController, scopedClusterClient: coreContext.elasticsearch.client, }), - searchSourceClient, + searchSourceClient: wrapSearchSourceClient({ + abortController, + searchSourceClient, + }), uiSettingsClient: coreContext.uiSettings.client, }, spaceId, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.test.ts new file mode 100644 index 0000000000000..c8fff85476957 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.test.ts @@ -0,0 +1,108 @@ +/* + * 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 { ISearchStartSearchSource } from '@kbn/data-plugin/common'; +import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; +import { of, throwError } from 'rxjs'; +import { wrapSearchSourceClient } from './wrap_search_source_client'; + +const createSearchSourceClientMock = () => { + const searchSourceMock = createSearchSourceMock(); + searchSourceMock.fetch$ = jest.fn().mockImplementation(() => of({})); + + return { + searchSourceMock, + searchSourceClientMock: { + create: jest.fn().mockReturnValue(searchSourceMock), + createEmpty: jest.fn().mockReturnValue(searchSourceMock), + } as unknown as ISearchStartSearchSource, + }; +}; + +describe('wrapSearchSourceClient', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('searches with provided abort controller', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + + const wrappedSearchClient = wrapSearchSourceClient({ + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await wrappedSearchClient.createEmpty(); + await wrappedSearchSource.fetch(); + + expect(searchSourceMock.fetch$).toHaveBeenCalledWith({ + abortSignal: abortController.signal, + }); + }); + + test('uses search options when specified', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + + const wrappedSearchClient = wrapSearchSourceClient({ + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await wrappedSearchClient.create(); + await wrappedSearchSource.fetch({ isStored: true }); + + expect(searchSourceMock.fetch$).toHaveBeenCalledWith({ + isStored: true, + abortSignal: abortController.signal, + }); + }); + + test('re-throws error when search throws error', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + searchSourceMock.fetch$ = jest + .fn() + .mockReturnValue(throwError(new Error('something went wrong!'))); + + const wrappedSearchClient = wrapSearchSourceClient({ + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await wrappedSearchClient.create(); + const fetch = wrappedSearchSource.fetch(); + + await expect(fetch).rejects.toThrowErrorMatchingInlineSnapshot('"something went wrong!"'); + }); + + test('throws error when search throws abort error', async () => { + const abortController = new AbortController(); + abortController.abort(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + searchSourceMock.fetch$ = jest + .fn() + .mockReturnValue(throwError(new Error('Request has been aborted by the user'))); + + const wrappedSearchClient = wrapSearchSourceClient({ + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await wrappedSearchClient.create(); + const fetch = wrappedSearchSource.fetch(); + + await expect(fetch).rejects.toThrowErrorMatchingInlineSnapshot( + '"Search has been aborted due to cancelled execution"' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.ts new file mode 100644 index 0000000000000..619a4dee788f7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ISearchOptions, + ISearchSource, + ISearchStartSearchSource, + SearchSource, + SerializedSearchSourceFields, +} from '@kbn/data-plugin/common'; +import { catchError, throwError } from 'rxjs'; + +interface Props { + abortController: AbortController; + searchSourceClient: ISearchStartSearchSource; +} + +interface WrapParams { + abortController: AbortController; + pureSearchSource: T; +} + +export function wrapSearchSourceClient({ + abortController, + searchSourceClient: pureSearchSourceClient, +}: Props) { + const wrappedSearchSourceClient: ISearchStartSearchSource = Object.create(pureSearchSourceClient); + + wrappedSearchSourceClient.createEmpty = () => { + const pureSearchSource = pureSearchSourceClient.createEmpty(); + + return wrapSearchSource({ + abortController, + pureSearchSource, + }); + }; + + wrappedSearchSourceClient.create = async (fields?: SerializedSearchSourceFields) => { + const pureSearchSource = await pureSearchSourceClient.create(fields); + + return wrapSearchSource({ + abortController, + pureSearchSource, + }); + }; + + return wrappedSearchSourceClient; +} + +function wrapSearchSource({ + pureSearchSource, + ...wrapParams +}: WrapParams): T { + const wrappedSearchSource = Object.create(pureSearchSource); + + wrappedSearchSource.createChild = wrapCreateChild({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.createCopy = wrapCreateCopy({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.create = wrapCreate({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.fetch$ = wrapFetch$({ ...wrapParams, pureSearchSource }); + + return wrappedSearchSource; +} + +function wrapCreate({ pureSearchSource, ...wrapParams }: WrapParams) { + return function () { + const pureCreatedSearchSource = pureSearchSource.create(); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureCreatedSearchSource, + }); + }; +} + +function wrapCreateChild({ pureSearchSource, ...wrapParams }: WrapParams) { + return function (options?: {}) { + const pureSearchSourceChild = pureSearchSource.createChild(options); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureSearchSourceChild, + }); + }; +} + +function wrapCreateCopy({ pureSearchSource, ...wrapParams }: WrapParams) { + return function () { + const pureSearchSourceChild = pureSearchSource.createCopy(); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureSearchSourceChild, + }) as SearchSource; + }; +} + +function wrapFetch$({ abortController, pureSearchSource }: WrapParams) { + return (options?: ISearchOptions) => { + const searchOptions = options ?? {}; + return pureSearchSource + .fetch$({ + ...searchOptions, + abortSignal: abortController.signal, + }) + .pipe( + catchError((error) => { + if (abortController.signal.aborted) { + return throwError( + () => new Error('Search has been aborted due to cancelled execution') + ); + } + return throwError(() => error); + }) + ); + }; +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts index 4f203b064592d..44708a1df90fd 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts @@ -51,10 +51,7 @@ export async function executor( alertId, params as OnlySearchSourceAlertParams, latestTimestamp, - { - searchSourceClient, - logger, - } + { searchSourceClient, logger } ); // apply the alert condition diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/lib/fetch_search_source_query.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/lib/fetch_search_source_query.ts index cff24f8975f0f..66e5ae8023a47 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/lib/fetch_search_source_query.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/lib/fetch_search_source_query.ts @@ -20,12 +20,12 @@ export async function fetchSearchSourceQuery( latestTimestamp: string | undefined, services: { logger: Logger; - searchSourceClient: Promise; + searchSourceClient: ISearchStartSearchSource; } ) { const { logger, searchSourceClient } = services; - const client = await searchSourceClient; - const initialSearchSource = await client.create(params.searchConfiguration); + + const initialSearchSource = await searchSourceClient.create(params.searchConfiguration); const { searchSource, dateStart, dateEnd } = updateSearchSource( initialSearchSource, From 12509f78c62252e1284f21e8033131de72bcb75e Mon Sep 17 00:00:00 2001 From: Faisal Kanout Date: Thu, 19 May 2022 10:33:17 +0300 Subject: [PATCH 040/150] Show "No actions" message instead of 0 (#132445) --- .../public/pages/rule_details/components/actions.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx index e3aadb60f8c4c..5a692e570281a 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx @@ -15,6 +15,7 @@ import { EuiLoadingSpinner, } from '@elastic/eui'; import { intersectionBy } from 'lodash'; +import { i18n } from '@kbn/i18n'; import { ActionsProps } from '../types'; import { useFetchRuleActions } from '../../../hooks/use_fetch_rule_actions'; import { useKibana } from '../../../utils/kibana_react'; @@ -37,7 +38,16 @@ export function Actions({ ruleActions }: ActionsProps) { notifications: { toasts }, } = useKibana().services; const { isLoadingActions, allActions, errorActions } = useFetchRuleActions({ http }); - if (ruleActions && ruleActions.length <= 0) return 0; + if (ruleActions && ruleActions.length <= 0) + return ( + + + {i18n.translate('xpack.observability.ruleDetails.noActions', { + defaultMessage: 'No actions', + })} + + + ); const actions = intersectionBy(allActions, ruleActions, 'actionTypeId'); if (isLoadingActions) return ; return ( From 1660bd9013a8c8df41e8d5992b5f78dea7b5cf92 Mon Sep 17 00:00:00 2001 From: Nodir Latipov Date: Thu, 19 May 2022 12:40:10 +0500 Subject: [PATCH 041/150] [Unified Search] Hide 'Include time filter' checkbox when Data view has no time field (#131276) * feat: add hide 'Include time filter' checkbox, if index pattern has no time field * feat: added checking DataView exists and has any element * fix: added a check for the absence of a timeFieldName value for each dataViews * feat: changed logic for check DataViews have value TimeFieldName * refactor: shouldRenderTimeFilterInSavedQueryForm * refact: minor * refact: minor * Update src/plugins/unified_search/public/search_bar/search_bar.tsx --- .../public/search_bar/search_bar.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index a8681319ebc21..a6ca444612402 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -10,8 +10,8 @@ import { compact } from 'lodash'; import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; import classNames from 'classnames'; import React, { Component } from 'react'; -import { get, isEqual } from 'lodash'; import { EuiIconProps, withEuiTheme, WithEuiThemeProps } from '@elastic/eui'; +import { get, isEqual } from 'lodash'; import memoizeOne from 'memoize-one'; import { METRIC_TYPE } from '@kbn/analytics'; @@ -213,11 +213,18 @@ class SearchBarUI extends Component { * in case you the date range (from/to) */ private shouldRenderTimeFilterInSavedQueryForm() { - const { dateRangeFrom, dateRangeTo, showDatePicker } = this.props; - return ( - showDatePicker || - (!showDatePicker && dateRangeFrom !== undefined && dateRangeTo !== undefined) - ); + const { dateRangeFrom, dateRangeTo, showDatePicker, indexPatterns } = this.props; + + if (!showDatePicker && dateRangeFrom !== undefined && dateRangeTo !== undefined) { + return false; + } + + if (indexPatterns?.length) { + // return true if at least one of the DateView has timeFieldName + return indexPatterns.some((dataView) => Boolean(dataView.timeFieldName)); + } + + return true; } public onSave = async (savedQueryMeta: SavedQueryMeta, saveAsNew = false) => { From 59120c9340499c5b8e17e45178a427df15c687e2 Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Thu, 19 May 2022 09:17:51 +0100 Subject: [PATCH 042/150] [ML] Transforms: Fix width of icon column in Messages table (#132444) --- .../components/transform_list/expanded_row_messages_pane.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx index 093f6da2233da..2261b399e5ff9 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx @@ -81,7 +81,7 @@ export const ExpandedRowMessagesPane: React.FC = ({ transformId }) => { { name: '', render: (message: TransformMessage) => , - width: `${theme.euiSizeXL}px`, + width: theme.euiSizeXL, }, { name: i18n.translate( From b7866ac7f07409b9f14a62538a02811a84272a61 Mon Sep 17 00:00:00 2001 From: Muhammad Ibragimov <53621505+mibragimov@users.noreply.github.com> Date: Thu, 19 May 2022 14:30:59 +0500 Subject: [PATCH 043/150] [Console] Refactor retrieval of mappings, aliases, templates, data-streams for autocomplete (#130633) * Create a specific route for fetching mappings, aliases, templates, etc... * Encapsulate data streams * Encapsulate the mappings data into a class * Setup up autocompleteInfo service and provide its instance through context * Migrate the logic from mappings.js to Kibana server * Translate the logic to consume the appropriate ES client method * Update related test cases * Lint * Address comments * Fix server proxy/mock * Add API integration tests for /api/console/autocomplete_entities * Lint * Add tests * Add API integration tests for autocomplete_entities API * Add deleted tests Co-authored-by: Muhammad Ibragimov Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- api_docs/deprecations_by_plugin.mdx | 4 +- .../console_editor/editor.test.mock.tsx | 4 - .../editor/legacy/console_editor/editor.tsx | 16 +- .../application/containers/settings.tsx | 94 ++-- .../contexts/services_context.mock.ts | 2 + .../application/contexts/services_context.tsx | 3 +- .../use_send_current_request.ts | 17 +- .../console/public/application/index.tsx | 5 +- .../models/sense_editor/integration.test.js | 12 +- ...mponent_template_autocomplete_component.js | 4 +- .../data_stream_autocomplete_component.js | 4 +- .../field_autocomplete_component.js | 4 +- .../index_autocomplete_component.js | 6 +- .../index_template_autocomplete_component.js | 4 +- .../legacy_template_autocomplete_component.js | 4 +- .../components/type_autocomplete_component.js | 2 +- .../username_autocomplete_component.js | 6 +- .../public/lib/autocomplete_entities/alias.ts | 65 +++ .../autocomplete_entities.test.js | 315 ++++++++++++++ .../autocomplete_entities/base_template.ts | 21 + .../component_template.ts | 16 + .../lib/autocomplete_entities/data_stream.ts | 25 ++ .../autocomplete_entities/expand_aliases.ts | 41 ++ .../public/lib/autocomplete_entities/index.ts | 15 + .../autocomplete_entities/index_template.ts | 16 + .../lib/autocomplete_entities/legacy/index.ts | 9 + .../legacy/legacy_template.ts | 16 + .../lib/autocomplete_entities/mapping.ts | 164 +++++++ .../public/lib/autocomplete_entities/type.ts | 44 ++ .../public/lib/autocomplete_entities/types.ts | 39 ++ src/plugins/console/public/lib/kb/kb.test.js | 14 +- .../public/lib/mappings/mapping.test.js | 278 ------------ .../console/public/lib/mappings/mappings.js | 410 ------------------ src/plugins/console/public/plugin.ts | 6 + .../public/services/autocomplete.mock.ts | 17 + .../console/public/services/autocomplete.ts | 107 +++++ src/plugins/console/public/services/index.ts | 1 + src/plugins/console/server/plugin.ts | 4 + .../console/autocomplete_entities/index.ts | 9 + .../register_get_route.ts | 95 ++++ .../register_mappings_route.ts | 14 + .../server/routes/api/console/proxy/mocks.ts | 2 + src/plugins/console/server/routes/index.ts | 6 + src/plugins/console/server/shared_imports.ts | 9 + .../apis/console/autocomplete_entities.ts | 133 ++++++ test/api_integration/apis/console/index.ts | 1 + 46 files changed, 1306 insertions(+), 777 deletions(-) create mode 100644 src/plugins/console/public/lib/autocomplete_entities/alias.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js create mode 100644 src/plugins/console/public/lib/autocomplete_entities/base_template.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/component_template.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/data_stream.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/expand_aliases.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/index.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/index_template.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/legacy/index.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/legacy/legacy_template.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/mapping.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/type.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/types.ts delete mode 100644 src/plugins/console/public/lib/mappings/mapping.test.js delete mode 100644 src/plugins/console/public/lib/mappings/mappings.js create mode 100644 src/plugins/console/public/services/autocomplete.mock.ts create mode 100644 src/plugins/console/public/services/autocomplete.ts create mode 100644 src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts create mode 100644 src/plugins/console/server/routes/api/console/autocomplete_entities/register_get_route.ts create mode 100644 src/plugins/console/server/routes/api/console/autocomplete_entities/register_mappings_route.ts create mode 100644 src/plugins/console/server/shared_imports.ts create mode 100644 test/api_integration/apis/console/autocomplete_entities.ts diff --git a/api_docs/deprecations_by_plugin.mdx b/api_docs/deprecations_by_plugin.mdx index bc9d1dac3a021..4904da587db13 100644 --- a/api_docs/deprecations_by_plugin.mdx +++ b/api_docs/deprecations_by_plugin.mdx @@ -130,12 +130,12 @@ warning: This document is auto-generated and is meant to be viewed inside our ex | Deprecated API | Reference location(s) | Remove By | | ---------------|-----------|-----------| -| | [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField)+ 16 more | - | +| | [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField)+ 16 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternsContract), [create_search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.ts#:~:text=IndexPatternsContract), [create_search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.ts#:~:text=IndexPatternsContract), [search_source_service.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source_service.ts#:~:text=IndexPatternsContract), [search_source_service.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source_service.ts#:~:text=IndexPatternsContract), [esaggs_fn.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts#:~:text=IndexPatternsContract), [esaggs_fn.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts#:~:text=IndexPatternsContract), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/search/types.ts#:~:text=IndexPatternsContract), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/search/types.ts#:~:text=IndexPatternsContract), [create_search_source.test.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.test.ts#:~:text=IndexPatternsContract)+ 19 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternsService), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/server/index.ts#:~:text=IndexPatternsService), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/server/index.ts#:~:text=IndexPatternsService), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/server/index.ts#:~:text=IndexPatternsService), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/index.ts#:~:text=IndexPatternsService) | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPattern), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/types.ts#:~:text=IndexPattern), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/types.ts#:~:text=IndexPattern), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/types.ts#:~:text=IndexPattern), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/types.ts#:~:text=IndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPattern), [tabify_docs.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/tabify/tabify_docs.ts#:~:text=IndexPattern), [tabify_docs.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/tabify/tabify_docs.ts#:~:text=IndexPattern)+ 89 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IFieldType), [date_histogram.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/buckets/date_histogram.ts#:~:text=IFieldType), [date_histogram.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/buckets/date_histogram.ts#:~:text=IFieldType), [generate_filters.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts#:~:text=IFieldType), [generate_filters.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts#:~:text=IFieldType), [generate_filters.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts#:~:text=IFieldType), [generate_filters.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts#:~:text=IFieldType), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/index.ts#:~:text=IFieldType), [create_filters_from_range_select.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts#:~:text=IFieldType), [create_filters_from_range_select.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts#:~:text=IFieldType)+ 6 more | 8.2 | -| | [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField)+ 16 more | - | +| | [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField)+ 16 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IIndexPattern), [get_time.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/query/timefilter/get_time.ts#:~:text=IIndexPattern), [get_time.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/query/timefilter/get_time.ts#:~:text=IIndexPattern), [get_time.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/query/timefilter/get_time.ts#:~:text=IIndexPattern), [get_time.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/query/timefilter/get_time.ts#:~:text=IIndexPattern), [normalize_sort_request.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/normalize_sort_request.ts#:~:text=IIndexPattern), [normalize_sort_request.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/normalize_sort_request.ts#:~:text=IIndexPattern), [normalize_sort_request.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/normalize_sort_request.ts#:~:text=IIndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IIndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IIndexPattern)+ 23 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternAttributes), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/index.ts#:~:text=IndexPatternAttributes), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/server/index.ts#:~:text=IndexPatternAttributes) | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternsContract), [create_search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.ts#:~:text=IndexPatternsContract), [create_search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.ts#:~:text=IndexPatternsContract), [search_source_service.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source_service.ts#:~:text=IndexPatternsContract), [search_source_service.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source_service.ts#:~:text=IndexPatternsContract), [esaggs_fn.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts#:~:text=IndexPatternsContract), [esaggs_fn.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts#:~:text=IndexPatternsContract), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/search/types.ts#:~:text=IndexPatternsContract), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/search/types.ts#:~:text=IndexPatternsContract), [create_search_source.test.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.test.ts#:~:text=IndexPatternsContract)+ 19 more | - | diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.mock.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.mock.tsx index b410e240151d7..fe88d651c12f1 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.mock.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.mock.tsx @@ -16,10 +16,6 @@ jest.mock('../../../../contexts/editor_context/editor_registry', () => ({ }, })); jest.mock('../../../../components/editor_example', () => {}); -jest.mock('../../../../../lib/mappings/mappings', () => ({ - retrieveAutoCompleteInfo: () => {}, - clearSubscriptions: () => {}, -})); jest.mock('../../../../models/sense_editor', () => { return { create: () => ({ diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx index d01a40bdd44b3..9219c6e076ca0 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx @@ -20,8 +20,6 @@ import { decompressFromEncodedURIComponent } from 'lz-string'; import { parse } from 'query-string'; import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; import { ace } from '@kbn/es-ui-shared-plugin/public'; -// @ts-ignore -import { retrieveAutoCompleteInfo, clearSubscriptions } from '../../../../../lib/mappings/mappings'; import { ConsoleMenu } from '../../../../components'; import { useEditorReadContext, useServicesContext } from '../../../../contexts'; import { @@ -66,7 +64,14 @@ const inputId = 'ConAppInputTextarea'; function EditorUI({ initialTextValue, setEditorInstance }: EditorProps) { const { - services: { history, notifications, settings: settingsService, esHostService, http }, + services: { + history, + notifications, + settings: settingsService, + esHostService, + http, + autocompleteInfo, + }, docLinkVersion, } = useServicesContext(); @@ -196,14 +201,14 @@ function EditorUI({ initialTextValue, setEditorInstance }: EditorProps) { setInputEditor(editor); setTextArea(editorRef.current!.querySelector('textarea')); - retrieveAutoCompleteInfo(http, settingsService, settingsService.getAutocomplete()); + autocompleteInfo.retrieve(settingsService, settingsService.getAutocomplete()); const unsubscribeResizer = subscribeResizeChecker(editorRef.current!, editor); setupAutosave(); return () => { unsubscribeResizer(); - clearSubscriptions(); + autocompleteInfo.clearSubscriptions(); window.removeEventListener('hashchange', onHashChange); if (editorInstanceRef.current) { editorInstanceRef.current.getCoreEditor().destroy(); @@ -217,6 +222,7 @@ function EditorUI({ initialTextValue, setEditorInstance }: EditorProps) { setInputEditor, settingsService, http, + autocompleteInfo, ]); useEffect(() => { diff --git a/src/plugins/console/public/application/containers/settings.tsx b/src/plugins/console/public/application/containers/settings.tsx index b4cbea5833f32..b9a9d68294e6d 100644 --- a/src/plugins/console/public/application/containers/settings.tsx +++ b/src/plugins/console/public/application/containers/settings.tsx @@ -8,11 +8,8 @@ import React from 'react'; -import type { HttpSetup } from '@kbn/core/public'; import { AutocompleteOptions, DevToolsSettingsModal } from '../components'; -// @ts-ignore -import { retrieveAutoCompleteInfo } from '../../lib/mappings/mappings'; import { useServicesContext, useEditorActionContext } from '../contexts'; import { DevToolsSettings, Settings as SettingsService } from '../../services'; import type { SenseEditor } from '../models'; @@ -27,48 +24,6 @@ const getAutocompleteDiff = ( }) as AutocompleteOptions[]; }; -const refreshAutocompleteSettings = ( - http: HttpSetup, - settings: SettingsService, - selectedSettings: DevToolsSettings['autocomplete'] -) => { - retrieveAutoCompleteInfo(http, settings, selectedSettings); -}; - -const fetchAutocompleteSettingsIfNeeded = ( - http: HttpSetup, - settings: SettingsService, - newSettings: DevToolsSettings, - prevSettings: DevToolsSettings -) => { - // We'll only retrieve settings if polling is on. The expectation here is that if the user - // disables polling it's because they want manual control over the fetch request (possibly - // because it's a very expensive request given their cluster and bandwidth). In that case, - // they would be unhappy with any request that's sent automatically. - if (newSettings.polling) { - const autocompleteDiff = getAutocompleteDiff(newSettings, prevSettings); - - const isSettingsChanged = autocompleteDiff.length > 0; - const isPollingChanged = prevSettings.polling !== newSettings.polling; - - if (isSettingsChanged) { - // If the user has changed one of the autocomplete settings, then we'll fetch just the - // ones which have changed. - const changedSettings: DevToolsSettings['autocomplete'] = autocompleteDiff.reduce( - (changedSettingsAccum, setting) => { - changedSettingsAccum[setting] = newSettings.autocomplete[setting]; - return changedSettingsAccum; - }, - {} as DevToolsSettings['autocomplete'] - ); - retrieveAutoCompleteInfo(http, settings, changedSettings); - } else if (isPollingChanged && newSettings.polling) { - // If the user has turned polling on, then we'll fetch all selected autocomplete settings. - retrieveAutoCompleteInfo(http, settings, settings.getAutocomplete()); - } - } -}; - export interface Props { onClose: () => void; editorInstance: SenseEditor | null; @@ -76,14 +31,57 @@ export interface Props { export function Settings({ onClose, editorInstance }: Props) { const { - services: { settings, http }, + services: { settings, autocompleteInfo }, } = useServicesContext(); const dispatch = useEditorActionContext(); + const refreshAutocompleteSettings = ( + settingsService: SettingsService, + selectedSettings: DevToolsSettings['autocomplete'] + ) => { + autocompleteInfo.retrieve(settingsService, selectedSettings); + }; + + const fetchAutocompleteSettingsIfNeeded = ( + settingsService: SettingsService, + newSettings: DevToolsSettings, + prevSettings: DevToolsSettings + ) => { + // We'll only retrieve settings if polling is on. The expectation here is that if the user + // disables polling it's because they want manual control over the fetch request (possibly + // because it's a very expensive request given their cluster and bandwidth). In that case, + // they would be unhappy with any request that's sent automatically. + if (newSettings.polling) { + const autocompleteDiff = getAutocompleteDiff(newSettings, prevSettings); + + const isSettingsChanged = autocompleteDiff.length > 0; + const isPollingChanged = prevSettings.polling !== newSettings.polling; + + if (isSettingsChanged) { + // If the user has changed one of the autocomplete settings, then we'll fetch just the + // ones which have changed. + const changedSettings: DevToolsSettings['autocomplete'] = autocompleteDiff.reduce( + (changedSettingsAccum, setting) => { + changedSettingsAccum[setting] = newSettings.autocomplete[setting]; + return changedSettingsAccum; + }, + {} as DevToolsSettings['autocomplete'] + ); + autocompleteInfo.retrieve(settingsService, { + ...settingsService.getAutocomplete(), + ...changedSettings, + }); + } else if (isPollingChanged && newSettings.polling) { + // If the user has turned polling on, then we'll fetch all selected autocomplete settings. + autocompleteInfo.retrieve(settingsService, settingsService.getAutocomplete()); + } + } + }; + const onSaveSettings = (newSettings: DevToolsSettings) => { const prevSettings = settings.toJSON(); - fetchAutocompleteSettingsIfNeeded(http, settings, newSettings, prevSettings); + fetchAutocompleteSettingsIfNeeded(settings, newSettings, prevSettings); // Update the new settings in localStorage settings.updateSettings(newSettings); @@ -101,7 +99,7 @@ export function Settings({ onClose, editorInstance }: Props) { onClose={onClose} onSaveSettings={onSaveSettings} refreshAutocompleteSettings={(selectedSettings) => - refreshAutocompleteSettings(http, settings, selectedSettings) + refreshAutocompleteSettings(settings, selectedSettings) } settings={settings.toJSON()} editorInstance={editorInstance} diff --git a/src/plugins/console/public/application/contexts/services_context.mock.ts b/src/plugins/console/public/application/contexts/services_context.mock.ts index 5ede7f58d4bdc..5d3c7ea6e172d 100644 --- a/src/plugins/console/public/application/contexts/services_context.mock.ts +++ b/src/plugins/console/public/application/contexts/services_context.mock.ts @@ -17,6 +17,7 @@ import type { ObjectStorageClient } from '../../../common/types'; import { HistoryMock } from '../../services/history.mock'; import { SettingsMock } from '../../services/settings.mock'; import { StorageMock } from '../../services/storage.mock'; +import { AutocompleteInfoMock } from '../../services/autocomplete.mock'; import { createApi, createEsHostService } from '../lib'; import { ContextValue } from './services_context'; @@ -38,6 +39,7 @@ export const serviceContextMock = { notifications: notificationServiceMock.createSetupContract(), objectStorageClient: {} as unknown as ObjectStorageClient, http, + autocompleteInfo: new AutocompleteInfoMock(), }, docLinkVersion: 'NA', theme$: themeServiceMock.create().start().theme$, diff --git a/src/plugins/console/public/application/contexts/services_context.tsx b/src/plugins/console/public/application/contexts/services_context.tsx index c60e41d8f14bb..f133a49ca1fe1 100644 --- a/src/plugins/console/public/application/contexts/services_context.tsx +++ b/src/plugins/console/public/application/contexts/services_context.tsx @@ -10,7 +10,7 @@ import React, { createContext, useContext, useEffect } from 'react'; import { Observable } from 'rxjs'; import type { NotificationsSetup, CoreTheme, DocLinksStart, HttpSetup } from '@kbn/core/public'; -import { History, Settings, Storage } from '../../services'; +import { AutocompleteInfo, History, Settings, Storage } from '../../services'; import { ObjectStorageClient } from '../../../common/types'; import { MetricsTracker } from '../../types'; import { EsHostService } from '../lib'; @@ -24,6 +24,7 @@ interface ContextServices { trackUiMetric: MetricsTracker; esHostService: EsHostService; http: HttpSetup; + autocompleteInfo: AutocompleteInfo; } export interface ContextValue { diff --git a/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts b/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts index ed08304d8d660..6cd1eaddc3583 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts +++ b/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts @@ -11,8 +11,6 @@ import { useCallback } from 'react'; import { toMountPoint } from '../../../shared_imports'; import { isQuotaExceededError } from '../../../services/history'; -// @ts-ignore -import { retrieveAutoCompleteInfo } from '../../../lib/mappings/mappings'; import { instance as registry } from '../../contexts/editor_context/editor_registry'; import { useRequestActionContext, useServicesContext } from '../../contexts'; import { StorageQuotaError } from '../../components/storage_quota_error'; @@ -21,7 +19,7 @@ import { track } from './track'; export const useSendCurrentRequest = () => { const { - services: { history, settings, notifications, trackUiMetric, http }, + services: { history, settings, notifications, trackUiMetric, http, autocompleteInfo }, theme$, } = useServicesContext(); @@ -102,7 +100,7 @@ export const useSendCurrentRequest = () => { // or templates may have changed, so we'll need to update this data. Assume that if // the user disables polling they're trying to optimize performance or otherwise // preserve resources, so they won't want this request sent either. - retrieveAutoCompleteInfo(http, settings, settings.getAutocomplete()); + autocompleteInfo.retrieve(settings, settings.getAutocomplete()); } dispatch({ @@ -129,5 +127,14 @@ export const useSendCurrentRequest = () => { }); } } - }, [dispatch, http, settings, notifications.toasts, trackUiMetric, history, theme$]); + }, [ + dispatch, + http, + settings, + notifications.toasts, + trackUiMetric, + history, + theme$, + autocompleteInfo, + ]); }; diff --git a/src/plugins/console/public/application/index.tsx b/src/plugins/console/public/application/index.tsx index 1950ab0c37951..e9f37c232eeaa 100644 --- a/src/plugins/console/public/application/index.tsx +++ b/src/plugins/console/public/application/index.tsx @@ -19,7 +19,7 @@ import { import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import { KibanaThemeProvider } from '../shared_imports'; -import { createStorage, createHistory, createSettings } from '../services'; +import { createStorage, createHistory, createSettings, AutocompleteInfo } from '../services'; import { createUsageTracker } from '../services/tracker'; import * as localStorageObjectClient from '../lib/local_storage_object_client'; import { Main } from './containers'; @@ -35,6 +35,7 @@ export interface BootDependencies { element: HTMLElement; theme$: Observable; docLinks: DocLinksStart['links']; + autocompleteInfo: AutocompleteInfo; } export function renderApp({ @@ -46,6 +47,7 @@ export function renderApp({ http, theme$, docLinks, + autocompleteInfo, }: BootDependencies) { const trackUiMetric = createUsageTracker(usageCollection); trackUiMetric.load('opened_app'); @@ -76,6 +78,7 @@ export function renderApp({ trackUiMetric, objectStorageClient, http, + autocompleteInfo, }, theme$, }} diff --git a/src/plugins/console/public/application/models/sense_editor/integration.test.js b/src/plugins/console/public/application/models/sense_editor/integration.test.js index e60b4175f668f..9159e0d08740e 100644 --- a/src/plugins/console/public/application/models/sense_editor/integration.test.js +++ b/src/plugins/console/public/application/models/sense_editor/integration.test.js @@ -12,10 +12,12 @@ import _ from 'lodash'; import $ from 'jquery'; import * as kb from '../../../lib/kb/kb'; -import * as mappings from '../../../lib/mappings/mappings'; +import { AutocompleteInfo, setAutocompleteInfo } from '../../../services'; describe('Integration', () => { let senseEditor; + let autocompleteInfo; + beforeEach(() => { // Set up our document body document.body.innerHTML = @@ -24,10 +26,14 @@ describe('Integration', () => { senseEditor = create(document.querySelector('#ConAppEditor')); $(senseEditor.getCoreEditor().getContainer()).show(); senseEditor.autocomplete._test.removeChangeListener(); + autocompleteInfo = new AutocompleteInfo(); + setAutocompleteInfo(autocompleteInfo); }); afterEach(() => { $(senseEditor.getCoreEditor().getContainer()).hide(); senseEditor.autocomplete._test.addChangeListener(); + autocompleteInfo = null; + setAutocompleteInfo(null); }); function processContextTest(data, mapping, kbSchemes, requestLine, testToRun) { @@ -45,8 +51,8 @@ describe('Integration', () => { testToRun.cursor.lineNumber += lineOffset; - mappings.clear(); - mappings.loadMappings(mapping); + autocompleteInfo.clear(); + autocompleteInfo.mapping.loadMappings(mapping); const json = {}; json[test.name] = kbSchemes || {}; const testApi = kb._test.loadApisFromJson(json); diff --git a/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js index ca59e077116e4..2b547d698415c 100644 --- a/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { getComponentTemplates } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; export class ComponentTemplateAutocompleteComponent extends ListComponent { constructor(name, parent) { - super(name, getComponentTemplates, parent, true, true); + super(name, getAutocompleteInfo().getEntityProvider('componentTemplates'), parent, true, true); } getContextKey() { diff --git a/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js index 015136b7670f5..0b043410c3b25 100644 --- a/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { getDataStreams } from '../../mappings/mappings'; import { ListComponent } from './list_component'; +import { getAutocompleteInfo } from '../../../services'; export class DataStreamAutocompleteComponent extends ListComponent { constructor(name, parent, multiValued) { - super(name, getDataStreams, parent, multiValued); + super(name, getAutocompleteInfo().getEntityProvider('dataStreams'), parent, multiValued); } getContextKey() { diff --git a/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js index 76cd37b7e8d99..e3257b2bd86b8 100644 --- a/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js @@ -7,11 +7,11 @@ */ import _ from 'lodash'; -import { getFields } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; function FieldGenerator(context) { - return _.map(getFields(context.indices, context.types), function (field) { + return _.map(getAutocompleteInfo().getEntityProvider('fields', context), function (field) { return { name: field.name, meta: field.type }; }); } diff --git a/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js index 0ec53be7e56af..c2a7e2fb14286 100644 --- a/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js @@ -7,14 +7,16 @@ */ import _ from 'lodash'; -import { getIndices } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; + function nonValidIndexType(token) { return !(token === '_all' || token[0] !== '_'); } + export class IndexAutocompleteComponent extends ListComponent { constructor(name, parent, multiValued) { - super(name, getIndices, parent, multiValued); + super(name, getAutocompleteInfo().getEntityProvider('indices'), parent, multiValued); } validateTokens(tokens) { if (!this.multiValued && tokens.length > 1) { diff --git a/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js index 444e40e756f7b..7bb3c32239751 100644 --- a/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { getIndexTemplates } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; export class IndexTemplateAutocompleteComponent extends ListComponent { constructor(name, parent) { - super(name, getIndexTemplates, parent, true, true); + super(name, getAutocompleteInfo().getEntityProvider('indexTemplates'), parent, true, true); } getContextKey() { diff --git a/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js index b68ae952702f5..73a9e3ea65c17 100644 --- a/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { getLegacyTemplates } from '../../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../../services'; import { ListComponent } from '../list_component'; export class LegacyTemplateAutocompleteComponent extends ListComponent { constructor(name, parent) { - super(name, getLegacyTemplates, parent, true, true); + super(name, getAutocompleteInfo().getEntityProvider('legacyTemplates'), parent, true, true); } getContextKey() { return 'template'; diff --git a/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js index bab45f28710e0..f7caf05e5805f 100644 --- a/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js @@ -8,7 +8,7 @@ import _ from 'lodash'; import { ListComponent } from './list_component'; -import { getTypes } from '../../mappings/mappings'; +import { getTypes } from '../../autocomplete_entities'; function TypeGenerator(context) { return getTypes(context.indices); } diff --git a/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js index 78b24f26444d6..c505f66a68b0c 100644 --- a/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js @@ -7,14 +7,16 @@ */ import _ from 'lodash'; -import { getIndices } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; + function nonValidUsernameType(token) { return token[0] === '_'; } + export class UsernameAutocompleteComponent extends ListComponent { constructor(name, parent, multiValued) { - super(name, getIndices, parent, multiValued); + super(name, getAutocompleteInfo().getEntityProvider('indices'), parent, multiValued); } validateTokens(tokens) { if (!this.multiValued && tokens.length > 1) { diff --git a/src/plugins/console/public/lib/autocomplete_entities/alias.ts b/src/plugins/console/public/lib/autocomplete_entities/alias.ts new file mode 100644 index 0000000000000..9bce35ab510c0 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/alias.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndicesGetAliasResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { BaseMapping } from './mapping'; + +interface BaseAlias { + getIndices(includeAliases: boolean, collaborator: BaseMapping): string[]; + loadAliases(aliases: IndicesGetAliasResponse, collaborator: BaseMapping): void; + clearAliases(): void; +} + +export class Alias implements BaseAlias { + public perAliasIndexes: Record = {}; + + getIndices = (includeAliases: boolean, collaborator: BaseMapping): string[] => { + const ret: string[] = []; + const perIndexTypes = collaborator.perIndexTypes; + Object.keys(perIndexTypes).forEach((index) => { + // ignore .ds* indices in the suggested indices list. + if (!index.startsWith('.ds')) { + ret.push(index); + } + }); + + if (typeof includeAliases === 'undefined' ? true : includeAliases) { + Object.keys(this.perAliasIndexes).forEach((alias) => { + ret.push(alias); + }); + } + return ret; + }; + + loadAliases = (aliases: IndicesGetAliasResponse, collaborator: BaseMapping) => { + this.perAliasIndexes = {}; + const perIndexTypes = collaborator.perIndexTypes; + + Object.entries(aliases).forEach(([index, indexAliases]) => { + // verify we have an index defined. useful when mapping loading is disabled + perIndexTypes[index] = perIndexTypes[index] || {}; + Object.keys(indexAliases.aliases || {}).forEach((alias) => { + if (alias === index) { + return; + } // alias which is identical to index means no index. + let curAliases = this.perAliasIndexes[alias]; + if (!curAliases) { + curAliases = []; + this.perAliasIndexes[alias] = curAliases; + } + curAliases.push(index); + }); + }); + const includeAliases = false; + this.perAliasIndexes._all = this.getIndices(includeAliases, collaborator); + }; + + clearAliases = () => { + this.perAliasIndexes = {}; + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js b/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js new file mode 100644 index 0000000000000..5349538799d9b --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js @@ -0,0 +1,315 @@ +/* + * 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 '../../application/models/sense_editor/sense_editor.test.mocks'; +import { setAutocompleteInfo, AutocompleteInfo } from '../../services'; +import { expandAliases } from './expand_aliases'; + +function fc(f1, f2) { + if (f1.name < f2.name) { + return -1; + } + if (f1.name > f2.name) { + return 1; + } + return 0; +} + +function f(name, type) { + return { name, type: type || 'string' }; +} + +describe('Autocomplete entities', () => { + let mapping; + let alias; + let legacyTemplate; + let indexTemplate; + let componentTemplate; + let dataStream; + let autocompleteInfo; + beforeEach(() => { + autocompleteInfo = new AutocompleteInfo(); + setAutocompleteInfo(autocompleteInfo); + mapping = autocompleteInfo.mapping; + alias = autocompleteInfo.alias; + legacyTemplate = autocompleteInfo.legacyTemplate; + indexTemplate = autocompleteInfo.indexTemplate; + componentTemplate = autocompleteInfo.componentTemplate; + dataStream = autocompleteInfo.dataStream; + }); + afterEach(() => { + autocompleteInfo.clear(); + autocompleteInfo = null; + }); + + describe('Mappings', function () { + test('Multi fields 1.0 style', function () { + mapping.loadMappings({ + index: { + properties: { + first_name: { + type: 'string', + index: 'analyzed', + path: 'just_name', + fields: { + any_name: { type: 'string', index: 'analyzed' }, + }, + }, + last_name: { + type: 'string', + index: 'no', + fields: { + raw: { type: 'string', index: 'analyzed' }, + }, + }, + }, + }, + }); + + expect(mapping.getMappings('index').sort(fc)).toEqual([ + f('any_name', 'string'), + f('first_name', 'string'), + f('last_name', 'string'), + f('last_name.raw', 'string'), + ]); + }); + + test('Simple fields', function () { + mapping.loadMappings({ + index: { + properties: { + str: { + type: 'string', + }, + number: { + type: 'int', + }, + }, + }, + }); + + expect(mapping.getMappings('index').sort(fc)).toEqual([ + f('number', 'int'), + f('str', 'string'), + ]); + }); + + test('Simple fields - 1.0 style', function () { + mapping.loadMappings({ + index: { + mappings: { + properties: { + str: { + type: 'string', + }, + number: { + type: 'int', + }, + }, + }, + }, + }); + + expect(mapping.getMappings('index').sort(fc)).toEqual([ + f('number', 'int'), + f('str', 'string'), + ]); + }); + + test('Nested fields', function () { + mapping.loadMappings({ + index: { + properties: { + person: { + type: 'object', + properties: { + name: { + properties: { + first_name: { type: 'string' }, + last_name: { type: 'string' }, + }, + }, + sid: { type: 'string', index: 'not_analyzed' }, + }, + }, + message: { type: 'string' }, + }, + }, + }); + + expect(mapping.getMappings('index', []).sort(fc)).toEqual([ + f('message'), + f('person.name.first_name'), + f('person.name.last_name'), + f('person.sid'), + ]); + }); + + test('Enabled fields', function () { + mapping.loadMappings({ + index: { + properties: { + person: { + type: 'object', + properties: { + name: { + type: 'object', + enabled: false, + }, + sid: { type: 'string', index: 'not_analyzed' }, + }, + }, + message: { type: 'string' }, + }, + }, + }); + + expect(mapping.getMappings('index', []).sort(fc)).toEqual([f('message'), f('person.sid')]); + }); + + test('Path tests', function () { + mapping.loadMappings({ + index: { + properties: { + name1: { + type: 'object', + path: 'just_name', + properties: { + first1: { type: 'string' }, + last1: { type: 'string', index_name: 'i_last_1' }, + }, + }, + name2: { + type: 'object', + path: 'full', + properties: { + first2: { type: 'string' }, + last2: { type: 'string', index_name: 'i_last_2' }, + }, + }, + }, + }, + }); + + expect(mapping.getMappings().sort(fc)).toEqual([ + f('first1'), + f('i_last_1'), + f('name2.first2'), + f('name2.i_last_2'), + ]); + }); + + test('Use index_name tests', function () { + mapping.loadMappings({ + index: { + properties: { + last1: { type: 'string', index_name: 'i_last_1' }, + }, + }, + }); + + expect(mapping.getMappings().sort(fc)).toEqual([f('i_last_1')]); + }); + }); + + describe('Aliases', function () { + test('Aliases', function () { + alias.loadAliases( + { + test_index1: { + aliases: { + alias1: {}, + }, + }, + test_index2: { + aliases: { + alias2: { + filter: { + term: { + FIELD: 'VALUE', + }, + }, + }, + alias1: {}, + }, + }, + }, + mapping + ); + mapping.loadMappings({ + test_index1: { + properties: { + last1: { type: 'string', index_name: 'i_last_1' }, + }, + }, + test_index2: { + properties: { + last1: { type: 'string', index_name: 'i_last_1' }, + }, + }, + }); + + expect(alias.getIndices(true, mapping).sort()).toEqual([ + '_all', + 'alias1', + 'alias2', + 'test_index1', + 'test_index2', + ]); + expect(alias.getIndices(false, mapping).sort()).toEqual(['test_index1', 'test_index2']); + expect(expandAliases(['alias1', 'test_index2']).sort()).toEqual([ + 'test_index1', + 'test_index2', + ]); + expect(expandAliases('alias2')).toEqual('test_index2'); + }); + }); + + describe('Templates', function () { + test('legacy templates, index templates, component templates', function () { + legacyTemplate.loadTemplates({ + test_index1: { order: 0 }, + test_index2: { order: 0 }, + test_index3: { order: 0 }, + }); + + indexTemplate.loadTemplates({ + index_templates: [ + { name: 'test_index1' }, + { name: 'test_index2' }, + { name: 'test_index3' }, + ], + }); + + componentTemplate.loadTemplates({ + component_templates: [ + { name: 'test_index1' }, + { name: 'test_index2' }, + { name: 'test_index3' }, + ], + }); + + const expectedResult = ['test_index1', 'test_index2', 'test_index3']; + + expect(legacyTemplate.getTemplates()).toEqual(expectedResult); + expect(indexTemplate.getTemplates()).toEqual(expectedResult); + expect(componentTemplate.getTemplates()).toEqual(expectedResult); + }); + }); + + describe('Data streams', function () { + test('data streams', function () { + dataStream.loadDataStreams({ + data_streams: [{ name: 'test_index1' }, { name: 'test_index2' }, { name: 'test_index3' }], + }); + + const expectedResult = ['test_index1', 'test_index2', 'test_index3']; + expect(dataStream.getDataStreams()).toEqual(expectedResult); + }); + }); +}); diff --git a/src/plugins/console/public/lib/autocomplete_entities/base_template.ts b/src/plugins/console/public/lib/autocomplete_entities/base_template.ts new file mode 100644 index 0000000000000..2304150d94e77 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/base_template.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export abstract class BaseTemplate { + protected templates: string[] = []; + + public abstract loadTemplates(templates: T): void; + + public getTemplates = (): string[] => { + return [...this.templates]; + }; + + public clearTemplates = () => { + this.templates = []; + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/component_template.ts b/src/plugins/console/public/lib/autocomplete_entities/component_template.ts new file mode 100644 index 0000000000000..b6699438de011 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/component_template.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ClusterGetComponentTemplateResponse } from '@elastic/elasticsearch/lib/api/types'; +import { BaseTemplate } from './base_template'; + +export class ComponentTemplate extends BaseTemplate { + loadTemplates = (templates: ClusterGetComponentTemplateResponse) => { + this.templates = (templates.component_templates ?? []).map(({ name }) => name).sort(); + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/data_stream.ts b/src/plugins/console/public/lib/autocomplete_entities/data_stream.ts new file mode 100644 index 0000000000000..2b65d086aeb13 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/data_stream.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndicesGetDataStreamResponse } from '@elastic/elasticsearch/lib/api/types'; + +export class DataStream { + private dataStreams: string[] = []; + + getDataStreams = (): string[] => { + return [...this.dataStreams]; + }; + + loadDataStreams = (dataStreams: IndicesGetDataStreamResponse) => { + this.dataStreams = (dataStreams.data_streams ?? []).map(({ name }) => name).sort(); + }; + + clearDataStreams = () => { + this.dataStreams = []; + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/expand_aliases.ts b/src/plugins/console/public/lib/autocomplete_entities/expand_aliases.ts new file mode 100644 index 0000000000000..27f8211f533a9 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/expand_aliases.ts @@ -0,0 +1,41 @@ +/* + * 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 { getAutocompleteInfo } from '../../services'; + +export function expandAliases(indicesOrAliases: string | string[]) { + // takes a list of indices or aliases or a string which may be either and returns a list of indices + // returns a list for multiple values or a string for a single. + const perAliasIndexes = getAutocompleteInfo().alias.perAliasIndexes; + if (!indicesOrAliases) { + return indicesOrAliases; + } + + if (typeof indicesOrAliases === 'string') { + indicesOrAliases = [indicesOrAliases]; + } + + indicesOrAliases = indicesOrAliases.flatMap((iOrA) => { + if (perAliasIndexes[iOrA]) { + return perAliasIndexes[iOrA]; + } + return [iOrA]; + }); + + let ret = ([] as string[]).concat.apply([], indicesOrAliases); + ret.sort(); + ret = ret.reduce((result, value, index, array) => { + const last = array[index - 1]; + if (last !== value) { + result.push(value); + } + return result; + }, [] as string[]); + + return ret.length > 1 ? ret : ret[0]; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/index.ts b/src/plugins/console/public/lib/autocomplete_entities/index.ts new file mode 100644 index 0000000000000..e523ce42ddc79 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { Alias } from './alias'; +export { Mapping } from './mapping'; +export { DataStream } from './data_stream'; +export { LegacyTemplate } from './legacy'; +export { IndexTemplate } from './index_template'; +export { ComponentTemplate } from './component_template'; +export { getTypes } from './type'; diff --git a/src/plugins/console/public/lib/autocomplete_entities/index_template.ts b/src/plugins/console/public/lib/autocomplete_entities/index_template.ts new file mode 100644 index 0000000000000..ab3081841f0d4 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/index_template.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndicesGetIndexTemplateResponse } from '@elastic/elasticsearch/lib/api/types'; +import { BaseTemplate } from './base_template'; + +export class IndexTemplate extends BaseTemplate { + loadTemplates = (templates: IndicesGetIndexTemplateResponse) => { + this.templates = (templates.index_templates ?? []).map(({ name }) => name).sort(); + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/legacy/index.ts b/src/plugins/console/public/lib/autocomplete_entities/legacy/index.ts new file mode 100644 index 0000000000000..9f0c06ad6a518 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/legacy/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { LegacyTemplate } from './legacy_template'; diff --git a/src/plugins/console/public/lib/autocomplete_entities/legacy/legacy_template.ts b/src/plugins/console/public/lib/autocomplete_entities/legacy/legacy_template.ts new file mode 100644 index 0000000000000..73d17745702a8 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/legacy/legacy_template.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndicesGetTemplateResponse } from '@elastic/elasticsearch/lib/api/types'; +import { BaseTemplate } from '../base_template'; + +export class LegacyTemplate extends BaseTemplate { + loadTemplates = (templates: IndicesGetTemplateResponse) => { + this.templates = Object.keys(templates).sort(); + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/mapping.ts b/src/plugins/console/public/lib/autocomplete_entities/mapping.ts new file mode 100644 index 0000000000000..ddb6905fa6e53 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/mapping.ts @@ -0,0 +1,164 @@ +/* + * 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 _ from 'lodash'; +import type { IndicesGetMappingResponse } from '@elastic/elasticsearch/lib/api/types'; +import { expandAliases } from './expand_aliases'; +import type { Field, FieldMapping } from './types'; + +function getFieldNamesFromProperties(properties: Record = {}) { + const fieldList = Object.entries(properties).flatMap(([fieldName, fieldMapping]) => { + return getFieldNamesFromFieldMapping(fieldName, fieldMapping); + }); + + // deduping + return _.uniqBy(fieldList, function (f) { + return f.name + ':' + f.type; + }); +} + +function getFieldNamesFromFieldMapping( + fieldName: string, + fieldMapping: FieldMapping +): Array<{ name: string; type: string | undefined }> { + if (fieldMapping.enabled === false) { + return []; + } + let nestedFields; + + function applyPathSettings(nestedFieldNames: Array<{ name: string; type: string | undefined }>) { + const pathType = fieldMapping.path || 'full'; + if (pathType === 'full') { + return nestedFieldNames.map((f) => { + f.name = fieldName + '.' + f.name; + return f; + }); + } + return nestedFieldNames; + } + + if (fieldMapping.properties) { + // derived object type + nestedFields = getFieldNamesFromProperties(fieldMapping.properties); + return applyPathSettings(nestedFields); + } + + const fieldType = fieldMapping.type; + + const ret = { name: fieldName, type: fieldType }; + + if (fieldMapping.index_name) { + ret.name = fieldMapping.index_name; + } + + if (fieldMapping.fields) { + nestedFields = Object.entries(fieldMapping.fields).flatMap(([name, mapping]) => { + return getFieldNamesFromFieldMapping(name, mapping); + }); + nestedFields = applyPathSettings(nestedFields); + nestedFields.unshift(ret); + return nestedFields; + } + + return [ret]; +} + +export interface BaseMapping { + perIndexTypes: Record; + getMappings(indices: string | string[], types?: string | string[]): Field[]; + loadMappings(mappings: IndicesGetMappingResponse): void; + clearMappings(): void; +} + +export class Mapping implements BaseMapping { + public perIndexTypes: Record = {}; + + getMappings = (indices: string | string[], types?: string | string[]) => { + // get fields for indices and types. Both can be a list, a string or null (meaning all). + let ret: Field[] = []; + indices = expandAliases(indices); + + if (typeof indices === 'string') { + const typeDict = this.perIndexTypes[indices] as Record; + if (!typeDict) { + return []; + } + + if (typeof types === 'string') { + const f = typeDict[types]; + if (Array.isArray(f)) { + ret = f; + } + } else { + // filter what we need + Object.entries(typeDict).forEach(([type, fields]) => { + if (!types || types.length === 0 || types.includes(type)) { + ret.push(fields as Field); + } + }); + + ret = ([] as Field[]).concat.apply([], ret); + } + } else { + // multi index mode. + Object.keys(this.perIndexTypes).forEach((index) => { + if (!indices || indices.length === 0 || indices.includes(index)) { + ret.push(this.getMappings(index, types) as unknown as Field); + } + }); + + ret = ([] as Field[]).concat.apply([], ret); + } + + return _.uniqBy(ret, function (f) { + return f.name + ':' + f.type; + }); + }; + + loadMappings = (mappings: IndicesGetMappingResponse) => { + const maxMappingSize = Object.keys(mappings).length > 10 * 1024 * 1024; + let mappingsResponse; + if (maxMappingSize) { + // eslint-disable-next-line no-console + console.warn( + `Mapping size is larger than 10MB (${ + Object.keys(mappings).length / 1024 / 1024 + } MB). Ignoring...` + ); + mappingsResponse = {}; + } else { + mappingsResponse = mappings; + } + + this.perIndexTypes = {}; + + Object.entries(mappingsResponse).forEach(([index, indexMapping]) => { + const normalizedIndexMappings: Record = {}; + let transformedMapping: Record = indexMapping; + + // Migrate 1.0.0 mappings. This format has changed, so we need to extract the underlying mapping. + if (indexMapping.mappings && Object.keys(indexMapping).length === 1) { + transformedMapping = indexMapping.mappings; + } + + Object.entries(transformedMapping).forEach(([typeName, typeMapping]) => { + if (typeName === 'properties') { + const fieldList = getFieldNamesFromProperties(typeMapping); + normalizedIndexMappings[typeName] = fieldList; + } else { + normalizedIndexMappings[typeName] = []; + } + }); + this.perIndexTypes[index] = normalizedIndexMappings; + }); + }; + + clearMappings = () => { + this.perIndexTypes = {}; + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/type.ts b/src/plugins/console/public/lib/autocomplete_entities/type.ts new file mode 100644 index 0000000000000..5f1d8b1308d77 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/type.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 _ from 'lodash'; +import { getAutocompleteInfo } from '../../services'; +import { expandAliases } from './expand_aliases'; + +export function getTypes(indices: string | string[]) { + let ret: string[] = []; + const perIndexTypes = getAutocompleteInfo().mapping.perIndexTypes; + indices = expandAliases(indices); + if (typeof indices === 'string') { + const typeDict = perIndexTypes[indices]; + if (!typeDict) { + return []; + } + + // filter what we need + if (Array.isArray(typeDict)) { + typeDict.forEach((type) => { + ret.push(type); + }); + } else if (typeof typeDict === 'object') { + Object.keys(typeDict).forEach((type) => { + ret.push(type); + }); + } + } else { + // multi index mode. + Object.keys(perIndexTypes).forEach((index) => { + if (!indices || indices.includes(index)) { + ret.push(getTypes(index) as unknown as string); + } + }); + ret = ([] as string[]).concat.apply([], ret); + } + + return _.uniq(ret); +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/types.ts b/src/plugins/console/public/lib/autocomplete_entities/types.ts new file mode 100644 index 0000000000000..e49f8f106f37a --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/types.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ClusterGetComponentTemplateResponse, + IndicesGetAliasResponse, + IndicesGetDataStreamResponse, + IndicesGetIndexTemplateResponse, + IndicesGetMappingResponse, + IndicesGetTemplateResponse, +} from '@elastic/elasticsearch/lib/api/types'; + +export interface Field { + name: string; + type: string; +} + +export interface FieldMapping { + enabled?: boolean; + path?: string; + properties?: Record; + type?: string; + index_name?: string; + fields?: FieldMapping[]; +} + +export interface MappingsApiResponse { + mappings: IndicesGetMappingResponse; + aliases: IndicesGetAliasResponse; + dataStreams: IndicesGetDataStreamResponse; + legacyTemplates: IndicesGetTemplateResponse; + indexTemplates: IndicesGetIndexTemplateResponse; + componentTemplates: ClusterGetComponentTemplateResponse; +} diff --git a/src/plugins/console/public/lib/kb/kb.test.js b/src/plugins/console/public/lib/kb/kb.test.js index ff0ddba37281a..8b1af7103c40b 100644 --- a/src/plugins/console/public/lib/kb/kb.test.js +++ b/src/plugins/console/public/lib/kb/kb.test.js @@ -11,16 +11,20 @@ import { populateContext } from '../autocomplete/engine'; import '../../application/models/sense_editor/sense_editor.test.mocks'; import * as kb from '.'; -import * as mappings from '../mappings/mappings'; +import { AutocompleteInfo, setAutocompleteInfo } from '../../services'; describe('Knowledge base', () => { + let autocompleteInfo; beforeEach(() => { - mappings.clear(); kb.setActiveApi(kb._test.loadApisFromJson({})); + autocompleteInfo = new AutocompleteInfo(); + setAutocompleteInfo(autocompleteInfo); + autocompleteInfo.mapping.clearMappings(); }); afterEach(() => { - mappings.clear(); kb.setActiveApi(kb._test.loadApisFromJson({})); + autocompleteInfo = null; + setAutocompleteInfo(null); }); const MAPPING = { @@ -122,7 +126,7 @@ describe('Knowledge base', () => { kb.setActiveApi(testApi); - mappings.loadMappings(MAPPING); + autocompleteInfo.mapping.loadMappings(MAPPING); testUrlContext(tokenPath, otherTokenValues, expectedContext); }); } @@ -165,7 +169,7 @@ describe('Knowledge base', () => { ); kb.setActiveApi(testApi); - mappings.loadMappings(MAPPING); + autocompleteInfo.mapping.loadMappings(MAPPING); testUrlContext(tokenPath, otherTokenValues, expectedContext); }); diff --git a/src/plugins/console/public/lib/mappings/mapping.test.js b/src/plugins/console/public/lib/mappings/mapping.test.js deleted file mode 100644 index e2def74e892cc..0000000000000 --- a/src/plugins/console/public/lib/mappings/mapping.test.js +++ /dev/null @@ -1,278 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 '../../application/models/sense_editor/sense_editor.test.mocks'; -import * as mappings from './mappings'; - -describe('Mappings', () => { - beforeEach(() => { - mappings.clear(); - }); - afterEach(() => { - mappings.clear(); - }); - - function fc(f1, f2) { - if (f1.name < f2.name) { - return -1; - } - if (f1.name > f2.name) { - return 1; - } - return 0; - } - - function f(name, type) { - return { name: name, type: type || 'string' }; - } - - test('Multi fields 1.0 style', function () { - mappings.loadMappings({ - index: { - properties: { - first_name: { - type: 'string', - index: 'analyzed', - path: 'just_name', - fields: { - any_name: { type: 'string', index: 'analyzed' }, - }, - }, - last_name: { - type: 'string', - index: 'no', - fields: { - raw: { type: 'string', index: 'analyzed' }, - }, - }, - }, - }, - }); - - expect(mappings.getFields('index').sort(fc)).toEqual([ - f('any_name', 'string'), - f('first_name', 'string'), - f('last_name', 'string'), - f('last_name.raw', 'string'), - ]); - }); - - test('Simple fields', function () { - mappings.loadMappings({ - index: { - properties: { - str: { - type: 'string', - }, - number: { - type: 'int', - }, - }, - }, - }); - - expect(mappings.getFields('index').sort(fc)).toEqual([f('number', 'int'), f('str', 'string')]); - }); - - test('Simple fields - 1.0 style', function () { - mappings.loadMappings({ - index: { - mappings: { - properties: { - str: { - type: 'string', - }, - number: { - type: 'int', - }, - }, - }, - }, - }); - - expect(mappings.getFields('index').sort(fc)).toEqual([f('number', 'int'), f('str', 'string')]); - }); - - test('Nested fields', function () { - mappings.loadMappings({ - index: { - properties: { - person: { - type: 'object', - properties: { - name: { - properties: { - first_name: { type: 'string' }, - last_name: { type: 'string' }, - }, - }, - sid: { type: 'string', index: 'not_analyzed' }, - }, - }, - message: { type: 'string' }, - }, - }, - }); - - expect(mappings.getFields('index', []).sort(fc)).toEqual([ - f('message'), - f('person.name.first_name'), - f('person.name.last_name'), - f('person.sid'), - ]); - }); - - test('Enabled fields', function () { - mappings.loadMappings({ - index: { - properties: { - person: { - type: 'object', - properties: { - name: { - type: 'object', - enabled: false, - }, - sid: { type: 'string', index: 'not_analyzed' }, - }, - }, - message: { type: 'string' }, - }, - }, - }); - - expect(mappings.getFields('index', []).sort(fc)).toEqual([f('message'), f('person.sid')]); - }); - - test('Path tests', function () { - mappings.loadMappings({ - index: { - properties: { - name1: { - type: 'object', - path: 'just_name', - properties: { - first1: { type: 'string' }, - last1: { type: 'string', index_name: 'i_last_1' }, - }, - }, - name2: { - type: 'object', - path: 'full', - properties: { - first2: { type: 'string' }, - last2: { type: 'string', index_name: 'i_last_2' }, - }, - }, - }, - }, - }); - - expect(mappings.getFields().sort(fc)).toEqual([ - f('first1'), - f('i_last_1'), - f('name2.first2'), - f('name2.i_last_2'), - ]); - }); - - test('Use index_name tests', function () { - mappings.loadMappings({ - index: { - properties: { - last1: { type: 'string', index_name: 'i_last_1' }, - }, - }, - }); - - expect(mappings.getFields().sort(fc)).toEqual([f('i_last_1')]); - }); - - test('Aliases', function () { - mappings.loadAliases({ - test_index1: { - aliases: { - alias1: {}, - }, - }, - test_index2: { - aliases: { - alias2: { - filter: { - term: { - FIELD: 'VALUE', - }, - }, - }, - alias1: {}, - }, - }, - }); - mappings.loadMappings({ - test_index1: { - properties: { - last1: { type: 'string', index_name: 'i_last_1' }, - }, - }, - test_index2: { - properties: { - last1: { type: 'string', index_name: 'i_last_1' }, - }, - }, - }); - - expect(mappings.getIndices().sort()).toEqual([ - '_all', - 'alias1', - 'alias2', - 'test_index1', - 'test_index2', - ]); - expect(mappings.getIndices(false).sort()).toEqual(['test_index1', 'test_index2']); - expect(mappings.expandAliases(['alias1', 'test_index2']).sort()).toEqual([ - 'test_index1', - 'test_index2', - ]); - expect(mappings.expandAliases('alias2')).toEqual('test_index2'); - }); - - test('Templates', function () { - mappings.loadLegacyTemplates({ - test_index1: { order: 0 }, - test_index2: { order: 0 }, - test_index3: { order: 0 }, - }); - - mappings.loadIndexTemplates({ - index_templates: [{ name: 'test_index1' }, { name: 'test_index2' }, { name: 'test_index3' }], - }); - - mappings.loadComponentTemplates({ - component_templates: [ - { name: 'test_index1' }, - { name: 'test_index2' }, - { name: 'test_index3' }, - ], - }); - - const expectedResult = ['test_index1', 'test_index2', 'test_index3']; - - expect(mappings.getLegacyTemplates()).toEqual(expectedResult); - expect(mappings.getIndexTemplates()).toEqual(expectedResult); - expect(mappings.getComponentTemplates()).toEqual(expectedResult); - }); - - test('Data streams', function () { - mappings.loadDataStreams({ - data_streams: [{ name: 'test_index1' }, { name: 'test_index2' }, { name: 'test_index3' }], - }); - - const expectedResult = ['test_index1', 'test_index2', 'test_index3']; - expect(mappings.getDataStreams()).toEqual(expectedResult); - }); -}); diff --git a/src/plugins/console/public/lib/mappings/mappings.js b/src/plugins/console/public/lib/mappings/mappings.js deleted file mode 100644 index 289bfb9aa17bb..0000000000000 --- a/src/plugins/console/public/lib/mappings/mappings.js +++ /dev/null @@ -1,410 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 _ from 'lodash'; -import * as es from '../es/es'; - -let pollTimeoutId; - -let perIndexTypes = {}; -let perAliasIndexes = {}; -let legacyTemplates = []; -let indexTemplates = []; -let componentTemplates = []; -let dataStreams = []; - -export function expandAliases(indicesOrAliases) { - // takes a list of indices or aliases or a string which may be either and returns a list of indices - // returns a list for multiple values or a string for a single. - - if (!indicesOrAliases) { - return indicesOrAliases; - } - - if (typeof indicesOrAliases === 'string') { - indicesOrAliases = [indicesOrAliases]; - } - - indicesOrAliases = indicesOrAliases.map((iOrA) => { - if (perAliasIndexes[iOrA]) { - return perAliasIndexes[iOrA]; - } - return [iOrA]; - }); - let ret = [].concat.apply([], indicesOrAliases); - ret.sort(); - ret = ret.reduce((result, value, index, array) => { - const last = array[index - 1]; - if (last !== value) { - result.push(value); - } - return result; - }, []); - - return ret.length > 1 ? ret : ret[0]; -} - -export function getLegacyTemplates() { - return [...legacyTemplates]; -} - -export function getIndexTemplates() { - return [...indexTemplates]; -} - -export function getComponentTemplates() { - return [...componentTemplates]; -} - -export function getDataStreams() { - return [...dataStreams]; -} - -export function getFields(indices, types) { - // get fields for indices and types. Both can be a list, a string or null (meaning all). - let ret = []; - indices = expandAliases(indices); - - if (typeof indices === 'string') { - const typeDict = perIndexTypes[indices]; - if (!typeDict) { - return []; - } - - if (typeof types === 'string') { - const f = typeDict[types]; - ret = f ? f : []; - } else { - // filter what we need - Object.entries(typeDict).forEach(([type, fields]) => { - if (!types || types.length === 0 || types.includes(type)) { - ret.push(fields); - } - }); - - ret = [].concat.apply([], ret); - } - } else { - // multi index mode. - Object.keys(perIndexTypes).forEach((index) => { - if (!indices || indices.length === 0 || indices.includes(index)) { - ret.push(getFields(index, types)); - } - }); - - ret = [].concat.apply([], ret); - } - - return _.uniqBy(ret, function (f) { - return f.name + ':' + f.type; - }); -} - -export function getTypes(indices) { - let ret = []; - indices = expandAliases(indices); - if (typeof indices === 'string') { - const typeDict = perIndexTypes[indices]; - if (!typeDict) { - return []; - } - - // filter what we need - if (Array.isArray(typeDict)) { - typeDict.forEach((type) => { - ret.push(type); - }); - } else if (typeof typeDict === 'object') { - Object.keys(typeDict).forEach((type) => { - ret.push(type); - }); - } - } else { - // multi index mode. - Object.keys(perIndexTypes).forEach((index) => { - if (!indices || indices.includes(index)) { - ret.push(getTypes(index)); - } - }); - ret = [].concat.apply([], ret); - } - - return _.uniq(ret); -} - -export function getIndices(includeAliases) { - const ret = []; - Object.keys(perIndexTypes).forEach((index) => { - // ignore .ds* indices in the suggested indices list. - if (!index.startsWith('.ds')) { - ret.push(index); - } - }); - - if (typeof includeAliases === 'undefined' ? true : includeAliases) { - Object.keys(perAliasIndexes).forEach((alias) => { - ret.push(alias); - }); - } - return ret; -} - -function getFieldNamesFromFieldMapping(fieldName, fieldMapping) { - if (fieldMapping.enabled === false) { - return []; - } - let nestedFields; - - function applyPathSettings(nestedFieldNames) { - const pathType = fieldMapping.path || 'full'; - if (pathType === 'full') { - return nestedFieldNames.map((f) => { - f.name = fieldName + '.' + f.name; - return f; - }); - } - return nestedFieldNames; - } - - if (fieldMapping.properties) { - // derived object type - nestedFields = getFieldNamesFromProperties(fieldMapping.properties); - return applyPathSettings(nestedFields); - } - - const fieldType = fieldMapping.type; - - const ret = { name: fieldName, type: fieldType }; - - if (fieldMapping.index_name) { - ret.name = fieldMapping.index_name; - } - - if (fieldMapping.fields) { - nestedFields = Object.entries(fieldMapping.fields).flatMap(([fieldName, fieldMapping]) => { - return getFieldNamesFromFieldMapping(fieldName, fieldMapping); - }); - nestedFields = applyPathSettings(nestedFields); - nestedFields.unshift(ret); - return nestedFields; - } - - return [ret]; -} - -function getFieldNamesFromProperties(properties = {}) { - const fieldList = Object.entries(properties).flatMap(([fieldName, fieldMapping]) => { - return getFieldNamesFromFieldMapping(fieldName, fieldMapping); - }); - - // deduping - return _.uniqBy(fieldList, function (f) { - return f.name + ':' + f.type; - }); -} - -export function loadLegacyTemplates(templatesObject = {}) { - legacyTemplates = Object.keys(templatesObject); -} - -export function loadIndexTemplates(data) { - indexTemplates = (data.index_templates ?? []).map(({ name }) => name); -} - -export function loadComponentTemplates(data) { - componentTemplates = (data.component_templates ?? []).map(({ name }) => name); -} - -export function loadDataStreams(data) { - dataStreams = (data.data_streams ?? []).map(({ name }) => name); -} - -export function loadMappings(mappings) { - perIndexTypes = {}; - - Object.entries(mappings).forEach(([index, indexMapping]) => { - const normalizedIndexMappings = {}; - - // Migrate 1.0.0 mappings. This format has changed, so we need to extract the underlying mapping. - if (indexMapping.mappings && Object.keys(indexMapping).length === 1) { - indexMapping = indexMapping.mappings; - } - - Object.entries(indexMapping).forEach(([typeName, typeMapping]) => { - if (typeName === 'properties') { - const fieldList = getFieldNamesFromProperties(typeMapping); - normalizedIndexMappings[typeName] = fieldList; - } else { - normalizedIndexMappings[typeName] = []; - } - }); - perIndexTypes[index] = normalizedIndexMappings; - }); -} - -export function loadAliases(aliases) { - perAliasIndexes = {}; - Object.entries(aliases).forEach(([index, omdexAliases]) => { - // verify we have an index defined. useful when mapping loading is disabled - perIndexTypes[index] = perIndexTypes[index] || {}; - - Object.keys(omdexAliases.aliases || {}).forEach((alias) => { - if (alias === index) { - return; - } // alias which is identical to index means no index. - let curAliases = perAliasIndexes[alias]; - if (!curAliases) { - curAliases = []; - perAliasIndexes[alias] = curAliases; - } - curAliases.push(index); - }); - }); - - perAliasIndexes._all = getIndices(false); -} - -export function clear() { - perIndexTypes = {}; - perAliasIndexes = {}; - legacyTemplates = []; - indexTemplates = []; - componentTemplates = []; -} - -function retrieveSettings(http, settingsKey, settingsToRetrieve) { - const settingKeyToPathMap = { - fields: '_mapping', - indices: '_aliases', - legacyTemplates: '_template', - indexTemplates: '_index_template', - componentTemplates: '_component_template', - dataStreams: '_data_stream', - }; - // Fetch autocomplete info if setting is set to true, and if user has made changes. - if (settingsToRetrieve[settingsKey] === true) { - // Use pretty=false in these request in order to compress the response by removing whitespace - const path = `${settingKeyToPathMap[settingsKey]}?pretty=false`; - const method = 'GET'; - const asSystemRequest = true; - const withProductOrigin = true; - - return es.send({ http, method, path, asSystemRequest, withProductOrigin }); - } else { - if (settingsToRetrieve[settingsKey] === false) { - // If the user doesn't want autocomplete suggestions, then clear any that exist - return Promise.resolve({}); - // return settingsPromise.resolveWith(this, [{}]); - } else { - // If the user doesn't want autocomplete suggestions, then clear any that exist - return Promise.resolve(); - } - } -} - -// Retrieve all selected settings by default. -// TODO: We should refactor this to be easier to consume. Ideally this function should retrieve -// whatever settings are specified, otherwise just use the saved settings. This requires changing -// the behavior to not *clear* whatever settings have been unselected, but it's hard to tell if -// this is possible without altering the autocomplete behavior. These are the scenarios we need to -// support: -// 1. Manual refresh. Specify what we want. Fetch specified, leave unspecified alone. -// 2. Changed selection and saved: Specify what we want. Fetch changed and selected, leave -// unchanged alone (both selected and unselected). -// 3. Poll: Use saved. Fetch selected. Ignore unselected. - -export function clearSubscriptions() { - if (pollTimeoutId) { - clearTimeout(pollTimeoutId); - } -} - -const retrieveMappings = async (http, settingsToRetrieve) => { - const mappings = await retrieveSettings(http, 'fields', settingsToRetrieve); - - if (mappings) { - const maxMappingSize = Object.keys(mappings).length > 10 * 1024 * 1024; - let mappingsResponse; - if (maxMappingSize) { - console.warn( - `Mapping size is larger than 10MB (${ - Object.keys(mappings).length / 1024 / 1024 - } MB). Ignoring...` - ); - mappingsResponse = '{}'; - } else { - mappingsResponse = mappings; - } - loadMappings(mappingsResponse); - } -}; - -const retrieveAliases = async (http, settingsToRetrieve) => { - const aliases = await retrieveSettings(http, 'indices', settingsToRetrieve); - - if (aliases) { - loadAliases(aliases); - } -}; - -const retrieveTemplates = async (http, settingsToRetrieve) => { - const legacyTemplates = await retrieveSettings(http, 'legacyTemplates', settingsToRetrieve); - const indexTemplates = await retrieveSettings(http, 'indexTemplates', settingsToRetrieve); - const componentTemplates = await retrieveSettings(http, 'componentTemplates', settingsToRetrieve); - - if (legacyTemplates) { - loadLegacyTemplates(legacyTemplates); - } - - if (indexTemplates) { - loadIndexTemplates(indexTemplates); - } - - if (componentTemplates) { - loadComponentTemplates(componentTemplates); - } -}; - -const retrieveDataStreams = async (http, settingsToRetrieve) => { - const dataStreams = await retrieveSettings(http, 'dataStreams', settingsToRetrieve); - - if (dataStreams) { - loadDataStreams(dataStreams); - } -}; -/** - * - * @param settings Settings A way to retrieve the current settings - * @param settingsToRetrieve any - */ -export function retrieveAutoCompleteInfo(http, settings, settingsToRetrieve) { - clearSubscriptions(); - - const templatesSettingToRetrieve = { - ...settingsToRetrieve, - legacyTemplates: settingsToRetrieve.templates, - indexTemplates: settingsToRetrieve.templates, - componentTemplates: settingsToRetrieve.templates, - }; - - Promise.allSettled([ - retrieveMappings(http, settingsToRetrieve), - retrieveAliases(http, settingsToRetrieve), - retrieveTemplates(http, templatesSettingToRetrieve), - retrieveDataStreams(http, settingsToRetrieve), - ]).then(() => { - // Schedule next request. - pollTimeoutId = setTimeout(() => { - // This looks strange/inefficient, but it ensures correct behavior because we don't want to send - // a scheduled request if the user turns off polling. - if (settings.getPolling()) { - retrieveAutoCompleteInfo(http, settings, settings.getAutocomplete()); - } - }, settings.getPollInterval()); - }); -} diff --git a/src/plugins/console/public/plugin.ts b/src/plugins/console/public/plugin.ts index e6a4d7fff61b0..33ee5446dc268 100644 --- a/src/plugins/console/public/plugin.ts +++ b/src/plugins/console/public/plugin.ts @@ -15,8 +15,10 @@ import { ConsolePluginSetup, ConsoleUILocatorParams, } from './types'; +import { AutocompleteInfo, setAutocompleteInfo } from './services'; export class ConsoleUIPlugin implements Plugin { + private readonly autocompleteInfo = new AutocompleteInfo(); constructor(private ctx: PluginInitializerContext) {} public setup( @@ -27,6 +29,9 @@ export class ConsoleUIPlugin implements Plugin(); + this.autocompleteInfo.setup(http); + setAutocompleteInfo(this.autocompleteInfo); + if (isConsoleUiEnabled) { if (home) { home.featureCatalogue.register({ @@ -70,6 +75,7 @@ export class ConsoleUIPlugin implements Plugin | undefined; + + public setup(http: HttpSetup) { + this.http = http; + } + + public getEntityProvider( + type: string, + context: { indices: string[]; types: string[] } = { indices: [], types: [] } + ) { + switch (type) { + case 'indices': + const includeAliases = true; + const collaborator = this.mapping; + return () => this.alias.getIndices(includeAliases, collaborator); + case 'fields': + return this.mapping.getMappings(context.indices, context.types); + case 'indexTemplates': + return () => this.indexTemplate.getTemplates(); + case 'componentTemplates': + return () => this.componentTemplate.getTemplates(); + case 'legacyTemplates': + return () => this.legacyTemplate.getTemplates(); + case 'dataStreams': + return () => this.dataStream.getDataStreams(); + default: + throw new Error(`Unsupported type: ${type}`); + } + } + + public retrieve(settings: Settings, settingsToRetrieve: DevToolsSettings['autocomplete']) { + this.clearSubscriptions(); + this.http + .get(`${API_BASE_PATH}/autocomplete_entities`, { + query: { ...settingsToRetrieve }, + }) + .then((data) => { + this.load(data); + // Schedule next request. + this.pollTimeoutId = setTimeout(() => { + // This looks strange/inefficient, but it ensures correct behavior because we don't want to send + // a scheduled request if the user turns off polling. + if (settings.getPolling()) { + this.retrieve(settings, settings.getAutocomplete()); + } + }, settings.getPollInterval()); + }); + } + + public clearSubscriptions() { + if (this.pollTimeoutId) { + clearTimeout(this.pollTimeoutId); + } + } + + private load(data: MappingsApiResponse) { + this.mapping.loadMappings(data.mappings); + const collaborator = this.mapping; + this.alias.loadAliases(data.aliases, collaborator); + this.indexTemplate.loadTemplates(data.indexTemplates); + this.componentTemplate.loadTemplates(data.componentTemplates); + this.legacyTemplate.loadTemplates(data.legacyTemplates); + this.dataStream.loadDataStreams(data.dataStreams); + } + + public clear() { + this.alias.clearAliases(); + this.mapping.clearMappings(); + this.dataStream.clearDataStreams(); + this.legacyTemplate.clearTemplates(); + this.indexTemplate.clearTemplates(); + this.componentTemplate.clearTemplates(); + } +} + +export const [getAutocompleteInfo, setAutocompleteInfo] = + createGetterSetter('AutocompleteInfo'); diff --git a/src/plugins/console/public/services/index.ts b/src/plugins/console/public/services/index.ts index c37c9d9359a16..2447ab1438ba4 100644 --- a/src/plugins/console/public/services/index.ts +++ b/src/plugins/console/public/services/index.ts @@ -10,3 +10,4 @@ export { createHistory, History } from './history'; export { createStorage, Storage, StorageKeys } from './storage'; export type { DevToolsSettings } from './settings'; export { createSettings, Settings, DEFAULT_SETTINGS } from './settings'; +export { AutocompleteInfo, getAutocompleteInfo, setAutocompleteInfo } from './autocomplete'; diff --git a/src/plugins/console/server/plugin.ts b/src/plugins/console/server/plugin.ts index c1ae53bbaabc6..2ab87d4e9fcc5 100644 --- a/src/plugins/console/server/plugin.ts +++ b/src/plugins/console/server/plugin.ts @@ -16,6 +16,7 @@ import { ConsoleConfig, ConsoleConfig7x } from './config'; import { registerRoutes } from './routes'; import { ESConfigForProxy, ConsoleSetup, ConsoleStart } from './types'; +import { handleEsError } from './shared_imports'; export class ConsoleServerPlugin implements Plugin { log: Logger; @@ -58,6 +59,9 @@ export class ConsoleServerPlugin implements Plugin { esLegacyConfigService: this.esLegacyConfigService, specDefinitionService: this.specDefinitionsService, }, + lib: { + handleEsError, + }, proxy: { readLegacyESConfig: async (): Promise => { const legacyConfig = await this.esLegacyConfigService.readConfig(); diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts new file mode 100644 index 0000000000000..796451b2721f3 --- /dev/null +++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { registerMappingsRoute } from './register_mappings_route'; diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/register_get_route.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/register_get_route.ts new file mode 100644 index 0000000000000..9d5778f0a9b0f --- /dev/null +++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/register_get_route.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { IScopedClusterClient } from '@kbn/core/server'; +import { parse } from 'query-string'; +import type { RouteDependencies } from '../../..'; +import { API_BASE_PATH } from '../../../../../common/constants'; + +interface Settings { + indices: boolean; + fields: boolean; + templates: boolean; + dataStreams: boolean; +} + +async function getMappings(esClient: IScopedClusterClient, settings: Settings) { + if (settings.fields) { + return esClient.asInternalUser.indices.getMapping(); + } + // If the user doesn't want autocomplete suggestions, then clear any that exist. + return Promise.resolve({}); +} + +async function getAliases(esClient: IScopedClusterClient, settings: Settings) { + if (settings.indices) { + return esClient.asInternalUser.indices.getAlias(); + } + // If the user doesn't want autocomplete suggestions, then clear any that exist. + return Promise.resolve({}); +} + +async function getDataStreams(esClient: IScopedClusterClient, settings: Settings) { + if (settings.dataStreams) { + return esClient.asInternalUser.indices.getDataStream(); + } + // If the user doesn't want autocomplete suggestions, then clear any that exist. + return Promise.resolve({}); +} + +async function getTemplates(esClient: IScopedClusterClient, settings: Settings) { + if (settings.templates) { + return Promise.all([ + esClient.asInternalUser.indices.getTemplate(), + esClient.asInternalUser.indices.getIndexTemplate(), + esClient.asInternalUser.cluster.getComponentTemplate(), + ]); + } + // If the user doesn't want autocomplete suggestions, then clear any that exist. + return Promise.resolve([]); +} + +export function registerGetRoute({ router, lib: { handleEsError } }: RouteDependencies) { + router.get( + { + path: `${API_BASE_PATH}/autocomplete_entities`, + validate: false, + }, + async (ctx, request, response) => { + try { + const settings = parse(request.url.search, { parseBooleans: true }) as unknown as Settings; + + // If no settings are provided return 400 + if (Object.keys(settings).length === 0) { + return response.badRequest({ + body: 'Request must contain a query param of autocomplete settings', + }); + } + + const esClient = (await ctx.core).elasticsearch.client; + const mappings = await getMappings(esClient, settings); + const aliases = await getAliases(esClient, settings); + const dataStreams = await getDataStreams(esClient, settings); + const [legacyTemplates = {}, indexTemplates = {}, componentTemplates = {}] = + await getTemplates(esClient, settings); + + return response.ok({ + body: { + mappings, + aliases, + dataStreams, + legacyTemplates, + indexTemplates, + componentTemplates, + }, + }); + } catch (e) { + return handleEsError({ error: e, response }); + } + } + ); +} diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/register_mappings_route.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/register_mappings_route.ts new file mode 100644 index 0000000000000..53d12f69d30e5 --- /dev/null +++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/register_mappings_route.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { RouteDependencies } from '../../..'; +import { registerGetRoute } from './register_get_route'; + +export function registerMappingsRoute(deps: RouteDependencies) { + registerGetRoute(deps); +} diff --git a/src/plugins/console/server/routes/api/console/proxy/mocks.ts b/src/plugins/console/server/routes/api/console/proxy/mocks.ts index cef9ea34a11ca..61f8e510f9735 100644 --- a/src/plugins/console/server/routes/api/console/proxy/mocks.ts +++ b/src/plugins/console/server/routes/api/console/proxy/mocks.ts @@ -17,6 +17,7 @@ import { MAJOR_VERSION } from '../../../../../common/constants'; import { ProxyConfigCollection } from '../../../../lib'; import { RouteDependencies, ProxyDependencies } from '../../..'; import { EsLegacyConfigService, SpecDefinitionsService } from '../../../../services'; +import { handleEsError } from '../../../../shared_imports'; const kibanaVersion = new SemVer(MAJOR_VERSION); @@ -65,5 +66,6 @@ export const getProxyRouteHandlerDeps = ({ : defaultProxyValue, log, kibanaVersion, + lib: { handleEsError }, }; }; diff --git a/src/plugins/console/server/routes/index.ts b/src/plugins/console/server/routes/index.ts index a3263fff2e435..b82b2ffbffa8e 100644 --- a/src/plugins/console/server/routes/index.ts +++ b/src/plugins/console/server/routes/index.ts @@ -12,10 +12,12 @@ import { SemVer } from 'semver'; import { EsLegacyConfigService, SpecDefinitionsService } from '../services'; import { ESConfigForProxy } from '../types'; import { ProxyConfigCollection } from '../lib'; +import { handleEsError } from '../shared_imports'; import { registerEsConfigRoute } from './api/console/es_config'; import { registerProxyRoute } from './api/console/proxy'; import { registerSpecDefinitionsRoute } from './api/console/spec_definitions'; +import { registerMappingsRoute } from './api/console/autocomplete_entities'; export interface ProxyDependencies { readLegacyESConfig: () => Promise; @@ -31,6 +33,9 @@ export interface RouteDependencies { esLegacyConfigService: EsLegacyConfigService; specDefinitionService: SpecDefinitionsService; }; + lib: { + handleEsError: typeof handleEsError; + }; kibanaVersion: SemVer; } @@ -38,4 +43,5 @@ export const registerRoutes = (dependencies: RouteDependencies) => { registerEsConfigRoute(dependencies); registerProxyRoute(dependencies); registerSpecDefinitionsRoute(dependencies); + registerMappingsRoute(dependencies); }; diff --git a/src/plugins/console/server/shared_imports.ts b/src/plugins/console/server/shared_imports.ts new file mode 100644 index 0000000000000..f709280aa013b --- /dev/null +++ b/src/plugins/console/server/shared_imports.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { handleEsError } from '@kbn/es-ui-shared-plugin/server'; diff --git a/test/api_integration/apis/console/autocomplete_entities.ts b/test/api_integration/apis/console/autocomplete_entities.ts new file mode 100644 index 0000000000000..7f74156f379a0 --- /dev/null +++ b/test/api_integration/apis/console/autocomplete_entities.ts @@ -0,0 +1,133 @@ +/* + * 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 expect from '@kbn/expect'; +import type { Response } from 'superagent'; +import type { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + + function utilTest(name: string, query: object, test: (response: Response) => void) { + it(name, async () => { + const response = await supertest.get('/api/console/autocomplete_entities').query(query); + test(response); + }); + } + + describe('/api/console/autocomplete_entities', () => { + utilTest('should not succeed if no settings are provided in query params', {}, (response) => { + const { status } = response; + expect(status).to.be(400); + }); + + utilTest( + 'should return an object with properties of "mappings", "aliases", "dataStreams", "legacyTemplates", "indexTemplates", "componentTemplates"', + { + indices: true, + fields: true, + templates: true, + dataStreams: true, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(Object.keys(body).sort()).to.eql([ + 'aliases', + 'componentTemplates', + 'dataStreams', + 'indexTemplates', + 'legacyTemplates', + 'mappings', + ]); + } + ); + + utilTest( + 'should return empty payload with all settings are set to false', + { + indices: false, + fields: false, + templates: false, + dataStreams: false, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.legacyTemplates).to.eql({}); + expect(body.indexTemplates).to.eql({}); + expect(body.componentTemplates).to.eql({}); + expect(body.aliases).to.eql({}); + expect(body.mappings).to.eql({}); + expect(body.dataStreams).to.eql({}); + } + ); + + utilTest( + 'should return empty templates with templates setting is set to false', + { + indices: true, + fields: true, + templates: false, + dataStreams: true, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.legacyTemplates).to.eql({}); + expect(body.indexTemplates).to.eql({}); + expect(body.componentTemplates).to.eql({}); + } + ); + + utilTest( + 'should return empty data streams with dataStreams setting is set to false', + { + indices: true, + fields: true, + templates: true, + dataStreams: false, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.dataStreams).to.eql({}); + } + ); + + utilTest( + 'should return empty aliases with indices setting is set to false', + { + indices: false, + fields: true, + templates: true, + dataStreams: true, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.aliases).to.eql({}); + } + ); + + utilTest( + 'should return empty mappings with fields setting is set to false', + { + indices: true, + fields: false, + templates: true, + dataStreams: true, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.mappings).to.eql({}); + } + ); + }); +}; diff --git a/test/api_integration/apis/console/index.ts b/test/api_integration/apis/console/index.ts index ad4f8256f97ad..81f6f17f77b87 100644 --- a/test/api_integration/apis/console/index.ts +++ b/test/api_integration/apis/console/index.ts @@ -11,5 +11,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('core', () => { loadTestFile(require.resolve('./proxy_route')); + loadTestFile(require.resolve('./autocomplete_entities')); }); } From fab11ee537a13d009b4c74f28e4f7e316ab3ca26 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 19 May 2022 12:53:15 +0300 Subject: [PATCH 044/150] [ResponseOps]: Sub action connectors framework (backend) (#129307) Co-authored-by: Xavier Mouligneau Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../builtin_action_types/servicenow/types.ts | 1 - x-pack/plugins/actions/server/index.ts | 4 + x-pack/plugins/actions/server/mocks.ts | 3 + x-pack/plugins/actions/server/plugin.ts | 29 +- .../server/sub_action_framework/README.md | 356 ++++++++++++++++++ .../server/sub_action_framework/case.test.ts | 206 ++++++++++ .../server/sub_action_framework/case.ts | 119 ++++++ .../sub_action_framework/executor.test.ts | 198 ++++++++++ .../server/sub_action_framework/executor.ts | 86 +++++ .../server/sub_action_framework/index.ts | 33 ++ .../server/sub_action_framework/mocks.ts | 194 ++++++++++ .../sub_action_framework/register.test.ts | 58 +++ .../server/sub_action_framework/register.ts | 57 +++ .../sub_action_connector.test.ts | 343 +++++++++++++++++ .../sub_action_connector.ts | 175 +++++++++ .../sub_action_framework/translations.ts | 20 + .../server/sub_action_framework/types.ts | 83 ++++ .../sub_action_framework/validators.test.ts | 98 +++++ .../server/sub_action_framework/validators.ts | 38 ++ .../alerting_api_integration/common/config.ts | 2 + .../plugins/alerts/server/action_types.ts | 12 + .../alerts/server/sub_action_connector.ts | 109 ++++++ .../group2/tests/actions/index.ts | 5 + .../actions/sub_action_framework/index.ts | 318 ++++++++++++++++ 24 files changed, 2545 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/actions/server/sub_action_framework/README.md create mode 100644 x-pack/plugins/actions/server/sub_action_framework/case.test.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/case.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/executor.test.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/executor.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/index.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/mocks.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/register.test.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/register.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/translations.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/types.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/validators.test.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/validators.ts create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/sub_action_framework/index.ts diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index ff3a92e935818..63cb0195a14f4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -245,7 +245,6 @@ export interface ImportSetApiResponseError { export type ImportSetApiResponse = ImportSetApiResponseSuccess | ImportSetApiResponseError; export interface GetApplicationInfoResponse { - id: string; name: string; scope: string; version: string; diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index 6b0070af0b022..3b9869be91413 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -55,6 +55,10 @@ export { ACTION_SAVED_OBJECT_TYPE } from './constants/saved_objects'; export const plugin = (initContext: PluginInitializerContext) => new ActionsPlugin(initContext); +export { SubActionConnector } from './sub_action_framework/sub_action_connector'; +export { CaseConnector } from './sub_action_framework/case'; +export type { ServiceParams } from './sub_action_framework/types'; + export const config: PluginConfigDescriptor = { schema: configSchema, exposeToBrowser: { diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index 00cca942fe14b..c6e5d7979c55f 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -24,7 +24,10 @@ const logger = loggingSystemMock.create().get() as jest.Mocked; const createSetupMock = () => { const mock: jest.Mocked = { registerType: jest.fn(), + registerSubActionConnectorType: jest.fn(), isPreconfiguredConnector: jest.fn(), + getSubActionConnectorClass: jest.fn(), + getCaseConnectorClass: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 131563fd3e731..4bbdb26b8e6a1 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -97,6 +97,10 @@ import { isConnectorDeprecated, ConnectorWithOptionalDeprecation, } from './lib/is_conector_deprecated'; +import { createSubActionConnectorFramework } from './sub_action_framework'; +import { IServiceAbstract, SubActionConnectorType } from './sub_action_framework/types'; +import { SubActionConnector } from './sub_action_framework/sub_action_connector'; +import { CaseConnector } from './sub_action_framework/case'; export interface PluginSetupContract { registerType< @@ -107,8 +111,15 @@ export interface PluginSetupContract { >( actionType: ActionType ): void; - + registerSubActionConnectorType< + Config extends ActionTypeConfig = ActionTypeConfig, + Secrets extends ActionTypeSecrets = ActionTypeSecrets + >( + connector: SubActionConnectorType + ): void; isPreconfiguredConnector(connectorId: string): boolean; + getSubActionConnectorClass: () => IServiceAbstract; + getCaseConnectorClass: () => IServiceAbstract; } export interface PluginStartContract { @@ -310,6 +321,12 @@ export class ActionsPlugin implements Plugin(), @@ -342,11 +359,21 @@ export class ActionsPlugin implements Plugin( + connector: SubActionConnectorType + ) => { + subActionFramework.registerConnector(connector); + }, isPreconfiguredConnector: (connectorId: string): boolean => { return !!this.preconfiguredActions.find( (preconfigured) => preconfigured.id === connectorId ); }, + getSubActionConnectorClass: () => SubActionConnector, + getCaseConnectorClass: () => CaseConnector, }; } diff --git a/x-pack/plugins/actions/server/sub_action_framework/README.md b/x-pack/plugins/actions/server/sub_action_framework/README.md new file mode 100644 index 0000000000000..90951692f5457 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/README.md @@ -0,0 +1,356 @@ +# Sub actions framework + +## Summary + +The Kibana actions plugin provides a framework to create executable actions that supports sub actions. That means you can execute different flows (sub actions) when you execute an action. The framework provides tools to aid you to focus only on the business logic of your connector. You can: + +- Register a sub action and map it to a function of your choice. +- Define a schema for the parameters of your sub action. +- Define a response schema for responses from external services. +- Create connectors that are supported by the Cases management system. + +The framework is built on top of the current actions framework and it is not a replacement of it. All practices described on the plugin's main [README](../../README.md#developing-new-action-types) applies to this framework also. + +## Classes + +The framework provides two classes. The `SubActionConnector` class and the `CaseConnector` class. When registering your connector you should provide a class that implements the business logic of your connector. The class must extend one of the two classes provided by the framework. The classes provides utility functions to register sub actions and make requests to external services. + + +If you extend the `SubActionConnector`, you should implement the following abstract methods: +- `getResponseErrorMessage(error: AxiosError): string;` + + +If you extend the `CaseConnector`, you should implement the following abstract methods: + +- `getResponseErrorMessage(error: AxiosError): string;` +- `addComment({ incidentId, comment }): Promise` +- `createIncident(incident): Promise` +- `updateIncident({ incidentId, incident }): Promise` +- `getIncident({ id }): Promise` + +where + +``` +interface ExternalServiceIncidentResponse { + id: string; + title: string; + url: string; + pushedDate: string; +} +``` + +The `CaseConnector` class registers automatically the `pushToService` sub action and implements the corresponding method that is needed by Cases. + + +### Class Diagrams + +```mermaid +classDiagram + SubActionConnector <|-- CaseConnector + + class SubActionConnector{ + -subActions + #config + #secrets + #registerSubAction(subAction) + +getResponseErrorMessage(error)* + +getSubActions() + +registerSubAction(subAction) + } + + class CaseConnector{ + +addComment(comment)* + +createIncident(incident)* + +updateIncident(incidentId, incident)* + +getIncident(incidentId)* + +pushToService(params) + } +``` + +### Examples of extending the classes + +```mermaid +classDiagram + SubActionConnector <|-- CaseConnector + SubActionConnector <|-- Tines + CaseConnector <|-- ServiceNow + + class SubActionConnector{ + -subActions + #config + #secrets + #registerSubAction(subAction) + +getSubActions() + +register(params) + } + + class CaseConnector{ + +addComment(comment)* + +createIncident(incident)* + +updateIncident(incidentId, incident)* + +getIncident(incidentId)* + +pushToService(params) + } + + class ServiceNow{ + +getFields() + +getChoices() + } + + class Tines{ + +getStories() + +getWebooks(storyId) + +runAction(actionId) + } +``` + +## Usage + +This guide assumes that you created a class that extends one of the two classes provided by the framework. + +### Register a sub action + +To register a sub action use the `registerSubAction` method provided by the base classes. It expects the name of the sub action, the name of the method of the class that will be called when the sub action is triggered, and a validation schema for the sub action parameters. Example: + +``` +this.registerSubAction({ name: 'fields', method: 'getFields', schema: schema.object({ incidentId: schema.string() }) }) +``` + +If your method does not accepts any arguments pass `null` to the schema property. Example: + +``` +this.registerSubAction({ name: 'noParams', method: 'noParams', schema: null }) +``` + +### Request to an external service + +To make a request to an external you should use the `request` method provided by the base classes. It accepts all attributes of the [request configuration object](https://github.com/axios/axios#request-config) of axios plus the expected response schema. Example: + +``` +const res = await this.request({ + auth: this.getBasicAuth(), + url: 'https://example/com/api/incident/1', + method: 'get', + responseSchema: schema.object({ id: schema.string(), name: schema.string() }) }, + }); +``` + +The message returned by the `getResponseErrorMessage` method will be used by the framework as an argument to the constructor of the `Error` class. Then the framework will thrown the `error`. + +The request method does the following: + +- Logs the request URL and method for debugging purposes. +- Asserts the URL. +- Normalizes the URL. +- Ensures that the URL is in the allow list. +- Configures proxies. +- Validates the response. + +### Error messages from external services + +Each external service has a different response schema for errors. For that reason, you have to implement the abstract method `getResponseErrorMessage` which returns a string representing the error message of the response. Example: + +``` +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } +``` + +### Remove null or undefined values from data + +There is a possibility that an external service would throw an error for fields with `null` values. For that reason, the base classes provide the `removeNullOrUndefinedFields` utility function to remove or `null` or `undefined` values from an object. Example: + +``` +// Returns { foo: 'foo' } +this.removeNullOrUndefinedFields({ toBeRemoved: null, foo: 'foo' }) +``` + +## Example: Sub action connector + +``` +import { schema, TypeOf } from '@kbn/config-schema'; +import { AxiosError } from 'axios'; +import { SubActionConnector } from './basic'; +import { CaseConnector } from './case'; +import { ExternalServiceIncidentResponse, ServiceParams } from './types'; + +export const TestConfigSchema = schema.object({ url: schema.string() }); +export const TestSecretsSchema = schema.object({ + username: schema.string(), + password: schema.string(), +}); +export type TestConfig = TypeOf; +export type TestSecrets = TypeOf; + +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +export class TestBasicConnector extends SubActionConnector { + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'mySubAction', + method: 'triggerSubAction', + schema: schema.object({ id: schema.string() }), + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async triggerSubAction({ id }: { id: string; }) { + const res = await this.request({ + url, + data, + headers: { 'X-Test-Header': 'test' }, + responseSchema: schema.object({ status: schema.string() }), + }); + + return res; + } +} +``` + +## Example: Case connector + +``` +import { schema, TypeOf } from '@kbn/config-schema'; +import { AxiosError } from 'axios'; +import { SubActionConnector } from './basic'; +import { CaseConnector } from './case'; +import { ExternalServiceIncidentResponse, ServiceParams } from './types'; + +export const TestConfigSchema = schema.object({ url: schema.string() }); +export const TestSecretsSchema = schema.object({ + username: schema.string(), + password: schema.string(), +}); +export type TestConfig = TypeOf; +export type TestSecrets = TypeOf; + +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +export class TestCaseConnector extends CaseConnector { + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'categories', + method: 'getCategories', + schema: null, + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async createIncident(incident: { + incident: Record + }): Promise { + const res = await this.request({ + method: 'post', + url: 'https://example.com/api/incident', + data: { incident }, + responseSchema: schema.object({ id: schema.string(), title: schema.string() }), + }); + + return { + id: res.data.id, + title: res.data.title, + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async addComment({ + incidentId, + comment, + }: { + incidentId: string; + comment: string; + }): Promise { + const res = await this.request({ + url: `https://example.com/api/incident/${incidentId}/comment`, + data: { comment }, + responseSchema: schema.object({ id: schema.string(), title: schema.string() }), + }); + + return { + id: res.data.id, + title: res.data.title, + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async updateIncident({ + incidentId, + incident, + }: { + incidentId: string; + incident: { category: string }; + }): Promise { + const res = await this.request({ + method: 'put', + url: `https://example.com/api/incident/${incidentId}`', + responseSchema: schema.object({ id: schema.string(), title: schema.string() }), + }); + + return { + id: res.data.id, + title: res.data.title, + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async getIncident({ id }: { id: string }): Promise { + const res = await this.request({ + url: 'https://example.com/api/incident/1', + responseSchema: schema.object({ id: schema.string(), title: schema.string() }), + }); + + return { + id: res.data.id, + title: res.data.title, + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async getCategories() { + const res = await this.request({ + url: 'https://example.com/api/categories', + responseSchema: schema.object({ categories: schema.array(schema.string()) }), + }); + + return res; + } +``` + +### Example: Register sub action connector + +The actions framework exports the `registerSubActionConnectorType` to register sub action connectors. Example: + +``` +plugins.actions.registerSubActionConnectorType({ + id: '.test-sub-action-connector', + name: 'Test: Sub action connector', + minimumLicenseRequired: 'platinum' as const, + schema: { config: TestConfigSchema, secrets: TestSecretsSchema }, + Service: TestSubActionConnector, +}); +``` + +You can see a full example in [x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts](../../../../test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts) \ No newline at end of file diff --git a/x-pack/plugins/actions/server/sub_action_framework/case.test.ts b/x-pack/plugins/actions/server/sub_action_framework/case.test.ts new file mode 100644 index 0000000000000..7de7e4f903e0d --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/case.test.ts @@ -0,0 +1,206 @@ +/* + * 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 { loggingSystemMock } from '@kbn/core/server/mocks'; +import { MockedLogger } from '@kbn/logging-mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionsMock } from '../mocks'; +import { TestCaseConnector } from './mocks'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +describe('CaseConnector', () => { + const pushToServiceParams = { externalId: null, comments: [] }; + let logger: MockedLogger; + let services: ReturnType; + let mockedActionsConfig: jest.Mocked; + let service: TestCaseConnector; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + + logger = loggingSystemMock.createLogger(); + services = actionsMock.createServices(); + mockedActionsConfig = actionsConfigMock.create(); + + mockedActionsConfig.getResponseSettings.mockReturnValue({ + maxContentLength: 1000000, + timeout: 360000, + }); + + service = new TestCaseConnector({ + configurationUtilities: mockedActionsConfig, + logger, + connector: { id: 'test-id', type: '.test' }, + config: { url: 'https://example.com' }, + secrets: { username: 'elastic', password: 'changeme' }, + services, + }); + }); + + describe('Sub actions', () => { + it('registers the pushToService sub action correctly', async () => { + const subActions = service.getSubActions(); + expect(subActions.get('pushToService')).toEqual({ + method: 'pushToService', + name: 'pushToService', + schema: expect.anything(), + }); + }); + + it('should validate the schema of pushToService correctly', async () => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect( + subAction?.schema?.validate({ + externalId: 'test', + comments: [{ comment: 'comment', commentId: 'comment-id' }], + }) + ).toEqual({ + externalId: 'test', + comments: [{ comment: 'comment', commentId: 'comment-id' }], + }); + }); + + it('should accept null for externalId', async () => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect(subAction?.schema?.validate({ externalId: null, comments: [] })); + }); + + it.each([[undefined], [1], [false], [{ test: 'hello' }], [['test']], [{ test: 'hello' }]])( + 'should throw if externalId is %p', + async (externalId) => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect(() => subAction?.schema?.validate({ externalId, comments: [] })); + } + ); + + it('should accept null for comments', async () => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect(subAction?.schema?.validate({ externalId: 'test', comments: null })); + }); + + it.each([ + [undefined], + [1], + [false], + [{ test: 'hello' }], + [['test']], + [{ test: 'hello' }], + [{ comment: 'comment', commentId: 'comment-id', foo: 'foo' }], + ])('should throw if comments %p', async (comments) => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect(() => subAction?.schema?.validate({ externalId: 'test', comments })); + }); + + it('should allow any field in the params', async () => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect( + subAction?.schema?.validate({ + externalId: 'test', + comments: [{ comment: 'comment', commentId: 'comment-id' }], + foo: 'foo', + bar: 1, + baz: [{ test: 'hello' }, 1, 'test', false], + isValid: false, + val: null, + }) + ).toEqual({ + externalId: 'test', + comments: [{ comment: 'comment', commentId: 'comment-id' }], + foo: 'foo', + bar: 1, + baz: [{ test: 'hello' }, 1, 'test', false], + isValid: false, + val: null, + }); + }); + }); + + describe('pushToService', () => { + it('should create an incident if externalId is null', async () => { + const res = await service.pushToService(pushToServiceParams); + expect(res).toEqual({ + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }); + }); + + it('should update an incident if externalId is not null', async () => { + const res = await service.pushToService({ ...pushToServiceParams, externalId: 'test-id' }); + expect(res).toEqual({ + id: 'update-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }); + }); + + it('should add comments', async () => { + const res = await service.pushToService({ + ...pushToServiceParams, + comments: [ + { comment: 'comment-1', commentId: 'comment-id-1' }, + { comment: 'comment-2', commentId: 'comment-id-2' }, + ], + }); + + expect(res).toEqual({ + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + comments: [ + { + commentId: 'comment-id-1', + pushedDate: '2022-05-06T09:41:00.401Z', + }, + { + commentId: 'comment-id-2', + pushedDate: '2022-05-06T09:41:00.401Z', + }, + ], + }); + }); + + it.each([[undefined], [null]])('should throw if externalId is %p', async (comments) => { + const res = await service.pushToService({ + ...pushToServiceParams, + // @ts-expect-error + comments, + }); + + expect(res).toEqual({ + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }); + }); + + it('should not add comments if comments are an empty array', async () => { + const res = await service.pushToService({ + ...pushToServiceParams, + comments: [], + }); + + expect(res).toEqual({ + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/case.ts b/x-pack/plugins/actions/server/sub_action_framework/case.ts new file mode 100644 index 0000000000000..49e6586926645 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/case.ts @@ -0,0 +1,119 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { + ExternalServiceIncidentResponse, + PushToServiceParams, + PushToServiceResponse, +} from './types'; +import { SubActionConnector } from './sub_action_connector'; +import { ServiceParams } from './types'; + +export interface CaseConnectorInterface { + addComment: ({ + incidentId, + comment, + }: { + incidentId: string; + comment: string; + }) => Promise; + createIncident: (incident: Record) => Promise; + updateIncident: ({ + incidentId, + incident, + }: { + incidentId: string; + incident: Record; + }) => Promise; + getIncident: ({ id }: { id: string }) => Promise; + pushToService: (params: PushToServiceParams) => Promise; +} + +export abstract class CaseConnector + extends SubActionConnector + implements CaseConnectorInterface +{ + constructor(params: ServiceParams) { + super(params); + + this.registerSubAction({ + name: 'pushToService', + method: 'pushToService', + schema: schema.object( + { + externalId: schema.nullable(schema.string()), + comments: schema.nullable( + schema.arrayOf( + schema.object({ + comment: schema.string(), + commentId: schema.string(), + }) + ) + ), + }, + { unknowns: 'allow' } + ), + }); + } + + public abstract addComment({ + incidentId, + comment, + }: { + incidentId: string; + comment: string; + }): Promise; + + public abstract createIncident( + incident: Record + ): Promise; + public abstract updateIncident({ + incidentId, + incident, + }: { + incidentId: string; + incident: Record; + }): Promise; + public abstract getIncident({ id }: { id: string }): Promise; + + public async pushToService(params: PushToServiceParams) { + const { externalId, comments, ...rest } = params; + + let res: PushToServiceResponse; + + if (externalId != null) { + res = await this.updateIncident({ + incidentId: externalId, + incident: rest, + }); + } else { + res = await this.createIncident(rest); + } + + if (comments && Array.isArray(comments) && comments.length > 0) { + res.comments = []; + + for (const currentComment of comments) { + await this.addComment({ + incidentId: res.id, + comment: currentComment.comment, + }); + + res.comments = [ + ...(res.comments ?? []), + { + commentId: currentComment.commentId, + pushedDate: res.pushedDate, + }, + ]; + } + } + + return res; + } +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/executor.test.ts b/x-pack/plugins/actions/server/sub_action_framework/executor.test.ts new file mode 100644 index 0000000000000..410bcda0f30d7 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/executor.test.ts @@ -0,0 +1,198 @@ +/* + * 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 { loggingSystemMock } from '@kbn/core/server/mocks'; +import { MockedLogger } from '@kbn/logging-mocks'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionsMock } from '../mocks'; +import { buildExecutor } from './executor'; +import { + TestSecretsSchema, + TestConfigSchema, + TestNoSubActions, + TestConfig, + TestSecrets, + TestExecutor, +} from './mocks'; +import { IService } from './types'; + +describe('Executor', () => { + const actionId = 'test-action-id'; + const config = { url: 'https://example.com' }; + const secrets = { username: 'elastic', password: 'changeme' }; + const params = { subAction: 'testUrl', subActionParams: { url: 'https://example.com' } }; + let logger: MockedLogger; + let services: ReturnType; + let mockedActionsConfig: jest.Mocked; + + const createExecutor = (Service: IService) => { + const connector = { + id: '.test', + name: 'Test', + minimumLicenseRequired: 'basic' as const, + schema: { + config: TestConfigSchema, + secrets: TestSecretsSchema, + }, + Service, + }; + + return buildExecutor({ configurationUtilities: mockedActionsConfig, logger, connector }); + }; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + + logger = loggingSystemMock.createLogger(); + services = actionsMock.createServices(); + mockedActionsConfig = actionsConfigMock.create(); + }); + + it('should execute correctly', async () => { + const executor = createExecutor(TestExecutor); + + const res = await executor({ + actionId, + params: { subAction: 'echo', subActionParams: { id: 'test-id' } }, + config, + secrets, + services, + }); + + expect(res).toEqual({ + actionId: 'test-action-id', + data: { + id: 'test-id', + }, + status: 'ok', + }); + }); + + it('should execute correctly without schema validation', async () => { + const executor = createExecutor(TestExecutor); + + const res = await executor({ + actionId, + params: { subAction: 'noSchema', subActionParams: { id: 'test-id' } }, + config, + secrets, + services, + }); + + expect(res).toEqual({ + actionId: 'test-action-id', + data: { + id: 'test-id', + }, + status: 'ok', + }); + }); + + it('should return an empty object if the func returns undefined', async () => { + const executor = createExecutor(TestExecutor); + + const res = await executor({ + actionId, + params: { ...params, subAction: 'noData' }, + config, + secrets, + services, + }); + + expect(res).toEqual({ + actionId: 'test-action-id', + data: {}, + status: 'ok', + }); + }); + + it('should execute a non async function', async () => { + const executor = createExecutor(TestExecutor); + + const res = await executor({ + actionId, + params: { ...params, subAction: 'noAsync' }, + config, + secrets, + services, + }); + + expect(res).toEqual({ + actionId: 'test-action-id', + data: {}, + status: 'ok', + }); + }); + + it('throws if the are not sub actions registered', async () => { + const executor = createExecutor(TestNoSubActions); + + await expect(async () => + executor({ actionId, params, config, secrets, services }) + ).rejects.toThrowError('You should register at least one subAction for your connector type'); + }); + + it('throws if the sub action is not registered', async () => { + const executor = createExecutor(TestExecutor); + + await expect(async () => + executor({ + actionId, + params: { subAction: 'not-exist', subActionParams: {} }, + config, + secrets, + services, + }) + ).rejects.toThrowError( + 'Sub action "not-exist" is not registered. Connector id: test-action-id. Connector name: Test. Connector type: .test' + ); + }); + + it('throws if the method does not exists', async () => { + const executor = createExecutor(TestExecutor); + + await expect(async () => + executor({ + actionId, + params, + config, + secrets, + services, + }) + ).rejects.toThrowError( + 'Method "not-exist" does not exists in service. Sub action: "testUrl". Connector id: test-action-id. Connector name: Test. Connector type: .test' + ); + }); + + it('throws if the registered method is not a function', async () => { + const executor = createExecutor(TestExecutor); + + await expect(async () => + executor({ + actionId, + params: { ...params, subAction: 'notAFunction' }, + config, + secrets, + services, + }) + ).rejects.toThrowError( + 'Method "notAFunction" must be a function. Connector id: test-action-id. Connector name: Test. Connector type: .test' + ); + }); + + it('throws if the sub actions params are not valid', async () => { + const executor = createExecutor(TestExecutor); + + await expect(async () => + executor({ actionId, params: { ...params, subAction: 'echo' }, config, secrets, services }) + ).rejects.toThrowError( + 'Request validation failed (Error: [id]: expected value of type [string] but got [undefined])' + ); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/executor.ts b/x-pack/plugins/actions/server/sub_action_framework/executor.ts new file mode 100644 index 0000000000000..469cc383e3d93 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/executor.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 { Logger } from '@kbn/core/server'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { ExecutorType } from '../types'; +import { ExecutorParams, SubActionConnectorType } from './types'; + +const isFunction = (v: unknown): v is Function => { + return typeof v === 'function'; +}; + +const getConnectorErrorMsg = (actionId: string, connector: { id: string; name: string }) => + `Connector id: ${actionId}. Connector name: ${connector.name}. Connector type: ${connector.id}`; + +export const buildExecutor = ({ + configurationUtilities, + connector, + logger, +}: { + connector: SubActionConnectorType; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; +}): ExecutorType => { + return async ({ actionId, params, config, secrets, services }) => { + const subAction = params.subAction; + const subActionParams = params.subActionParams; + + const service = new connector.Service({ + connector: { id: actionId, type: connector.id }, + config, + secrets, + configurationUtilities, + logger, + services, + }); + + const subActions = service.getSubActions(); + + if (subActions.size === 0) { + throw new Error('You should register at least one subAction for your connector type'); + } + + const action = subActions.get(subAction); + + if (!action) { + throw new Error( + `Sub action "${subAction}" is not registered. ${getConnectorErrorMsg(actionId, connector)}` + ); + } + + const method = action.method; + + if (!service[method]) { + throw new Error( + `Method "${method}" does not exists in service. Sub action: "${subAction}". ${getConnectorErrorMsg( + actionId, + connector + )}` + ); + } + + const func = service[method]; + + if (!isFunction(func)) { + throw new Error( + `Method "${method}" must be a function. ${getConnectorErrorMsg(actionId, connector)}` + ); + } + + if (action.schema) { + try { + action.schema.validate(subActionParams); + } catch (reqValidationError) { + throw new Error(`Request validation failed (${reqValidationError})`); + } + } + + const data = await func.call(service, subActionParams); + return { status: 'ok', data: data ?? {}, actionId }; + }; +}; diff --git a/x-pack/plugins/actions/server/sub_action_framework/index.ts b/x-pack/plugins/actions/server/sub_action_framework/index.ts new file mode 100644 index 0000000000000..02eb281fa6e1b --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/index.ts @@ -0,0 +1,33 @@ +/* + * 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 { PublicMethodsOf } from '@kbn/utility-types'; +import { Logger } from '@kbn/core/server'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +import { ActionTypeRegistry } from '../action_type_registry'; +import { register } from './register'; +import { SubActionConnectorType } from './types'; +import { ActionTypeConfig, ActionTypeSecrets } from '../types'; + +export const createSubActionConnectorFramework = ({ + actionsConfigUtils: configurationUtilities, + actionTypeRegistry, + logger, +}: { + actionTypeRegistry: PublicMethodsOf; + logger: Logger; + actionsConfigUtils: ActionsConfigurationUtilities; +}) => { + return { + registerConnector: ( + connector: SubActionConnectorType + ) => { + register({ actionTypeRegistry, logger, connector, configurationUtilities }); + }, + }; +}; diff --git a/x-pack/plugins/actions/server/sub_action_framework/mocks.ts b/x-pack/plugins/actions/server/sub_action_framework/mocks.ts new file mode 100644 index 0000000000000..274662bb7a35f --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/mocks.ts @@ -0,0 +1,194 @@ +/* + * 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. + */ +/* eslint-disable max-classes-per-file */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { AxiosError } from 'axios'; +import { SubActionConnector } from './sub_action_connector'; +import { CaseConnector } from './case'; +import { ExternalServiceIncidentResponse, ServiceParams } from './types'; + +export const TestConfigSchema = schema.object({ url: schema.string() }); +export const TestSecretsSchema = schema.object({ + username: schema.string(), + password: schema.string(), +}); +export type TestConfig = TypeOf; +export type TestSecrets = TypeOf; + +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +export class TestSubActionConnector extends SubActionConnector { + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'testUrl', + method: 'testUrl', + schema: schema.object({ url: schema.string() }), + }); + + this.registerSubAction({ + name: 'testData', + method: 'testData', + schema: null, + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async testUrl({ url, data = {} }: { url: string; data?: Record | null }) { + const res = await this.request({ + url, + data, + headers: { 'X-Test-Header': 'test' }, + responseSchema: schema.object({ status: schema.string() }), + }); + + return res; + } + + public async testData({ data }: { data: Record }) { + const res = await this.request({ + url: 'https://example.com', + data: this.removeNullOrUndefinedFields(data), + headers: { 'X-Test-Header': 'test' }, + responseSchema: schema.object({ status: schema.string() }), + }); + + return res; + } +} + +export class TestNoSubActions extends SubActionConnector { + protected getResponseErrorMessage(error: AxiosError) { + return `Error`; + } +} + +export class TestExecutor extends SubActionConnector { + public notAFunction: string = 'notAFunction'; + + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'testUrl', + method: 'not-exist', + schema: schema.object({}), + }); + + this.registerSubAction({ + name: 'notAFunction', + method: 'notAFunction', + schema: schema.object({}), + }); + + this.registerSubAction({ + name: 'echo', + method: 'echo', + schema: schema.object({ id: schema.string() }), + }); + + this.registerSubAction({ + name: 'noSchema', + method: 'noSchema', + schema: null, + }); + + this.registerSubAction({ + name: 'noData', + method: 'noData', + schema: null, + }); + + this.registerSubAction({ + name: 'noAsync', + method: 'noAsync', + schema: null, + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Error`; + } + + public async echo({ id }: { id: string }) { + return Promise.resolve({ id }); + } + + public async noSchema({ id }: { id: string }) { + return { id }; + } + + public async noData() {} + + public noAsync() {} +} + +export class TestCaseConnector extends CaseConnector { + constructor(params: ServiceParams) { + super(params); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async createIncident(incident: { + category: string; + }): Promise { + return { + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async addComment({ + incidentId, + comment, + }: { + incidentId: string; + comment: string; + }): Promise { + return { + id: 'add-comment', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async updateIncident({ + incidentId, + incident, + }: { + incidentId: string; + incident: { category: string }; + }): Promise { + return { + id: 'update-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async getIncident({ id }: { id: string }): Promise { + return { + id: 'get-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/register.test.ts b/x-pack/plugins/actions/server/sub_action_framework/register.test.ts new file mode 100644 index 0000000000000..85d630736a3b1 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/register.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionTypeRegistryMock } from '../action_type_registry.mock'; +import { + TestSecretsSchema, + TestConfigSchema, + TestConfig, + TestSecrets, + TestSubActionConnector, +} from './mocks'; +import { register } from './register'; + +describe('Registration', () => { + const connector = { + id: '.test', + name: 'Test', + minimumLicenseRequired: 'basic' as const, + schema: { + config: TestConfigSchema, + secrets: TestSecretsSchema, + }, + Service: TestSubActionConnector, + }; + + const actionTypeRegistry = actionTypeRegistryMock.create(); + const mockedActionsConfig = actionsConfigMock.create(); + const logger = loggingSystemMock.createLogger(); + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + it('registers the connector correctly', async () => { + register({ + actionTypeRegistry, + connector, + configurationUtilities: mockedActionsConfig, + logger, + }); + + expect(actionTypeRegistry.register).toHaveBeenCalledTimes(1); + expect(actionTypeRegistry.register).toHaveBeenCalledWith({ + id: connector.id, + name: connector.name, + minimumLicenseRequired: connector.minimumLicenseRequired, + validate: expect.anything(), + executor: expect.anything(), + }); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/register.ts b/x-pack/plugins/actions/server/sub_action_framework/register.ts new file mode 100644 index 0000000000000..ff9cf50e514cd --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/register.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PublicMethodsOf } from '@kbn/utility-types'; +import { Logger } from '@kbn/core/server'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { ActionTypeRegistry } from '../action_type_registry'; +import { SubActionConnector } from './sub_action_connector'; +import { CaseConnector } from './case'; +import { ActionTypeConfig, ActionTypeSecrets } from '../types'; +import { buildExecutor } from './executor'; +import { ExecutorParams, SubActionConnectorType, IService } from './types'; +import { buildValidators } from './validators'; + +const validateService = (Service: IService) => { + if ( + !(Service.prototype instanceof CaseConnector) && + !(Service.prototype instanceof SubActionConnector) + ) { + throw new Error( + 'Service must be extend one of the abstract classes: SubActionConnector or CaseConnector' + ); + } +}; + +export const register = ({ + actionTypeRegistry, + connector, + logger, + configurationUtilities, +}: { + configurationUtilities: ActionsConfigurationUtilities; + actionTypeRegistry: PublicMethodsOf; + connector: SubActionConnectorType; + logger: Logger; +}) => { + validateService(connector.Service); + + const validators = buildValidators({ connector, configurationUtilities }); + const executor = buildExecutor({ + connector, + logger, + configurationUtilities, + }); + + actionTypeRegistry.register({ + id: connector.id, + name: connector.name, + minimumLicenseRequired: connector.minimumLicenseRequired, + validate: validators, + executor, + }); +}; diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts new file mode 100644 index 0000000000000..957d8875547c2 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts @@ -0,0 +1,343 @@ +/* + * 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 { Agent as HttpsAgent } from 'https'; +import HttpProxyAgent from 'http-proxy-agent'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { MockedLogger } from '@kbn/logging-mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionsMock } from '../mocks'; +import { TestSubActionConnector } from './mocks'; +import { getCustomAgents } from '../builtin_action_types/lib/get_custom_agents'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +jest.mock('axios'); +const axiosMock = axios as jest.Mocked; + +const createAxiosError = (): AxiosError => { + const error = new Error() as AxiosError; + error.isAxiosError = true; + error.config = { method: 'get', url: 'https://example.com' }; + error.response = { + data: { errorMessage: 'An error occurred', errorCode: 500 }, + } as AxiosResponse; + + return error; +}; + +describe('SubActionConnector', () => { + const axiosInstanceMock = jest.fn(); + let logger: MockedLogger; + let services: ReturnType; + let mockedActionsConfig: jest.Mocked; + let service: TestSubActionConnector; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + + axiosInstanceMock.mockReturnValue({ data: { status: 'ok' } }); + axiosMock.create.mockImplementation(() => { + return axiosInstanceMock as unknown as AxiosInstance; + }); + + logger = loggingSystemMock.createLogger(); + services = actionsMock.createServices(); + mockedActionsConfig = actionsConfigMock.create(); + + mockedActionsConfig.getResponseSettings.mockReturnValue({ + maxContentLength: 1000000, + timeout: 360000, + }); + + service = new TestSubActionConnector({ + configurationUtilities: mockedActionsConfig, + logger, + connector: { id: 'test-id', type: '.test' }, + config: { url: 'https://example.com' }, + secrets: { username: 'elastic', password: 'changeme' }, + services, + }); + }); + + describe('Sub actions', () => { + it('gets the sub actions correctly', async () => { + const subActions = service.getSubActions(); + expect(subActions.get('testUrl')).toEqual({ + method: 'testUrl', + name: 'testUrl', + schema: expect.anything(), + }); + }); + }); + + describe('URL validation', () => { + it('removes double slashes correctly', async () => { + await service.testUrl({ url: 'https://example.com//api///test-endpoint' }); + expect(axiosInstanceMock.mock.calls[0][0]).toBe('https://example.com/api/test-endpoint'); + }); + + it('removes the ending slash correctly', async () => { + await service.testUrl({ url: 'https://example.com/' }); + expect(axiosInstanceMock.mock.calls[0][0]).toBe('https://example.com'); + }); + + it('throws an error if the url is invalid', async () => { + expect.assertions(1); + await expect(async () => service.testUrl({ url: 'invalid-url' })).rejects.toThrow( + 'URL Error: Invalid URL: invalid-url' + ); + }); + + it('throws an error if the url starts with backslashes', async () => { + expect.assertions(1); + await expect(async () => service.testUrl({ url: '//example.com/foo' })).rejects.toThrow( + 'URL Error: Invalid URL: //example.com/foo' + ); + }); + + it('throws an error if the protocol is not supported', async () => { + expect.assertions(1); + await expect(async () => service.testUrl({ url: 'ftp://example.com' })).rejects.toThrow( + 'URL Error: Invalid protocol' + ); + }); + + it('throws if the host is the URI is not allowed', async () => { + expect.assertions(1); + + mockedActionsConfig.ensureUriAllowed.mockImplementation(() => { + throw new Error('URI is not allowed'); + }); + + await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow( + 'error configuring connector action: URI is not allowed' + ); + }); + }); + + describe('Data', () => { + it('sets data to an empty object if the data are null', async () => { + await service.testUrl({ url: 'https://example.com', data: null }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { data } = axiosInstanceMock.mock.calls[0][1]; + expect(data).toEqual({}); + }); + + it('pass data to axios correctly if not null', async () => { + await service.testUrl({ url: 'https://example.com', data: { foo: 'foo' } }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { data } = axiosInstanceMock.mock.calls[0][1]; + expect(data).toEqual({ foo: 'foo' }); + }); + + it('removeNullOrUndefinedFields: removes null values and undefined values correctly', async () => { + await service.testData({ data: { foo: 'foo', bar: null, baz: undefined } }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { data } = axiosInstanceMock.mock.calls[0][1]; + expect(data).toEqual({ foo: 'foo' }); + }); + + it.each([[null], [undefined], [[]], [() => {}], [new Date()]])( + 'removeNullOrUndefinedFields: returns data if it is not an object', + async (dataToTest) => { + // @ts-expect-error + await service.testData({ data: dataToTest }); + + const { data } = axiosInstanceMock.mock.calls[0][1]; + expect(data).toEqual({}); + } + ); + }); + + describe('Fetching', () => { + it('fetch correctly', async () => { + const res = await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + expect(axiosInstanceMock).toBeCalledWith('https://example.com', { + method: 'get', + data: {}, + headers: { + 'Content-Type': 'application/json', + 'X-Test-Header': 'test', + }, + httpAgent: undefined, + httpsAgent: expect.any(HttpsAgent), + proxy: false, + maxContentLength: 1000000, + timeout: 360000, + }); + + expect(logger.debug).toBeCalledWith( + 'Request to external service. Connector Id: test-id. Connector type: .test Method: get. URL: https://example.com' + ); + + expect(res).toEqual({ data: { status: 'ok' } }); + }); + + it('validates the response correctly', async () => { + axiosInstanceMock.mockReturnValue({ data: { invalidField: 'test' } }); + await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow( + 'Response validation failed (Error: [status]: expected value of type [string] but got [undefined])' + ); + }); + + it('formats the response error correctly', async () => { + axiosInstanceMock.mockImplementation(() => { + throw createAxiosError(); + }); + + await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow( + 'Message: An error occurred. Code: 500' + ); + + expect(logger.debug).toHaveBeenLastCalledWith( + 'Request to external service failed. Connector Id: test-id. Connector type: .test. Method: get. URL: https://example.com' + ); + }); + }); + + describe('Proxy', () => { + it('have been called with proper proxy agent for a valid url', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://localhost:1212', + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + const { httpAgent, httpsAgent } = getCustomAgents( + mockedActionsConfig, + logger, + 'https://example.com' + ); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toBeCalledWith('https://example.com', { + method: 'get', + data: {}, + headers: { + 'X-Test-Header': 'test', + 'Content-Type': 'application/json', + }, + httpAgent, + httpsAgent, + proxy: false, + maxContentLength: 1000000, + timeout: 360000, + }); + }); + + it('have been called with proper proxy agent for an invalid url', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxyUrl: ':nope:', + proxySSLSettings: { + verificationMode: 'none', + }, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toBeCalledWith('https://example.com', { + method: 'get', + data: {}, + headers: { + 'X-Test-Header': 'test', + 'Content-Type': 'application/json', + }, + httpAgent: undefined, + httpsAgent: expect.any(HttpsAgent), + proxy: false, + maxContentLength: 1000000, + timeout: 360000, + }); + }); + + it('bypasses with proxyBypassHosts when expected', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: new Set(['example.com']), + proxyOnlyHosts: undefined, + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(false); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(false); + }); + + it('does not bypass with proxyBypassHosts when expected', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: new Set(['not-example.com']), + proxyOnlyHosts: undefined, + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(true); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(true); + }); + + it('proxies with proxyOnlyHosts when expected', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set(['example.com']), + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(true); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(true); + }); + + it('does not proxy with proxyOnlyHosts when expected', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set(['not-example.com']), + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(false); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts new file mode 100644 index 0000000000000..4e2be22a6834e --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts @@ -0,0 +1,175 @@ +/* + * 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 { isPlainObject, isEmpty } from 'lodash'; +import { Type } from '@kbn/config-schema'; +import { Logger } from '@kbn/logging'; +import axios, { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + Method, + AxiosError, + AxiosRequestHeaders, +} from 'axios'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { getCustomAgents } from '../builtin_action_types/lib/get_custom_agents'; +import { SubAction } from './types'; +import { ServiceParams } from './types'; +import * as i18n from './translations'; + +const isObject = (value: unknown): value is Record => { + return isPlainObject(value); +}; + +const isAxiosError = (error: unknown): error is AxiosError => (error as AxiosError).isAxiosError; + +export abstract class SubActionConnector { + [k: string]: ((params: unknown) => unknown) | unknown; + private axiosInstance: AxiosInstance; + private validProtocols: string[] = ['http:', 'https:']; + private subActions: Map = new Map(); + private configurationUtilities: ActionsConfigurationUtilities; + protected logger: Logger; + protected connector: ServiceParams['connector']; + protected config: Config; + protected secrets: Secrets; + + constructor(params: ServiceParams) { + this.connector = params.connector; + this.logger = params.logger; + this.config = params.config; + this.secrets = params.secrets; + this.configurationUtilities = params.configurationUtilities; + this.axiosInstance = axios.create(); + } + + private normalizeURL(url: string) { + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const replaceDoubleSlashesRegex = new RegExp('([^:]/)/+', 'g'); + return urlWithoutTrailingSlash.replace(replaceDoubleSlashesRegex, '$1'); + } + + private normalizeData(data: unknown | undefined | null) { + if (isEmpty(data)) { + return {}; + } + + return data; + } + + private assertURL(url: string) { + try { + const parsedUrl = new URL(url); + + if (!parsedUrl.hostname) { + throw new Error('URL must contain hostname'); + } + + if (!this.validProtocols.includes(parsedUrl.protocol)) { + throw new Error('Invalid protocol'); + } + } catch (error) { + throw new Error(`URL Error: ${error.message}`); + } + } + + private ensureUriAllowed(url: string) { + try { + this.configurationUtilities.ensureUriAllowed(url); + } catch (allowedListError) { + throw new Error(i18n.ALLOWED_HOSTS_ERROR(allowedListError.message)); + } + } + + private getHeaders(headers?: AxiosRequestHeaders) { + return { ...headers, 'Content-Type': 'application/json' }; + } + + private validateResponse(responseSchema: Type, data: unknown) { + try { + responseSchema.validate(data); + } catch (resValidationError) { + throw new Error(`Response validation failed (${resValidationError})`); + } + } + + protected registerSubAction(subAction: SubAction) { + this.subActions.set(subAction.name, subAction); + } + + protected removeNullOrUndefinedFields(data: unknown | undefined) { + if (isObject(data)) { + return Object.fromEntries(Object.entries(data).filter(([_, value]) => value != null)); + } + + return data; + } + + public getSubActions() { + return this.subActions; + } + + protected abstract getResponseErrorMessage(error: AxiosError): string; + + protected async request({ + url, + data, + method = 'get', + responseSchema, + headers, + ...config + }: { + url: string; + responseSchema: Type; + method?: Method; + } & AxiosRequestConfig): Promise> { + try { + this.assertURL(url); + this.ensureUriAllowed(url); + const normalizedURL = this.normalizeURL(url); + + const { httpAgent, httpsAgent } = getCustomAgents( + this.configurationUtilities, + this.logger, + url + ); + const { maxContentLength, timeout } = this.configurationUtilities.getResponseSettings(); + + this.logger.debug( + `Request to external service. Connector Id: ${this.connector.id}. Connector type: ${this.connector.type} Method: ${method}. URL: ${normalizedURL}` + ); + const res = await this.axiosInstance(normalizedURL, { + ...config, + method, + headers: this.getHeaders(headers), + data: this.normalizeData(data), + // use httpAgent and httpsAgent and set axios proxy: false, to be able to handle fail on invalid certs + httpAgent, + httpsAgent, + proxy: false, + maxContentLength, + timeout, + }); + + this.validateResponse(responseSchema, res.data); + + return res; + } catch (error) { + if (isAxiosError(error)) { + this.logger.debug( + `Request to external service failed. Connector Id: ${this.connector.id}. Connector type: ${this.connector.type}. Method: ${error.config.method}. URL: ${error.config.url}` + ); + + const errorMessage = this.getResponseErrorMessage(error); + throw new Error(errorMessage); + } + + throw error; + } + } +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/translations.ts b/x-pack/plugins/actions/server/sub_action_framework/translations.ts new file mode 100644 index 0000000000000..3ffaa230cf23b --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/translations.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const NAME = i18n.translate('xpack.actions.builtin.cases.jiraTitle', { + defaultMessage: 'Jira', +}); + +export const ALLOWED_HOSTS_ERROR = (message: string) => + i18n.translate('xpack.actions.apiAllowedHostsError', { + defaultMessage: 'error configuring connector action: {message}', + values: { + message, + }, + }); diff --git a/x-pack/plugins/actions/server/sub_action_framework/types.ts b/x-pack/plugins/actions/server/sub_action_framework/types.ts new file mode 100644 index 0000000000000..f3080310b1fc0 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/types.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Type } from '@kbn/config-schema'; +import { Logger } from '@kbn/logging'; +import type { LicenseType } from '@kbn/licensing-plugin/common/types'; + +import { ActionsConfigurationUtilities } from '../actions_config'; +import { ActionTypeParams, Services } from '../types'; +import { SubActionConnector } from './sub_action_connector'; + +export interface ServiceParams { + /** + * The type is the connector type id. For example ".servicenow" + * The id is the connector's SavedObject UUID. + */ + connector: { id: string; type: string }; + config: Config; + configurationUtilities: ActionsConfigurationUtilities; + logger: Logger; + secrets: Secrets; + services: Services; +} + +export type IService = new ( + params: ServiceParams +) => SubActionConnector; + +export type IServiceAbstract = abstract new ( + params: ServiceParams +) => SubActionConnector; + +export interface SubActionConnectorType { + id: string; + name: string; + minimumLicenseRequired: LicenseType; + schema: { + config: Type; + secrets: Type; + }; + Service: IService; +} + +export interface ExecutorParams extends ActionTypeParams { + subAction: string; + subActionParams: Record; +} + +export type ExtractFunctionKeys = { + [P in keyof T]-?: T[P] extends Function ? P : never; +}[keyof T]; + +export interface SubAction { + name: string; + method: string; + schema: Type | null; +} + +export interface PushToServiceParams { + externalId: string | null; + comments: Array<{ commentId: string; comment: string }>; + [x: string]: unknown; +} + +export interface ExternalServiceIncidentResponse { + id: string; + title: string; + url: string; + pushedDate: string; +} + +export interface ExternalServiceCommentResponse { + commentId: string; + pushedDate: string; + externalCommentId?: string; +} +export interface PushToServiceResponse extends ExternalServiceIncidentResponse { + comments?: ExternalServiceCommentResponse[]; +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/validators.test.ts b/x-pack/plugins/actions/server/sub_action_framework/validators.test.ts new file mode 100644 index 0000000000000..78c3f042efce6 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/validators.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { ActionsConfigurationUtilities } from '../actions_config'; +import { actionsConfigMock } from '../actions_config.mock'; +import { + TestSecretsSchema, + TestConfigSchema, + TestConfig, + TestSecrets, + TestSubActionConnector, +} from './mocks'; +import { IService } from './types'; +import { buildValidators } from './validators'; + +describe('Validators', () => { + let mockedActionsConfig: jest.Mocked; + + const createValidator = (Service: IService) => { + const connector = { + id: '.test', + name: 'Test', + minimumLicenseRequired: 'basic' as const, + schema: { + config: TestConfigSchema, + secrets: TestSecretsSchema, + }, + Service, + }; + + return buildValidators({ configurationUtilities: mockedActionsConfig, connector }); + }; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + + mockedActionsConfig = actionsConfigMock.create(); + }); + + it('should create the config and secrets validators correctly', async () => { + const validator = createValidator(TestSubActionConnector); + const { config, secrets } = validator; + + expect(config).toEqual(TestConfigSchema); + expect(secrets).toEqual(TestSecretsSchema); + }); + + it('should validate the params correctly', async () => { + const validator = createValidator(TestSubActionConnector); + const { params } = validator; + expect(params.validate({ subAction: 'test', subActionParams: {} })); + }); + + it('should allow any field in subActionParams', async () => { + const validator = createValidator(TestSubActionConnector); + const { params } = validator; + expect( + params.validate({ + subAction: 'test', + subActionParams: { + foo: 'foo', + bar: 1, + baz: [{ test: 'hello' }, 1, 'test', false], + isValid: false, + val: null, + }, + }) + ).toEqual({ + subAction: 'test', + subActionParams: { + foo: 'foo', + bar: 1, + baz: [{ test: 'hello' }, 1, 'test', false], + isValid: false, + val: null, + }, + }); + }); + + it.each([ + [undefined], + [null], + [1], + [false], + [{ test: 'hello' }], + [['test']], + [{ test: 'hello' }], + ])('should throw if the subAction is %p', async (subAction) => { + const validator = createValidator(TestSubActionConnector); + const { params } = validator; + expect(() => params.validate({ subAction, subActionParams: {} })).toThrow(); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/validators.ts b/x-pack/plugins/actions/server/sub_action_framework/validators.ts new file mode 100644 index 0000000000000..2c272a7d858d6 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/validators.ts @@ -0,0 +1,38 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { ActionTypeConfig, ActionTypeSecrets } from '../types'; +import { SubActionConnectorType } from './types'; + +export const buildValidators = < + Config extends ActionTypeConfig, + Secrets extends ActionTypeSecrets +>({ + connector, + configurationUtilities, +}: { + configurationUtilities: ActionsConfigurationUtilities; + connector: SubActionConnectorType; +}) => { + return { + config: connector.schema.config, + secrets: connector.schema.secrets, + params: schema.object({ + subAction: schema.string(), + /** + * With this validation we enforce the subActionParams to be an object. + * Each sub action has different parameters and they are validated inside the executor + * (x-pack/plugins/actions/server/sub_action_framework/executor.ts). For that reason, + * we allow all unknowns at this level of validation as they are not known at this + * time of execution. + */ + subActionParams: schema.object({}, { unknowns: 'allow' }), + }), + }; +}; diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index ffdf0c09ad216..d1bf39b575ab5 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -43,6 +43,8 @@ const enabledActionTypes = [ '.slack', '.webhook', '.xmatters', + '.test-sub-action-connector', + '.test-sub-action-connector-without-sub-actions', 'test.authorization', 'test.failing', 'test.index-record', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/action_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/action_types.ts index c83a1c543b5a7..fb7d65990d34f 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/action_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/action_types.ts @@ -9,6 +9,10 @@ import { CoreSetup } from '@kbn/core/server'; import { schema, TypeOf } from '@kbn/config-schema'; import { ActionType } from '@kbn/actions-plugin/server'; import { FixtureStartDeps, FixtureSetupDeps } from './plugin'; +import { + getTestSubActionConnector, + getTestSubActionConnectorWithoutSubActions, +} from './sub_action_connector'; export function defineActionTypes( core: CoreSetup, @@ -23,6 +27,7 @@ export function defineActionTypes( return { status: 'ok', actionId: '' }; }, }; + const throwActionType: ActionType = { id: 'test.throw', name: 'Test: Throw', @@ -31,6 +36,7 @@ export function defineActionTypes( throw new Error('this action is intended to fail'); }, }; + const cappedActionType: ActionType = { id: 'test.capped', name: 'Test: Capped', @@ -39,6 +45,7 @@ export function defineActionTypes( return { status: 'ok', actionId: '' }; }, }; + actions.registerType(noopActionType); actions.registerType(throwActionType); actions.registerType(cappedActionType); @@ -49,6 +56,11 @@ export function defineActionTypes( actions.registerType(getNoAttemptsRateLimitedActionType()); actions.registerType(getAuthorizationActionType(core)); actions.registerType(getExcludedActionType()); + + /** Sub action framework */ + + actions.registerSubActionConnectorType(getTestSubActionConnector(actions)); + actions.registerSubActionConnectorType(getTestSubActionConnectorWithoutSubActions(actions)); } function getIndexRecordActionType() { diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts new file mode 100644 index 0000000000000..39e8a704cc978 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts @@ -0,0 +1,109 @@ +/* + * 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. + */ + +// eslint-disable-next-line max-classes-per-file +import { AxiosError } from 'axios'; +import type { ServiceParams } from '@kbn/actions-plugin/server'; +import { PluginSetupContract as ActionsPluginSetup } from '@kbn/actions-plugin/server/plugin'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types'; + +const TestConfigSchema = schema.object({ url: schema.string() }); +const TestSecretsSchema = schema.object({ + username: schema.string(), + password: schema.string(), +}); + +type TestConfig = TypeOf; +type TestSecrets = TypeOf; + +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +export const getTestSubActionConnector = ( + actions: ActionsPluginSetup +): SubActionConnectorType => { + const SubActionConnector = actions.getSubActionConnectorClass(); + + class TestSubActionConnector extends SubActionConnector { + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'subActionWithParams', + method: 'subActionWithParams', + schema: schema.object({ id: schema.string() }), + }); + + this.registerSubAction({ + name: 'subActionWithoutParams', + method: 'subActionWithoutParams', + schema: null, + }); + + this.registerSubAction({ + name: 'notExist', + method: 'notExist', + schema: schema.object({}), + }); + + this.registerSubAction({ + name: 'notAFunction', + method: 'notAFunction', + schema: schema.object({}), + }); + + this.registerSubAction({ + name: 'noData', + method: 'noData', + schema: null, + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async subActionWithParams({ id }: { id: string }) { + return { id }; + } + + public async subActionWithoutParams() { + return { id: 'test' }; + } + + public async noData() {} + } + return { + id: '.test-sub-action-connector', + name: 'Test: Sub action connector', + minimumLicenseRequired: 'platinum' as const, + schema: { config: TestConfigSchema, secrets: TestSecretsSchema }, + Service: TestSubActionConnector, + }; +}; + +export const getTestSubActionConnectorWithoutSubActions = ( + actions: ActionsPluginSetup +): SubActionConnectorType => { + const SubActionConnector = actions.getSubActionConnectorClass(); + + class TestNoSubActions extends SubActionConnector { + protected getResponseErrorMessage(error: AxiosError) { + return `Error`; + } + } + + return { + id: '.test-sub-action-connector-without-sub-actions', + name: 'Test: Sub action connector', + minimumLicenseRequired: 'platinum' as const, + schema: { config: TestConfigSchema, secrets: TestSecretsSchema }, + Service: TestNoSubActions, + }; +}; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts index 9c1b6a4fd8299..8175445b4f1c0 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts @@ -41,5 +41,10 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./connector_types')); loadTestFile(require.resolve('./update')); + + /** + * Sub action framework + */ + loadTestFile(require.resolve('./sub_action_framework')); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/sub_action_framework/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/sub_action_framework/index.ts new file mode 100644 index 0000000000000..350361d58a395 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/sub_action_framework/index.ts @@ -0,0 +1,318 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type SuperTest from 'supertest'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; + +/** + * The sub action connector is defined here + * x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts + */ +const createSubActionConnector = async ({ + supertest, + config, + secrets, + connectorTypeId = '.test-sub-action-connector', + expectedHttpCode = 200, +}: { + supertest: SuperTest.SuperTest; + config?: Record; + secrets?: Record; + connectorTypeId?: string; + expectedHttpCode?: number; +}) => { + const response = await supertest + .post(`${getUrlPrefix('default')}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My sub connector', + connector_type_id: connectorTypeId, + config: { + url: 'https://example.com', + ...config, + }, + secrets: { + username: 'elastic', + password: 'changeme', + ...secrets, + }, + }) + .expect(expectedHttpCode); + + return response; +}; + +const executeSubAction = async ({ + supertest, + connectorId, + subAction, + subActionParams, + expectedHttpCode = 200, +}: { + supertest: SuperTest.SuperTest; + connectorId: string; + subAction: string; + subActionParams: Record; + expectedHttpCode?: number; +}) => { + const response = await supertest + .post(`${getUrlPrefix('default')}/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction, + subActionParams, + }, + }) + .expect(expectedHttpCode); + + return response; +}; + +// eslint-disable-next-line import/no-default-export +export default function createActionTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Sub action framework', () => { + const objectRemover = new ObjectRemover(supertest); + after(() => objectRemover.removeAll()); + + describe('Create', () => { + it('creates the sub action connector correctly', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + expect(res.body).to.eql({ + id: res.body.id, + is_preconfigured: false, + is_deprecated: false, + is_missing_secrets: false, + name: 'My sub connector', + connector_type_id: '.test-sub-action-connector', + config: { + url: 'https://example.com', + }, + }); + }); + }); + + describe('Schema validation', () => { + it('passes the config schema to the actions framework and validates correctly', async () => { + const res = await createSubActionConnector({ + supertest, + config: { foo: 'foo' }, + expectedHttpCode: 400, + }); + + expect(res.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'error validating action type config: [foo]: definition for this key is missing', + }); + }); + + it('passes the secrets schema to the actions framework and validates correctly', async () => { + const res = await createSubActionConnector({ + supertest, + secrets: { foo: 'foo' }, + expectedHttpCode: 400, + }); + + expect(res.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [foo]: definition for this key is missing', + }); + }); + }); + + describe('Sub actions', () => { + it('executes a sub action with parameters correctly', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'subActionWithParams', + subActionParams: { id: 'test-id' }, + }); + + expect(execRes.body).to.eql({ + status: 'ok', + data: { id: 'test-id' }, + connector_id: res.body.id, + }); + }); + + it('validates the subParams correctly', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'subActionWithParams', + subActionParams: { foo: 'foo' }, + }); + + expect(execRes.body).to.eql({ + status: 'error', + message: 'an error occurred while running the action', + retry: false, + connector_id: res.body.id, + service_message: + 'Request validation failed (Error: [id]: expected value of type [string] but got [undefined])', + }); + }); + + it('validates correctly if the subActionParams is not an object', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + for (const subActionParams of ['foo', 1, true, null, ['bar']]) { + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'subActionWithParams', + // @ts-expect-error + subActionParams, + }); + + const { message, ...resWithoutMessage } = execRes.body; + expect(resWithoutMessage).to.eql({ + status: 'error', + retry: false, + connector_id: res.body.id, + }); + } + }); + + it('should execute correctly without schema validation', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'subActionWithoutParams', + subActionParams: {}, + }); + + expect(execRes.body).to.eql({ + status: 'ok', + data: { id: 'test' }, + connector_id: res.body.id, + }); + }); + + it('should return an empty object if the func returns undefined', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'noData', + subActionParams: {}, + }); + + expect(execRes.body).to.eql({ + status: 'ok', + data: {}, + connector_id: res.body.id, + }); + }); + + it('should return an error if sub action is not registered', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'notRegistered', + subActionParams: { foo: 'foo' }, + }); + + expect(execRes.body).to.eql({ + status: 'error', + message: 'an error occurred while running the action', + retry: false, + connector_id: res.body.id, + service_message: `Sub action \"notRegistered\" is not registered. Connector id: ${res.body.id}. Connector name: Test: Sub action connector. Connector type: .test-sub-action-connector`, + }); + }); + + it('should return an error if the registered method is not a function', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'notAFunction', + subActionParams: { foo: 'foo' }, + }); + + expect(execRes.body).to.eql({ + status: 'error', + message: 'an error occurred while running the action', + retry: false, + connector_id: res.body.id, + service_message: `Method \"notAFunction\" does not exists in service. Sub action: \"notAFunction\". Connector id: ${res.body.id}. Connector name: Test: Sub action connector. Connector type: .test-sub-action-connector`, + }); + }); + + it('should return an error if the registered method does not exists', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'notExist', + subActionParams: { foo: 'foo' }, + }); + + expect(execRes.body).to.eql({ + status: 'error', + message: 'an error occurred while running the action', + retry: false, + connector_id: res.body.id, + service_message: `Method \"notExist\" does not exists in service. Sub action: \"notExist\". Connector id: ${res.body.id}. Connector name: Test: Sub action connector. Connector type: .test-sub-action-connector`, + }); + }); + + it('should return an error if there are no sub actions registered', async () => { + const res = await createSubActionConnector({ + supertest, + connectorTypeId: '.test-sub-action-connector-without-sub-actions', + }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'notRegistered', + subActionParams: { foo: 'foo' }, + }); + + expect(execRes.body).to.eql({ + status: 'error', + message: 'an error occurred while running the action', + retry: false, + connector_id: res.body.id, + service_message: 'You should register at least one subAction for your connector type', + }); + }); + }); + }); +} From 4f99212c87d3af1a9e257a27102dda2047b2967c Mon Sep 17 00:00:00 2001 From: Milton Hultgren Date: Thu, 19 May 2022 10:58:14 +0100 Subject: [PATCH 045/150] [Metrics UI] Fix reporting of missing metrics in Infra metrics tables (#132329) * [Metrics UI] Fix null metrics reporting in infra tables (#130642) * Fix sorting after null check was fixed * Center loading spinner in container * Fix lazy evaluation risk Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../container/container_metrics_table.tsx | 6 +- .../container/use_container_metrics_table.ts | 93 ++++++++++---- .../host/host_metrics_table.tsx | 6 +- .../host/use_host_metrics_table.ts | 113 ++++++++++++++---- .../pod/pod_metrics_table.tsx | 6 +- .../pod/use_pod_metrics_table.ts | 93 ++++++++++---- .../hooks/use_infrastructure_node_metrics.ts | 54 ++++++--- 7 files changed, 285 insertions(+), 86 deletions(-) diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx index 02c7d0501cdef..b7ba7e17915e4 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx @@ -68,7 +68,11 @@ export const ContainerMetricsTable = (props: ContainerMetricsTableProps) => { ); if (isLoading) { - return ; + return ( + + + + ); } return ( diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts index 23c95c665aa91..fe570a80b6615 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts @@ -83,33 +83,77 @@ export function useContainerMetricsTable({ function seriesToContainerNodeMetricsRow(series: MetricsExplorerSeries): ContainerNodeMetricsRow { if (series.rows.length === 0) { - return { - name: series.id, - uptime: null, - averageCpuUsagePercent: null, - averageMemoryUsageMegabytes: null, - }; + return rowWithoutMetrics(series.id); } - let uptime: number = 0; - let averageCpuUsagePercent: number = 0; - let averageMemoryUsageMegabytes: number = 0; - series.rows.forEach((row) => { - const metricValues = unpackMetrics(row); - uptime += metricValues.uptime ?? 0; - averageCpuUsagePercent += metricValues.averageCpuUsagePercent ?? 0; - averageMemoryUsageMegabytes += metricValues.averageMemoryUsageMegabytes ?? 0; + return { + name: series.id, + ...calculateMetricAverages(series.rows), + }; +} + +function rowWithoutMetrics(name: string) { + return { + name, + uptime: null, + averageCpuUsagePercent: null, + averageMemoryUsageMegabytes: null, + }; +} + +function calculateMetricAverages(rows: MetricsExplorerRow[]) { + const { uptimeValues, averageCpuUsagePercentValues, averageMemoryUsageMegabytesValues } = + collectMetricValues(rows); + + let uptime = null; + if (uptimeValues.length !== 0) { + uptime = averageOfValues(uptimeValues); + } + + let averageCpuUsagePercent = null; + if (averageCpuUsagePercentValues.length !== 0) { + averageCpuUsagePercent = averageOfValues(averageCpuUsagePercentValues); + } + + let averageMemoryUsageMegabytes = null; + if (averageMemoryUsageMegabytesValues.length !== 0) { + const averageInBytes = averageOfValues(averageMemoryUsageMegabytesValues); + const bytesPerMegabyte = 1000000; + averageMemoryUsageMegabytes = Math.floor(averageInBytes / bytesPerMegabyte); + } + + return { + uptime, + averageCpuUsagePercent, + averageMemoryUsageMegabytes, + }; +} + +function collectMetricValues(rows: MetricsExplorerRow[]) { + const uptimeValues: number[] = []; + const averageCpuUsagePercentValues: number[] = []; + const averageMemoryUsageMegabytesValues: number[] = []; + + rows.forEach((row) => { + const { uptime, averageCpuUsagePercent, averageMemoryUsageMegabytes } = unpackMetrics(row); + + if (uptime !== null) { + uptimeValues.push(uptime); + } + + if (averageCpuUsagePercent !== null) { + averageCpuUsagePercentValues.push(averageCpuUsagePercent); + } + + if (averageMemoryUsageMegabytes !== null) { + averageMemoryUsageMegabytesValues.push(averageMemoryUsageMegabytes); + } }); - const bucketCount = series.rows.length; - const bytesPerMegabyte = 1000000; return { - name: series.id, - uptime: uptime / bucketCount, - averageCpuUsagePercent: averageCpuUsagePercent / bucketCount, - averageMemoryUsageMegabytes: Math.floor( - averageMemoryUsageMegabytes / bucketCount / bytesPerMegabyte - ), + uptimeValues, + averageCpuUsagePercentValues, + averageMemoryUsageMegabytesValues, }; } @@ -124,3 +168,8 @@ function unpackMetrics(row: MetricsExplorerRow): Omit acc + value, 0); + return sum / values.length; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx index d878fc091722b..8df9c973e5a17 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx @@ -68,7 +68,11 @@ export const HostMetricsTable = (props: HostMetricsTableProps) => { ); if (isLoading) { - return ; + return ( + + + + ); } return ( diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts index dddd5ad03c7b0..f82463e97a303 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts @@ -80,35 +80,95 @@ export function useHostMetricsTable({ timerange, filterClauseDsl }: UseNodeMetri function seriesToHostNodeMetricsRow(series: MetricsExplorerSeries): HostNodeMetricsRow { if (series.rows.length === 0) { - return { - name: series.id, - cpuCount: null, - averageCpuUsagePercent: null, - totalMemoryMegabytes: null, - averageMemoryUsagePercent: null, - }; + return rowWithoutMetrics(series.id); } - let cpuCount = 0; - let averageCpuUsagePercent = 0; - let totalMemoryMegabytes = 0; - let averageMemoryUsagePercent = 0; - series.rows.forEach((row) => { - const metricValues = unpackMetrics(row); - cpuCount += metricValues.cpuCount ?? 0; - averageCpuUsagePercent += metricValues.averageCpuUsagePercent ?? 0; - totalMemoryMegabytes += metricValues.totalMemoryMegabytes ?? 0; - averageMemoryUsagePercent += metricValues.averageMemoryUsagePercent ?? 0; + return { + name: series.id, + ...calculateMetricAverages(series.rows), + }; +} + +function rowWithoutMetrics(name: string) { + return { + name, + cpuCount: null, + averageCpuUsagePercent: null, + totalMemoryMegabytes: null, + averageMemoryUsagePercent: null, + }; +} + +function calculateMetricAverages(rows: MetricsExplorerRow[]) { + const { + cpuCountValues, + averageCpuUsagePercentValues, + totalMemoryMegabytesValues, + averageMemoryUsagePercentValues, + } = collectMetricValues(rows); + + let cpuCount = null; + if (cpuCountValues.length !== 0) { + cpuCount = averageOfValues(cpuCountValues); + } + + let averageCpuUsagePercent = null; + if (averageCpuUsagePercentValues.length !== 0) { + averageCpuUsagePercent = averageOfValues(averageCpuUsagePercentValues); + } + + let totalMemoryMegabytes = null; + if (totalMemoryMegabytesValues.length !== 0) { + const averageInBytes = averageOfValues(totalMemoryMegabytesValues); + const bytesPerMegabyte = 1000000; + totalMemoryMegabytes = Math.floor(averageInBytes / bytesPerMegabyte); + } + + let averageMemoryUsagePercent = null; + if (averageMemoryUsagePercentValues.length !== 0) { + averageMemoryUsagePercent = averageOfValues(averageMemoryUsagePercentValues); + } + + return { + cpuCount, + averageCpuUsagePercent, + totalMemoryMegabytes, + averageMemoryUsagePercent, + }; +} + +function collectMetricValues(rows: MetricsExplorerRow[]) { + const cpuCountValues: number[] = []; + const averageCpuUsagePercentValues: number[] = []; + const totalMemoryMegabytesValues: number[] = []; + const averageMemoryUsagePercentValues: number[] = []; + + rows.forEach((row) => { + const { cpuCount, averageCpuUsagePercent, totalMemoryMegabytes, averageMemoryUsagePercent } = + unpackMetrics(row); + + if (cpuCount !== null) { + cpuCountValues.push(cpuCount); + } + + if (averageCpuUsagePercent !== null) { + averageCpuUsagePercentValues.push(averageCpuUsagePercent); + } + + if (totalMemoryMegabytes !== null) { + totalMemoryMegabytesValues.push(totalMemoryMegabytes); + } + + if (averageMemoryUsagePercent !== null) { + averageMemoryUsagePercentValues.push(averageMemoryUsagePercent); + } }); - const bucketCount = series.rows.length; - const bytesPerMegabyte = 1000000; return { - name: series.id, - cpuCount: cpuCount / bucketCount, - averageCpuUsagePercent: averageCpuUsagePercent / bucketCount, - totalMemoryMegabytes: Math.floor(totalMemoryMegabytes / bucketCount / bytesPerMegabyte), - averageMemoryUsagePercent: averageMemoryUsagePercent / bucketCount, + cpuCountValues, + averageCpuUsagePercentValues, + totalMemoryMegabytesValues, + averageMemoryUsagePercentValues, }; } @@ -120,3 +180,8 @@ function unpackMetrics(row: MetricsExplorerRow): Omit acc + value, 0); + return sum / values.length; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx index 3739d6b468292..fa6d4b899f157 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx @@ -66,7 +66,11 @@ export const PodMetricsTable = (props: PodMetricsTableProps) => { }; if (isLoading) { - return ; + return ( + + + + ); } return ( diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts index 004ab2ab3ffff..e070d1ca9100c 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts @@ -80,33 +80,77 @@ export function usePodMetricsTable({ timerange, filterClauseDsl }: UseNodeMetric function seriesToPodNodeMetricsRow(series: MetricsExplorerSeries): PodNodeMetricsRow { if (series.rows.length === 0) { - return { - name: series.id, - uptime: null, - averageCpuUsagePercent: null, - averageMemoryUsageMegabytes: null, - }; + return rowWithoutMetrics(series.id); } - let uptime: number = 0; - let averageCpuUsagePercent: number = 0; - let averageMemoryUsagePercent: number = 0; - series.rows.forEach((row) => { - const metricValues = unpackMetrics(row); - uptime += metricValues.uptime ?? 0; - averageCpuUsagePercent += metricValues.averageCpuUsagePercent ?? 0; - averageMemoryUsagePercent += metricValues.averageMemoryUsageMegabytes ?? 0; + return { + name: series.id, + ...calculateMetricAverages(series.rows), + }; +} + +function rowWithoutMetrics(name: string) { + return { + name, + uptime: null, + averageCpuUsagePercent: null, + averageMemoryUsageMegabytes: null, + }; +} + +function calculateMetricAverages(rows: MetricsExplorerRow[]) { + const { uptimeValues, averageCpuUsagePercentValues, averageMemoryUsageMegabytesValues } = + collectMetricValues(rows); + + let uptime = null; + if (uptimeValues.length !== 0) { + uptime = averageOfValues(uptimeValues); + } + + let averageCpuUsagePercent = null; + if (averageCpuUsagePercentValues.length !== 0) { + averageCpuUsagePercent = averageOfValues(averageCpuUsagePercentValues); + } + + let averageMemoryUsageMegabytes = null; + if (averageMemoryUsageMegabytesValues.length !== 0) { + const averageInBytes = averageOfValues(averageMemoryUsageMegabytesValues); + const bytesPerMegabyte = 1000000; + averageMemoryUsageMegabytes = Math.floor(averageInBytes / bytesPerMegabyte); + } + + return { + uptime, + averageCpuUsagePercent, + averageMemoryUsageMegabytes, + }; +} + +function collectMetricValues(rows: MetricsExplorerRow[]) { + const uptimeValues: number[] = []; + const averageCpuUsagePercentValues: number[] = []; + const averageMemoryUsageMegabytesValues: number[] = []; + + rows.forEach((row) => { + const { uptime, averageCpuUsagePercent, averageMemoryUsageMegabytes } = unpackMetrics(row); + + if (uptime !== null) { + uptimeValues.push(uptime); + } + + if (averageCpuUsagePercent !== null) { + averageCpuUsagePercentValues.push(averageCpuUsagePercent); + } + + if (averageMemoryUsageMegabytes !== null) { + averageMemoryUsageMegabytesValues.push(averageMemoryUsageMegabytes); + } }); - const bucketCount = series.rows.length; - const bytesPerMegabyte = 1000000; return { - name: series.id, - uptime: uptime / bucketCount, - averageCpuUsagePercent: averageCpuUsagePercent / bucketCount, - averageMemoryUsageMegabytes: Math.floor( - averageMemoryUsagePercent / bucketCount / bytesPerMegabyte - ), + uptimeValues, + averageCpuUsagePercentValues, + averageMemoryUsageMegabytesValues, }; } @@ -121,3 +165,8 @@ function unpackMetrics(row: MetricsExplorerRow): Omit | null, }; } + +function averageOfValues(values: number[]) { + const sum = values.reduce((acc, value) => acc + value, 0); + return sum / values.length; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts index e165ee4d6ac48..374685a374f24 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts @@ -153,22 +153,46 @@ function makeSortNodes(sortState: SortState) { const nodeAValue = nodeA[sortState.field]; const nodeBValue = nodeB[sortState.field]; - if (typeof nodeAValue === 'string' && typeof nodeBValue === 'string') { - if (sortState.direction === 'asc') { - return nodeAValue.localeCompare(nodeBValue); - } else { - return nodeBValue.localeCompare(nodeAValue); - } + if (sortState.direction === 'asc') { + return sortAscending(nodeAValue, nodeBValue); } - if (typeof nodeAValue === 'number' && typeof nodeBValue === 'number') { - if (sortState.direction === 'asc') { - return nodeAValue - nodeBValue; - } else { - return nodeBValue - nodeAValue; - } - } - - return 0; + return sortDescending(nodeAValue, nodeBValue); }; } + +function sortAscending(nodeAValue: unknown, nodeBValue: unknown) { + if (nodeAValue === null) { + return -1; + } else if (nodeBValue === null) { + return 1; + } + + if (typeof nodeAValue === 'string' && typeof nodeBValue === 'string') { + return nodeAValue.localeCompare(nodeBValue); + } + + if (typeof nodeAValue === 'number' && typeof nodeBValue === 'number') { + return nodeAValue - nodeBValue; + } + + return 0; +} + +function sortDescending(nodeAValue: unknown, nodeBValue: unknown) { + if (nodeAValue === null) { + return 1; + } else if (nodeBValue === null) { + return -1; + } + + if (typeof nodeAValue === 'string' && typeof nodeBValue === 'string') { + return nodeBValue.localeCompare(nodeAValue); + } + + if (typeof nodeAValue === 'number' && typeof nodeBValue === 'number') { + return nodeBValue - nodeAValue; + } + + return 0; +} From ae0c68346a064361f73cc366115e4cfe2352fa11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Thu, 19 May 2022 12:03:10 +0200 Subject: [PATCH 046/150] Bump @storybook@6.4.22 (#129787) --- package.json | 52 +- packages/kbn-pm/dist/index.js | 156 +- packages/kbn-storybook/src/index.ts | 9 +- .../kbn-storybook/src/lib/default_config.ts | 6 +- packages/kbn-storybook/templates/index.ejs | 10 +- renovate.json | 8 + .../public/__stories__/shared/arg_types.ts | 6 +- .../replacement_card.component.tsx | 1 + .../discover/.storybook/discover.webpack.ts | 4 +- .../waterfall/accordion_waterfall.tsx | 12 +- .../analyze_data_button.stories.tsx | 8 +- .../context/breadcrumbs/use_breadcrumb.ts | 16 +- .../simple_template.stories.storyshot | 92 +- .../simple_template.stories.storyshot | 132 +- .../simple_template.stories.storyshot | 250 ++- .../canvas/storybook/canvas_webpack.ts | 3 +- .../fleet/.storybook/context/index.tsx | 4 +- .../test_utils/use_global_storybook_theme.tsx | 12 +- .../pages/overview/overview.stories.tsx | 6 +- .../event_details/table/field_value_cell.tsx | 5 +- yarn.lock | 1443 ++++++++--------- 21 files changed, 1078 insertions(+), 1157 deletions(-) diff --git a/package.json b/package.json index 2d3009b7b7099..7e4e2ea78175a 100644 --- a/package.json +++ b/package.json @@ -219,7 +219,7 @@ "@types/jsonwebtoken": "^8.5.6", "@types/mapbox__vector-tile": "1.3.0", "@types/moment-duration-format": "^2.2.3", - "@types/react-is": "^16.7.1", + "@types/react-is": "^16.7.2", "@types/rrule": "^2.2.9", "JSONStream": "1.3.5", "abort-controller": "^3.0.0", @@ -358,16 +358,16 @@ "rbush": "^3.0.1", "re-resizable": "^6.1.1", "re2": "1.17.4", - "react": "^16.12.0", + "react": "^16.14.0", "react-ace": "^7.0.5", "react-beautiful-dnd": "^13.1.0", "react-color": "^2.13.8", - "react-dom": "^16.12.0", + "react-dom": "^16.14.0", "react-dropzone": "^4.2.9", "react-fast-compare": "^2.0.4", "react-grid-layout": "^0.16.2", "react-intl": "^2.8.0", - "react-is": "^16.8.0", + "react-is": "^16.13.1", "react-markdown": "^4.3.1", "react-moment-proptypes": "^1.7.0", "react-monaco-editor": "^0.41.2", @@ -527,25 +527,26 @@ "@microsoft/api-extractor": "7.18.19", "@octokit/rest": "^16.35.0", "@percy/agent": "^0.28.6", - "@storybook/addon-a11y": "^6.3.12", - "@storybook/addon-actions": "^6.3.12", - "@storybook/addon-docs": "^6.3.12", - "@storybook/addon-essentials": "^6.3.12", - "@storybook/addon-knobs": "^6.3.1", - "@storybook/addon-storyshots": "^6.3.12", - "@storybook/addons": "^6.3.12", - "@storybook/api": "^6.3.12", - "@storybook/components": "^6.3.12", - "@storybook/core": "^6.3.12", - "@storybook/core-common": "^6.3.12", - "@storybook/core-events": "^6.3.12", - "@storybook/node-logger": "^6.3.12", - "@storybook/react": "^6.3.12", - "@storybook/testing-react": "^0.0.22", - "@storybook/theming": "^6.3.12", + "@storybook/addon-a11y": "^6.4.22", + "@storybook/addon-actions": "^6.4.22", + "@storybook/addon-controls": "^6.4.22", + "@storybook/addon-docs": "^6.4.22", + "@storybook/addon-essentials": "^6.4.22", + "@storybook/addon-knobs": "^6.4.0", + "@storybook/addon-storyshots": "^6.4.22", + "@storybook/addons": "^6.4.22", + "@storybook/api": "^6.4.22", + "@storybook/components": "^6.4.22", + "@storybook/core": "^6.4.22", + "@storybook/core-common": "^6.4.22", + "@storybook/core-events": "^6.4.22", + "@storybook/node-logger": "^6.4.22", + "@storybook/react": "^6.4.22", + "@storybook/testing-react": "^1.2.4", + "@storybook/theming": "^6.4.22", "@testing-library/dom": "^8.12.0", "@testing-library/jest-dom": "^5.16.3", - "@testing-library/react": "^12.1.4", + "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^13.5.0", "@types/apidoc": "^0.22.3", @@ -707,6 +708,7 @@ "@types/lz-string": "^1.3.34", "@types/markdown-it": "^12.2.3", "@types/md5": "^2.2.0", + "@types/micromatch": "^4.0.2", "@types/mime": "^2.0.1", "@types/mime-types": "^2.1.0", "@types/minimatch": "^2.0.29", @@ -734,10 +736,9 @@ "@types/pretty-ms": "^5.0.0", "@types/prop-types": "^15.7.3", "@types/rbush": "^3.0.0", - "@types/reach__router": "^1.2.6", - "@types/react": "^16.9.36", + "@types/react": "^16.14.25", "@types/react-beautiful-dnd": "^13.0.0", - "@types/react-dom": "^16.9.8", + "@types/react-dom": "^16.9.15", "@types/react-grid-layout": "^0.16.7", "@types/react-intl": "^2.3.15", "@types/react-redux": "^7.1.9", @@ -818,6 +819,7 @@ "cpy": "^8.1.1", "css-loader": "^3.4.2", "cssnano": "^4.1.11", + "csstype": "^3.0.2", "cypress": "^9.6.1", "cypress-axe": "^0.14.0", "cypress-file-upload": "^5.0.8", @@ -924,7 +926,7 @@ "prettier": "^2.6.2", "pretty-format": "^27.5.1", "q": "^1.5.1", - "react-test-renderer": "^16.12.0", + "react-test-renderer": "^16.14.0", "read-pkg": "^5.2.0", "regenerate": "^1.4.0", "resolve": "^1.22.0", diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 5045611c2ac2c..5699df6aa3666 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -45125,82 +45125,6 @@ exports.wrapOutput = (input, state = {}, options = {}) => { }; -/***/ }), - -/***/ "../../node_modules/pify/index.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -const processFn = (fn, options) => function (...args) { - const P = options.promiseModule; - - return new P((resolve, reject) => { - if (options.multiArgs) { - args.push((...result) => { - if (options.errorFirst) { - if (result[0]) { - reject(result); - } else { - result.shift(); - resolve(result); - } - } else { - resolve(result); - } - }); - } else if (options.errorFirst) { - args.push((error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } - }); - } else { - args.push(resolve); - } - - fn.apply(this, args); - }); -}; - -module.exports = (input, options) => { - options = Object.assign({ - exclude: [/.+(Sync|Stream)$/], - errorFirst: true, - promiseModule: Promise - }, options); - - const objType = typeof input; - if (!(input !== null && (objType === 'object' || objType === 'function'))) { - throw new TypeError(`Expected \`input\` to be a \`Function\` or \`Object\`, got \`${input === null ? 'null' : objType}\``); - } - - const filter = key => { - const match = pattern => typeof pattern === 'string' ? key === pattern : pattern.test(key); - return options.include ? options.include.some(match) : !options.exclude.some(match); - }; - - let ret; - if (objType === 'function') { - ret = function (...args) { - return options.excludeMain ? input(...args) : processFn(input, options).apply(this, args); - }; - } else { - ret = Object.create(Object.getPrototypeOf(input)); - } - - for (const key in input) { // eslint-disable-line guard-for-in - const property = input[key]; - ret[key] = typeof property === 'function' && filter(key) ? processFn(property, options) : property; - } - - return ret; -}; - - /***/ }), /***/ "../../node_modules/pump/index.js": @@ -59599,7 +59523,7 @@ const fs = __webpack_require__("../../node_modules/graceful-fs/graceful-fs.js"); const writeFileAtomic = __webpack_require__("../../node_modules/write-json-file/node_modules/write-file-atomic/index.js"); const sortKeys = __webpack_require__("../../node_modules/sort-keys/index.js"); const makeDir = __webpack_require__("../../node_modules/write-json-file/node_modules/make-dir/index.js"); -const pify = __webpack_require__("../../node_modules/pify/index.js"); +const pify = __webpack_require__("../../node_modules/write-json-file/node_modules/pify/index.js"); const detectIndent = __webpack_require__("../../node_modules/write-json-file/node_modules/detect-indent/index.js"); const init = (fn, filePath, data, options) => { @@ -59810,7 +59734,7 @@ module.exports = str => { const fs = __webpack_require__("fs"); const path = __webpack_require__("path"); -const pify = __webpack_require__("../../node_modules/pify/index.js"); +const pify = __webpack_require__("../../node_modules/write-json-file/node_modules/pify/index.js"); const semver = __webpack_require__("../../node_modules/write-json-file/node_modules/semver/semver.js"); const defaults = { @@ -59948,6 +59872,82 @@ module.exports.sync = (input, options) => { }; +/***/ }), + +/***/ "../../node_modules/write-json-file/node_modules/pify/index.js": +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +const processFn = (fn, options) => function (...args) { + const P = options.promiseModule; + + return new P((resolve, reject) => { + if (options.multiArgs) { + args.push((...result) => { + if (options.errorFirst) { + if (result[0]) { + reject(result); + } else { + result.shift(); + resolve(result); + } + } else { + resolve(result); + } + }); + } else if (options.errorFirst) { + args.push((error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + } else { + args.push(resolve); + } + + fn.apply(this, args); + }); +}; + +module.exports = (input, options) => { + options = Object.assign({ + exclude: [/.+(Sync|Stream)$/], + errorFirst: true, + promiseModule: Promise + }, options); + + const objType = typeof input; + if (!(input !== null && (objType === 'object' || objType === 'function'))) { + throw new TypeError(`Expected \`input\` to be a \`Function\` or \`Object\`, got \`${input === null ? 'null' : objType}\``); + } + + const filter = key => { + const match = pattern => typeof pattern === 'string' ? key === pattern : pattern.test(key); + return options.include ? options.include.some(match) : !options.exclude.some(match); + }; + + let ret; + if (objType === 'function') { + ret = function (...args) { + return options.excludeMain ? input(...args) : processFn(input, options).apply(this, args); + }; + } else { + ret = Object.create(Object.getPrototypeOf(input)); + } + + for (const key in input) { // eslint-disable-line guard-for-in + const property = input[key]; + ret[key] = typeof property === 'function' && filter(key) ? processFn(property, options) : property; + } + + return ret; +}; + + /***/ }), /***/ "../../node_modules/write-json-file/node_modules/semver/semver.js": diff --git a/packages/kbn-storybook/src/index.ts b/packages/kbn-storybook/src/index.ts index b3258be91ed82..f986e35d1b4ed 100644 --- a/packages/kbn-storybook/src/index.ts +++ b/packages/kbn-storybook/src/index.ts @@ -6,6 +6,13 @@ * Side Public License, v 1. */ -export { defaultConfig, defaultConfigWebFinal, mergeWebpackFinal } from './lib/default_config'; +import { + defaultConfig, + defaultConfigWebFinal, + mergeWebpackFinal, + StorybookConfig, +} from './lib/default_config'; +export { defaultConfig, defaultConfigWebFinal, mergeWebpackFinal }; +export type { StorybookConfig }; export { runStorybookCli } from './lib/run_storybook_cli'; export { default as WebpackConfig } from './webpack.config'; diff --git a/packages/kbn-storybook/src/lib/default_config.ts b/packages/kbn-storybook/src/lib/default_config.ts index 0f0b8070ff8b0..a2712d3d6f24e 100644 --- a/packages/kbn-storybook/src/lib/default_config.ts +++ b/packages/kbn-storybook/src/lib/default_config.ts @@ -7,12 +7,14 @@ */ import * as path from 'path'; -import { StorybookConfig } from '@storybook/core-common'; +import type { StorybookConfig } from '@storybook/core-common'; import { Configuration } from 'webpack'; import webpackMerge from 'webpack-merge'; import { REPO_ROOT } from './constants'; import { default as WebpackConfig } from '../webpack.config'; +export type { StorybookConfig }; + const toPath = (_path: string) => path.join(REPO_ROOT, _path); // This ignore pattern excludes all of node_modules EXCEPT for `@kbn`. This allows for @@ -81,7 +83,7 @@ export const defaultConfig: StorybookConfig = { // an issue with storybook typescript setup see this issue for more details // https://github.com/storybookjs/storybook/issues/9610 -export const defaultConfigWebFinal = { +export const defaultConfigWebFinal: StorybookConfig = { ...defaultConfig, webpackFinal: (config: Configuration) => { return WebpackConfig({ config }); diff --git a/packages/kbn-storybook/templates/index.ejs b/packages/kbn-storybook/templates/index.ejs index 53dc0f5e55750..73367d44cd393 100644 --- a/packages/kbn-storybook/templates/index.ejs +++ b/packages/kbn-storybook/templates/index.ejs @@ -6,10 +6,10 @@ - <%= options.title || 'Storybook'%> + <%= htmlWebpackPlugin.options.title || 'Storybook'%> - <% if (files.favicon) { %> - + <% if (htmlWebpackPlugin.files.favicon) { %> + <% } %> @@ -26,7 +26,7 @@ <% if (typeof headHtmlSnippet !== 'undefined') { %> <%= headHtmlSnippet %> <% } %> <% - files.css.forEach(file => { %> + htmlWebpackPlugin.files.css.forEach(file => { %> <% }); %> @@ -58,7 +58,7 @@ <% } %> - <% files.js.forEach(file => { %> + <% htmlWebpackPlugin.files.js.forEach(file => { %> <% }); %> diff --git a/renovate.json b/renovate.json index 4b9418311ced7..3d24e88d638b0 100644 --- a/renovate.json +++ b/renovate.json @@ -157,6 +157,14 @@ "matchBaseBranches": ["main"], "labels": ["Team:Operations", "release_note:skip"], "enabled": true + }, + { + "groupName": "@storybook", + "reviewers": ["team:kibana-operations"], + "matchBaseBranches": ["main"], + "matchPackagePatterns": ["^@storybook"], + "labels": ["Team:Operations", "release_note:skip"], + "enabled": true } ] } diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts index 1a18c905548d4..7b1b83429ef7b 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts @@ -54,14 +54,14 @@ export const argTypes: ArgTypes = { palette: { name: `${visConfigName}.palette`, description: 'Palette', - type: { name: 'palette', required: false }, + type: { name: 'other', required: true, value: 'string' }, table: { type: { summary: 'object' } }, control: { type: 'object' }, }, labels: { name: `${visConfigName}.labels`, description: 'Labels configuration', - type: { name: 'object', required: false }, + type: { name: 'other', required: false, value: 'string' }, table: { type: { summary: 'object', @@ -81,7 +81,7 @@ export const argTypes: ArgTypes = { dimensions: { name: `${visConfigName}.dimensions`, description: 'dimensions configuration', - type: { name: 'object', required: false }, + type: { name: 'other', required: false, value: 'string' }, table: { type: { summary: 'object', diff --git a/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx b/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx index 9b5e1248d1938..8115872749c3e 100644 --- a/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx +++ b/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx @@ -5,6 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +/** @jsxRuntime classic */ /** @jsx jsx */ import { css, jsx } from '@emotion/react'; diff --git a/src/plugins/discover/.storybook/discover.webpack.ts b/src/plugins/discover/.storybook/discover.webpack.ts index 7b978a4e7110e..c548162f7730c 100644 --- a/src/plugins/discover/.storybook/discover.webpack.ts +++ b/src/plugins/discover/.storybook/discover.webpack.ts @@ -5,9 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { defaultConfig } from '@kbn/storybook'; +import { defaultConfig, StorybookConfig } from '@kbn/storybook'; -export const discoverStorybookConfig = { +export const discoverStorybookConfig: StorybookConfig = { ...defaultConfig, stories: ['../**/*.stories.tsx'], addons: [...(defaultConfig.addons || []), './addon/target/register'], diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx index 695ebfd9a8976..804a27481422e 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx @@ -13,7 +13,7 @@ import { EuiIcon, EuiText, } from '@elastic/eui'; -import React, { Dispatch, SetStateAction, useState } from 'react'; +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { Margins } from '../../../../../shared/charts/timeline'; import { @@ -76,8 +76,6 @@ const StyledAccordion = euiStyled(EuiAccordion).withConfig({ `; export function AccordionWaterfall(props: AccordionWaterfallProps) { - const [isOpen, setIsOpen] = useState(props.isOpen); - const { item, level, @@ -89,8 +87,12 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) { onClickWaterfallItem, } = props; - const nextLevel = level + 1; - setMaxLevel(nextLevel); + const [isOpen, setIsOpen] = useState(props.isOpen); + const [nextLevel] = useState(level + 1); + + useEffect(() => { + setMaxLevel(nextLevel); + }, [nextLevel, setMaxLevel]); const children = waterfall.childrenByParentId[item.id] || []; const errorCount = waterfall.getErrorCount(item.id); diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx index 9245302539efb..2708c46b52960 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { Story, StoryContext } from '@storybook/react'; -import React, { ComponentType } from 'react'; +import type { Story, DecoratorFn } from '@storybook/react'; +import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { CoreStart } from '@kbn/core/public'; import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; @@ -26,7 +26,7 @@ export default { title: 'routing/templates/ApmServiceTemplate/AnalyzeDataButton', component: AnalyzeDataButton, decorators: [ - (StoryComponent: ComponentType, { args }: StoryContext) => { + (StoryComponent, { args }) => { const { agentName, canShowDashboard, environment, serviceName } = args; const KibanaContext = createKibanaReactContext({ @@ -61,7 +61,7 @@ export default { ); }, - ], + ] as DecoratorFn[], }; export const Example: Story = () => { diff --git a/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts b/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts index dfc33c0f10ffc..980c7986d098a 100644 --- a/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts +++ b/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts @@ -21,17 +21,17 @@ export function useBreadcrumb(breadcrumb: Breadcrumb | Breadcrumb[]) { const matchedRoute = useRef(match?.route); - if (matchedRoute.current && matchedRoute.current !== match?.route) { - api.unset(matchedRoute.current); - } + useEffect(() => { + if (matchedRoute.current && matchedRoute.current !== match?.route) { + api.unset(matchedRoute.current); + } - matchedRoute.current = match?.route; + matchedRoute.current = match?.route; - if (matchedRoute.current) { - api.set(matchedRoute.current, castArray(breadcrumb)); - } + if (matchedRoute.current) { + api.set(matchedRoute.current, castArray(breadcrumb)); + } - useEffect(() => { return () => { if (matchedRoute.current) { api.unset(matchedRoute.current); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/simple_template.stories.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/simple_template.stories.storyshot index 118f300ccab09..0b9358714e71c 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/simple_template.stories.storyshot +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/simple_template.stories.storyshot @@ -11,38 +11,28 @@ exports[`Storyshots arguments/AxisConfig simple 1`] = ` } >
-
- -
+ className="euiSwitch__thumb" + /> + + +
`; @@ -58,38 +48,28 @@ exports[`Storyshots arguments/AxisConfig/components simple template 1`] = ` } >
-
- -
+ className="euiSwitch__thumb" + /> + + +
`; diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__stories__/__snapshots__/simple_template.stories.storyshot b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__stories__/__snapshots__/simple_template.stories.storyshot index af099aefbc0e5..10a5c634da162 100644 --- a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__stories__/__snapshots__/simple_template.stories.storyshot +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__stories__/__snapshots__/simple_template.stories.storyshot @@ -13,55 +13,45 @@ exports[`Storyshots arguments/ContainerStyle simple 1`] = `
-
- -
+ } + /> +
+
@@ -81,55 +71,45 @@ exports[`Storyshots arguments/ContainerStyle/components simple template 1`] = `
-
- -
+ } + /> +
+
diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__stories__/__snapshots__/simple_template.stories.storyshot b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__stories__/__snapshots__/simple_template.stories.storyshot index f5298c1d1a908..f444266239314 100644 --- a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__stories__/__snapshots__/simple_template.stories.storyshot +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__stories__/__snapshots__/simple_template.stories.storyshot @@ -11,42 +11,32 @@ exports[`Storyshots arguments/SeriesStyle simple 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
@@ -64,42 +54,32 @@ exports[`Storyshots arguments/SeriesStyle/components simple: defaults 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
@@ -117,42 +97,32 @@ exports[`Storyshots arguments/SeriesStyle/components simple: no labels 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
@@ -170,62 +140,52 @@ exports[`Storyshots arguments/SeriesStyle/components simple: no series 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
-
+
+ - - Info - + Info -
+
@@ -242,42 +202,32 @@ exports[`Storyshots arguments/SeriesStyle/components simple: with series 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
diff --git a/x-pack/plugins/canvas/storybook/canvas_webpack.ts b/x-pack/plugins/canvas/storybook/canvas_webpack.ts index db59af20440e2..e8ce5ff03b812 100644 --- a/x-pack/plugins/canvas/storybook/canvas_webpack.ts +++ b/x-pack/plugins/canvas/storybook/canvas_webpack.ts @@ -7,6 +7,7 @@ import { resolve } from 'path'; import { defaultConfig, mergeWebpackFinal } from '@kbn/storybook'; +import type { StorybookConfig } from '@kbn/storybook'; import { KIBANA_ROOT } from './constants'; export const canvasWebpack = { @@ -61,7 +62,7 @@ export const canvasWebpack = { }, }; -export const canvasStorybookConfig = { +export const canvasStorybookConfig: StorybookConfig = { ...defaultConfig, addons: [...(defaultConfig.addons || []), './addon/target/register'], ...mergeWebpackFinal(canvasWebpack), diff --git a/x-pack/plugins/fleet/.storybook/context/index.tsx b/x-pack/plugins/fleet/.storybook/context/index.tsx index 15ee77506cc0e..2877f265f8c1c 100644 --- a/x-pack/plugins/fleet/.storybook/context/index.tsx +++ b/x-pack/plugins/fleet/.storybook/context/index.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useMemo, useCallback } from 'react'; import { EMPTY } from 'rxjs'; -import type { StoryContext } from '@storybook/react'; +import type { DecoratorFn } from '@storybook/react'; import { createBrowserHistory } from 'history'; import { I18nProvider } from '@kbn/i18n-react'; @@ -40,7 +40,7 @@ import { getExecutionContext } from './execution_context'; // mock later, (or, ideally, Fleet starts to use a service abstraction). // // Expect this to grow as components that are given Stories need access to mocked services. -export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({ +export const StorybookContext: React.FC<{ storyContext?: Parameters[1] }> = ({ storyContext, children: storyChildren, }) => { diff --git a/x-pack/plugins/infra/public/test_utils/use_global_storybook_theme.tsx b/x-pack/plugins/infra/public/test_utils/use_global_storybook_theme.tsx index 7d32cb6360fdf..4d1feb4617dcf 100644 --- a/x-pack/plugins/infra/public/test_utils/use_global_storybook_theme.tsx +++ b/x-pack/plugins/infra/public/test_utils/use_global_storybook_theme.tsx @@ -5,13 +5,15 @@ * 2.0. */ -import type { StoryContext } from '@storybook/addons'; +import type { DecoratorFn } from '@storybook/react'; import React, { useEffect, useMemo, useState } from 'react'; import { BehaviorSubject } from 'rxjs'; import type { CoreTheme } from '@kbn/core/public'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +type StoryContext = Parameters[1]; + export const useGlobalStorybookTheme = ({ globals: { euiTheme } }: StoryContext) => { const theme = useMemo(() => euiThemeFromId(euiTheme), [euiTheme]); const [theme$] = useState(() => new BehaviorSubject(theme)); @@ -38,11 +40,9 @@ export const GlobalStorybookThemeProviders: React.FC<{ storyContext: StoryContex ); }; -export const decorateWithGlobalStorybookThemeProviders = < - StoryFnReactReturnType extends React.ReactNode ->( - wrappedStory: () => StoryFnReactReturnType, - storyContext: StoryContext +export const decorateWithGlobalStorybookThemeProviders: DecoratorFn = ( + wrappedStory, + storyContext ) => ( {wrappedStory()} diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index 95d263168f82e..097d0d0845dca 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -8,7 +8,7 @@ import { makeDecorator } from '@storybook/addons'; import { storiesOf } from '@storybook/react'; import { AppMountParameters, CoreStart } from '@kbn/core/public'; -import React from 'react'; +import React, { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { UI_SETTINGS } from '@kbn/data-plugin/public'; import { createKibanaReactContext, KibanaPageTemplate } from '@kbn/kibana-react-plugin/public'; @@ -37,7 +37,7 @@ const sampleAPMIndices = { transaction: 'apm-*' } as ApmIndicesConfig; const withCore = makeDecorator({ name: 'withCore', parameterName: 'core', - wrapper: (storyFn, context, { options: { theme, ...options } }) => { + wrapper: (storyFn, context) => { unregisterAll(); const KibanaReactContext = createKibanaReactContext({ application: { @@ -93,7 +93,7 @@ const withCore = makeDecorator({ kibanaFeatures: [], }} > - {storyFn(context)} + {storyFn(context) as ReactNode} diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx index 2be7b4071f15a..8c9bc4830b6d8 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx @@ -5,9 +5,8 @@ * 2.0. */ -import React from 'react'; +import React, { CSSProperties } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { CSSObject } from 'styled-components'; import { BrowserField } from '../../../containers/source'; import { OverflowField } from '../../tables/helpers'; import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field'; @@ -22,7 +21,7 @@ export interface FieldValueCellProps { getLinkValue?: (field: string) => string | null; isDraggable?: boolean; linkValue?: string | null | undefined; - style?: CSSObject | undefined; + style?: CSSProperties | undefined; values: string[] | null | undefined; } diff --git a/yarn.lock b/yarn.lock index 30f73d40cd149..ec5afced2df22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -63,13 +63,6 @@ "@nicolo-ribaudo/chokidar-2" "2.1.8-no-fsevents.3" chokidar "^3.4.0" -"@babel/code-frame@7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" - integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== - dependencies: - "@babel/highlight" "^7.10.4" - "@babel/code-frame@7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" @@ -4048,16 +4041,19 @@ which "^2.0.1" winston "^3.0.0" -"@pmmmwh/react-refresh-webpack-plugin@^0.4.3": - version "0.4.3" - resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.4.3.tgz#1eec460596d200c0236bf195b078a5d1df89b766" - integrity sha512-br5Qwvh8D2OQqSXpd1g/xqXKnK0r+Jz6qVKBbWmpUcrbGOxUrf39V5oZ1876084CGn18uMdR5uvPqBv9UqtBjQ== +"@pmmmwh/react-refresh-webpack-plugin@^0.5.1": + version "0.5.5" + resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.5.tgz#e77aac783bd079f548daa0a7f080ab5b5a9741ca" + integrity sha512-RbG7h6TuP6nFFYKJwbcToA1rjC1FyPg25NR2noAZ0vKI+la01KTSRPkuVPE+U88jXv7javx2JHglUcL1MHcshQ== dependencies: - ansi-html "^0.0.7" + ansi-html-community "^0.0.8" + common-path-prefix "^3.0.0" + core-js-pure "^3.8.1" error-stack-parser "^2.0.6" - html-entities "^1.2.1" - native-url "^0.2.6" - schema-utils "^2.6.5" + find-up "^5.0.0" + html-entities "^2.1.0" + loader-utils "^2.0.0" + schema-utils "^3.0.0" source-map "^0.7.3" "@polka/url@^1.0.0-next.20": @@ -4130,16 +4126,6 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= -"@reach/router@^1.3.4": - version "1.3.4" - resolved "https://registry.yarnpkg.com/@reach/router/-/router-1.3.4.tgz#d2574b19370a70c80480ed91f3da840136d10f8c" - integrity sha512-+mtn9wjlB9NN2CNnnC/BRYtwdKBfSyyasPYraNAyvaV1occr/5NnB4CVzjEZipNHwYebQwcndGUmpFzxAUoqSA== - dependencies: - create-react-context "0.3.0" - invariant "^2.2.3" - prop-types "^15.6.1" - react-lifecycles-compat "^3.0.4" - "@redux-saga/core@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@redux-saga/core/-/core-1.1.3.tgz#3085097b57a4ea8db5528d58673f20ce0950f6a4" @@ -4311,62 +4297,64 @@ "@types/node" ">=8.9.0" axios "^0.21.1" -"@storybook/addon-a11y@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-a11y/-/addon-a11y-6.3.12.tgz#2f930fc84fc275a4ed43a716fc09cc12caf4e110" - integrity sha512-q1NdRHFJV6sLEEJw0hatCc5ZIthELqM/AWdrEWDyhcJNyiq7Tq4nKqQBMTQSYwHiUAmxVgw7i4oa1vM2M51/3g== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/channels" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/theming" "6.3.12" +"@storybook/addon-a11y@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-a11y/-/addon-a11y-6.4.22.tgz#df75f1a82c83973c165984e8b0944ceed64c30e9" + integrity sha512-y125LDx5VR6JmiHB6/0RHWudwhe9QcFXqoAqGqWIj4zRv0kb9AyDPDtWvtDOSImCDXIPRmd8P05xTOnYH0ET3w== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/theming" "6.4.22" axe-core "^4.2.0" core-js "^3.8.2" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" react-sizeme "^3.0.1" regenerator-runtime "^0.13.7" ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/addon-actions@6.3.12", "@storybook/addon-actions@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-6.3.12.tgz#69eb5f8f780f1b00456051da6290d4b959ba24a0" - integrity sha512-mzuN4Ano4eyicwycM2PueGzzUCAEzt9/6vyptWEIVJu0sjK0J9KtBRlqFi1xGQxmCfimDR/n/vWBBkc7fp2uJA== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/theming" "6.3.12" +"@storybook/addon-actions@6.4.22", "@storybook/addon-actions@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-6.4.22.tgz#ec1b4332e76a8021dc0a1375dfd71a0760457588" + integrity sha512-t2w3iLXFul+R/1ekYxIEzUOZZmvEa7EzUAVAuCHP4i6x0jBnTTZ7sAIUVRaxVREPguH5IqI/2OklYhKanty2Yw== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/theming" "6.4.22" core-js "^3.8.2" fast-deep-equal "^3.1.3" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" polished "^4.0.5" prop-types "^15.7.2" react-inspector "^5.1.0" regenerator-runtime "^0.13.7" + telejson "^5.3.2" ts-dedent "^2.0.0" util-deprecate "^1.0.2" uuid-browser "^3.1.0" -"@storybook/addon-backgrounds@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-backgrounds/-/addon-backgrounds-6.3.12.tgz#5feecd461f48178aa976ba2694418e9ea1d621b3" - integrity sha512-51cHBx0HV7K/oRofJ/1pE05qti6sciIo8m4iPred1OezXIrJ/ckzP+gApdaUdzgcLAr6/MXQWLk0sJuImClQ6w== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/theming" "6.3.12" +"@storybook/addon-backgrounds@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-backgrounds/-/addon-backgrounds-6.4.22.tgz#5d9dbff051eefc1ca6e6c7973c01d17fbef4c2f5" + integrity sha512-xQIV1SsjjRXP7P5tUoGKv+pul1EY8lsV7iBXQb5eGbp4AffBj3qoYBSZbX4uiazl21o0MQiQoeIhhaPVaFIIGg== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/theming" "6.4.22" core-js "^3.8.2" global "^4.4.0" memoizerific "^1.11.3" @@ -4374,24 +4362,28 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/addon-controls@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-6.3.12.tgz#dbb732c62cf06fb7ccaf87d6ab11c876d14456fc" - integrity sha512-WO/PbygE4sDg3BbstJ49q0uM3Xu5Nw4lnHR5N4hXSvRAulZt1d1nhphRTHjfX+CW+uBcfzkq9bksm6nKuwmOyw== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/node-logger" "6.3.12" - "@storybook/theming" "6.3.12" +"@storybook/addon-controls@6.4.22", "@storybook/addon-controls@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-6.4.22.tgz#42c7f426eb7ba6d335e8e14369d6d13401878665" + integrity sha512-f/M/W+7UTEUnr/L6scBMvksq+ZA8GTfh3bomE5FtWyOyaFppq9k8daKAvdYNlzXAOrUUsoZVJDgpb20Z2VBiSQ== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-common" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/node-logger" "6.4.22" + "@storybook/store" "6.4.22" + "@storybook/theming" "6.4.22" core-js "^3.8.2" + lodash "^4.17.21" ts-dedent "^2.0.0" -"@storybook/addon-docs@6.3.12", "@storybook/addon-docs@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-6.3.12.tgz#2ec73b4f231d9f190d5c89295bc47bea6a95c6d1" - integrity sha512-iUrqJBMTOn2PgN8AWNQkfxfIPkh8pEg27t8UndMgfOpeGK/VWGw2UEifnA82flvntcilT4McxmVbRHkeBY9K5A== +"@storybook/addon-docs@6.4.22", "@storybook/addon-docs@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-6.4.22.tgz#19f22ede8ae31291069af7ab5abbc23fa269012b" + integrity sha512-9j+i+W+BGHJuRe4jUrqk6ubCzP4fc1xgFS2o8pakRiZgPn5kUQPdkticmsyh1XeEJifwhqjKJvkEDrcsleytDA== dependencies: "@babel/core" "^7.12.10" "@babel/generator" "^7.12.11" @@ -4402,20 +4394,21 @@ "@mdx-js/loader" "^1.6.22" "@mdx-js/mdx" "^1.6.22" "@mdx-js/react" "^1.6.22" - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/builder-webpack4" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/csf" "0.0.1" - "@storybook/csf-tools" "6.3.12" - "@storybook/node-logger" "6.3.12" - "@storybook/postinstall" "6.3.12" - "@storybook/source-loader" "6.3.12" - "@storybook/theming" "6.3.12" + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/builder-webpack4" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/csf-tools" "6.4.22" + "@storybook/node-logger" "6.4.22" + "@storybook/postinstall" "6.4.22" + "@storybook/preview-web" "6.4.22" + "@storybook/source-loader" "6.4.22" + "@storybook/store" "6.4.22" + "@storybook/theming" "6.4.22" acorn "^7.4.1" acorn-jsx "^5.3.1" acorn-walk "^7.2.0" @@ -4427,41 +4420,42 @@ html-tags "^3.1.0" js-string-escape "^1.0.1" loader-utils "^2.0.0" - lodash "^4.17.20" + lodash "^4.17.21" + nanoid "^3.1.23" p-limit "^3.1.0" - prettier "~2.2.1" + prettier ">=2.2.1 <=2.3.0" prop-types "^15.7.2" - react-element-to-jsx-string "^14.3.2" + react-element-to-jsx-string "^14.3.4" regenerator-runtime "^0.13.7" remark-external-links "^8.0.0" remark-slug "^6.0.0" ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/addon-essentials@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-essentials/-/addon-essentials-6.3.12.tgz#445cc4bc2eb9168a9e5de1fdfb5ef3b92974e74b" - integrity sha512-PK0pPE0xkq00kcbBcFwu/5JGHQTu4GvLIHfwwlEGx6GWNQ05l6Q+1Z4nE7xJGv2PSseSx3CKcjn8qykNLe6O6g== - dependencies: - "@storybook/addon-actions" "6.3.12" - "@storybook/addon-backgrounds" "6.3.12" - "@storybook/addon-controls" "6.3.12" - "@storybook/addon-docs" "6.3.12" - "@storybook/addon-measure" "^2.0.0" - "@storybook/addon-toolbars" "6.3.12" - "@storybook/addon-viewport" "6.3.12" - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/node-logger" "6.3.12" +"@storybook/addon-essentials@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-essentials/-/addon-essentials-6.4.22.tgz#6981c89e8b315cda7ce93b9bf74e98ca80aec00a" + integrity sha512-GTv291fqvWq2wzm7MruBvCGuWaCUiuf7Ca3kzbQ/WqWtve7Y/1PDsqRNQLGZrQxkXU0clXCqY1XtkTrtA3WGFQ== + dependencies: + "@storybook/addon-actions" "6.4.22" + "@storybook/addon-backgrounds" "6.4.22" + "@storybook/addon-controls" "6.4.22" + "@storybook/addon-docs" "6.4.22" + "@storybook/addon-measure" "6.4.22" + "@storybook/addon-outline" "6.4.22" + "@storybook/addon-toolbars" "6.4.22" + "@storybook/addon-viewport" "6.4.22" + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/node-logger" "6.4.22" core-js "^3.8.2" regenerator-runtime "^0.13.7" - storybook-addon-outline "^1.4.1" ts-dedent "^2.0.0" -"@storybook/addon-knobs@^6.3.1": - version "6.3.1" - resolved "https://registry.yarnpkg.com/@storybook/addon-knobs/-/addon-knobs-6.3.1.tgz#2115c6f0d5759e4fe73d5f25710f4a94ebd6f0db" - integrity sha512-2GGGnQSPXXUhHHYv4IW6pkyQlCPYXKYiyGzfhV7Zhs95M2Ban08OA6KLmliMptWCt7U9tqTO8dB5u0C2cWmCTw== +"@storybook/addon-knobs@^6.4.0": + version "6.4.0" + resolved "https://registry.yarnpkg.com/@storybook/addon-knobs/-/addon-knobs-6.4.0.tgz#fa5943ef21826cdc2e20ded74edfdf5a6dc71dcf" + integrity sha512-DiH1/5e2AFHoHrncl1qLu18ZHPHzRMMPvOLFz8AWvvmc+VCqTdIaE+tdxKr3e8rYylKllibgvDOzrLjfTNjF+Q== dependencies: copy-to-clipboard "^3.3.1" core-js "^3.8.2" @@ -4475,25 +4469,52 @@ react-lifecycles-compat "^3.0.4" react-select "^3.2.0" -"@storybook/addon-measure@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-measure/-/addon-measure-2.0.0.tgz#c40bbe91bacd3f795963dc1ee6ff86be87deeda9" - integrity sha512-ZhdT++cX+L9LwjhGYggvYUUVQH/MGn2rwbrAwCMzA/f2QTFvkjxzX8nDgMxIhaLCDC+gHIxfJG2wrWN0jkBr3g== +"@storybook/addon-measure@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-measure/-/addon-measure-6.4.22.tgz#5e2daac4184a4870b6b38ff71536109b7811a12a" + integrity sha512-CjDXoCNIXxNfXfgyJXPc0McjCcwN1scVNtHa9Ckr+zMjiQ8pPHY7wDZCQsG69KTqcWHiVfxKilI82456bcHYhQ== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + core-js "^3.8.2" + global "^4.4.0" + +"@storybook/addon-outline@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-outline/-/addon-outline-6.4.22.tgz#7a2776344785f7deab83338fbefbefd5e6cfc8cf" + integrity sha512-VIMEzvBBRbNnupGU7NV0ahpFFb6nKVRGYWGREjtABdFn2fdKr1YicOHFe/3U7hRGjb5gd+VazSvyUvhaKX9T7Q== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + core-js "^3.8.2" + global "^4.4.0" + regenerator-runtime "^0.13.7" + ts-dedent "^2.0.0" -"@storybook/addon-storyshots@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-storyshots/-/addon-storyshots-6.3.12.tgz#542bba23a6ad65a4a0b77427169f177e24f5c5f1" - integrity sha512-plpy/q3pPpXtK9DyofE0trTeCZIyU0Z+baybbxltsM/tKFuQxbHSxTwgluq/7LOMkaRPgbddGyHForHoRLjsWg== +"@storybook/addon-storyshots@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-storyshots/-/addon-storyshots-6.4.22.tgz#a2e4053eb36394667dfeabfe0de4d0e91cc4ad40" + integrity sha512-9u+uigHH4khxHB18z1TOau+RKpLo/8tdhvKVqgjy6pr3FSsgp+JyoI+ubDtgWAWFHQ0Zhh5MBWNDmPOo5pwBdA== dependencies: "@jest/transform" "^26.6.2" - "@storybook/addons" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/core" "6.3.12" - "@storybook/core-common" "6.3.12" + "@storybook/addons" "6.4.22" + "@storybook/babel-plugin-require-context-hook" "1.0.1" + "@storybook/client-api" "6.4.22" + "@storybook/core" "6.4.22" + "@storybook/core-client" "6.4.22" + "@storybook/core-common" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" "@types/glob" "^7.1.3" "@types/jest" "^26.0.16" "@types/jest-specific-snapshot" "^0.5.3" - babel-plugin-require-context-hook "^1.0.0" core-js "^3.8.2" glob "^7.1.6" global "^4.4.0" @@ -4505,81 +4526,84 @@ regenerator-runtime "^0.13.7" ts-dedent "^2.0.0" -"@storybook/addon-toolbars@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-toolbars/-/addon-toolbars-6.3.12.tgz#bc0d420b3476c891c42f7b0ab3b457e9e5ef7ca5" - integrity sha512-8GvP6zmAfLPRnYRARSaIwLkQClLIRbflRh4HZoFk6IMjQLXZb4NL3JS5OLFKG+HRMMU2UQzfoSDqjI7k7ptyRw== +"@storybook/addon-toolbars@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-toolbars/-/addon-toolbars-6.4.22.tgz#858a4e5939987c188c96ed374ebeea88bdd9e8de" + integrity sha512-FFyj6XDYpBBjcUu6Eyng7R805LUbVclEfydZjNiByAoDVyCde9Hb4sngFxn/T4fKAfBz/32HKVXd5iq4AHYtLg== dependencies: - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/theming" "6.3.12" + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/theming" "6.4.22" core-js "^3.8.2" regenerator-runtime "^0.13.7" -"@storybook/addon-viewport@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-viewport/-/addon-viewport-6.3.12.tgz#2fd61e60644fb07185a662f75b3e9dad8ad14f01" - integrity sha512-TRjyfm85xouOPmXxeLdEIzXLfJZZ1ePQ7p/5yphDGBHdxMU4m4qiZr8wYpUaxHsRu/UB3dKfaOyGT+ivogbnbw== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/theming" "6.3.12" +"@storybook/addon-viewport@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-viewport/-/addon-viewport-6.4.22.tgz#381a2fc4764fe0851889994a5ba36c3121300c11" + integrity sha512-6jk0z49LemeTblez5u2bYXYr6U+xIdLbywe3G283+PZCBbEDE6eNYy2d2HDL+LbCLbezJBLYPHPalElphjJIcw== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/theming" "6.4.22" core-js "^3.8.2" global "^4.4.0" memoizerific "^1.11.3" prop-types "^15.7.2" regenerator-runtime "^0.13.7" -"@storybook/addons@6.3.12", "@storybook/addons@^6.3.0", "@storybook/addons@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-6.3.12.tgz#8773dcc113c5086dfff722388b7b65580e43b65b" - integrity sha512-UgoMyr7Qr0FS3ezt8u6hMEcHgyynQS9ucr5mAwZky3wpXRPFyUTmMto9r4BBUdqyUvTUj/LRKIcmLBfj+/l0Fg== - dependencies: - "@storybook/api" "6.3.12" - "@storybook/channels" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/router" "6.3.12" - "@storybook/theming" "6.3.12" +"@storybook/addons@6.4.22", "@storybook/addons@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-6.4.22.tgz#e165407ca132c2182de2d466b7ff7c5644b6ad7b" + integrity sha512-P/R+Jsxh7pawKLYo8MtE3QU/ilRFKbtCewV/T1o5U/gm8v7hKQdFz3YdRMAra4QuCY8bQIp7MKd2HrB5aH5a1A== + dependencies: + "@storybook/api" "6.4.22" + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/router" "6.4.22" + "@storybook/theming" "6.4.22" + "@types/webpack-env" "^1.16.0" core-js "^3.8.2" global "^4.4.0" regenerator-runtime "^0.13.7" -"@storybook/api@6.3.12", "@storybook/api@^6.3.0", "@storybook/api@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/api/-/api-6.3.12.tgz#2845c20464d5348d676d09665e8ab527825ed7b5" - integrity sha512-LScRXUeCWEW/OP+jiooNMQICVdusv7azTmULxtm72fhkXFRiQs2CdRNTiqNg46JLLC9z95f1W+pGK66X6HiiQA== - dependencies: - "@reach/router" "^1.3.4" - "@storybook/channels" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/csf" "0.0.1" - "@storybook/router" "6.3.12" +"@storybook/api@6.4.22", "@storybook/api@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/api/-/api-6.4.22.tgz#d63f7ad3ffdd74af01ae35099bff4c39702cf793" + integrity sha512-lAVI3o2hKupYHXFTt+1nqFct942up5dHH6YD7SZZJGyW21dwKC3HK1IzCsTawq3fZAKkgWFgmOO649hKk60yKg== + dependencies: + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/router" "6.4.22" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.3.12" - "@types/reach__router" "^1.3.7" + "@storybook/theming" "6.4.22" core-js "^3.8.2" fast-deep-equal "^3.1.3" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" memoizerific "^1.11.3" - qs "^6.10.0" regenerator-runtime "^0.13.7" store2 "^2.12.0" telejson "^5.3.2" ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/builder-webpack4@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/builder-webpack4/-/builder-webpack4-6.3.12.tgz#288d541e2801892721c975259476022da695dbfe" - integrity sha512-Dlm5Fc1svqpFDnVPZdAaEBiM/IDZHMV3RfEGbUTY/ZC0q8b/Ug1czzp/w0aTIjOFRuBDcG6IcplikaqHL8CJLg== +"@storybook/babel-plugin-require-context-hook@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@storybook/babel-plugin-require-context-hook/-/babel-plugin-require-context-hook-1.0.1.tgz#0a4ec9816f6c7296ebc97dd8de3d2b7ae76f2e26" + integrity sha512-WM4vjgSVi8epvGiYfru7BtC3f0tGwNs7QK3Uc4xQn4t5hHQvISnCqbNrHdDYmNW56Do+bBztE8SwP6NGUvd7ww== + +"@storybook/builder-webpack4@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/builder-webpack4/-/builder-webpack4-6.4.22.tgz#d3384b146e97a2b3a6357c6eb8279ff0f1c7f8f5" + integrity sha512-A+GgGtKGnBneRFSFkDarUIgUTI8pYFdLmUVKEAGdh2hL+vLXAz9A46sEY7C8LQ85XWa8TKy3OTDxqR4+4iWj3A== dependencies: "@babel/core" "^7.12.10" "@babel/plugin-proposal-class-properties" "^7.12.1" @@ -4602,34 +4626,34 @@ "@babel/preset-env" "^7.12.11" "@babel/preset-react" "^7.12.10" "@babel/preset-typescript" "^7.12.7" - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/channel-postmessage" "6.3.12" - "@storybook/channels" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core-common" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/node-logger" "6.3.12" - "@storybook/router" "6.3.12" + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/channel-postmessage" "6.4.22" + "@storybook/channels" "6.4.22" + "@storybook/client-api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-common" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/node-logger" "6.4.22" + "@storybook/preview-web" "6.4.22" + "@storybook/router" "6.4.22" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.3.12" - "@storybook/ui" "6.3.12" + "@storybook/store" "6.4.22" + "@storybook/theming" "6.4.22" + "@storybook/ui" "6.4.22" "@types/node" "^14.0.10" "@types/webpack" "^4.41.26" autoprefixer "^9.8.6" - babel-loader "^8.2.2" + babel-loader "^8.0.0" babel-plugin-macros "^2.8.0" babel-plugin-polyfill-corejs3 "^0.1.0" case-sensitive-paths-webpack-plugin "^2.3.0" core-js "^3.8.2" css-loader "^3.6.0" - dotenv-webpack "^1.8.0" file-loader "^6.2.0" find-up "^5.0.0" fork-ts-checker-webpack-plugin "^4.1.6" - fs-extra "^9.0.1" glob "^7.1.6" glob-promise "^3.4.0" global "^4.4.0" @@ -4639,7 +4663,6 @@ postcss-flexbugs-fixes "^4.2.1" postcss-loader "^4.2.0" raw-loader "^4.0.2" - react-dev-utils "^11.0.3" stable "^0.1.8" style-loader "^1.3.0" terser-webpack-plugin "^4.2.3" @@ -4649,72 +4672,85 @@ webpack "4" webpack-dev-middleware "^3.7.3" webpack-filter-warnings-plugin "^1.2.1" - webpack-hot-middleware "^2.25.0" + webpack-hot-middleware "^2.25.1" webpack-virtual-modules "^0.2.2" -"@storybook/channel-postmessage@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/channel-postmessage/-/channel-postmessage-6.3.12.tgz#3ff9412ac0f445e3b8b44dd414e783a5a47ff7c1" - integrity sha512-Ou/2Ga3JRTZ/4sSv7ikMgUgLTeZMsXXWLXuscz4oaYhmOqAU9CrJw0G1NitwBgK/+qC83lEFSLujHkWcoQDOKg== +"@storybook/channel-postmessage@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/channel-postmessage/-/channel-postmessage-6.4.22.tgz#8be0be1ea1e667a49fb0f09cdfdeeb4a45829637" + integrity sha512-gt+0VZLszt2XZyQMh8E94TqjHZ8ZFXZ+Lv/Mmzl0Yogsc2H+6VzTTQO4sv0IIx6xLbpgG72g5cr8VHsxW5kuDQ== dependencies: - "@storybook/channels" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/core-events" "6.3.12" + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" core-js "^3.8.2" global "^4.4.0" qs "^6.10.0" telejson "^5.3.2" -"@storybook/channels@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-6.3.12.tgz#aa0d793895a8b211f0ad3459c61c1bcafd0093c7" - integrity sha512-l4sA+g1PdUV8YCbgs47fIKREdEQAKNdQIZw0b7BfTvY9t0x5yfBywgQhYON/lIeiNGz2OlIuD+VUtqYfCtNSyw== +"@storybook/channel-websocket@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/channel-websocket/-/channel-websocket-6.4.22.tgz#d541f69125873123c453757e2b879a75a9266c65" + integrity sha512-Bm/FcZ4Su4SAK5DmhyKKfHkr7HiHBui6PNutmFkASJInrL9wBduBfN8YQYaV7ztr8ezoHqnYRx8sj28jpwa6NA== + dependencies: + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + core-js "^3.8.2" + global "^4.4.0" + telejson "^5.3.2" + +"@storybook/channels@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-6.4.22.tgz#710f732763d63f063f615898ab1afbe74e309596" + integrity sha512-cfR74tu7MLah1A8Rru5sak71I+kH2e/sY6gkpVmlvBj4hEmdZp4Puj9PTeaKcMXh9DgIDPNA5mb8yvQH6VcyxQ== dependencies: core-js "^3.8.2" ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/client-api@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/client-api/-/client-api-6.3.12.tgz#a0c6d72a871d1cb02b4b98675472839061e39b5b" - integrity sha512-xnW+lKKK2T774z+rOr9Wopt1aYTStfb86PSs9p3Fpnc2Btcftln+C3NtiHZl8Ccqft8Mz/chLGgewRui6tNI8g== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/channel-postmessage" "6.3.12" - "@storybook/channels" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/csf" "0.0.1" +"@storybook/client-api@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/client-api/-/client-api-6.4.22.tgz#df14f85e7900b94354c26c584bab53a67c47eae9" + integrity sha512-sO6HJNtrrdit7dNXQcZMdlmmZG1k6TswH3gAyP/DoYajycrTwSJ6ovkarzkO+0QcJ+etgra4TEdTIXiGHBMe/A== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/channel-postmessage" "6.4.22" + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/store" "6.4.22" "@types/qs" "^6.9.5" "@types/webpack-env" "^1.16.0" core-js "^3.8.2" + fast-deep-equal "^3.1.3" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" memoizerific "^1.11.3" qs "^6.10.0" regenerator-runtime "^0.13.7" - stable "^0.1.8" store2 "^2.12.0" + synchronous-promise "^2.0.15" ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/client-logger@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-6.3.12.tgz#6585c98923b49fcb25dbceeeb96ef2a83e28e0f4" - integrity sha512-zNDsamZvHnuqLznDdP9dUeGgQ9TyFh4ray3t1VGO7ZqWVZ2xtVCCXjDvMnOXI2ifMpX5UsrOvshIPeE9fMBmiQ== +"@storybook/client-logger@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-6.4.22.tgz#51abedb7d3c9bc21921aeb153ac8a19abc625cd6" + integrity sha512-LXhxh/lcDsdGnK8kimqfhu3C0+D2ylCSPPQNbU0IsLRmTfbpQYMdyl0XBjPdHiRVwlL7Gkw5OMjYemQgJ02zlw== dependencies: core-js "^3.8.2" global "^4.4.0" -"@storybook/components@6.3.12", "@storybook/components@^6.3.0", "@storybook/components@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/components/-/components-6.3.12.tgz#0c7967c60354c84afa20dfab4753105e49b1927d" - integrity sha512-kdQt8toUjynYAxDLrJzuG7YSNL6as1wJoyzNUaCfG06YPhvIAlKo7le9tS2mThVFN5e9nbKrW3N1V1sp6ypZXQ== +"@storybook/components@6.4.22", "@storybook/components@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/components/-/components-6.4.22.tgz#4d425280240702883225b6a1f1abde7dc1a0e945" + integrity sha512-dCbXIJF9orMvH72VtAfCQsYbe57OP7fAADtR6YTwfCw9Sm1jFuZr8JbblQ1HcrXEoJG21nOyad3Hm5EYVb/sBw== dependencies: "@popperjs/core" "^2.6.0" - "@storybook/client-logger" "6.3.12" - "@storybook/csf" "0.0.1" - "@storybook/theming" "6.3.12" + "@storybook/client-logger" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/theming" "6.4.22" "@types/color-convert" "^2.0.0" "@types/overlayscrollbars" "^1.12.0" "@types/react-syntax-highlighter" "11.0.5" @@ -4722,7 +4758,7 @@ core-js "^3.8.2" fast-deep-equal "^3.1.3" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" markdown-to-jsx "^7.1.3" memoizerific "^1.11.3" overlayscrollbars "^1.13.1" @@ -4736,33 +4772,36 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/core-client@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/core-client/-/core-client-6.3.12.tgz#fd01bfbc69c331f4451973a4e7597624dc3737e5" - integrity sha512-8Smd9BgZHJpAdevLKQYinwtjSyCZAuBMoetP4P5hnn53mWl0NFbrHFaAdT+yNchDLZQUbf7Y18VmIqEH+RCR5w== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/channel-postmessage" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/csf" "0.0.1" - "@storybook/ui" "6.3.12" +"@storybook/core-client@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/core-client/-/core-client-6.4.22.tgz#9079eda8a9c8e6ba24b84962a749b1c99668cb2a" + integrity sha512-uHg4yfCBeM6eASSVxStWRVTZrAnb4FT6X6v/xDqr4uXCpCttZLlBzrSDwPBLNNLtCa7ntRicHM8eGKIOD5lMYQ== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/channel-postmessage" "6.4.22" + "@storybook/channel-websocket" "6.4.22" + "@storybook/client-api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/preview-web" "6.4.22" + "@storybook/store" "6.4.22" + "@storybook/ui" "6.4.22" airbnb-js-shims "^2.2.1" ansi-to-html "^0.6.11" core-js "^3.8.2" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" qs "^6.10.0" regenerator-runtime "^0.13.7" ts-dedent "^2.0.0" unfetch "^4.2.0" util-deprecate "^1.0.2" -"@storybook/core-common@6.3.12", "@storybook/core-common@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/core-common/-/core-common-6.3.12.tgz#95ce953d7efda44394b159322d6a2280c202f21c" - integrity sha512-xlHs2QXELq/moB4MuXjYOczaxU64BIseHsnFBLyboJYN6Yso3qihW5RB7cuJlGohkjb4JwY74dvfT4Ww66rkBA== +"@storybook/core-common@6.4.22", "@storybook/core-common@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/core-common/-/core-common-6.4.22.tgz#b00fa3c0625e074222a50be3196cb8052dd7f3bf" + integrity sha512-PD3N/FJXPNRHeQS2zdgzYFtqPLdi3MLwAicbnw+U3SokcsspfsAuyYHZOYZgwO8IAEKy6iCc7TpBdiSJZ/vAKQ== dependencies: "@babel/core" "^7.12.10" "@babel/plugin-proposal-class-properties" "^7.12.1" @@ -4785,13 +4824,11 @@ "@babel/preset-react" "^7.12.10" "@babel/preset-typescript" "^7.12.7" "@babel/register" "^7.12.1" - "@storybook/node-logger" "6.3.12" + "@storybook/node-logger" "6.4.22" "@storybook/semver" "^7.3.2" - "@types/glob-base" "^0.3.0" - "@types/micromatch" "^4.0.1" "@types/node" "^14.0.10" "@types/pretty-hrtime" "^1.0.0" - babel-loader "^8.2.2" + babel-loader "^8.0.0" babel-plugin-macros "^3.0.1" babel-plugin-polyfill-corejs3 "^0.1.0" chalk "^4.1.0" @@ -4800,79 +4837,91 @@ file-system-cache "^1.0.5" find-up "^5.0.0" fork-ts-checker-webpack-plugin "^6.0.4" + fs-extra "^9.0.1" glob "^7.1.6" - glob-base "^0.3.0" + handlebars "^4.7.7" interpret "^2.2.0" json5 "^2.1.3" lazy-universal-dotenv "^3.0.1" - micromatch "^4.0.2" + picomatch "^2.3.0" pkg-dir "^5.0.0" pretty-hrtime "^1.0.3" resolve-from "^5.0.0" + slash "^3.0.0" + telejson "^5.3.2" ts-dedent "^2.0.0" util-deprecate "^1.0.2" webpack "4" -"@storybook/core-events@6.3.12", "@storybook/core-events@^6.3.0", "@storybook/core-events@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-6.3.12.tgz#73f6271d485ef2576234e578bb07705b92805290" - integrity sha512-SXfD7xUUMazaeFkB92qOTUV8Y/RghE4SkEYe5slAdjeocSaH7Nz2WV0rqNEgChg0AQc+JUI66no8L9g0+lw4Gw== +"@storybook/core-events@6.4.22", "@storybook/core-events@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-6.4.22.tgz#c09b0571951affd4254028b8958a4d8652700989" + integrity sha512-5GYY5+1gd58Gxjqex27RVaX6qbfIQmJxcbzbNpXGNSqwqAuIIepcV1rdCVm6I4C3Yb7/AQ3cN5dVbf33QxRIwA== dependencies: core-js "^3.8.2" -"@storybook/core-server@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-6.3.12.tgz#d906f823b263d78a4b087be98810b74191d263cd" - integrity sha512-T/Mdyi1FVkUycdyOnhXvoo3d9nYXLQFkmaJkltxBFLzAePAJUSgAsPL9odNC3+p8Nr2/UDsDzvu/Ow0IF0mzLQ== +"@storybook/core-server@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-6.4.22.tgz#254409ec2ba49a78b23f5e4a4c0faea5a570a32b" + integrity sha512-wFh3e2fa0un1d4+BJP+nd3FVWUO7uHTqv3OGBfOmzQMKp4NU1zaBNdSQG7Hz6mw0fYPBPZgBjPfsJRwIYLLZyw== dependencies: "@discoveryjs/json-ext" "^0.5.3" - "@storybook/builder-webpack4" "6.3.12" - "@storybook/core-client" "6.3.12" - "@storybook/core-common" "6.3.12" - "@storybook/csf-tools" "6.3.12" - "@storybook/manager-webpack4" "6.3.12" - "@storybook/node-logger" "6.3.12" + "@storybook/builder-webpack4" "6.4.22" + "@storybook/core-client" "6.4.22" + "@storybook/core-common" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/csf-tools" "6.4.22" + "@storybook/manager-webpack4" "6.4.22" + "@storybook/node-logger" "6.4.22" "@storybook/semver" "^7.3.2" + "@storybook/store" "6.4.22" "@types/node" "^14.0.10" "@types/node-fetch" "^2.5.7" "@types/pretty-hrtime" "^1.0.0" "@types/webpack" "^4.41.26" better-opn "^2.1.1" - boxen "^4.2.0" + boxen "^5.1.2" chalk "^4.1.0" - cli-table3 "0.6.0" + cli-table3 "^0.6.1" commander "^6.2.1" compression "^1.7.4" core-js "^3.8.2" - cpy "^8.1.1" + cpy "^8.1.2" detect-port "^1.3.0" express "^4.17.1" file-system-cache "^1.0.5" fs-extra "^9.0.1" globby "^11.0.2" ip "^1.1.5" + lodash "^4.17.21" node-fetch "^2.6.1" pretty-hrtime "^1.0.3" prompts "^2.4.0" regenerator-runtime "^0.13.7" serve-favicon "^2.5.0" + slash "^3.0.0" + telejson "^5.3.3" ts-dedent "^2.0.0" util-deprecate "^1.0.2" + watchpack "^2.2.0" webpack "4" + ws "^8.2.3" -"@storybook/core@6.3.12", "@storybook/core@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/core/-/core-6.3.12.tgz#eb945f7ed5c9039493318bcd2bb5a3a897b91cfd" - integrity sha512-FJm2ns8wk85hXWKslLWiUWRWwS9KWRq7jlkN6M9p57ghFseSGr4W71Orcoab4P3M7jI97l5yqBfppbscinE74g== +"@storybook/core@6.4.22", "@storybook/core@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/core/-/core-6.4.22.tgz#cf14280d7831b41d5dea78f76b414bdfde5918f0" + integrity sha512-KZYJt7GM5NgKFXbPRZZZPEONZ5u/tE/cRbMdkn/zWN3He8+VP+65/tz8hbriI/6m91AWVWkBKrODSkeq59NgRA== dependencies: - "@storybook/core-client" "6.3.12" - "@storybook/core-server" "6.3.12" + "@storybook/core-client" "6.4.22" + "@storybook/core-server" "6.4.22" -"@storybook/csf-tools@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/csf-tools/-/csf-tools-6.3.12.tgz#d979c6a79d1e9d6c8b5a5e8834d07fcf5b793844" - integrity sha512-wNrX+99ajAXxLo0iRwrqw65MLvCV6SFC0XoPLYrtBvyKr+hXOOnzIhO2f5BNEii8velpC2gl2gcLKeacpVYLqA== +"@storybook/csf-tools@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/csf-tools/-/csf-tools-6.4.22.tgz#f6d64bcea1b36114555972acae66a1dbe9e34b5c" + integrity sha512-LMu8MZAiQspJAtMBLU2zitsIkqQv7jOwX7ih5JrXlyaDticH7l2j6Q+1mCZNWUOiMTizj0ivulmUsSaYbpToSw== dependencies: + "@babel/core" "^7.12.10" "@babel/generator" "^7.12.11" "@babel/parser" "^7.12.11" "@babel/plugin-transform-react-jsx" "^7.12.12" @@ -4880,43 +4929,44 @@ "@babel/traverse" "^7.12.11" "@babel/types" "^7.12.11" "@mdx-js/mdx" "^1.6.22" - "@storybook/csf" "^0.0.1" + "@storybook/csf" "0.0.2--canary.87bc651.0" core-js "^3.8.2" fs-extra "^9.0.1" + global "^4.4.0" js-string-escape "^1.0.1" - lodash "^4.17.20" - prettier "~2.2.1" + lodash "^4.17.21" + prettier ">=2.2.1 <=2.3.0" regenerator-runtime "^0.13.7" + ts-dedent "^2.0.0" -"@storybook/csf@0.0.1", "@storybook/csf@^0.0.1": - version "0.0.1" - resolved "https://registry.yarnpkg.com/@storybook/csf/-/csf-0.0.1.tgz#95901507dc02f0bc6f9ac8ee1983e2fc5bb98ce6" - integrity sha512-USTLkZze5gkel8MYCujSRBVIrUQ3YPBrLOx7GNk/0wttvVtlzWXAq9eLbQ4p/NicGxP+3T7KPEMVV//g+yubpw== +"@storybook/csf@0.0.2--canary.87bc651.0": + version "0.0.2--canary.87bc651.0" + resolved "https://registry.yarnpkg.com/@storybook/csf/-/csf-0.0.2--canary.87bc651.0.tgz#c7b99b3a344117ef67b10137b6477a3d2750cf44" + integrity sha512-ajk1Uxa+rBpFQHKrCcTmJyQBXZ5slfwHVEaKlkuFaW77it8RgbPJp/ccna3sgoi8oZ7FkkOyvv1Ve4SmwFqRqw== dependencies: lodash "^4.17.15" -"@storybook/manager-webpack4@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/manager-webpack4/-/manager-webpack4-6.3.12.tgz#1c10a60b0acec3f9136dd8b7f22a25469d8b91e5" - integrity sha512-OkPYNrHXg2yZfKmEfTokP6iKx4OLTr0gdI5yehi/bLEuQCSHeruxBc70Dxm1GBk1Mrf821wD9WqMXNDjY5Qtug== +"@storybook/manager-webpack4@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/manager-webpack4/-/manager-webpack4-6.4.22.tgz#eabd674beee901c7f755d9b679e9f969cbab636d" + integrity sha512-nzhDMJYg0vXdcG0ctwE6YFZBX71+5NYaTGkxg3xT7gbgnP1YFXn9gVODvgq3tPb3gcRapjyOIxUa20rV+r8edA== dependencies: "@babel/core" "^7.12.10" "@babel/plugin-transform-template-literals" "^7.12.1" "@babel/preset-react" "^7.12.10" - "@storybook/addons" "6.3.12" - "@storybook/core-client" "6.3.12" - "@storybook/core-common" "6.3.12" - "@storybook/node-logger" "6.3.12" - "@storybook/theming" "6.3.12" - "@storybook/ui" "6.3.12" + "@storybook/addons" "6.4.22" + "@storybook/core-client" "6.4.22" + "@storybook/core-common" "6.4.22" + "@storybook/node-logger" "6.4.22" + "@storybook/theming" "6.4.22" + "@storybook/ui" "6.4.22" "@types/node" "^14.0.10" "@types/webpack" "^4.41.26" - babel-loader "^8.2.2" + babel-loader "^8.0.0" case-sensitive-paths-webpack-plugin "^2.3.0" chalk "^4.1.0" core-js "^3.8.2" css-loader "^3.6.0" - dotenv-webpack "^1.8.0" express "^4.17.1" file-loader "^6.2.0" file-system-cache "^1.0.5" @@ -4938,24 +4988,46 @@ webpack-dev-middleware "^3.7.3" webpack-virtual-modules "^0.2.2" -"@storybook/node-logger@6.3.12", "@storybook/node-logger@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-6.3.12.tgz#a67cfbe266d2692f317914ef583721627498df19" - integrity sha512-iktOem/Ls2+dsZY9PhPeC6T1QhX/y7OInP88neLsqEPEbB2UXca3Ydv7OZBhBVbvN25W45b05MRzbtNUxYLNRw== +"@storybook/node-logger@6.4.22", "@storybook/node-logger@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-6.4.22.tgz#c4ec00f8714505f44eda7671bc88bb44abf7ae59" + integrity sha512-sUXYFqPxiqM7gGH7gBXvO89YEO42nA4gBicJKZjj9e+W4QQLrftjF9l+mAw2K0mVE10Bn7r4pfs5oEZ0aruyyA== dependencies: "@types/npmlog" "^4.1.2" chalk "^4.1.0" core-js "^3.8.2" - npmlog "^4.1.2" + npmlog "^5.0.1" pretty-hrtime "^1.0.3" -"@storybook/postinstall@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/postinstall/-/postinstall-6.3.12.tgz#ed98caff76d8c1a1733ec630565ef4162b274614" - integrity sha512-HkZ+abtZ3W6JbGPS6K7OSnNXbwaTwNNd5R02kRs4gV9B29XsBPDtFT6vIwzM3tmVQC7ihL5a8ceWp2OvzaNOuw== +"@storybook/postinstall@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/postinstall/-/postinstall-6.4.22.tgz#592c7406f197fd25a5644c3db7a87d9b5da77e85" + integrity sha512-LdIvA+l70Mp5FSkawOC16uKocefc+MZLYRHqjTjgr7anubdi6y7W4n9A7/Yw4IstZHoknfL88qDj/uK5N+Ahzw== dependencies: core-js "^3.8.2" +"@storybook/preview-web@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/preview-web/-/preview-web-6.4.22.tgz#58bfc6492503ff4265b50f42a27ea8b0bfcf738a" + integrity sha512-sWS+sgvwSvcNY83hDtWUUL75O2l2LY/GTAS0Zp2dh3WkObhtuJ/UehftzPZlZmmv7PCwhb4Q3+tZDKzMlFxnKQ== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/channel-postmessage" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/store" "6.4.22" + ansi-to-html "^0.6.11" + core-js "^3.8.2" + global "^4.4.0" + lodash "^4.17.21" + qs "^6.10.0" + regenerator-runtime "^0.13.7" + synchronous-promise "^2.0.15" + ts-dedent "^2.0.0" + unfetch "^4.2.0" + util-deprecate "^1.0.2" + "@storybook/react-docgen-typescript-plugin@1.0.2-canary.253f8c1.0": version "1.0.2-canary.253f8c1.0" resolved "https://registry.yarnpkg.com/@storybook/react-docgen-typescript-plugin/-/react-docgen-typescript-plugin-1.0.2-canary.253f8c1.0.tgz#f2da40e6aae4aa586c2fb284a4a1744602c3c7fa" @@ -4969,49 +5041,51 @@ react-docgen-typescript "^2.0.0" tslib "^2.0.0" -"@storybook/react@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/react/-/react-6.3.12.tgz#2e172cbfc06f656d2890743dcf49741a10fa1629" - integrity sha512-c1Y/3/eNzye+ZRwQ3BXJux6pUMVt3lhv1/M9Qagl9JItP3jDSj5Ed3JHCgwEqpprP8mvNNXwEJ8+M7vEQyDuHg== +"@storybook/react@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/react/-/react-6.4.22.tgz#5940e5492bc87268555b47f12aff4be4b67eae54" + integrity sha512-5BFxtiguOcePS5Ty/UoH7C6odmvBYIZutfiy4R3Ua6FYmtxac5vP9r5KjCz1IzZKT8mCf4X+PuK1YvDrPPROgQ== dependencies: "@babel/preset-flow" "^7.12.1" "@babel/preset-react" "^7.12.10" - "@pmmmwh/react-refresh-webpack-plugin" "^0.4.3" - "@storybook/addons" "6.3.12" - "@storybook/core" "6.3.12" - "@storybook/core-common" "6.3.12" - "@storybook/node-logger" "6.3.12" + "@pmmmwh/react-refresh-webpack-plugin" "^0.5.1" + "@storybook/addons" "6.4.22" + "@storybook/core" "6.4.22" + "@storybook/core-common" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/node-logger" "6.4.22" "@storybook/react-docgen-typescript-plugin" "1.0.2-canary.253f8c1.0" "@storybook/semver" "^7.3.2" + "@storybook/store" "6.4.22" "@types/webpack-env" "^1.16.0" babel-plugin-add-react-displayname "^0.0.5" babel-plugin-named-asset-import "^0.3.1" babel-plugin-react-docgen "^4.2.1" core-js "^3.8.2" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" prop-types "^15.7.2" - react-dev-utils "^11.0.3" - react-refresh "^0.8.3" + react-refresh "^0.11.0" read-pkg-up "^7.0.1" regenerator-runtime "^0.13.7" ts-dedent "^2.0.0" webpack "4" -"@storybook/router@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/router/-/router-6.3.12.tgz#0d572ec795f588ca886f39cb9b27b94ff3683f84" - integrity sha512-G/pNGCnrJRetCwyEZulHPT+YOcqEj/vkPVDTUfii2qgqukup6K0cjwgd7IukAURnAnnzTi1gmgFuEKUi8GE/KA== +"@storybook/router@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/router/-/router-6.4.22.tgz#e3cc5cd8595668a367e971efb9695bbc122ed95e" + integrity sha512-zeuE8ZgFhNerQX8sICQYNYL65QEi3okyzw7ynF58Ud6nRw4fMxSOHcj2T+nZCIU5ufozRL4QWD/Rg9P2s/HtLw== dependencies: - "@reach/router" "^1.3.4" - "@storybook/client-logger" "6.3.12" - "@types/reach__router" "^1.3.7" + "@storybook/client-logger" "6.4.22" core-js "^3.8.2" fast-deep-equal "^3.1.3" global "^4.4.0" - lodash "^4.17.20" + history "5.0.0" + lodash "^4.17.21" memoizerific "^1.11.3" qs "^6.10.0" + react-router "^6.0.0" + react-router-dom "^6.0.0" ts-dedent "^2.0.0" "@storybook/semver@^7.3.2": @@ -5022,36 +5096,59 @@ core-js "^3.6.5" find-up "^4.1.0" -"@storybook/source-loader@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/source-loader/-/source-loader-6.3.12.tgz#86e72824c04ad0eaa89b807857bd845db97e57bd" - integrity sha512-Lfe0LOJGqAJYkZsCL8fhuQOeFSCgv8xwQCt4dkcBd0Rw5zT2xv0IXDOiIOXGaWBMDtrJUZt/qOXPEPlL81Oaqg== +"@storybook/source-loader@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/source-loader/-/source-loader-6.4.22.tgz#c931b81cf1bd63f79b51bfa9311de7f5a04a7b77" + integrity sha512-O4RxqPgRyOgAhssS6q1Rtc8LiOvPBpC1EqhCYWRV3K+D2EjFarfQMpjgPj18hC+QzpUSfzoBZYqsMECewEuLNw== dependencies: - "@storybook/addons" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/csf" "0.0.1" + "@storybook/addons" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" core-js "^3.8.2" estraverse "^5.2.0" global "^4.4.0" loader-utils "^2.0.0" - lodash "^4.17.20" - prettier "~2.2.1" + lodash "^4.17.21" + prettier ">=2.2.1 <=2.3.0" regenerator-runtime "^0.13.7" -"@storybook/testing-react@^0.0.22": - version "0.0.22" - resolved "https://registry.yarnpkg.com/@storybook/testing-react/-/testing-react-0.0.22.tgz#65d3defefbac0183eded0dafb601241d8f135c66" - integrity sha512-XBJpH1cROXkwwKwD89kIcyhyMPEN5zfSyOUanrN+/Tx4nB5IwzVc/Om+7mtSFvh4UTSNOk5G42Y12KE/HbH7VA== +"@storybook/store@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/store/-/store-6.4.22.tgz#f291fbe3639f14d25f875cac86abb209a97d4e2a" + integrity sha512-lrmcZtYJLc2emO+1l6AG4Txm9445K6Pyv9cGAuhOJ9Kks0aYe0YtvMkZVVry0RNNAIv6Ypz72zyKc/QK+tZLAQ== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + core-js "^3.8.2" + fast-deep-equal "^3.1.3" + global "^4.4.0" + lodash "^4.17.21" + memoizerific "^1.11.3" + regenerator-runtime "^0.13.7" + slash "^3.0.0" + stable "^0.1.8" + synchronous-promise "^2.0.15" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + +"@storybook/testing-react@^1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@storybook/testing-react/-/testing-react-1.2.4.tgz#2cc8bf6685e358e8c570a9d823dacecb5995ef37" + integrity sha512-qkyXpE66zp0iyfhdiMV2jxNF32SWXW8vOo+rqLHg29Vg/ssor+G7o+wgWkGP9PaID6pTMstzotVSp/mUa3oN3w== + dependencies: + "@storybook/csf" "0.0.2--canary.87bc651.0" -"@storybook/theming@6.3.12", "@storybook/theming@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-6.3.12.tgz#5bddf9bd90a60709b5ab238ecdb7d9055dd7862e" - integrity sha512-wOJdTEa/VFyFB2UyoqyYGaZdym6EN7RALuQOAMT6zHA282FBmKw8nL5DETHEbctpnHdcrMC/391teK4nNSrdOA== +"@storybook/theming@6.4.22", "@storybook/theming@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-6.4.22.tgz#19097eec0366447ddd0d6917b0e0f81d0ec5e51e" + integrity sha512-NVMKH/jxSPtnMTO4VCN1k47uztq+u9fWv4GSnzq/eezxdGg9ceGL4/lCrNGoNajht9xbrsZ4QvsJ/V2sVGM8wA== dependencies: "@emotion/core" "^10.1.1" "@emotion/is-prop-valid" "^0.8.6" "@emotion/styled" "^10.0.27" - "@storybook/client-logger" "6.3.12" + "@storybook/client-logger" "6.4.22" core-js "^3.8.2" deep-object-diff "^1.1.0" emotion-theming "^10.0.27" @@ -5061,22 +5158,21 @@ resolve-from "^5.0.0" ts-dedent "^2.0.0" -"@storybook/ui@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/ui/-/ui-6.3.12.tgz#349e1a4c58c4fd18ea65b2ab56269a7c3a164ee7" - integrity sha512-PC2yEz4JMfarq7rUFbeA3hCA+31p5es7YPEtxLRvRwIZhtL0P4zQUfHpotb3KgWdoAIfZesAuoIQwMPQmEFYrw== +"@storybook/ui@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/ui/-/ui-6.4.22.tgz#49badd7994465d78d984ca4c42533c1c22201c46" + integrity sha512-UVjMoyVsqPr+mkS1L7m30O/xrdIEgZ5SCWsvqhmyMUok3F3tRB+6M+OA5Yy+cIVfvObpA7MhxirUT1elCGXsWQ== dependencies: "@emotion/core" "^10.1.1" - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/channels" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/router" "6.3.12" + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/router" "6.4.22" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.3.12" - "@types/markdown-to-jsx" "^6.11.3" + "@storybook/theming" "6.4.22" copy-to-clipboard "^3.3.1" core-js "^3.8.2" core-js-pure "^3.8.2" @@ -5084,8 +5180,8 @@ emotion-theming "^10.0.27" fuse.js "^3.6.1" global "^4.4.0" - lodash "^4.17.20" - markdown-to-jsx "^6.11.4" + lodash "^4.17.21" + markdown-to-jsx "^7.1.3" memoizerific "^1.11.3" polished "^4.0.5" qs "^6.10.0" @@ -5170,14 +5266,14 @@ "@types/react-test-renderer" ">=16.9.0" react-error-boundary "^3.1.0" -"@testing-library/react@^12.1.4": - version "12.1.4" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.4.tgz#09674b117e550af713db3f4ec4c0942aa8bbf2c0" - integrity sha512-jiPKOm7vyUw311Hn/HlNQ9P8/lHNtArAx0PisXyFixDDvfl8DbD6EUdbshK5eqauvBSvzZd19itqQ9j3nferJA== +"@testing-library/react@^12.1.5": + version "12.1.5" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" + integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg== dependencies: "@babel/runtime" "^7.12.5" "@testing-library/dom" "^8.0.0" - "@types/react-dom" "*" + "@types/react-dom" "<18.0.0" "@testing-library/user-event@^13.5.0": version "13.5.0" @@ -5737,11 +5833,6 @@ resolved "https://registry.yarnpkg.com/@types/getos/-/getos-3.0.0.tgz#582c758e99e9d634f31f471faf7ce59cf1c39a71" integrity sha512-g5O9kykBPMaK5USwU+zM5AyXaztqbvHjSQ7HaBjqgO3f5lKGChkRhLP58Z/Nrr4RBGNNPrBcJkWZwnmbmi9YjQ== -"@types/glob-base@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@types/glob-base/-/glob-base-0.3.0.tgz#a581d688347e10e50dd7c17d6f2880a10354319d" - integrity sha1-pYHWiDR+EOUN18F9byiAoQNUMZ0= - "@types/glob-stream@*": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/glob-stream/-/glob-stream-6.1.0.tgz#7ede8a33e59140534f8d8adfb8ac9edfb31897bc" @@ -6477,13 +6568,6 @@ "@types/linkify-it" "*" "@types/mdurl" "*" -"@types/markdown-to-jsx@^6.11.3": - version "6.11.3" - resolved "https://registry.yarnpkg.com/@types/markdown-to-jsx/-/markdown-to-jsx-6.11.3.tgz#cdd1619308fecbc8be7e6a26f3751260249b020e" - integrity sha512-30nFYpceM/ZEvhGiqWjm5quLUxNeld0HCzJEXMZZDpq53FPkS85mTwkWtCXzCqq8s5JYLgM5W392a02xn8Bdaw== - dependencies: - "@types/react" "*" - "@types/md5@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@types/md5/-/md5-2.2.0.tgz#cd82e16b95973f94bb03dee40c5b6be4a7fb7fb4" @@ -6503,10 +6587,10 @@ resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.2.tgz#e2ce9d83a613bacf284c7be7d491945e39e1f8e9" integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA== -"@types/micromatch@^4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@types/micromatch/-/micromatch-4.0.1.tgz#9381449dd659fc3823fd2a4190ceacc985083bc7" - integrity sha512-my6fLBvpY70KattTNzYOK6KU1oR1+UCz9ug/JbcF5UrEmeCt9P7DV2t7L8+t18mMPINqGQCE4O8PLOPbI84gxw== +"@types/micromatch@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/micromatch/-/micromatch-4.0.2.tgz#ce29c8b166a73bf980a5727b1e4a4d099965151d" + integrity sha512-oqXqVb0ci19GtH0vOA/U2TmHTcRY9kuZl4mqUxe0QmJAlIW13kzhuK5pi1i9+ngav8FjpSb9FVS/GE00GLX1VA== dependencies: "@types/braces" "*" @@ -6788,13 +6872,6 @@ resolved "https://registry.yarnpkg.com/@types/rbush/-/rbush-3.0.0.tgz#b6887d99b159e87ae23cd14eceff34f139842aa6" integrity sha512-W3ue/GYWXBOpkRm0VSoifrP3HV0Ni47aVJWvXyWMcbtpBy/l/K/smBRiJ+fI8f7shXRjZBiux+iJzYbh7VmcZg== -"@types/reach__router@^1.2.6", "@types/reach__router@^1.3.7": - version "1.3.7" - resolved "https://registry.yarnpkg.com/@types/reach__router/-/reach__router-1.3.7.tgz#de8ab374259ae7f7499fc1373b9697a5f3cd6428" - integrity sha512-cyBEb8Ef3SJNH5NYEIDGPoMMmYUxROatuxbICusVRQIqZUB85UCt6R2Ok60tKS/TABJsJYaHyNTW3kqbpxlMjg== - dependencies: - "@types/react" "*" - "@types/react-beautiful-dnd@^13.0.0": version "13.0.0" resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz#e60d3d965312fcf1516894af92dc3e9249587db4" @@ -6809,12 +6886,12 @@ dependencies: "@types/react" "*" -"@types/react-dom@*", "@types/react-dom@>=16.9.0", "@types/react-dom@^16.9.8": - version "16.9.8" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423" - integrity sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA== +"@types/react-dom@<18.0.0", "@types/react-dom@>=16.9.0", "@types/react-dom@^16.9.15": + version "16.9.15" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.15.tgz#7bf41f2b2b86915ff9c0de475cb111d904df12c6" + integrity sha512-PjWhZj54ACucQX2hDmnHyqHz+N2On5g3Lt5BeNn+wy067qvOokVSQw1nEog1XGfvLYrSl3cyrdebEfjQQNXD3A== dependencies: - "@types/react" "*" + "@types/react" "^16" "@types/react-grid-layout@^0.16.7": version "0.16.7" @@ -6835,7 +6912,7 @@ resolved "https://registry.yarnpkg.com/@types/react-intl/-/react-intl-2.3.17.tgz#e1fc6e46e8af58bdef9531259d509380a8a99e8e" integrity sha512-FGd6J1GQ7zvl1GZ3BBev83B7nfak8dqoR2PZ+l5MoisKMpd4xOLhZJC1ugpmk3Rz5F85t6HbOg9mYqXW97BsNA== -"@types/react-is@^16.7.1": +"@types/react-is@^16.7.2": version "16.7.2" resolved "https://registry.yarnpkg.com/@types/react-is/-/react-is-16.7.2.tgz#8c2862013d00d741be189ceb71da8e8d21e8fa7d" integrity sha512-rdQUu9J+RUz4Vcr768UyTzv+fZGzKBy1/PPhaxTfzAfaHSW4+b0olA6czXLZv7PO7/ktbHu41kcpAG7Z46kvDQ== @@ -6936,13 +7013,14 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16", "@types/react@^16.9.36": - version "16.9.36" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.36.tgz#ade589ff51e2a903e34ee4669e05dbfa0c1ce849" - integrity sha512-mGgUb/Rk/vGx4NCvquRuSH0GHBQKb1OqpGS9cT9lFxlTLHZgkksgI60TuIxubmn7JuCb+sENHhQciqa0npm0AQ== +"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16", "@types/react@^16.14.25": + version "16.14.25" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.25.tgz#d003f712c7563fdef5a87327f1892825af375608" + integrity sha512-cXRVHd7vBT5v1is72mmvmsg9stZrbJO04DJqFeh3Yj2tVKO6vmxg5BI+ybI6Ls7ROXRG3aFbZj9x0WA3ZAoDQw== dependencies: "@types/prop-types" "*" - csstype "^2.2.0" + "@types/scheduler" "*" + csstype "^3.0.2" "@types/read-pkg@^4.0.0": version "4.0.0" @@ -7013,6 +7091,11 @@ dependencies: rrule "*" +"@types/scheduler@*": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" + integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== + "@types/seedrandom@>=2.0.0 <4.0.0": version "2.4.28" resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-2.4.28.tgz#9ce8fa048c1e8c85cb71d7fe4d704e000226036f" @@ -7755,7 +7838,7 @@ acorn@^8.4.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.5.0.tgz#4512ccb99b3698c752591e9bb4472e38ad43cee2" integrity sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q== -address@1.1.2, address@^1.0.1: +address@^1.0.1: version "1.1.2" resolved "https://registry.yarnpkg.com/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA== @@ -7994,7 +8077,12 @@ ansi-green@^0.1.1: dependencies: ansi-wrap "0.1.0" -ansi-html@0.0.7, ansi-html@^0.0.7: +ansi-html-community@0.0.8, ansi-html-community@^0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/ansi-html-community/-/ansi-html-community-0.0.8.tgz#69fbc4d6ccbe383f9736934ae34c3f8290f1bf41" + integrity sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw== + +ansi-html@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4= @@ -8211,6 +8299,14 @@ archy@^1.0.0: resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" integrity sha1-+cjBN1fMHde8N5rHeyxipcKGjEA= +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + are-we-there-yet@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-3.0.0.tgz#ba20bd6b553e31d62fc8c31bd23d22b95734390d" @@ -8762,7 +8858,7 @@ babel-jest@^26.6.3: graceful-fs "^4.2.4" slash "^3.0.0" -babel-loader@^8.2.2: +babel-loader@^8.0.0, babel-loader@^8.2.2: version "8.2.2" resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.2.2.tgz#9363ce84c10c9a40e6c753748e1441b60c8a0b81" integrity sha512-JvTd0/D889PQBtUXJ2PXaKU/pjZDMtHA9V2ecm+eNRmmBCMR09a+fmpGTNwnJtFmFl5Ei7Vy47LjBb+L0wQ99g== @@ -9250,20 +9346,6 @@ bowser@^1.7.3: resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a" integrity sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ== -boxen@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-4.2.0.tgz#e411b62357d6d6d36587c8ac3d5d974daa070e64" - integrity sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ== - dependencies: - ansi-align "^3.0.0" - camelcase "^5.3.1" - chalk "^3.0.0" - cli-boxes "^2.2.0" - string-width "^4.1.0" - term-size "^2.1.0" - type-fest "^0.8.1" - widest-line "^3.1.0" - boxen@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.0.1.tgz#657528bdd3f59a772b8279b831f27ec2c744664b" @@ -9278,6 +9360,20 @@ boxen@^5.0.0: widest-line "^3.1.0" wrap-ansi "^7.0.0" +boxen@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" + integrity sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ== + dependencies: + ansi-align "^3.0.0" + camelcase "^6.2.0" + chalk "^4.1.0" + cli-boxes "^2.2.1" + string-width "^4.2.2" + type-fest "^0.20.2" + widest-line "^3.1.0" + wrap-ansi "^7.0.0" + brace-expansion@^1.1.7: version "1.1.8" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292" @@ -9581,16 +9677,6 @@ browserify@^17.0.0: vm-browserify "^1.0.0" xtend "^4.0.0" -browserslist@4.14.2: - version "4.14.2" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.2.tgz#1b3cec458a1ba87588cc5e9be62f19b6d48813ce" - integrity sha512-HI4lPveGKUR0x2StIz+2FXfDk9SfVMrxn6PLh1JeGUwcuoDkdKZebWiyLRJ68iIPDpMI4JLVDf7S7XzslgWOhw== - dependencies: - caniuse-lite "^1.0.30001125" - electron-to-chromium "^1.3.564" - escalade "^3.0.2" - node-releases "^1.1.61" - browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.17.5, browserslist@^4.17.6, browserslist@^4.19.1: version "4.19.1" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.19.1.tgz#4ac0435b35ab655896c31d53018b6dd5e9e4c9a3" @@ -9933,7 +10019,7 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001097, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001286: +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001097, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001286: version "1.0.30001335" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001335.tgz" integrity sha512-ddP1Tgm7z2iIxu6QTtbZUv6HJxSaV/PZeSrWFZtbY4JZ69tOeNhBCl3HyRQgeNZKE5AOn1kpV7fhljigy0Ty3w== @@ -9999,15 +10085,6 @@ chai@3.5.0: deep-eql "^0.1.3" type-detect "^1.0.0" -chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - chalk@4.1.0, chalk@^4.0.0, chalk@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" @@ -10035,6 +10112,15 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" +chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + chalk@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" @@ -10254,11 +10340,6 @@ clean-webpack-plugin@^3.0.0: "@types/webpack" "^4.4.31" del "^4.1.1" -cli-boxes@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.0.tgz#538ecae8f9c6ca508e3c3c95b453fe93cb4c168d" - integrity sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w== - cli-boxes@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" @@ -10283,17 +10364,7 @@ cli-spinners@^2.2.0, cli-spinners@^2.5.0: resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.5.0.tgz#12763e47251bf951cb75c201dfa58ff1bcb2d047" integrity sha512-PC+AmIuK04E6aeSs/pUccSujsTzBhu4HzC2dL+CfJB/Jcc2qTRbEwZQDfIUpt2Xl8BodYBEq8w4fc0kU2I9DjQ== -cli-table3@0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.0.tgz#b7b1bc65ca8e7b5cef9124e13dc2b21e2ce4faee" - integrity sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ== - dependencies: - object-assign "^4.1.0" - string-width "^4.2.0" - optionalDependencies: - colors "^1.1.2" - -cli-table3@~0.6.1: +cli-table3@^0.6.1, cli-table3@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.1.tgz#36ce9b7af4847f288d3cdd081fbd09bf7bd237b8" integrity sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA== @@ -10558,7 +10629,7 @@ color-string@^1.5.2, color-string@^1.6.0, color-string@^1.9.0: color-name "^1.0.0" simple-swizzle "^0.2.2" -color-support@^1.1.3: +color-support@^1.1.2, color-support@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== @@ -10592,7 +10663,7 @@ colorette@^1.2.0, colorette@^1.2.1, colorette@^1.2.2: resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== -colors@1.4.0, colors@^1.1.2, colors@^1.3.2: +colors@1.4.0, colors@^1.3.2: version "1.4.0" resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== @@ -10682,6 +10753,11 @@ commander@^9.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-9.0.0.tgz#86d58f24ee98126568936bd1d3574e0308a99a40" integrity sha512-JJfP2saEKbQqvW+FI93OYUB4ByV5cizMpFMiiJI8xDbBvQvSkIk0VvQdn1CZ8mqAO8Loq2h0gYTYtDFUZUeERw== +common-path-prefix@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/common-path-prefix/-/common-path-prefix-3.0.0.tgz#7d007a7e07c58c4b4d5f433131a19141b29f11e0" + integrity sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w== + common-tags@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937" @@ -10970,7 +11046,7 @@ core-js-compat@^3.8.1: browserslist "^4.17.6" semver "7.0.0" -core-js-pure@^3.0.0, core-js-pure@^3.8.2: +core-js-pure@^3.0.0, core-js-pure@^3.8.1, core-js-pure@^3.8.2: version "3.19.1" resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.19.1.tgz#edffc1fc7634000a55ba05e95b3f0fe9587a5aa4" integrity sha512-Q0Knr8Es84vtv62ei6/6jXH/7izKmOrtrxH9WJTHLCMAVeU+8TF8z8Nr08CsH4Ot0oJKzBzJJL9SJBYIv7WlfQ== @@ -11070,6 +11146,21 @@ cpy@^8.1.1: p-filter "^2.1.0" p-map "^3.0.0" +cpy@^8.1.2: + version "8.1.2" + resolved "https://registry.yarnpkg.com/cpy/-/cpy-8.1.2.tgz#e339ea54797ad23f8e3919a5cffd37bfc3f25935" + integrity sha512-dmC4mUesv0OYH2kNFEidtf/skUwv4zePmGeepjyyJ0qTo5+8KhA1o99oIAwVVLzQMAeDJml74d6wPPKb6EZUTg== + dependencies: + arrify "^2.0.1" + cp-file "^7.0.0" + globby "^9.2.0" + has-glob "^1.0.0" + junk "^3.1.0" + nested-error-stacks "^2.1.0" + p-all "^2.1.0" + p-filter "^2.1.0" + p-map "^3.0.0" + crc-32@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.0.tgz#cb2db6e29b88508e32d9dd0ec1693e7b41a18208" @@ -11138,7 +11229,7 @@ create-react-class@^15.5.2: loose-envify "^1.3.1" object-assign "^4.1.1" -create-react-context@0.3.0, create-react-context@^0.3.0: +create-react-context@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.3.0.tgz#546dede9dc422def0d3fc2fe03afe0bc0f4f7d8c" integrity sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw== @@ -11163,15 +11254,6 @@ cross-env@^6.0.3: dependencies: cross-spawn "^7.0.0" -cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - cross-spawn@^6.0.0, cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -11183,6 +11265,15 @@ cross-spawn@^6.0.0, cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" +cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + crypt@~0.0.1: version "0.0.2" resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" @@ -12433,14 +12524,6 @@ detect-node@2.1.0, detect-node@^2.0.4, detect-node@^2.1.0: resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== -detect-port-alt@1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/detect-port-alt/-/detect-port-alt-1.1.6.tgz#24707deabe932d4a3cf621302027c2b266568275" - integrity sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q== - dependencies: - address "^1.0.1" - debug "^2.6.0" - detect-port@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/detect-port/-/detect-port-1.3.0.tgz#d9c40e9accadd4df5cac6a782aefd014d573d1f1" @@ -12754,35 +12837,16 @@ dot-prop@^5.2.0: dependencies: is-obj "^2.0.0" -dotenv-defaults@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/dotenv-defaults/-/dotenv-defaults-1.0.2.tgz#441cf5f067653fca4bbdce9dd3b803f6f84c585d" - integrity sha512-iXFvHtXl/hZPiFj++1hBg4lbKwGM+t/GlvELDnRtOFdjXyWP7mubkVr+eZGWG62kdsbulXAef6v/j6kiWc/xGA== - dependencies: - dotenv "^6.2.0" - dotenv-expand@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0" integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA== -dotenv-webpack@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/dotenv-webpack/-/dotenv-webpack-1.8.0.tgz#7ca79cef2497dd4079d43e81e0796bc9d0f68a5e" - integrity sha512-o8pq6NLBehtrqA8Jv8jFQNtG9nhRtVqmoD4yWbgUyoU3+9WBlPe+c2EAiaJok9RB28QvrWvdWLZGeTT5aATDMg== - dependencies: - dotenv-defaults "^1.0.2" - dotenv@^16.0.0: version "16.0.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.0.tgz#c619001253be89ebb638d027b609c75c26e47411" integrity sha512-qD9WU0MPM4SWLPJy/r2Be+2WgQj8plChsyrCNQzW/0WjvcJQiKQJ9mH3ZgB3fxbUUxgc/11ZJ0Fi5KiimWGz2Q== -dotenv@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.2.0.tgz#941c0410535d942c8becf28d3f357dbd9d476064" - integrity sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w== - dotenv@^8.0.0, dotenv@^8.1.0: version "8.2.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" @@ -12976,7 +13040,7 @@ elasticsearch@^16.4.0: chalk "^1.0.0" lodash "^4.17.10" -electron-to-chromium@^1.3.564, electron-to-chromium@^1.4.17: +electron-to-chromium@^1.4.17: version "1.4.66" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.66.tgz#d7453d363dcd7b06ed1757adcde34d724e27b367" integrity sha512-f1RXFMsvwufWLwYUxTiP7HmjprKXrqEWHiQkjAYa9DJeVIlZk5v8gBGcaV+FhtXLly6C1OTVzQY+2UQrACiLlg== @@ -13403,7 +13467,7 @@ es6-weak-map@^2.0.1, es6-weak-map@^2.0.2: es6-iterator "^2.0.1" es6-symbol "^3.1.1" -escalade@^3.0.2, escalade@^3.1.1: +escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== @@ -13418,11 +13482,6 @@ escape-html@^1.0.3, escape-html@~1.0.3: resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= -escape-string-regexp@2.0.0, escape-string-regexp@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" - integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== - escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" @@ -13433,6 +13492,11 @@ escape-string-regexp@^1.0.0, escape-string-regexp@^1.0.2, escape-string-regexp@^ resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + escodegen@^1.11.0, escodegen@^1.11.1, escodegen@^1.14.1: version "1.14.3" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" @@ -14452,11 +14516,6 @@ filelist@^1.0.1: dependencies: minimatch "^3.0.4" -filesize@6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/filesize/-/filesize-6.1.0.tgz#e81bdaa780e2451d714d71c0d7a4f3238d37ad00" - integrity sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg== - fill-range@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" @@ -14510,14 +14569,6 @@ find-root@^1.1.0: resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== -find-up@4.1.0, find-up@^4.0.0, find-up@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== - dependencies: - locate-path "^5.0.0" - path-exists "^4.0.0" - find-up@5.0.0, find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" @@ -14548,6 +14599,14 @@ find-up@^3.0.0: dependencies: locate-path "^3.0.0" +find-up@^4.0.0, find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + findup-sync@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc" @@ -14705,7 +14764,7 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= -fork-ts-checker-webpack-plugin@4.1.6, fork-ts-checker-webpack-plugin@^4.1.6: +fork-ts-checker-webpack-plugin@^4.1.6: version "4.1.6" resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.6.tgz#5055c703febcf37fa06405d400c122b905167fc5" integrity sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw== @@ -14952,6 +15011,21 @@ fuse.js@^3.6.1: resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.6.1.tgz#7de85fdd6e1b3377c23ce010892656385fd9b10c" integrity sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw== +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + object-assign "^4.1.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + gauge@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/gauge/-/gauge-4.0.2.tgz#c3777652f542b6ef62797246e8c7caddecb32cc7" @@ -15199,21 +15273,6 @@ glob-all@^3.2.1: glob "^7.1.2" yargs "^15.3.1" -glob-base@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" - integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q= - dependencies: - glob-parent "^2.0.0" - is-glob "^2.0.0" - -glob-parent@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" - integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg= - dependencies: - is-glob "^2.0.0" - glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" @@ -15257,7 +15316,7 @@ glob-to-regexp@^0.3.0: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs= -glob-to-regexp@^0.4.0: +glob-to-regexp@^0.4.0, glob-to-regexp@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== @@ -15316,13 +15375,6 @@ global-dirs@^3.0.0: dependencies: ini "2.0.0" -global-modules@2.0.0, global-modules@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" - integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== - dependencies: - global-prefix "^3.0.0" - global-modules@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea" @@ -15332,6 +15384,13 @@ global-modules@^1.0.0: is-windows "^1.0.1" resolve-dir "^1.0.0" +global-modules@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" + integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== + dependencies: + global-prefix "^3.0.0" + global-prefix@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe" @@ -15401,18 +15460,6 @@ globby@10.0.0: merge2 "^1.2.3" slash "^3.0.0" -globby@11.0.1: - version "11.0.1" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357" - integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.1.1" - ignore "^5.1.4" - merge2 "^1.3.0" - slash "^3.0.0" - globby@11.0.4, globby@^11.0.1, globby@^11.0.2, globby@^11.0.3, globby@^11.0.4: version "11.0.4" resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5" @@ -15711,14 +15758,6 @@ gulplog@^1.0.0: dependencies: glogg "^1.0.0" -gzip-size@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274" - integrity sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA== - dependencies: - duplexer "^0.1.1" - pify "^4.0.1" - gzip-size@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" @@ -16042,6 +16081,13 @@ highlight.js@^10.1.1, highlight.js@^10.4.1, highlight.js@~10.4.0: resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0" integrity sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg== +history@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/history/-/history-5.0.0.tgz#0cabbb6c4bbf835addb874f8259f6d25101efd08" + integrity sha512-3NyRMKIiFSJmIPdq7FxkNMJkQ7ZEtVblOQ38VtKaA0zZMW1Eo6Q6W8oDKEflr1kNNTItSnk4JMCO1deeSgbLLg== + dependencies: + "@babel/runtime" "^7.7.6" + history@^4.9.0: version "4.9.0" resolved "https://registry.yarnpkg.com/history/-/history-4.9.0.tgz#84587c2068039ead8af769e9d6a6860a14fa1bca" @@ -16054,6 +16100,13 @@ history@^4.9.0: tiny-warning "^1.0.0" value-equal "^0.4.0" +history@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b" + integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ== + dependencies: + "@babel/runtime" "^7.7.6" + hjson@3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/hjson/-/hjson-3.2.1.tgz#20de41dc87fc9a10d1557d0230b0e02afb1b09ac" @@ -16140,11 +16193,16 @@ html-encoding-sniffer@^2.0.1: dependencies: whatwg-encoding "^1.0.5" -html-entities@^1.2.0, html-entities@^1.2.1, html-entities@^1.3.1: +html-entities@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.3.1.tgz#fb9a1a4b5b14c5daba82d3e34c6ae4fe701a0e44" integrity sha512-rhE/4Z3hIhzHAUKbW8jVcCyuT5oJCXXqhN/6mXXVCpzTmvJnoH2HL/bt3EZ6p55jbFJBeAe1ZNpL5BugLujxNA== +html-entities@^2.1.0: + version "2.3.3" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46" + integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA== + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -16504,11 +16562,6 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= -immer@8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" - integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== - immer@^9.0.1, immer@^9.0.6: version "9.0.6" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73" @@ -16790,7 +16843,7 @@ intl@^1.2.5: resolved "https://registry.yarnpkg.com/intl/-/intl-1.2.5.tgz#82244a2190c4e419f8371f5aa34daa3420e2abde" integrity sha1-giRKIZDE5Bn4Nx9ao02qNCDiq94= -invariant@^2.1.0, invariant@^2.1.1, invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4: +invariant@^2.1.0, invariant@^2.1.1, invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -17041,11 +17094,6 @@ is-extendable@^1.0.1: dependencies: is-plain-object "^2.0.4" -is-extglob@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" - integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA= - is-extglob@^2.1.0, is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -17090,13 +17138,6 @@ is-generator-function@^1.0.7: resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.7.tgz#d2132e529bb0000a7f80794d4bdf5cd5e5813522" integrity sha512-YZc5EwyO4f2kWCax7oegfuSr9mFz1ZvieNYBEjmukLxgXfBUbxAWGVF7GZf0zidYtoBl3WvC07YK0wT76a+Rtw== -is-glob@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" - integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM= - dependencies: - is-extglob "^1.0.0" - is-glob@^3.0.0, is-glob@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" @@ -17323,11 +17364,6 @@ is-resolvable@^1.0.0: resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== -is-root@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-root/-/is-root-2.1.0.tgz#809e18129cf1129644302a4f8544035d51984a9c" - integrity sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg== - is-set@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43" @@ -19671,14 +19707,6 @@ markdown-table@^2.0.0: dependencies: repeat-string "^1.0.0" -markdown-to-jsx@^6.11.4: - version "6.11.4" - resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-6.11.4.tgz#b4528b1ab668aef7fe61c1535c27e837819392c5" - integrity sha512-3lRCD5Sh+tfA52iGgfs/XZiw33f7fFX9Bn55aNnVNUd2GzLDkOWyKYYD8Yju2B1Vn+feiEdgJs8T6Tg0xNokPw== - dependencies: - prop-types "^15.6.2" - unquote "^1.1.0" - markdown-to-jsx@^7.1.3: version "7.1.3" resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.1.3.tgz#f00bae66c0abe7dd2d274123f84cb6bd2a2c7c6a" @@ -20538,7 +20566,7 @@ nano-time@1.0.0: dependencies: big-integer "^1.6.16" -nanoid@3.2.0: +nanoid@3.2.0, nanoid@^3.1.23: version "3.2.0" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c" integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA== @@ -20566,13 +20594,6 @@ napi-build-utils@^1.0.1: resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== -native-url@^0.2.6: - version "0.2.6" - resolved "https://registry.yarnpkg.com/native-url/-/native-url-0.2.6.tgz#ca1258f5ace169c716ff44eccbddb674e10399ae" - integrity sha512-k4bDC87WtgrdD362gZz6zoiXQrl40kYlBmpfmSjwRO1VU0V5ccwJTlxuE72F6m3V0vc1xOf6n3UCP9QyerRqmA== - dependencies: - querystring "^0.2.0" - natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -20853,11 +20874,6 @@ node-preload@^0.2.1: dependencies: process-on-spawn "^1.0.0" -node-releases@^1.1.61: - version "1.1.61" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.61.tgz#707b0fca9ce4e11783612ba4a2fcba09047af16e" - integrity sha512-DD5vebQLg8jLCOzwupn954fbIiZht05DAZs0k2u8NStSe6h9XdsuIQL8hSRKYiU8WUQRznmSDrKGbv3ObOmC7g== - node-releases@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.1.tgz#3d1d395f204f1f2f29a54358b9fb678765ad2fc5" @@ -21022,6 +21038,16 @@ npmlog@^4.0.0, npmlog@^4.0.1, npmlog@^4.1.2: gauge "~2.7.3" set-blocking "~2.0.0" +npmlog@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + npmlog@^6.0.0: version "6.0.1" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-6.0.1.tgz#06f1344a174c06e8de9c6c70834cfba2964bba17" @@ -21344,7 +21370,7 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" -open@^7.0.2, open@^7.0.3: +open@^7.0.3: version "7.1.0" resolved "https://registry.yarnpkg.com/open/-/open-7.1.0.tgz#68865f7d3cb238520fa1225a63cf28bcf8368a1c" integrity sha512-lLPI5KgOwEYCDKXf4np7y1PBEkj7HYIyP2DY8mVDRnx0VIIu6bNrRB0R66TuO7Mack6EnTNLm4uvcl1UoklTpA== @@ -22211,13 +22237,6 @@ pkg-dir@^5.0.0: dependencies: find-up "^5.0.0" -pkg-up@3.1.0, pkg-up@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" - integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA== - dependencies: - find-up "^3.0.0" - pkg-up@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-2.0.0.tgz#c819ac728059a461cab1c3889a2be3c49a004d7f" @@ -22225,6 +22244,13 @@ pkg-up@^2.0.0: dependencies: find-up "^2.1.0" +pkg-up@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" + integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA== + dependencies: + find-up "^3.0.0" + platform@^1.3.0: version "1.3.5" resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.5.tgz#fb6958c696e07e2918d2eeda0f0bc9448d733444" @@ -22857,16 +22883,16 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" +"prettier@>=2.2.1 <=2.3.0": + version "2.2.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5" + integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== + prettier@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032" integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew== -prettier@~2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5" - integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== - pretty-bytes@^5.6.0: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" @@ -23038,7 +23064,7 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -prompts@2.4.0, prompts@^2.0.1: +prompts@^2.0.1: version "2.4.0" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.0.tgz#4aa5de0723a231d1ee9121c40fdf663df73f61d7" integrity sha512-awZAKrk3vN6CroQukBL+R9051a4R3zCZBlJm/HBfrSZ8iTpYix3VX1vU4mveiLpiwmOJT4wokTF9m6HUk4KqWQ== @@ -23547,36 +23573,6 @@ react-colorful@^5.1.2: resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.5.1.tgz#29d9c4e496f2ca784dd2bb5053a3a4340cfaf784" integrity sha512-M1TJH2X3RXEt12sWkpa6hLc/bbYS0H6F4rIqjQZ+RxNBstpY67d9TrFXtqdZwhpmBXcCwEi7stKqFue3ZRkiOg== -react-dev-utils@^11.0.3: - version "11.0.4" - resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-11.0.4.tgz#a7ccb60257a1ca2e0efe7a83e38e6700d17aa37a" - integrity sha512-dx0LvIGHcOPtKbeiSUM4jqpBl3TcY7CDjZdfOIcKeznE7BWr9dg0iPG90G5yfVQ+p/rGNMXdbfStvzQZEVEi4A== - dependencies: - "@babel/code-frame" "7.10.4" - address "1.1.2" - browserslist "4.14.2" - chalk "2.4.2" - cross-spawn "7.0.3" - detect-port-alt "1.1.6" - escape-string-regexp "2.0.0" - filesize "6.1.0" - find-up "4.1.0" - fork-ts-checker-webpack-plugin "4.1.6" - global-modules "2.0.0" - globby "11.0.1" - gzip-size "5.1.1" - immer "8.0.1" - is-root "2.1.0" - loader-utils "2.0.0" - open "^7.0.2" - pkg-up "3.1.0" - prompts "2.4.0" - react-error-overlay "^6.0.9" - recursive-readdir "2.2.2" - shell-quote "1.7.2" - strip-ansi "6.0.0" - text-table "0.2.0" - react-docgen-typescript@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/react-docgen-typescript/-/react-docgen-typescript-2.1.1.tgz#c9f9ccb1fa67e0f4caf3b12f2a07512a201c2dcf" @@ -23596,15 +23592,15 @@ react-docgen@^5.0.0: node-dir "^0.1.10" strip-indent "^3.0.0" -react-dom@^16.12.0: - version "16.12.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.12.0.tgz#0da4b714b8d13c2038c9396b54a92baea633fe11" - integrity sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw== +react-dom@^16.14.0: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" + integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "^0.18.0" + scheduler "^0.19.1" react-draggable@3.x, "react-draggable@^2.2.6 || ^3.0.3": version "3.0.5" @@ -23639,7 +23635,7 @@ react-dropzone@^4.2.9: attr-accept "^1.1.3" prop-types "^15.5.7" -react-element-to-jsx-string@^14.3.2, react-element-to-jsx-string@^14.3.4: +react-element-to-jsx-string@^14.3.4: version "14.3.4" resolved "https://registry.yarnpkg.com/react-element-to-jsx-string/-/react-element-to-jsx-string-14.3.4.tgz#709125bc72f06800b68f9f4db485f2c7d31218a8" integrity sha512-t4ZwvV6vwNxzujDQ+37bspnLwA4JlgUPWhLjBJWsNIDceAf6ZKUTCjdm08cN6WeZ5pTMKiCJkmAYnpmR4Bm+dg== @@ -23655,11 +23651,6 @@ react-error-boundary@^3.1.0: dependencies: "@babel/runtime" "^7.12.5" -react-error-overlay@^6.0.9: - version "6.0.9" - resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a" - integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== - react-fast-compare@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" @@ -23751,21 +23742,16 @@ react-intl@^2.8.0: intl-relativeformat "^2.1.0" invariant "^2.1.1" -react-is@17.0.2, react-is@^17.0.2: +react-is@17.0.2, react-is@^17.0.1, react-is@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-is@^16.12.0, react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: +react-is@^16.12.0, react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^17.0.1: - version "17.0.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" - integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== - react-lib-adler32@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/react-lib-adler32/-/react-lib-adler32-1.0.3.tgz#63df1aed274eabcc1c5067077ea281ec30888ba7" @@ -23873,10 +23859,10 @@ react-redux@^7.1.0, react-redux@^7.2.0: prop-types "^15.7.2" react-is "^16.9.0" -react-refresh@^0.8.3: - version "0.8.3" - resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" - integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== +react-refresh@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" + integrity sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A== react-remove-scroll-bar@^2.1.0: version "2.1.0" @@ -23949,6 +23935,14 @@ react-router-dom@^5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-router-dom@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d" + integrity sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw== + dependencies: + history "^5.2.0" + react-router "6.3.0" + react-router-redux@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/react-router-redux/-/react-router-redux-4.0.8.tgz#227403596b5151e182377dab835b5d45f0f8054e" @@ -23970,6 +23964,13 @@ react-router@5.2.0, react-router@^5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-router@6.3.0, react-router@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" + integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== + dependencies: + history "^5.2.0" + react-select@^2.4.4: version "2.4.4" resolved "https://registry.yarnpkg.com/react-select/-/react-select-2.4.4.tgz#ba72468ef1060c7d46fbb862b0748f96491f1f73" @@ -24057,15 +24058,15 @@ react-syntax-highlighter@^13.5.3, react-syntax-highlighter@^15.3.1: prismjs "^1.22.0" refractor "^3.2.0" -react-test-renderer@^16.0.0-0, react-test-renderer@^16.12.0, "react-test-renderer@^16.8.0 || ^17.0.0": - version "16.12.0" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.12.0.tgz#11417ffda579306d4e841a794d32140f3da1b43f" - integrity sha512-Vj/teSqt2oayaWxkbhQ6gKis+t5JrknXfPVo+aIJ8QwYAqMPH77uptOdrlphyxl8eQI/rtkOYg86i/UWkpFu0w== +react-test-renderer@^16.0.0-0, react-test-renderer@^16.14.0, "react-test-renderer@^16.8.0 || ^17.0.0": + version "16.14.0" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.14.0.tgz#e98360087348e260c56d4fe2315e970480c228ae" + integrity sha512-L8yPjqPE5CZO6rKsKXRO/rVPiaCOy0tQQJbC+UjPNlobl5mad59lvPjwFsQHTvL03caVDIVr9x9/OSgDe6I5Eg== dependencies: object-assign "^4.1.1" prop-types "^15.6.2" react-is "^16.8.6" - scheduler "^0.18.0" + scheduler "^0.19.1" react-textarea-autosize@^8.3.0: version "8.3.3" @@ -24182,10 +24183,10 @@ react-window@^1.8.6: "@babel/runtime" "^7.0.0" memoize-one ">=3.1.1 <6" -react@^16.12.0: - version "16.12.0" - resolved "https://registry.yarnpkg.com/react/-/react-16.12.0.tgz#0c0a9c6a142429e3614834d5a778e18aa78a0b83" - integrity sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA== +react@^16.14.0: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" + integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -24392,13 +24393,6 @@ recompose@^0.26.0: hoist-non-react-statics "^2.3.1" symbol-observable "^1.0.4" -recursive-readdir@2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.2.tgz#9946fb3274e1628de6e36b2f6714953b4845094f" - integrity sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg== - dependencies: - minimatch "3.0.4" - redent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" @@ -25460,10 +25454,10 @@ saxes@^5.0.0: dependencies: xmlchars "^2.2.0" -scheduler@^0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.18.0.tgz#5901ad6659bc1d8f3fdaf36eb7a67b0d6746b1c4" - integrity sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ== +scheduler@^0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" + integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -25833,7 +25827,7 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -shell-quote@1.7.2, shell-quote@^1.4.2, shell-quote@^1.6.1: +shell-quote@^1.4.2, shell-quote@^1.6.1: version "1.7.2" resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2" integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== @@ -26543,17 +26537,6 @@ store2@^2.12.0: resolved "https://registry.yarnpkg.com/store2/-/store2-2.12.0.tgz#e1f1b7e1a59b6083b2596a8d067f6ee88fd4d3cf" integrity sha512-7t+/wpKLanLzSnQPX8WAcuLCCeuSHoWdQuh9SB3xD0kNOM38DNf+0Oa+wmvxmYueRzkmh6IcdKFtvTa+ecgPDw== -storybook-addon-outline@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/storybook-addon-outline/-/storybook-addon-outline-1.4.1.tgz#0a1b262b9c65df43fc63308a1fdbd4283c3d9458" - integrity sha512-Qvv9X86CoONbi+kYY78zQcTGmCgFaewYnOVR6WL7aOFJoW7TrLiIc/O4hH5X9PsEPZFqjfXEPUPENWVUQim6yw== - dependencies: - "@storybook/addons" "^6.3.0" - "@storybook/api" "^6.3.0" - "@storybook/components" "^6.3.0" - "@storybook/core-events" "^6.3.0" - ts-dedent "^2.1.1" - stream-browserify@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" @@ -26730,7 +26713,7 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" -string-width@^4.2.3: +string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -26835,13 +26818,6 @@ strip-ansi@*, strip-ansi@5.2.0, strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi dependencies: ansi-regex "^4.1.0" -strip-ansi@6.0.0, strip-ansi@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" - integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== - dependencies: - ansi-regex "^5.0.0" - strip-ansi@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-0.3.0.tgz#25f48ea22ca79187f3174a4db8759347bb126220" @@ -26863,7 +26839,7 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" -strip-ansi@^6.0.1: +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -27251,6 +27227,11 @@ symbol.prototype.description@^1.0.0: dependencies: has-symbols "^1.0.0" +synchronous-promise@^2.0.15: + version "2.0.15" + resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.15.tgz#07ca1822b9de0001f5ff73595f3d08c4f720eb8e" + integrity sha512-k8uzYIkIVwmT+TcglpdN50pS2y1BDcUnBPK9iJeGu0Pl1lOI8pD6wtzgw91Pjpe+RxtTncw32tLxs/R0yNL2Mg== + syntax-error@^1.1.1: version "1.4.0" resolved "https://registry.yarnpkg.com/syntax-error/-/syntax-error-1.4.0.tgz#2d9d4ff5c064acb711594a3e3b95054ad51d907c" @@ -27396,7 +27377,7 @@ tcp-port-used@^1.0.1: debug "4.1.0" is2 "2.0.1" -telejson@^5.3.2: +telejson@^5.3.2, telejson@^5.3.3: version "5.3.3" resolved "https://registry.yarnpkg.com/telejson/-/telejson-5.3.3.tgz#fa8ca84543e336576d8734123876a9f02bf41d2e" integrity sha512-PjqkJZpzEggA9TBpVtJi1LVptP7tYtXB6rEubwlHap76AMjzvOdKX41CxyaW7ahhzDU1aftXnMCx5kAPDZTQBA== @@ -27424,11 +27405,6 @@ tempy@^0.3.0: type-fest "^0.3.1" unique-string "^1.0.0" -term-size@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753" - integrity sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw== - terminal-link@^2.0.0, terminal-link@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" @@ -27513,7 +27489,7 @@ text-hex@1.0.x: resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== -text-table@0.2.0, text-table@^0.2.0: +text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= @@ -27908,7 +27884,7 @@ ts-debounce@^3.0.0: resolved "https://registry.yarnpkg.com/ts-debounce/-/ts-debounce-3.0.0.tgz#9beedf59c04de3b5bef8ff28bd6885624df357be" integrity sha512-7jiRWgN4/8IdvCxbIwnwg2W0bbYFBH6BxFqBjMKk442t7+liF2Z1H6AUCcl8e/pD93GjPru+axeiJwFmRww1WQ== -ts-dedent@^2.0.0, ts-dedent@^2.1.1: +ts-dedent@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ== @@ -28593,7 +28569,7 @@ unpipe@1.0.0, unpipe@~1.0.0: resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= -unquote@^1.1.0, unquote@~1.1.1: +unquote@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unquote/-/unquote-1.1.1.tgz#8fded7324ec6e88a0ff8b905e7c098cdc086d544" integrity sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ= @@ -29541,6 +29517,14 @@ watchpack@^1.6.0, watchpack@^1.7.4: chokidar "^3.4.1" watchpack-chokidar2 "^2.0.0" +watchpack@^2.2.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25" + integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" + wbuf@^1.1.0, wbuf@^1.7.3: version "1.7.3" resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" @@ -29682,15 +29666,15 @@ webpack-filter-warnings-plugin@^1.2.1: resolved "https://registry.yarnpkg.com/webpack-filter-warnings-plugin/-/webpack-filter-warnings-plugin-1.2.1.tgz#dc61521cf4f9b4a336fbc89108a75ae1da951cdb" integrity sha512-Ez6ytc9IseDMLPo0qCuNNYzgtUl8NovOqjIq4uAU8LTD4uoa1w1KpZyyzFtLTEMZpkkOkLfL9eN+KGYdk1Qtwg== -webpack-hot-middleware@^2.25.0: - version "2.25.0" - resolved "https://registry.yarnpkg.com/webpack-hot-middleware/-/webpack-hot-middleware-2.25.0.tgz#4528a0a63ec37f8f8ef565cf9e534d57d09fe706" - integrity sha512-xs5dPOrGPCzuRXNi8F6rwhawWvQQkeli5Ro48PRuQh8pYPCPmNnltP9itiUPT4xI8oW+y0m59lyyeQk54s5VgA== +webpack-hot-middleware@^2.25.1: + version "2.25.1" + resolved "https://registry.yarnpkg.com/webpack-hot-middleware/-/webpack-hot-middleware-2.25.1.tgz#581f59edf0781743f4ca4c200fd32c9266c6cf7c" + integrity sha512-Koh0KyU/RPYwel/khxbsDz9ibDivmUbrRuKSSQvW42KSDdO4w23WI3SkHpSUKHE76LrFnnM/L7JCrpBwu8AXYw== dependencies: - ansi-html "0.0.7" - html-entities "^1.2.0" + ansi-html-community "0.0.8" + html-entities "^2.1.0" querystring "^0.2.0" - strip-ansi "^3.0.0" + strip-ansi "^6.0.0" webpack-log@^2.0.0: version "2.0.0" @@ -29877,7 +29861,7 @@ which@^1.2.14, which@^1.2.9, which@^1.3.1: dependencies: isexe "^2.0.0" -wide-align@^1.1.0, wide-align@^1.1.5: +wide-align@^1.1.0, wide-align@^1.1.2, wide-align@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== @@ -30123,6 +30107,11 @@ ws@^7.3.1: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.6.tgz#e59fc509fb15ddfb65487ee9765c5a51dec5fe7b" integrity sha512-6GLgCqo2cy2A2rjCNFlxQS6ZljG/coZfZXclldI8FB/1G3CCI36Zd8xy2HrFVACi8tfk5XrgLQEk+P0Tnz9UcA== +ws@^8.2.3: + version "8.5.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f" + integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg== + x-is-function@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/x-is-function/-/x-is-function-1.0.4.tgz#5d294dc3d268cbdd062580e0c5df77a391d1fa1e" From 5d5603a57237d8fe9cf186916c713b9ddddf039d Mon Sep 17 00:00:00 2001 From: Yash Tewari Date: Thu, 19 May 2022 13:12:35 +0300 Subject: [PATCH 047/150] Add cloudbeat index to agent policy defaults. (#132452) * Add cloudbeat index to agent policy defaults. The associated indices are used by filebeat to send cloudbeat logs and metrics to Kibana. * Commit using elastic.co Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Yash Tewari --- x-pack/plugins/fleet/common/constants/agent_policy.ts | 1 + .../__snapshots__/monitoring_permissions.test.ts.snap | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/x-pack/plugins/fleet/common/constants/agent_policy.ts b/x-pack/plugins/fleet/common/constants/agent_policy.ts index 95078d8ead84f..316c66d2c75d6 100644 --- a/x-pack/plugins/fleet/common/constants/agent_policy.ts +++ b/x-pack/plugins/fleet/common/constants/agent_policy.ts @@ -24,4 +24,5 @@ export const AGENT_POLICY_DEFAULT_MONITORING_DATASETS = [ 'elastic_agent.endpoint_security', 'elastic_agent.auditbeat', 'elastic_agent.heartbeat', + 'elastic_agent.cloudbeat', ]; diff --git a/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/monitoring_permissions.test.ts.snap b/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/monitoring_permissions.test.ts.snap index a54d4beb6c041..d46e7a92475ac 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/monitoring_permissions.test.ts.snap +++ b/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/monitoring_permissions.test.ts.snap @@ -116,6 +116,7 @@ Object { "logs-elastic_agent.endpoint_security-testnamespace123", "logs-elastic_agent.auditbeat-testnamespace123", "logs-elastic_agent.heartbeat-testnamespace123", + "logs-elastic_agent.cloudbeat-testnamespace123", "metrics-elastic_agent-testnamespace123", "metrics-elastic_agent.elastic_agent-testnamespace123", "metrics-elastic_agent.apm_server-testnamespace123", @@ -127,6 +128,7 @@ Object { "metrics-elastic_agent.endpoint_security-testnamespace123", "metrics-elastic_agent.auditbeat-testnamespace123", "metrics-elastic_agent.heartbeat-testnamespace123", + "metrics-elastic_agent.cloudbeat-testnamespace123", ], "privileges": Array [ "auto_configure", @@ -155,6 +157,7 @@ Object { "logs-elastic_agent.endpoint_security-testnamespace123", "logs-elastic_agent.auditbeat-testnamespace123", "logs-elastic_agent.heartbeat-testnamespace123", + "logs-elastic_agent.cloudbeat-testnamespace123", ], "privileges": Array [ "auto_configure", @@ -183,6 +186,7 @@ Object { "metrics-elastic_agent.endpoint_security-testnamespace123", "metrics-elastic_agent.auditbeat-testnamespace123", "metrics-elastic_agent.heartbeat-testnamespace123", + "metrics-elastic_agent.cloudbeat-testnamespace123", ], "privileges": Array [ "auto_configure", From 895e425c652ef9b683b994fb50eab0ac2feaf55b Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 19 May 2022 07:42:03 -0400 Subject: [PATCH 048/150] Fix flaky user activation/deactivation tests (#132465) --- x-pack/test/functional/apps/security/users.ts | 3 +-- .../functional/page_objects/security_page.ts | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/x-pack/test/functional/apps/security/users.ts b/x-pack/test/functional/apps/security/users.ts index 67be1e7ddecce..8448750bf1ccd 100644 --- a/x-pack/test/functional/apps/security/users.ts +++ b/x-pack/test/functional/apps/security/users.ts @@ -202,8 +202,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/118728 - describe.skip('Deactivate/Activate user', () => { + describe('Deactivate/Activate user', () => { it('deactivates user when confirming', async () => { await PageObjects.security.deactivatesUser(optionalUser); const users = keyBy(await PageObjects.security.getElasticsearchUsers(), 'username'); diff --git a/x-pack/test/functional/page_objects/security_page.ts b/x-pack/test/functional/page_objects/security_page.ts index 8731a3a3f5459..508fb7106948a 100644 --- a/x-pack/test/functional/page_objects/security_page.ts +++ b/x-pack/test/functional/page_objects/security_page.ts @@ -37,6 +37,7 @@ export class SecurityPageObject extends FtrService { private readonly common = this.ctx.getPageObject('common'); private readonly header = this.ctx.getPageObject('header'); private readonly monacoEditor = this.ctx.getService('monacoEditor'); + private readonly es = this.ctx.getService('es'); public loginPage = Object.freeze({ login: async (username?: string, password?: string, options: LoginOptions = {}) => { @@ -350,13 +351,6 @@ export class SecurityPageObject extends FtrService { const btn = await this.find.byButtonText(privilege); await btn.click(); - - // const options = await this.find.byCssSelector(`.euiFilterSelectItem`); - // Object.entries(options).forEach(([key, prop]) => { - // console.log({ key, proto: prop.__proto__ }); - // }); - - // await options.click(); } async assignRoleToUser(role: string) { @@ -516,6 +510,13 @@ export class SecurityPageObject extends FtrService { await this.clickUserByUserName(user.username ?? ''); await this.testSubjects.click('editUserDisableUserButton'); await this.testSubjects.click('confirmModalConfirmButton'); + await this.testSubjects.missingOrFail('confirmModalConfirmButton'); + if (user.username) { + await this.retry.waitForWithTimeout('ES to acknowledge deactivation', 15000, async () => { + const userResponse = await this.es.security.getUser({ username: user.username }); + return userResponse[user.username!].enabled === false; + }); + } await this.submitUpdateUserForm(); } @@ -523,6 +524,13 @@ export class SecurityPageObject extends FtrService { await this.clickUserByUserName(user.username ?? ''); await this.testSubjects.click('editUserEnableUserButton'); await this.testSubjects.click('confirmModalConfirmButton'); + await this.testSubjects.missingOrFail('confirmModalConfirmButton'); + if (user.username) { + await this.retry.waitForWithTimeout('ES to acknowledge activation', 15000, async () => { + const userResponse = await this.es.security.getUser({ username: user.username }); + return userResponse[user.username!].enabled === true; + }); + } await this.submitUpdateUserForm(); } From 178773f816613f04484523fdc63a89ca55fa6e5c Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Thu, 19 May 2022 14:15:26 +0200 Subject: [PATCH 049/150] [Cases] Cases alerts table UI enhancements (#132417) --- .../case_view/case_view_page.test.tsx | 3 +- .../components/case_view/case_view_page.tsx | 26 +++++++++---- .../components/case_view_alerts.test.tsx | 14 +++++++ .../case_view/components/case_view_alerts.tsx | 19 +++++++++- .../components/case_view_alerts_empty.tsx | 23 ++++++++++++ .../components/case_view/translations.ts | 7 ++++ .../components/user_actions/comment/alert.tsx | 4 ++ .../user_actions/comment/comment.test.tsx | 7 +++- .../comment/show_alert_table_link.test.tsx | 37 +++++++++++++++++++ .../comment/show_alert_table_link.tsx | 34 +++++++++++++++++ .../components/user_actions/translations.ts | 7 ++++ .../containers/use_get_feature_ids.test.tsx | 26 ++++++++----- .../public/containers/use_get_feature_ids.tsx | 26 ++++++++++--- .../apps/cases/view_case.ts | 26 +++++++++++++ 14 files changed, 232 insertions(+), 27 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/case_view/components/case_view_alerts_empty.tsx create mode 100644 x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.test.tsx create mode 100644 x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.tsx diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index fd0f7eebe0095..55b78ba23514a 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -585,8 +585,7 @@ describe('CaseViewPage', () => { appMockRender = createAppMockRenderer(); }); - // unskip when alerts tab is activated - it.skip('renders tabs correctly', async () => { + it('renders tabs correctly', async () => { const result = appMockRender.render(); await act(async () => { expect(result.getByTestId('case-view-tab-title-alerts')).toBeTruthy(); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx index 0c6acee136f5c..98393e3081c7b 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx @@ -5,8 +5,9 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; +import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import styled from 'styled-components'; import { Case, UpdateKey } from '../../../common/ui'; import { useCaseViewNavigation, useUrlParams } from '../../common/navigation'; import { useGetCaseMetrics } from '../../containers/use_get_case_metrics'; @@ -16,6 +17,7 @@ import { useCasesFeatures } from '../cases_context/use_cases_features'; import { CaseActionBar } from '../case_action_bar'; import { HeaderPage } from '../header_page'; import { EditableTitle } from '../header_page/editable_title'; +import { EXPERIMENTAL_DESC, EXPERIMENTAL_LABEL } from '../header_page/translations'; import { useTimelineContext } from '../timeline_context/use_timeline_context'; import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs'; import { WhitePageWrapperNoBorder } from '../wrappers'; @@ -26,10 +28,9 @@ import { ACTIVITY_TAB, ALERTS_TAB } from './translations'; import { CaseViewPageProps, CASE_VIEW_PAGE_TABS } from './types'; import { useOnUpdateField } from './use_on_update_field'; -// This hardcoded constant is left here intentionally -// as a way to hide a wip functionality -// that will be merge in the 8.3 release. -const ENABLE_ALERTS_TAB = true; +const ExperimentalBadge = styled(EuiBetaBadge)` + margin-left: 5px; +`; export const CaseViewPage = React.memo( ({ @@ -182,11 +183,22 @@ export const CaseViewPage = React.memo( /> ), }, - ...(features.alerts.enabled && ENABLE_ALERTS_TAB + ...(features.alerts.enabled ? [ { id: CASE_VIEW_PAGE_TABS.ALERTS, - name: ALERTS_TAB, + name: ( + <> + {ALERTS_TAB} + + + ), content: , }, ] diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx index 30d4636275674..9649ea013c02d 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx @@ -65,4 +65,18 @@ describe('Case View Page activity tab', () => { ); }); }); + + it('should show an empty prompt when the cases has no alerts', async () => { + const result = appMockRender.render( + + ); + await waitFor(async () => { + expect(result.getByTestId('caseViewAlertsEmpty')).toBeTruthy(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx index 75da3fd3fe470..b1371b6d733b6 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx @@ -7,10 +7,12 @@ import React, { useMemo } from 'react'; +import { EuiFlexItem, EuiFlexGroup, EuiProgress } from '@elastic/eui'; import { Case } from '../../../../common'; import { useKibana } from '../../../common/lib/kibana'; import { getManualAlertIds, getRegistrationContextFromAlerts } from './helpers'; import { useGetFeatureIds } from '../../../containers/use_get_feature_ids'; +import { CaseViewAlertsEmpty } from './case_view_alerts_empty'; interface CaseViewAlertsProps { caseData: Case; @@ -31,7 +33,8 @@ export const CaseViewAlerts = ({ caseData }: CaseViewAlertsProps) => { [caseData.comments] ); - const alertFeatureIds = useGetFeatureIds(alertRegistrationContexts); + const { isLoading: isLoadingAlertFeatureIds, alertFeatureIds } = + useGetFeatureIds(alertRegistrationContexts); const alertStateProps = { alertsTableConfigurationRegistry: triggersActionsUi.alertsTableConfigurationRegistry, @@ -41,6 +44,18 @@ export const CaseViewAlerts = ({ caseData }: CaseViewAlertsProps) => { query: alertIdsQuery, }; - return <>{triggersActionsUi.getAlertsStateTable(alertStateProps)}; + if (alertIdsQuery.ids.values.length === 0) { + return ; + } + + return isLoadingAlertFeatureIds ? ( + + + + + + ) : ( + triggersActionsUi.getAlertsStateTable(alertStateProps) + ); }; CaseViewAlerts.displayName = 'CaseViewAlerts'; diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts_empty.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts_empty.tsx new file mode 100644 index 0000000000000..ce10dfe6adb62 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts_empty.tsx @@ -0,0 +1,23 @@ +/* + * 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 { EuiEmptyPrompt } from '@elastic/eui'; +import React from 'react'; +import { ALERTS_EMPTY_DESCRIPTION } from '../translations'; + +export const CaseViewAlertsEmpty = () => { + return ( + {ALERTS_EMPTY_DESCRIPTION}

} + /> + ); +}; +CaseViewAlertsEmpty.displayName = 'CaseViewAlertsEmpty'; diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts index 94c19165e515b..af418a1ae858d 100644 --- a/x-pack/plugins/cases/public/components/case_view/translations.ts +++ b/x-pack/plugins/cases/public/components/case_view/translations.ts @@ -163,3 +163,10 @@ export const ACTIVITY_TAB = i18n.translate('xpack.cases.caseView.tabs.activity', export const ALERTS_TAB = i18n.translate('xpack.cases.caseView.tabs.alerts', { defaultMessage: 'Alerts', }); + +export const ALERTS_EMPTY_DESCRIPTION = i18n.translate( + 'xpack.cases.caseView.tabs.alerts.emptyDescription', + { + defaultMessage: 'No alerts have been added to this case.', + } +); diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx index 939bd458ebdc0..2c9689960322a 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx @@ -18,6 +18,7 @@ import { UserActionUsernameWithAvatar } from '../avatar_username'; import { MultipleAlertsCommentEvent, SingleAlertCommentEvent } from './alert_event'; import { UserActionCopyLink } from '../copy_link'; import { UserActionShowAlert } from './show_alert'; +import { ShowAlertTableLink } from './show_alert_table_link'; type BuilderArgs = Pick< UserActionBuilderArgs, @@ -135,6 +136,9 @@ const getMultipleAlertsUserAction = ({ + + + ), }, diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx index f7023d92d5f54..8fbda9dfdec4a 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx @@ -21,10 +21,13 @@ import { import { TestProviders } from '../../../common/mock'; import { createCommentUserActionBuilder } from './comment'; import { getMockBuilderArgs } from '../mock'; +import { useCaseViewParams } from '../../../common/navigation'; jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/navigation/hooks'); +const useCaseViewParamsMock = useCaseViewParams as jest.Mock; + describe('createCommentUserActionBuilder', () => { const builderArgs = getMockBuilderArgs(); @@ -113,7 +116,8 @@ describe('createCommentUserActionBuilder', () => { }); describe('Multiple alerts', () => { - it('renders correctly multiple alerts', async () => { + it('renders correctly multiple alerts with a link to the alerts table', async () => { + useCaseViewParamsMock.mockReturnValue({ detailName: '1234' }); const userAction = getAlertUserAction(); const builder = createCommentUserActionBuilder({ @@ -141,6 +145,7 @@ describe('createCommentUserActionBuilder', () => { expect(screen.getByTestId('multiple-alerts-user-action-alert-action-id')).toHaveTextContent( 'added 2 alerts from Awesome rule' ); + expect(screen.getByTestId('comment-action-show-alerts-1234')); }); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.test.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.test.tsx new file mode 100644 index 0000000000000..51d5c3a2b547c --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { createAppMockRenderer } from '../../../common/mock'; +import { useCaseViewNavigation, useCaseViewParams } from '../../../common/navigation'; +import { ShowAlertTableLink } from './show_alert_table_link'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/navigation/hooks'); + +const useCaseViewParamsMock = useCaseViewParams as jest.Mock; +const useCaseViewNavigationMock = useCaseViewNavigation as jest.Mock; + +describe('case view alert table link', () => { + it('calls navigateToCaseView with the correct params', () => { + const appMockRenderer = createAppMockRenderer(); + const navigateToCaseView = jest.fn(); + + useCaseViewParamsMock.mockReturnValue({ detailName: 'case-id' }); + useCaseViewNavigationMock.mockReturnValue({ navigateToCaseView }); + + const result = appMockRenderer.render(); + expect(result.getByTestId('comment-action-show-alerts-case-id')).toBeInTheDocument(); + + userEvent.click(result.getByTestId('comment-action-show-alerts-case-id')); + expect(navigateToCaseView).toHaveBeenCalledWith({ + detailName: 'case-id', + tabId: 'alerts', + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.tsx new file mode 100644 index 0000000000000..3ec52e83e5dda --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { useCaseViewNavigation, useCaseViewParams } from '../../../common/navigation'; +import { CASE_VIEW_PAGE_TABS } from '../../case_view/types'; +import { SHOW_ALERT_TABLE_TOOLTIP } from '../translations'; + +export const ShowAlertTableLink = () => { + const { navigateToCaseView } = useCaseViewNavigation(); + const { detailName } = useCaseViewParams(); + + const handleShowAlertsTable = useCallback(() => { + navigateToCaseView({ detailName, tabId: CASE_VIEW_PAGE_TABS.ALERTS }); + }, [navigateToCaseView, detailName]); + return ( + {SHOW_ALERT_TABLE_TOOLTIP}

}> + +
+ ); +}; + +ShowAlertTableLink.displayName = 'ShowAlertTableLink'; diff --git a/x-pack/plugins/cases/public/components/user_actions/translations.ts b/x-pack/plugins/cases/public/components/user_actions/translations.ts index ad881a2e78c21..b5b5d902d3a4d 100644 --- a/x-pack/plugins/cases/public/components/user_actions/translations.ts +++ b/x-pack/plugins/cases/public/components/user_actions/translations.ts @@ -46,6 +46,13 @@ export const SHOW_ALERT_TOOLTIP = i18n.translate('xpack.cases.caseView.showAlert defaultMessage: 'Show alert details', }); +export const SHOW_ALERT_TABLE_TOOLTIP = i18n.translate( + 'xpack.cases.caseView.showAlertTableTooltip', + { + defaultMessage: 'Show alerts', + } +); + export const UNKNOWN_RULE = i18n.translate('xpack.cases.caseView.unknownRule.label', { defaultMessage: 'Unknown rule', }); diff --git a/x-pack/plugins/cases/public/containers/use_get_feature_ids.test.tsx b/x-pack/plugins/cases/public/containers/use_get_feature_ids.test.tsx index df39cc883d532..b173ea4ad19e0 100644 --- a/x-pack/plugins/cases/public/containers/use_get_feature_ids.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_feature_ids.test.tsx @@ -5,13 +5,12 @@ * 2.0. */ -import type { ValidFeatureId } from '@kbn/rule-data-utils'; import { renderHook, act } from '@testing-library/react-hooks'; -import { waitFor } from '@testing-library/dom'; import React from 'react'; import { TestProviders } from '../common/mock'; import { useGetFeatureIds } from './use_get_feature_ids'; import * as api from './api'; +import { waitFor } from '@testing-library/dom'; jest.mock('./api'); jest.mock('../common/lib/kibana'); @@ -24,17 +23,20 @@ describe('useGetFeaturesIds', () => { it('inits with empty data', async () => { jest.spyOn(api, 'getFeatureIds').mockRejectedValue([]); - const { result } = renderHook(() => useGetFeatureIds(['context1']), { + const { result } = renderHook(() => useGetFeatureIds(['context1']), { wrapper: ({ children }) => {children}, }); + act(() => { - expect(result.current).toEqual([]); + expect(result.current.alertFeatureIds).toEqual([]); + expect(result.current.isLoading).toEqual(true); + expect(result.current.isError).toEqual(false); }); }); - + // it('fetches data and returns it correctly', async () => { const spy = jest.spyOn(api, 'getFeatureIds'); - const { result } = renderHook(() => useGetFeatureIds(['context1']), { + const { result } = renderHook(() => useGetFeatureIds(['context1']), { wrapper: ({ children }) => {children}, }); @@ -45,19 +47,23 @@ describe('useGetFeaturesIds', () => { ); }); - expect(result.current).toEqual(['siem', 'observability']); + expect(result.current.alertFeatureIds).toEqual(['siem', 'observability']); + expect(result.current.isLoading).toEqual(false); + expect(result.current.isError).toEqual(false); }); - it('throws an error correctly', async () => { + it('sets isError to true when an error occurs', async () => { const spy = jest.spyOn(api, 'getFeatureIds'); spy.mockImplementation(() => { throw new Error('Something went wrong'); }); - const { result } = renderHook(() => useGetFeatureIds(['context1']), { + const { result } = renderHook(() => useGetFeatureIds(['context1']), { wrapper: ({ children }) => {children}, }); - expect(result.current).toEqual([]); + expect(result.current.alertFeatureIds).toEqual([]); + expect(result.current.isLoading).toEqual(false); + expect(result.current.isError).toEqual(true); }); }); diff --git a/x-pack/plugins/cases/public/containers/use_get_feature_ids.tsx b/x-pack/plugins/cases/public/containers/use_get_feature_ids.tsx index ca181c0596eec..082e0539792ff 100644 --- a/x-pack/plugins/cases/public/containers/use_get_feature_ids.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_feature_ids.tsx @@ -12,14 +12,27 @@ import * as i18n from './translations'; import { useToasts } from '../common/lib/kibana'; import { getFeatureIds } from './api'; -export const useGetFeatureIds = (alertRegistrationContexts: string[]): ValidFeatureId[] => { - const [alertFeatureIds, setAlertFeatureIds] = useState([]); +const initialStatus = { + isLoading: true, + alertFeatureIds: [] as ValidFeatureId[], + isError: false, +}; + +export const useGetFeatureIds = ( + alertRegistrationContexts: string[] +): { + isLoading: boolean; + isError: boolean; + alertFeatureIds: ValidFeatureId[]; +} => { const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); + const [status, setStatus] = useState(initialStatus); const fetchFeatureIds = useCallback( async (registrationContext: string[]) => { + setStatus({ isLoading: true, alertFeatureIds: [], isError: false }); try { isCancelledRef.current = false; abortCtrlRef.current.abort(); @@ -29,10 +42,11 @@ export const useGetFeatureIds = (alertRegistrationContexts: string[]): ValidFeat const response = await getFeatureIds(query, abortCtrlRef.current.signal); if (!isCancelledRef.current) { - setAlertFeatureIds(response); + setStatus({ isLoading: false, alertFeatureIds: response, isError: false }); } } catch (error) { if (!isCancelledRef.current) { + setStatus({ isLoading: false, alertFeatureIds: [], isError: true }); if (error.name !== 'AbortError') { toasts.addError( error.body && error.body.message ? new Error(error.body.message) : error, @@ -52,7 +66,9 @@ export const useGetFeatureIds = (alertRegistrationContexts: string[]): ValidFeat abortCtrlRef.current.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [alertRegistrationContexts]); + }, alertRegistrationContexts); - return alertFeatureIds; + return status; }; + +export type UseGetFeatureIds = typeof useGetFeatureIds; diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts index 9aaf523de6638..42d5d5074e18d 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts @@ -215,5 +215,31 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await find.byCssSelector('[data-test-subj*="severity-update-action"]'); }); }); + + describe('Tabs', () => { + // create the case to test on + before(async () => { + await cases.navigation.navigateToApp(); + await cases.api.createNthRandomCases(1); + await cases.casesTable.waitForCasesToBeListed(); + await cases.casesTable.goToFirstListedCase(); + await header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + it('shows the "activity" tab by default', async () => { + await testSubjects.existOrFail('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-content-activity'); + }); + + // there are no alerts in stack management yet + it.skip("shows the 'alerts' tab when clicked", async () => { + await testSubjects.click('case-view-tab-title-alerts'); + await testSubjects.existOrFail('case-view-tab-content-alerts'); + }); + }); }); }; From fa607d1fe582a2fd2b72cdd27be431d5e820954d Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Thu, 19 May 2022 14:20:30 +0200 Subject: [PATCH 050/150] [Stack Monitoring] Converts logstash api routes to typescript (#131946) * converts logstash api routes to typescript * fixes register routes function names * fixes cluster and node pipelines routes * fixes types --- .../common/http_api/logstash/index.ts | 14 ++++ .../post_logstash_cluster_pipelines.ts | 25 ++++++ .../http_api/logstash/post_logstash_node.ts | 24 ++++++ .../logstash/post_logstash_node_pipelines.ts | 26 ++++++ .../http_api/logstash/post_logstash_nodes.ts | 22 +++++ .../logstash/post_logstash_overview.ts | 22 +++++ .../logstash/post_logstash_pipeline.ts | 24 ++++++ .../post_logstash_pipeline_cluster_ids.ts | 22 +++++ x-pack/plugins/monitoring/common/types/es.ts | 16 ++-- .../server/lib/details/get_metrics.ts | 6 ++ .../server/lib/logstash/get_cluster_status.ts | 5 +- .../api/v1/logstash/{index.js => index.ts} | 0 ...{metric_set_node.js => metric_set_node.ts} | 7 +- ...set_overview.js => metric_set_overview.ts} | 4 +- .../api/v1/logstash/{node.js => node.ts} | 67 +++++++-------- .../api/v1/logstash/{nodes.js => nodes.ts} | 43 ++++------ .../v1/logstash/{overview.js => overview.ts} | 43 ++++------ .../v1/logstash/{pipeline.js => pipeline.ts} | 70 +++++++--------- ...ipeline_ids.js => cluster_pipeline_ids.ts} | 35 ++++---- .../logstash/pipelines/cluster_pipelines.js | 79 ------------------ .../logstash/pipelines/cluster_pipelines.ts | 69 ++++++++++++++++ .../v1/logstash/pipelines/node_pipelines.js | 82 ------------------- .../v1/logstash/pipelines/node_pipelines.ts | 69 ++++++++++++++++ x-pack/plugins/monitoring/server/types.ts | 22 +++-- 24 files changed, 457 insertions(+), 339 deletions(-) create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/index.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_cluster_pipelines.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node_pipelines.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_nodes.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_overview.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline_cluster_ids.ts rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{index.js => index.ts} (100%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{metric_set_node.js => metric_set_node.ts} (88%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{metric_set_overview.js => metric_set_overview.ts} (73%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{node.js => node.ts} (61%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{nodes.js => nodes.ts} (57%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{overview.js => overview.ts} (66%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{pipeline.js => pipeline.ts} (55%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/{cluster_pipeline_ids.js => cluster_pipeline_ids.ts} (53%) delete mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.ts delete mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.ts diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/index.ts b/x-pack/plugins/monitoring/common/http_api/logstash/index.ts new file mode 100644 index 0000000000000..938826a4556bc --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/index.ts @@ -0,0 +1,14 @@ +/* + * 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 * from './post_logstash_node'; +export * from './post_logstash_nodes'; +export * from './post_logstash_overview'; +export * from './post_logstash_pipeline'; +export * from './post_logstash_pipeline_cluster_ids'; +export * from './post_logstash_cluster_pipelines'; +export * from './post_logstash_node_pipelines'; diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_cluster_pipelines.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_cluster_pipelines.ts new file mode 100644 index 0000000000000..8892e63e365c4 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_cluster_pipelines.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT, timeRangeRT, paginationRT, sortingRT } from '../shared'; + +export const postLogstashClusterPipelinesRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, +}); + +export const postLogstashClusterPipelinesRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + sort: sortingRT, + queryText: rt.string, + }), + rt.type({ + timeRange: timeRangeRT, + pagination: paginationRT, + }), +]); diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node.ts new file mode 100644 index 0000000000000..1d5d538352884 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT, timeRangeRT } from '../shared'; + +export const postLogstashNodeRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, + logstashUuid: rt.string, +}); + +export const postLogstashNodeRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + }), + rt.type({ + timeRange: timeRangeRT, + is_advanced: rt.boolean, + }), +]); diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node_pipelines.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node_pipelines.ts new file mode 100644 index 0000000000000..ed674f5419d13 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node_pipelines.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. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT, timeRangeRT, paginationRT, sortingRT } from '../shared'; + +export const postLogstashNodePipelinesRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, + logstashUuid: rt.string, +}); + +export const postLogstashNodePipelinesRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + sort: sortingRT, + queryText: rt.string, + }), + rt.type({ + timeRange: timeRangeRT, + pagination: paginationRT, + }), +]); diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_nodes.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_nodes.ts new file mode 100644 index 0000000000000..df4fecf3bec78 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_nodes.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT, timeRangeRT } from '../shared'; + +export const postLogstashNodesRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, +}); + +export const postLogstashNodesRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + }), + rt.type({ + timeRange: timeRangeRT, + }), +]); diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_overview.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_overview.ts new file mode 100644 index 0000000000000..dcd179b14a9f7 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_overview.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT, timeRangeRT } from '../shared'; + +export const postLogstashOverviewRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, +}); + +export const postLogstashOverviewRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + }), + rt.type({ + timeRange: timeRangeRT, + }), +]); diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline.ts new file mode 100644 index 0000000000000..36cdc044fe090 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT } from '../shared'; + +export const postLogstashPipelineRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, + pipelineId: rt.string, + pipelineHash: rt.union([rt.string, rt.undefined]), +}); + +export const postLogstashPipelineRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + }), + rt.partial({ + detailVertexId: rt.string, + }), +]); diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline_cluster_ids.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline_cluster_ids.ts new file mode 100644 index 0000000000000..f1450481a1e51 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline_cluster_ids.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT, timeRangeRT } from '../shared'; + +export const postLogstashPipelineClusterIdsRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, +}); + +export const postLogstashPipelineClusterIdsRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + }), + rt.type({ + timeRange: timeRangeRT, + }), +]); diff --git a/x-pack/plugins/monitoring/common/types/es.ts b/x-pack/plugins/monitoring/common/types/es.ts index f4c4b385d625d..a6b91f22ae563 100644 --- a/x-pack/plugins/monitoring/common/types/es.ts +++ b/x-pack/plugins/monitoring/common/types/es.ts @@ -142,6 +142,14 @@ export interface ElasticsearchIndexStats { }; } +export interface ElasticsearchLogstashStatePipeline { + representation?: { + graph?: { + vertices?: ElasticsearchSourceLogstashPipelineVertex[]; + }; + }; +} + export interface ElasticsearchLegacySource { timestamp: string; cluster_uuid: string; @@ -204,13 +212,7 @@ export interface ElasticsearchLegacySource { expiry_date_in_millis?: number; }; logstash_state?: { - pipeline?: { - representation?: { - graph?: { - vertices?: ElasticsearchSourceLogstashPipelineVertex[]; - }; - }; - }; + pipeline?: ElasticsearchLogstashStatePipeline; }; logstash_stats?: { timestamp?: string; diff --git a/x-pack/plugins/monitoring/server/lib/details/get_metrics.ts b/x-pack/plugins/monitoring/server/lib/details/get_metrics.ts index 475d2c681596e..f7e65efa74737 100644 --- a/x-pack/plugins/monitoring/server/lib/details/get_metrics.ts +++ b/x-pack/plugins/monitoring/server/lib/details/get_metrics.ts @@ -22,6 +22,12 @@ export type SimpleMetricDescriptor = string; export type MetricDescriptor = SimpleMetricDescriptor | NamedMetricDescriptor; +export function isNamedMetricDescriptor( + metricDescriptor: MetricDescriptor +): metricDescriptor is NamedMetricDescriptor { + return (metricDescriptor as NamedMetricDescriptor).name !== undefined; +} + // TODO: Switch to an options object argument here export async function getMetrics( req: LegacyRequest, diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_cluster_status.ts b/x-pack/plugins/monitoring/server/lib/logstash/get_cluster_status.ts index 308a750f6ef02..21d3d91a34470 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_cluster_status.ts +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_cluster_status.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { get } from 'lodash'; import { getLogstashForClusters } from './get_logstash_for_clusters'; import { LegacyRequest } from '../../types'; @@ -20,7 +19,7 @@ import { LegacyRequest } from '../../types'; */ export function getClusterStatus(req: LegacyRequest, { clusterUuid }: { clusterUuid: string }) { const clusters = [{ cluster_uuid: clusterUuid }]; - return getLogstashForClusters(req, clusters).then((clusterStatus) => - get(clusterStatus, '[0].stats') + return getLogstashForClusters(req, clusters).then( + (clusterStatus) => clusterStatus && clusterStatus[0]?.stats ); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts similarity index 100% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_node.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_node.ts similarity index 88% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_node.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_node.ts index 908fab524901b..6137c47ea7141 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_node.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_node.ts @@ -5,7 +5,12 @@ * 2.0. */ -export const metricSets = { +import { MetricDescriptor } from '../../../../lib/details/get_metrics'; + +export const metricSets: { + advanced: MetricDescriptor[]; + overview: MetricDescriptor[]; +} = { advanced: [ { keys: ['logstash_node_cpu_utilization', 'logstash_node_cgroup_quota'], diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_overview.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_overview.ts similarity index 73% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_overview.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_overview.ts index 3e812c1ab9a7a..440112d841d4e 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_overview.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_overview.ts @@ -5,7 +5,9 @@ * 2.0. */ -export const metricSet = [ +import { SimpleMetricDescriptor } from '../../../../lib/details/get_metrics'; + +export const metricSet: SimpleMetricDescriptor[] = [ 'logstash_cluster_events_input_rate', 'logstash_cluster_events_output_rate', 'logstash_cluster_events_latency', diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.ts similarity index 61% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.ts index cf1551d260e17..7f5fea6dc78e3 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.ts @@ -5,46 +5,33 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; +import { + postLogstashNodeRequestParamsRT, + postLogstashNodeRequestPayloadRT, +} from '../../../../../common/http_api/logstash'; import { getNodeInfo } from '../../../../lib/logstash/get_node_info'; import { handleError } from '../../../../lib/errors'; -import { getMetrics } from '../../../../lib/details/get_metrics'; +import { + getMetrics, + isNamedMetricDescriptor, + NamedMetricDescriptor, +} from '../../../../lib/details/get_metrics'; import { metricSets } from './metric_set_node'; +import { MonitoringCore } from '../../../../types'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; const { advanced: metricSetAdvanced, overview: metricSetOverview } = metricSets; -/* - * Logstash Node route. - */ -export function logstashNodeRoute(server) { - /** - * Logstash Node request. - * - * This will fetch all data required to display a Logstash Node page. - * - * The current details returned are: - * - * - Logstash Node Summary (Status) - * - Metrics - */ +export function logstashNodeRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashNodeRequestParamsRT); + const validateBody = createValidationFunction(postLogstashNodeRequestPayloadRT); + server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/node/{logstashUuid}', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - logstashUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - is_advanced: schema.boolean(), - }), - }, + validate: { + params: validateParams, + body: validateBody, }, async handler(req) { const config = server.config; @@ -58,11 +45,17 @@ export function logstashNodeRoute(server) { metricSet = metricSetOverview; // set the cgroup option if needed const showCgroupMetricsLogstash = config.ui.container.logstash.enabled; - const metricCpu = metricSet.find((m) => m.name === 'logstash_node_cpu_metric'); - if (showCgroupMetricsLogstash) { - metricCpu.keys = ['logstash_node_cgroup_quota_as_cpu_utilization']; - } else { - metricCpu.keys = ['logstash_node_cpu_utilization']; + const metricCpu = metricSet.find( + (m): m is NamedMetricDescriptor => + isNamedMetricDescriptor(m) && m.name === 'logstash_node_cpu_metric' + ); + + if (metricCpu) { + if (showCgroupMetricsLogstash) { + metricCpu.keys = ['logstash_node_cgroup_quota_as_cpu_utilization']; + } else { + metricCpu.keys = ['logstash_node_cpu_utilization']; + } } } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/nodes.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/nodes.ts similarity index 57% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/nodes.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/nodes.ts index c483a4ac905dd..169165b0893fe 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/nodes.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/nodes.ts @@ -5,41 +5,26 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; import { getClusterStatus } from '../../../../lib/logstash/get_cluster_status'; import { getNodes } from '../../../../lib/logstash/get_nodes'; import { handleError } from '../../../../lib/errors'; +import { MonitoringCore } from '../../../../types'; +import { + postLogstashNodesRequestParamsRT, + postLogstashNodesRequestPayloadRT, +} from '../../../../../common/http_api/logstash/post_logstash_nodes'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; + +export function logstashNodesRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashNodesRequestParamsRT); + const validateBody = createValidationFunction(postLogstashNodesRequestPayloadRT); -/* - * Logstash Nodes route. - */ -export function logstashNodesRoute(server) { - /** - * Logstash Nodes request. - * - * This will fetch all data required to display the Logstash Nodes page. - * - * The current details returned are: - * - * - Logstash Cluster Status - * - Nodes list - */ server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/nodes', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - }), - }, + validate: { + params: validateParams, + body: validateBody, }, async handler(req) { const clusterUuid = req.params.clusterUuid; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.ts similarity index 66% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.ts index 797365da6e308..73cff6ad35ac8 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.ts @@ -5,42 +5,27 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; +import { + postLogstashOverviewRequestParamsRT, + postLogstashOverviewRequestPayloadRT, +} from '../../../../../common/http_api/logstash/post_logstash_overview'; import { getClusterStatus } from '../../../../lib/logstash/get_cluster_status'; import { getMetrics } from '../../../../lib/details/get_metrics'; import { handleError } from '../../../../lib/errors'; import { metricSet } from './metric_set_overview'; +import { MonitoringCore } from '../../../../types'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; + +export function logstashOverviewRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashOverviewRequestParamsRT); + const validateBody = createValidationFunction(postLogstashOverviewRequestPayloadRT); -/* - * Logstash Overview route. - */ -export function logstashOverviewRoute(server) { - /** - * Logstash Overview request. - * - * This will fetch all data required to display the Logstash Overview page. - * - * The current details returned are: - * - * - Logstash Cluster Status - * - Metrics - */ server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - }), - }, + validate: { + params: validateParams, + body: validateBody, }, async handler(req) { const clusterUuid = req.params.clusterUuid; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.ts similarity index 55% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.ts index fc06e36fe9132..ba4eb941f7ffe 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.ts @@ -6,53 +6,42 @@ */ import { notFound } from '@hapi/boom'; -import { schema } from '@kbn/config-schema'; import { handleError, PipelineNotFoundError } from '../../../../lib/errors'; +import { + postLogstashPipelineRequestParamsRT, + postLogstashPipelineRequestPayloadRT, +} from '../../../../../common/http_api/logstash/post_logstash_pipeline'; import { getPipelineVersions } from '../../../../lib/logstash/get_pipeline_versions'; import { getPipeline } from '../../../../lib/logstash/get_pipeline'; import { getPipelineVertex } from '../../../../lib/logstash/get_pipeline_vertex'; +import { MonitoringCore, PipelineVersion } from '../../../../types'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; -function getPipelineVersion(versions, pipelineHash) { - return pipelineHash ? versions.find(({ hash }) => hash === pipelineHash) : versions[0]; +function getPipelineVersion(versions: PipelineVersion[], pipelineHash: string | null) { + return pipelineHash + ? versions.find(({ hash }) => hash === pipelineHash) ?? versions[0] + : versions[0]; } -/* - * Logstash Pipeline route. - */ -export function logstashPipelineRoute(server) { - /** - * Logstash Pipeline Viewer request. - * - * This will fetch all data required to display a Logstash Pipeline Viewer page. - * - * The current details returned are: - * - * - Pipeline Metrics - */ +export function logstashPipelineRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashPipelineRequestParamsRT); + const validateBody = createValidationFunction(postLogstashPipelineRequestPayloadRT); + server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/pipeline/{pipelineId}/{pipelineHash?}', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - pipelineId: schema.string(), - pipelineHash: schema.maybe(schema.string()), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - detailVertexId: schema.maybe(schema.string()), - }), - }, + validate: { + params: validateParams, + body: validateBody, }, - handler: async (req) => { + async handler(req) { const config = server.config; const clusterUuid = req.params.clusterUuid; const detailVertexId = req.payload.detailVertexId; const pipelineId = req.params.pipelineId; // Optional params default to empty string, set to null to be more explicit. - const pipelineHash = req.params.pipelineHash || null; + const pipelineHash = req.params.pipelineHash ?? null; // Figure out which version of the pipeline we want to show let versions; @@ -67,16 +56,19 @@ export function logstashPipelineRoute(server) { } const version = getPipelineVersion(versions, pipelineHash); - // noinspection ES6MissingAwait - const promises = [getPipeline(req, config, clusterUuid, pipelineId, version)]; - if (detailVertexId) { - promises.push( - getPipelineVertex(req, config, clusterUuid, pipelineId, version, detailVertexId) - ); - } + const callGetPipelineVertexFunc = () => { + if (!detailVertexId) { + return Promise.resolve(undefined); + } + + return getPipelineVertex(req, config, clusterUuid, pipelineId, version, detailVertexId); + }; try { - const [pipeline, vertex] = await Promise.all(promises); + const [pipeline, vertex] = await Promise.all([ + getPipeline(req, config, clusterUuid, pipelineId, version), + callGetPipelineVertexFunc(), + ]); return { versions, pipeline, diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.ts similarity index 53% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.ts index ebe3f1a308ff3..fe4d2c2b64ed7 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.ts @@ -5,32 +5,27 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; import { handleError } from '../../../../../lib/errors'; import { getLogstashPipelineIds } from '../../../../../lib/logstash/get_pipeline_ids'; +import { MonitoringCore } from '../../../../../types'; +import { createValidationFunction } from '../../../../../lib/create_route_validation_function'; +import { + postLogstashPipelineClusterIdsRequestParamsRT, + postLogstashPipelineClusterIdsRequestPayloadRT, +} from '../../../../../../common/http_api/logstash'; + +export function logstashClusterPipelineIdsRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashPipelineClusterIdsRequestParamsRT); + const validateBody = createValidationFunction(postLogstashPipelineClusterIdsRequestPayloadRT); -/** - * Retrieve pipelines for a cluster - */ -export function logstashClusterPipelineIdsRoute(server) { server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/pipeline_ids', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - }), - }, + validate: { + params: validateParams, + body: validateBody, }, - handler: async (req) => { + async handler(req) { const config = server.config; const clusterUuid = req.params.clusterUuid; const size = config.ui.max_bucket_size; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js deleted file mode 100644 index 38ba810ca5a23..0000000000000 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; -import { getClusterStatus } from '../../../../../lib/logstash/get_cluster_status'; -import { handleError } from '../../../../../lib/errors'; -import { getPaginatedPipelines } from '../../../../../lib/logstash/get_paginated_pipelines'; - -/** - * Retrieve pipelines for a cluster - */ -export function logstashClusterPipelinesRoute(server) { - server.route({ - method: 'POST', - path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/pipelines', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - pagination: schema.object({ - index: schema.number(), - size: schema.number(), - }), - sort: schema.maybe( - schema.object({ - field: schema.string(), - direction: schema.string(), - }) - ), - queryText: schema.string({ defaultValue: '' }), - }), - }, - }, - handler: async (req) => { - const { pagination, sort, queryText } = req.payload; - const clusterUuid = req.params.clusterUuid; - - const throughputMetric = 'logstash_cluster_pipeline_throughput'; - const nodesCountMetric = 'logstash_cluster_pipeline_nodes_count'; - - // Mapping client and server metric keys together - const sortMetricSetMap = { - latestThroughput: throughputMetric, - latestNodesCount: nodesCountMetric, - }; - if (sort) { - sort.field = sortMetricSetMap[sort.field] || sort.field; - } - try { - const response = await getPaginatedPipelines({ - req, - clusterUuid, - metrics: { throughputMetric, nodesCountMetric }, - pagination, - sort, - queryText, - }); - - return { - ...response, - clusterStatus: await getClusterStatus(req, { clusterUuid }), - }; - } catch (err) { - throw handleError(err, req); - } - }, - }); -} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.ts b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.ts new file mode 100644 index 0000000000000..07404c28894c4 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getClusterStatus } from '../../../../../lib/logstash/get_cluster_status'; +import { handleError } from '../../../../../lib/errors'; +import { getPaginatedPipelines } from '../../../../../lib/logstash/get_paginated_pipelines'; +import { MonitoringCore, PipelineMetricKey } from '../../../../../types'; +import { createValidationFunction } from '../../../../../lib/create_route_validation_function'; +import { + postLogstashClusterPipelinesRequestParamsRT, + postLogstashClusterPipelinesRequestPayloadRT, +} from '../../../../../../common/http_api/logstash'; + +const throughputMetric = 'logstash_cluster_pipeline_throughput'; +const nodesCountMetric = 'logstash_cluster_pipeline_nodes_count'; + +// Mapping client and server metric keys together +const sortMetricSetMap = { + latestThroughput: throughputMetric, + latestNodesCount: nodesCountMetric, +}; + +export function logstashClusterPipelinesRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashClusterPipelinesRequestParamsRT); + const validateBody = createValidationFunction(postLogstashClusterPipelinesRequestPayloadRT); + + server.route({ + method: 'post', + path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/pipelines', + validate: { + params: validateParams, + body: validateBody, + }, + async handler(req) { + const { + pagination, + sort: { field = '', direction = 'desc' } = {}, + queryText = '', + } = req.payload; + const clusterUuid = req.params.clusterUuid; + + try { + const response = await getPaginatedPipelines({ + req, + clusterUuid, + metrics: { throughputMetric, nodesCountMetric }, + pagination, + sort: { + field: (sortMetricSetMap[field as keyof typeof sortMetricSetMap] ?? + field) as PipelineMetricKey, + direction, + }, + queryText, + }); + + return { + ...response, + clusterStatus: await getClusterStatus(req, { clusterUuid }), + }; + } catch (err) { + throw handleError(err, req); + } + }, + }); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js deleted file mode 100644 index d47f1e6e88ec8..0000000000000 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; -import { getNodeInfo } from '../../../../../lib/logstash/get_node_info'; -import { handleError } from '../../../../../lib/errors'; -import { getPaginatedPipelines } from '../../../../../lib/logstash/get_paginated_pipelines'; - -/** - * Retrieve pipelines for a node - */ -export function logstashNodePipelinesRoute(server) { - server.route({ - method: 'POST', - path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/node/{logstashUuid}/pipelines', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - logstashUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - pagination: schema.object({ - index: schema.number(), - size: schema.number(), - }), - sort: schema.maybe( - schema.object({ - field: schema.string(), - direction: schema.string(), - }) - ), - queryText: schema.string({ defaultValue: '' }), - }), - }, - }, - handler: async (req) => { - const { pagination, sort, queryText } = req.payload; - const { clusterUuid, logstashUuid } = req.params; - - const throughputMetric = 'logstash_node_pipeline_throughput'; - const nodesCountMetric = 'logstash_node_pipeline_nodes_count'; - - // Mapping client and server metric keys together - const sortMetricSetMap = { - latestThroughput: throughputMetric, - latestNodesCount: nodesCountMetric, - }; - if (sort) { - sort.field = sortMetricSetMap[sort.field] || sort.field; - } - - try { - const response = await getPaginatedPipelines({ - req, - clusterUuid, - logstashUuid, - metrics: { throughputMetric, nodesCountMetric }, - pagination, - sort, - queryText, - }); - - return { - ...response, - nodeSummary: await getNodeInfo(req, { clusterUuid, logstashUuid }), - }; - } catch (err) { - throw handleError(err, req); - } - }, - }); -} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.ts b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.ts new file mode 100644 index 0000000000000..8cf74c1d93cc7 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getNodeInfo } from '../../../../../lib/logstash/get_node_info'; +import { handleError } from '../../../../../lib/errors'; +import { getPaginatedPipelines } from '../../../../../lib/logstash/get_paginated_pipelines'; +import { MonitoringCore, PipelineMetricKey } from '../../../../../types'; +import { createValidationFunction } from '../../../../../lib/create_route_validation_function'; +import { + postLogstashNodePipelinesRequestParamsRT, + postLogstashNodePipelinesRequestPayloadRT, +} from '../../../../../../common/http_api/logstash'; + +const throughputMetric = 'logstash_node_pipeline_throughput'; +const nodesCountMetric = 'logstash_node_pipeline_nodes_count'; + +// Mapping client and server metric keys together +const sortMetricSetMap = { + latestThroughput: throughputMetric, + latestNodesCount: nodesCountMetric, +}; + +export function logstashNodePipelinesRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashNodePipelinesRequestParamsRT); + const validateBody = createValidationFunction(postLogstashNodePipelinesRequestPayloadRT); + server.route({ + method: 'post', + path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/node/{logstashUuid}/pipelines', + validate: { + params: validateParams, + body: validateBody, + }, + async handler(req) { + const { + pagination, + sort: { field = '', direction = 'desc' } = {}, + queryText = '', + } = req.payload; + const { clusterUuid, logstashUuid } = req.params; + + try { + const response = await getPaginatedPipelines({ + req, + clusterUuid, + logstashUuid, + metrics: { throughputMetric, nodesCountMetric }, + pagination, + sort: { + field: (sortMetricSetMap[field as keyof typeof sortMetricSetMap] ?? + field) as PipelineMetricKey, + direction, + }, + queryText, + }); + + return { + ...response, + nodeSummary: await getNodeInfo(req, { clusterUuid, logstashUuid }), + }; + } catch (err) { + throw handleError(err, req); + } + }, + }); +} diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index 5977484518146..86447a24fdf04 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -198,11 +198,7 @@ export type Pipeline = { [key in PipelineMetricKey]?: number; }; -export type PipelineMetricKey = - | 'logstash_cluster_pipeline_throughput' - | 'logstash_cluster_pipeline_node_count' - | 'logstash_node_pipeline_node_count' - | 'logstash_node_pipeline_throughput'; +export type PipelineMetricKey = PipelineThroughputMetricKey | PipelineNodeCountMetricKey; export type PipelineThroughputMetricKey = | 'logstash_cluster_pipeline_throughput' @@ -210,16 +206,18 @@ export type PipelineThroughputMetricKey = export type PipelineNodeCountMetricKey = | 'logstash_cluster_pipeline_node_count' - | 'logstash_node_pipeline_node_count'; + | 'logstash_cluster_pipeline_nodes_count' + | 'logstash_node_pipeline_node_count' + | 'logstash_node_pipeline_nodes_count'; export interface PipelineWithMetrics { id: string; - metrics: { - logstash_cluster_pipeline_throughput?: PipelineMetricsProcessed; - logstash_cluster_pipeline_node_count?: PipelineMetricsProcessed; - logstash_node_pipeline_throughput?: PipelineMetricsProcessed; - logstash_node_pipeline_node_count?: PipelineMetricsProcessed; - }; + metrics: + | { + [key in PipelineMetricKey]: PipelineMetricsProcessed | undefined; + } + // backward compat with references that don't properly type the metric keys + | { [key: string]: PipelineMetricsProcessed | undefined }; } export interface PipelineResponse { From 3f339f2596236b2c8903a6a37373cdf51e672804 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Thu, 19 May 2022 09:27:45 -0400 Subject: [PATCH 051/150] [Security Solution][Admin][Kql bar] Align kql bar with buttons on same row in endpoint list(#132468) --- .../public/management/pages/endpoint_hosts/view/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 97bae3e150848..9c644f59a8b8a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -726,7 +726,7 @@ export const EndpointList = () => { )} {transformFailedCallout} - + {shouldShowKQLBar && ( From d9e6ef3f23809cc00e87b0caf9ab755b5c7fa9e8 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 19 May 2022 09:28:53 -0400 Subject: [PATCH 052/150] Unskip flaky test; Add retry when parsing JSON from audit log (#132510) --- .../tests/audit/audit_log.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/x-pack/test/security_api_integration/tests/audit/audit_log.ts b/x-pack/test/security_api_integration/tests/audit/audit_log.ts index 7322a2638767b..65ceaa46dd44a 100644 --- a/x-pack/test/security_api_integration/tests/audit/audit_log.ts +++ b/x-pack/test/security_api_integration/tests/audit/audit_log.ts @@ -8,10 +8,11 @@ import Path from 'path'; import Fs from 'fs'; import expect from '@kbn/expect'; +import { RetryService } from '../../../../../test/common/services/retry'; import { FtrProviderContext } from '../../ftr_provider_context'; class FileWrapper { - constructor(private readonly path: string) {} + constructor(private readonly path: string, private readonly retry: RetryService) {} async reset() { // "touch" each file to ensure it exists and is empty before each test await Fs.promises.writeFile(this.path, ''); @@ -21,15 +22,17 @@ class FileWrapper { return content.trim().split('\n'); } async readJSON() { - const content = await this.read(); - try { - return content.map((l) => JSON.parse(l)); - } catch (err) { - const contentString = content.join('\n'); - throw new Error( - `Failed to parse audit log JSON, error: "${err.message}", audit.log contents:\n${contentString}` - ); - } + return this.retry.try(async () => { + const content = await this.read(); + try { + return content.map((l) => JSON.parse(l)); + } catch (err) { + const contentString = content.join('\n'); + throw new Error( + `Failed to parse audit log JSON, error: "${err.message}", audit.log contents:\n${contentString}` + ); + } + }); } // writing in a file is an async operation. we use this method to make sure logs have been written. async isNotEmpty() { @@ -44,10 +47,9 @@ export default function ({ getService }: FtrProviderContext) { const retry = getService('retry'); const { username, password } = getService('config').get('servers.kibana'); - // FLAKY: https://github.com/elastic/kibana/issues/119267 - describe.skip('Audit Log', function () { + describe('Audit Log', function () { const logFilePath = Path.resolve(__dirname, '../../fixtures/audit/audit.log'); - const logFile = new FileWrapper(logFilePath); + const logFile = new FileWrapper(logFilePath, retry); beforeEach(async () => { await logFile.reset(); From dd8bd6fdb3e3de88479568faa9fec2a7910730b0 Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Thu, 19 May 2022 16:32:00 +0300 Subject: [PATCH 053/150] [XY] Mark size configuration. (#130361) * Added tests for the case when markSizeRatio and markSizeAccessor are specified. * Added markSizeAccessor to extendedDataLayer and xyVis. * Fixed markSizeRatio default value. * Added `size` support from `pointseries`. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../expression_xy/common/__mocks__/index.ts | 19 ++++- .../extended_data_layer.test.ts.snap | 5 ++ .../__snapshots__/layered_xy_vis.test.ts.snap | 7 ++ .../__snapshots__/xy_vis.test.ts.snap | 6 ++ .../expression_functions/common_xy_args.ts | 4 + .../extended_data_layer.test.ts | 74 ++++++++++++++++++ .../extended_data_layer.ts | 4 + .../extended_data_layer_fn.ts | 3 + .../layered_xy_vis.test.ts | 76 +++++++++++++++++++ .../expression_functions/layered_xy_vis_fn.ts | 15 +++- .../common/expression_functions/validate.ts | 44 ++++++++++- .../expression_functions/xy_vis.test.ts | 51 +++++++++++++ .../common/expression_functions/xy_vis.ts | 4 + .../common/expression_functions/xy_vis_fn.ts | 11 +++ .../expression_xy/common/helpers/layers.ts | 17 +++-- .../expression_xy/common/i18n/index.tsx | 8 ++ .../common/types/expression_functions.ts | 6 ++ .../__snapshots__/xy_chart.test.tsx.snap | 56 ++++++++------ .../public/components/xy_chart.test.tsx | 38 ++++++++++ .../public/components/xy_chart.tsx | 7 +- .../public/helpers/data_layers.tsx | 25 ++++-- 21 files changed, 438 insertions(+), 42 deletions(-) create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/layered_xy_vis.test.ts.snap create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts diff --git a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts index b8969fd599765..76e524960b159 100644 --- a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts @@ -10,7 +10,7 @@ import { Position } from '@elastic/charts'; import type { PaletteOutput } from '@kbn/coloring'; import { Datatable, DatatableRow } from '@kbn/expressions-plugin'; import { LayerTypes } from '../constants'; -import { DataLayerConfig, XYProps } from '../types'; +import { DataLayerConfig, ExtendedDataLayerConfig, XYProps } from '../types'; export const mockPaletteOutput: PaletteOutput = { type: 'palette', @@ -35,7 +35,7 @@ export const createSampleDatatableWithRows = (rows: DatatableRow[]): Datatable = id: 'c', name: 'c', meta: { - type: 'date', + type: 'string', field: 'order_date', sourceParams: { type: 'date-histogram', params: { interval: 'auto' } }, params: { id: 'string' }, @@ -61,6 +61,21 @@ export const sampleLayer: DataLayerConfig = { table: createSampleDatatableWithRows([]), }; +export const sampleExtendedLayer: ExtendedDataLayerConfig = { + layerId: 'first', + type: 'extendedDataLayer', + layerType: LayerTypes.DATA, + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'ordinal', + isHistogram: false, + palette: mockPaletteOutput, + table: createSampleDatatableWithRows([]), +}; + export const createArgsWithLayers = ( layers: DataLayerConfig | DataLayerConfig[] = sampleLayer ): XYProps => ({ diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap new file mode 100644 index 0000000000000..68262f8a4f3de --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`extendedDataLayerConfig throws the error if markSizeAccessor doesn't have the corresponding column in the table 1`] = `"Provided column name or index is invalid: nonsense"`; + +exports[`extendedDataLayerConfig throws the error if markSizeAccessor is provided to the not line/area chart 1`] = `"\`markSizeAccessor\` can't be used. Dots are applied only for line or area charts"`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/layered_xy_vis.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/layered_xy_vis.test.ts.snap new file mode 100644 index 0000000000000..b8e7cb8c05d3f --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/layered_xy_vis.test.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`layeredXyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 1`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; + +exports[`layeredXyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 2`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; + +exports[`layeredXyVis it should throw error if markSizeRatio is specified if no markSizeAccessor is present 1`] = `"Mark size ratio can be applied only with \`markSizeAccessor\`"`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap index 3a33797bc0cbf..05109cc65446b 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap @@ -1,5 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`xyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 1`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; + +exports[`xyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 2`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; + +exports[`xyVis it should throw error if markSizeRatio is specified while markSizeAccessor is not 1`] = `"Mark size ratio can be applied only with \`markSizeAccessor\`"`; + exports[`xyVis it should throw error if minTimeBarInterval applied for not time bar chart 1`] = `"\`minTimeBarInterval\` argument is applicable only for time bar charts."`; exports[`xyVis it should throw error if minTimeBarInterval is invalid 1`] = `"Provided x-axis interval is invalid. The interval should include quantity and unit names. Examples: 1d, 24h, 1w."`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts index a09212d59cce3..0921760f9f676 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts @@ -128,6 +128,10 @@ export const commonXYArgs: CommonXYFn['args'] = { types: ['string'], help: strings.getAriaLabelHelp(), }, + markSizeRatio: { + types: ['number'], + help: strings.getMarkSizeRatioHelp(), + }, minTimeBarInterval: { types: ['string'], help: strings.getMinTimeBarIntervalHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts new file mode 100644 index 0000000000000..5b943b0790313 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { ExtendedDataLayerArgs } from '../types'; +import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; +import { mockPaletteOutput, sampleArgs } from '../__mocks__'; +import { LayerTypes } from '../constants'; +import { extendedDataLayerFunction } from './extended_data_layer'; + +describe('extendedDataLayerConfig', () => { + test('produces the correct arguments', async () => { + const { data } = sampleArgs(); + const args: ExtendedDataLayerArgs = { + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + isHistogram: false, + palette: mockPaletteOutput, + markSizeAccessor: 'b', + }; + + const result = await extendedDataLayerFunction.fn(data, args, createMockExecutionContext()); + + expect(result).toEqual({ + type: 'extendedDataLayer', + layerType: LayerTypes.DATA, + ...args, + table: data, + }); + }); + + test('throws the error if markSizeAccessor is provided to the not line/area chart', async () => { + const { data } = sampleArgs(); + const args: ExtendedDataLayerArgs = { + seriesType: 'bar', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + isHistogram: false, + palette: mockPaletteOutput, + markSizeAccessor: 'b', + }; + + expect( + extendedDataLayerFunction.fn(data, args, createMockExecutionContext()) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test("throws the error if markSizeAccessor doesn't have the corresponding column in the table", async () => { + const { data } = sampleArgs(); + const args: ExtendedDataLayerArgs = { + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + isHistogram: false, + palette: mockPaletteOutput, + markSizeAccessor: 'nonsense', + }; + + expect( + extendedDataLayerFunction.fn(data, args, createMockExecutionContext()) + ).rejects.toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.ts index a7aa63645d119..58da88a8d4b25 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.ts @@ -32,6 +32,10 @@ export const extendedDataLayerFunction: ExtendedDataLayerFn = { help: strings.getAccessorsHelp(), multi: true, }, + markSizeAccessor: { + types: ['string'], + help: strings.getMarkSizeAccessorHelp(), + }, table: { types: ['datatable'], help: strings.getTableHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts index 47e62f9ccae4a..8e5019e065133 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts @@ -10,6 +10,7 @@ import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; import { ExtendedDataLayerArgs, ExtendedDataLayerFn } from '../types'; import { EXTENDED_DATA_LAYER, LayerTypes } from '../constants'; import { getAccessors, normalizeTable } from '../helpers'; +import { validateMarkSizeForChartType } from './validate'; export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, context) => { const table = args.table ?? data; @@ -18,6 +19,8 @@ export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, validateAccessor(accessors.xAccessor, table.columns); validateAccessor(accessors.splitAccessor, table.columns); accessors.accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); + validateMarkSizeForChartType(args.markSizeAccessor, args.seriesType); + validateAccessor(args.markSizeAccessor, table.columns); const normalizedTable = normalizeTable(table, accessors.xAccessor); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts new file mode 100644 index 0000000000000..79427cbe4d3cc --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { layeredXyVisFunction } from '.'; +import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; +import { sampleArgs, sampleExtendedLayer } from '../__mocks__'; +import { XY_VIS } from '../constants'; + +describe('layeredXyVis', () => { + test('it renders with the specified data and args', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + const result = await layeredXyVisFunction.fn( + data, + { ...rest, layers: [sampleExtendedLayer] }, + createMockExecutionContext() + ); + + expect(result).toEqual({ + type: 'render', + as: XY_VIS, + value: { args: { ...rest, layers: [sampleExtendedLayer] } }, + }); + }); + + test('it should throw error if markSizeRatio is lower then 1 or greater then 100', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + + expect( + layeredXyVisFunction.fn( + data, + { + ...rest, + markSizeRatio: 0, + layers: [sampleExtendedLayer], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + + expect( + layeredXyVisFunction.fn( + data, + { + ...rest, + markSizeRatio: 101, + layers: [sampleExtendedLayer], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('it should throw error if markSizeRatio is specified if no markSizeAccessor is present', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + + expect( + layeredXyVisFunction.fn( + data, + { + ...rest, + markSizeRatio: 10, + layers: [sampleExtendedLayer], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts index c4e2decb3279d..29624d8037393 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts @@ -10,7 +10,12 @@ import { XY_VIS_RENDERER } from '../constants'; import { appendLayerIds, getDataLayers } from '../helpers'; import { LayeredXyVisFn } from '../types'; import { logDatatables } from '../utils'; -import { validateMinTimeBarInterval, hasBarLayer } from './validate'; +import { + validateMarkSizeRatioLimits, + validateMinTimeBarInterval, + hasBarLayer, + errors, +} from './validate'; export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) => { const layers = appendLayerIds(args.layers ?? [], 'layers'); @@ -19,7 +24,14 @@ export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) const dataLayers = getDataLayers(layers); const hasBar = hasBarLayer(dataLayers); + validateMarkSizeRatioLimits(args.markSizeRatio); validateMinTimeBarInterval(dataLayers, hasBar, args.minTimeBarInterval); + const hasMarkSizeAccessors = + dataLayers.filter((dataLayer) => dataLayer.markSizeAccessor !== undefined).length > 0; + + if (!hasMarkSizeAccessors && args.markSizeRatio !== undefined) { + throw new Error(errors.markSizeRatioWithoutAccessor()); + } return { type: 'render', @@ -28,6 +40,7 @@ export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) args: { ...args, layers, + markSizeRatio: hasMarkSizeAccessors && !args.markSizeRatio ? 10 : args.markSizeRatio, ariaLabel: args.ariaLabel ?? (handlers.variables?.embeddableTitle as string) ?? diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts index 2d1ecb2840c0a..60e590b0f8cca 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts @@ -8,8 +8,10 @@ import { i18n } from '@kbn/i18n'; import { isValidInterval } from '@kbn/data-plugin/common'; +import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import { AxisExtentModes, ValueLabelModes } from '../constants'; import { + SeriesType, AxisExtentConfigResult, DataLayerConfigResult, CommonXYDataLayerConfigResult, @@ -18,7 +20,23 @@ import { } from '../types'; import { isTimeChart } from '../helpers'; -const errors = { +export const errors = { + markSizeAccessorForNonLineOrAreaChartsError: () => + i18n.translate( + 'expressionXY.reusable.function.dataLayer.errors.markSizeAccessorForNonLineOrAreaChartsError', + { + defaultMessage: + "`markSizeAccessor` can't be used. Dots are applied only for line or area charts", + } + ), + markSizeRatioLimitsError: () => + i18n.translate('expressionXY.reusable.function.xyVis.errors.markSizeLimitsError', { + defaultMessage: 'Mark size ratio must be greater or equal to 1 and less or equal to 100', + }), + markSizeRatioWithoutAccessor: () => + i18n.translate('expressionXY.reusable.function.xyVis.errors.markSizeRatioWithoutAccessor', { + defaultMessage: 'Mark size ratio can be applied only with `markSizeAccessor`', + }), extendBoundsAreInvalidError: () => i18n.translate('expressionXY.reusable.function.xyVis.errors.extendBoundsAreInvalidError', { defaultMessage: @@ -117,6 +135,30 @@ export const validateValueLabels = ( } }; +export const validateMarkSizeForChartType = ( + markSizeAccessor: ExpressionValueVisDimension | string | undefined, + seriesType: SeriesType +) => { + if (markSizeAccessor && !seriesType.includes('line') && !seriesType.includes('area')) { + throw new Error(errors.markSizeAccessorForNonLineOrAreaChartsError()); + } +}; + +export const validateMarkSizeRatioLimits = (markSizeRatio?: number) => { + if (markSizeRatio !== undefined && (markSizeRatio < 1 || markSizeRatio > 100)) { + throw new Error(errors.markSizeRatioLimitsError()); + } +}; + +export const validateMarkSizeRatioWithAccessor = ( + markSizeRatio: number | undefined, + markSizeAccessor: ExpressionValueVisDimension | string | undefined +) => { + if (markSizeRatio !== undefined && !markSizeAccessor) { + throw new Error(errors.markSizeRatioWithoutAccessor()); + } +}; + export const validateMinTimeBarInterval = ( dataLayers: CommonXYDataLayerConfigResult[], hasBar: boolean, diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts index 9348e489ab391..8ec1961416638 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts @@ -50,6 +50,37 @@ describe('xyVis', () => { }); }); + test('it should throw error if markSizeRatio is lower then 1 or greater then 100', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...{ ...sampleLayer, markSizeAccessor: 'b' }, + markSizeRatio: 0, + referenceLineLayers: [], + annotationLayers: [], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...{ ...sampleLayer, markSizeAccessor: 'b' }, + markSizeRatio: 101, + referenceLineLayers: [], + annotationLayers: [], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); test('it should throw error if minTimeBarInterval is invalid', async () => { const { data, args } = sampleArgs(); const { layers, ...rest } = args; @@ -129,4 +160,24 @@ describe('xyVis', () => { ) ).rejects.toThrowErrorMatchingSnapshot(); }); + + test('it should throw error if markSizeRatio is specified while markSizeAccessor is not', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; + + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...restLayerArgs, + referenceLineLayers: [], + annotationLayers: [], + markSizeRatio: 5, + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); }); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts index e4e519b0a7433..37baf028178cc 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts @@ -51,6 +51,10 @@ export const xyVisFunction: XyVisFn = { types: ['vis_dimension', 'string'], help: strings.getSplitRowAccessorHelp(), }, + markSizeAccessor: { + types: ['vis_dimension', 'string'], + help: strings.getMarkSizeAccessorHelp(), + }, }, async fn(data, args, handlers) { const { xyVisFn } = await import('./xy_vis_fn'); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index 292e69988c37e..e879f33b76548 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -23,8 +23,11 @@ import { hasHistogramBarLayer, validateExtent, validateFillOpacity, + validateMarkSizeRatioLimits, validateValueLabels, validateMinTimeBarInterval, + validateMarkSizeForChartType, + validateMarkSizeRatioWithAccessor, } from './validate'; const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult => { @@ -63,6 +66,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { isHistogram, yConfig, palette, + markSizeAccessor, ...restArgs } = args; @@ -72,6 +76,9 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateAccessor(dataLayers[0].splitAccessor, data.columns); dataLayers[0].accessors.forEach((accessor) => validateAccessor(accessor, data.columns)); + validateMarkSizeForChartType(dataLayers[0].markSizeAccessor, args.seriesType); + validateAccessor(dataLayers[0].markSizeAccessor, data.columns); + const layers: XYLayerConfig[] = [ ...appendLayerIds(dataLayers, 'dataLayers'), ...appendLayerIds(referenceLineLayers, 'referenceLineLayers'), @@ -105,6 +112,8 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { const hasNotHistogramBars = !hasHistogramBarLayer(dataLayers); validateValueLabels(args.valueLabels, hasBar, hasNotHistogramBars); + validateMarkSizeRatioWithAccessor(args.markSizeRatio, dataLayers[0].markSizeAccessor); + validateMarkSizeRatioLimits(args.markSizeRatio); return { type: 'render', @@ -113,6 +122,8 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { args: { ...restArgs, layers, + markSizeRatio: + dataLayers[0].markSizeAccessor && !args.markSizeRatio ? 10 : args.markSizeRatio, ariaLabel: args.ariaLabel ?? (handlers.variables?.embeddableTitle as string) ?? diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts index 23aa8bd3218d2..b70211e4b0682 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts @@ -35,19 +35,24 @@ export function getDataLayers(layers: XYExtendedLayerConfigResult[]) { ); } -export function getAccessors( - args: U, - table: Datatable -) { +export function getAccessors< + T, + U extends { splitAccessor?: T; xAccessor?: T; accessors: T[]; markSizeAccessor?: T } +>(args: U, table: Datatable) { let splitAccessor: T | string | undefined = args.splitAccessor; let xAccessor: T | string | undefined = args.xAccessor; let accessors: Array = args.accessors ?? []; - if (!splitAccessor && !xAccessor && !(accessors && accessors.length)) { + let markSizeAccessor: T | string | undefined = args.markSizeAccessor; + + if (!splitAccessor && !xAccessor && !(accessors && accessors.length) && !markSizeAccessor) { const y = table.columns.find((column) => column.id === PointSeriesColumnNames.Y)?.id; xAccessor = table.columns.find((column) => column.id === PointSeriesColumnNames.X)?.id; splitAccessor = table.columns.find((column) => column.id === PointSeriesColumnNames.COLOR)?.id; accessors = y ? [y] : []; + markSizeAccessor = table.columns.find( + (column) => column.id === PointSeriesColumnNames.SIZE + )?.id; } - return { splitAccessor, xAccessor, accessors }; + return { splitAccessor, xAccessor, accessors, markSizeAccessor }; } diff --git a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx index 21230643fe078..f3425ec2db625 100644 --- a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx @@ -121,6 +121,10 @@ export const strings = { i18n.translate('expressionXY.xyVis.ariaLabel.help', { defaultMessage: 'Specifies the aria label of the xy chart', }), + getMarkSizeRatioHelp: () => + i18n.translate('expressionXY.xyVis.markSizeRatio.help', { + defaultMessage: 'Specifies the ratio of the dots at the line and area charts', + }), getMinTimeBarIntervalHelp: () => i18n.translate('expressionXY.xyVis.xAxisInterval.help', { defaultMessage: 'Specifies the min interval for time bar chart', @@ -169,6 +173,10 @@ export const strings = { i18n.translate('expressionXY.dataLayer.accessors.help', { defaultMessage: 'The columns to display on the y axis.', }), + getMarkSizeAccessorHelp: () => + i18n.translate('expressionXY.dataLayer.markSizeAccessor.help', { + defaultMessage: 'Mark size accessor', + }), getYConfigHelp: () => i18n.translate('expressionXY.dataLayer.yConfig.help', { defaultMessage: 'Additional configuration for y axes', diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index a9910032699e0..0e10f680811ec 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -100,6 +100,7 @@ export interface DataLayerArgs { xAccessor?: string | ExpressionValueVisDimension; hide?: boolean; splitAccessor?: string | ExpressionValueVisDimension; + markSizeAccessor?: string | ExpressionValueVisDimension; columnToLabel?: string; // Actually a JSON key-value pair xScaleType: XScaleType; isHistogram: boolean; @@ -118,10 +119,12 @@ export interface ExtendedDataLayerArgs { xAccessor?: string; hide?: boolean; splitAccessor?: string; + markSizeAccessor?: string; columnToLabel?: string; // Actually a JSON key-value pair xScaleType: XScaleType; isHistogram: boolean; palette: PaletteOutput; + // palette will always be set on the expression yConfig?: YConfigResult[]; table?: Datatable; } @@ -203,6 +206,7 @@ export interface XYArgs extends DataLayerArgs { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + markSizeRatio?: number; minTimeBarInterval?: string; splitRowAccessor?: ExpressionValueVisDimension | string; splitColumnAccessor?: ExpressionValueVisDimension | string; @@ -231,6 +235,7 @@ export interface LayeredXYArgs { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + markSizeRatio?: number; minTimeBarInterval?: string; } @@ -257,6 +262,7 @@ export interface XYProps { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + markSizeRatio?: number; minTimeBarInterval?: string; splitRowAccessor?: ExpressionValueVisDimension | string; splitColumnAccessor?: ExpressionValueVisDimension | string; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap index 0bc41100012de..e7a26ec20bbfc 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap @@ -324,6 +324,7 @@ exports[`XYChart component it renders area 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -645,7 +646,7 @@ exports[`XYChart component it renders area 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -735,7 +736,7 @@ exports[`XYChart component it renders area 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -868,6 +869,7 @@ exports[`XYChart component it renders bar 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -1189,7 +1191,7 @@ exports[`XYChart component it renders bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -1279,7 +1281,7 @@ exports[`XYChart component it renders bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -1412,6 +1414,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -1733,7 +1736,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -1823,7 +1826,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -1956,6 +1959,7 @@ exports[`XYChart component it renders line 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -2277,7 +2281,7 @@ exports[`XYChart component it renders line 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -2367,7 +2371,7 @@ exports[`XYChart component it renders line 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -2500,6 +2504,7 @@ exports[`XYChart component it renders stacked area 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -2821,7 +2826,7 @@ exports[`XYChart component it renders stacked area 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -2911,7 +2916,7 @@ exports[`XYChart component it renders stacked area 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -3044,6 +3049,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -3365,7 +3371,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -3455,7 +3461,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -3588,6 +3594,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -3909,7 +3916,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -3999,7 +4006,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -4132,6 +4139,7 @@ exports[`XYChart component split chart should render split chart if both, splitR "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -4210,7 +4218,7 @@ exports[`XYChart component split chart should render split chart if both, splitR }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -4708,7 +4716,7 @@ exports[`XYChart component split chart should render split chart if both, splitR }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -4798,7 +4806,7 @@ exports[`XYChart component split chart should render split chart if both, splitR }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -4931,6 +4939,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -5009,7 +5018,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -5506,7 +5515,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -5596,7 +5605,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -5729,6 +5738,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -5807,7 +5817,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -6304,7 +6314,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -6394,7 +6404,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx index 62f23ba86a166..d03a5e648f366 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx @@ -13,6 +13,7 @@ import { AreaSeries, Axis, BarSeries, + ColorVariant, Fit, GeometryValue, GroupBy, @@ -687,6 +688,40 @@ describe('XYChart component', () => { expect(component.find(Settings).at(0).prop('showLegendExtra')).toEqual(true); }); + test('applies the mark size ratio', () => { + const { args } = sampleArgs(); + const markSizeRatioArg = { markSizeRatio: 50 }; + const component = shallow( + + ); + expect(component.find(Settings).at(0).prop('theme')).toEqual( + expect.objectContaining(markSizeRatioArg) + ); + }); + + test('applies the mark size accessor', () => { + const { args } = sampleArgs(); + const markSizeAccessorArg = { markSizeAccessor: 'b' }; + const component = shallow( + + ); + const dataLayers = component.find(DataLayers).dive(); + const lineArea = dataLayers.find(LineSeries).at(0); + expect(lineArea.prop('markSizeAccessor')).toEqual(markSizeAccessorArg.markSizeAccessor); + const expectedSeriesStyle = expect.objectContaining({ + point: expect.objectContaining({ + visible: true, + fill: ColorVariant.Series, + }), + }); + + expect(lineArea.prop('areaSeriesStyle')).toEqual(expectedSeriesStyle); + expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); + }); + test('it renders bar', () => { const { args } = sampleArgs(); const component = shallow( @@ -2132,6 +2167,7 @@ describe('XYChart component', () => { mode: 'full', type: 'axisExtentConfig', }, + markSizeRatio: 1, layers: [ { layerId: 'first', @@ -2219,6 +2255,7 @@ describe('XYChart component', () => { mode: 'full', type: 'axisExtentConfig', }, + markSizeRatio: 1, yLeftScale: 'linear', yRightScale: 'linear', layers: [ @@ -2292,6 +2329,7 @@ describe('XYChart component', () => { mode: 'full', type: 'axisExtentConfig', }, + markSizeRatio: 1, yLeftScale: 'linear', yRightScale: 'linear', layers: [ diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index 7b31112c4b9ed..9bb3ea4f498e4 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -48,17 +48,15 @@ import { getAnnotationsLayers, getDataLayers, Series, - getFormattedTablesByLayers, - validateExtent, getFormat, -} from '../helpers'; -import { + getFormattedTablesByLayers, getFilteredLayers, getReferenceLayers, isDataLayer, getAxesConfiguration, GroupsConfiguration, getLinesCausedPaddings, + validateExtent, } from '../helpers'; import { getXDomain, XyEndzones } from './x_domain'; import { getLegendAction } from './legend_action'; @@ -571,6 +569,7 @@ export function XYChart({ shouldRotate ), }, + markSizeRatio: args.markSizeRatio, }} baseTheme={chartBaseTheme} tooltip={{ diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx index c2a7c847e150b..7ac661ed9709d 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx @@ -226,9 +226,14 @@ const getSeriesName: GetSeriesNameFn = ( return splitColumnId ? data.seriesKeys[0] : columnToLabelMap[data.seriesKeys[0]] ?? null; }; -const getPointConfig = (xAccessor?: string, emphasizeFitting?: boolean) => ({ - visible: !xAccessor, +const getPointConfig = ( + xAccessor: string | undefined, + markSizeAccessor: string | undefined, + emphasizeFitting?: boolean +) => ({ + visible: !xAccessor || markSizeAccessor !== undefined, radius: xAccessor && !emphasizeFitting ? 5 : 0, + fill: markSizeAccessor ? ColorVariant.Series : undefined, }); const getLineConfig = () => ({ visible: true, stroke: ColorVariant.Series, opacity: 1, dash: [] }); @@ -276,7 +281,7 @@ export const getSeriesProps: GetSeriesPropsFn = ({ fillOpacity, formattedDatatableInfo, }): SeriesSpec => { - const { table } = layer; + const { table, markSizeAccessor } = layer; const isStacked = layer.seriesType.includes('stacked'); const isPercentage = layer.seriesType.includes('percentage'); const isBarChart = layer.seriesType.includes('bar'); @@ -294,6 +299,14 @@ export const getSeriesProps: GetSeriesPropsFn = ({ : undefined; const splitFormatter = formatFactory(splitHint); + const markSizeColumnId = markSizeAccessor + ? getAccessorByDimension(markSizeAccessor, table.columns) + : undefined; + + const markFormatter = formatFactory( + markSizeAccessor ? getFormat(table.columns, markSizeAccessor) : undefined + ); + // what if row values are not primitive? That is the case of, for instance, Ranges // remaps them to their serialized version with the formatHint metadata // In order to do it we need to make a copy of the table as the raw one is required for more features (filters, etc...) later on @@ -326,6 +339,8 @@ export const getSeriesProps: GetSeriesPropsFn = ({ id: splitColumnId ? `${splitColumnId}-${accessor}` : accessor, xAccessor: xColumnId || 'unifiedX', yAccessors: [accessor], + markSizeAccessor: markSizeColumnId, + markFormat: (value) => markFormatter.convert(value), data: rows, xScaleType: xColumnId ? layer.xScaleType : 'ordinal', yScaleType: @@ -346,14 +361,14 @@ export const getSeriesProps: GetSeriesPropsFn = ({ stackMode: isPercentage ? StackMode.Percentage : undefined, timeZone, areaSeriesStyle: { - point: getPointConfig(xColumnId, emphasizeFitting), + point: getPointConfig(xColumnId, markSizeColumnId, emphasizeFitting), ...(fillOpacity && { area: { opacity: fillOpacity } }), ...(emphasizeFitting && { fit: { area: { opacity: fillOpacity || 0.5 }, line: getLineConfig() }, }), }, lineSeriesStyle: { - point: getPointConfig(xColumnId, emphasizeFitting), + point: getPointConfig(xColumnId, markSizeColumnId, emphasizeFitting), ...(emphasizeFitting && { fit: { line: getLineConfig() } }), }, name(d) { From e2064ae5b1822f7642886bd749d52993fdc0451b Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Thu, 19 May 2022 15:38:05 +0200 Subject: [PATCH 054/150] [Screenshotting] Fix failing screenshotting functional test (#132393) --- x-pack/test/examples/screenshotting/index.ts | 5 ++--- .../baseline/screenshotting_example_image.png | Bin 0 -> 10576 bytes 2 files changed, 2 insertions(+), 3 deletions(-) create mode 100644 x-pack/test/functional/screenshots/baseline/screenshotting_example_image.png diff --git a/x-pack/test/examples/screenshotting/index.ts b/x-pack/test/examples/screenshotting/index.ts index c64d84c7fcf3d..94a29f382a771 100644 --- a/x-pack/test/examples/screenshotting/index.ts +++ b/x-pack/test/examples/screenshotting/index.ts @@ -20,8 +20,7 @@ export default function ({ const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['common']); - // FAILING: https://github.com/elastic/kibana/issues/131190 - describe.skip('Screenshotting Example', function () { + describe('Screenshotting Example', function () { before(async () => { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/visualize.json'); @@ -71,7 +70,7 @@ export default function ({ const memory = await testSubjects.find('cpu'); const text = await memory.getVisibleText(); - expect(text).to.match(/\d+\.\d+%/); + expect(text).to.match(/\d+(\.\d+)?%/); }); it('should show an error message', async () => { diff --git a/x-pack/test/functional/screenshots/baseline/screenshotting_example_image.png b/x-pack/test/functional/screenshots/baseline/screenshotting_example_image.png new file mode 100644 index 0000000000000000000000000000000000000000..e4d80a167ae799a73723888745f2025756127c46 GIT binary patch literal 10576 zcmeHtWn7bg+qZ=+{1FgQLSVFlf`D`g!iXUtAW|wNARrA=Vt|6c*a*of9V0~PQjv~H z_fWdKYtJ$N*YmmG-!Gr%db7{i#(DnY_N=C3$LF~ zD`OBmx`&Y%L=Ep1BqsSj1^LHW|NZfQSL6S=+K7l2fQhsT`#WUHWvJyD($`$;qDaetxh?UR1{I=Z^~KGkO73*`|CY;qOZ21QP*n+p%C{m+>RGRP~p=bN87SlS)_RqDcr$YAAI+&zNnluhIw z`C}s8SRa-&)tSK_W$bwP=!p$W%W2dF0sY20Q<*>SJ2W^%MFX~1=~u9%EwO?E7elXQ z3P#Vl8{v@)V;##lOF`{fXcj_o%;u{GIJMR2t+XkbZnqZYraFw?QH#6zX1qzyaVCRnc@?++ z8edv^HdfUBQ8&Kiz5A||zsWOZOy^yg>x+#!V)e$n#8lrG2?`2|PE}6qStiG;nzWx` z6tF8lQq^Op2TRy3<9tr9w@=>;U~CVHH#cvN7scUwNH<>MS6ij9&l}8s_uJ$kY<|8C zgJnfzd^(26INW6}X!yM0S*y6|#diuXy|W)w_`?=xK4KjxT>O}s8DlKviH7yj!$}9A zg}rOlczJn02w5`gds9li_FiA~MTm)s&0Xb7N=!VDwC3i4D+=3phY#47z4PwsGIw9^ z^1be}r{L)11YsZ^^Ou3Cw54z3$IFX|J_}`K<~vYm%avt2Aac{H{R2Kf-;tLutG=np+(g24 zE*W-fY%DqK-^<ZbF z)iL=`PUXyLSRc>L1@lE>#oSO?RE6iJmY$wcS)uKdwnVWk-RwWrnx^$;f@ZeF@;3}P zLZaY;CXc z>Eg!>dIrDwd670^#TI(y_C`MK7EhGMy8r&Jv^CaoYAo65!%Yv~loW}QnZ~AurY3fq zUMTu@)!dckUX8wDE1`h3g_<=6A^ietVXg#s$$)h!TBNmd!*1mZgfKdzLpm)pGcZ*> z1|1cpYHZB!;ZZU+K92eK4<#zeKq!W`au1>Y0-L5xovdgvC(5R%++-w{)6i*Mv}CUR z3=(nPY`%P@~qtk0T19**-R z_U&o(eNGCWOd#=j@2sV2*PVa9x$q9bq%7MmXS?qo+#D^0o0+*3dClZ|oY-o5c6Lf? zsy~mmW!|$tj|HR8!md233Djwhk&5CDOwY)8H1n0=s$ua5yvuC&7rUY3oa!+uii#A% z!oq6N;;-JEp~uoGQh%`|m~O8`%-QVkoO=p;2?#FobnxfaGEHBht%SMxcz8sFwsB<; z9iLuUfyKNy9As`jzBFPWUDhqvr8Yy)b^DN z`2oxm(~HSv{Mm8b+M+e)wviE2SC_CF?nY4XWzo;(O}YrCa?GapVdRPI%l12mC7K$0 zVev$X?y>5zMR|7nci{3ZgC(ocx2$;8FNsbZoM5)tuMJjDOUicKsIS7%^fr8F!Zurxa?i842*(@t%D zSQo;yHy_c{SL$-vpz!JF7Xs%O=RU{U_Y6t_n2o&yDh(m;+DWL5(8f&O?(BhY(Ar@1 zhdnpuSi_XjDjX$~#1p-xwh}_AzNoD3?2Fsqxi9cPXtwe7#llM7*!qwZ=KSomvUvLX z!q6)gqQ~XM%FV;?{z>os5=D@XRRC~_7Bm;1?=Sq6Z0d<=K*$`lG3`y>mN6%mQ#8j2 z$Ij-I1;*NKi3pI^h1uA04!Y?Ey*i3`_yp1%Mr+lg1>;?92Y!9kr%5GzK&tBq*VHJg z#Y(^OmRedRebi1nPbq`l0Q2(urOy}Va6xMpqp`Dz54s>BQB@)uHmuw9ViX~fhZHHl`9$d0_jb>BGhO< zJ58cQ@WaXu4%7|z0%Kw>i9GF9uES6wl+n1TGYDnGc>tf4EG1?HKtxYfB}zPgq>)d+ za%*Yz;>{)Q#-^r>^z__Bk$;j*9db*(-gqP;Vd*EG?}VZU->d)o&`@ zn3_6#Pghsd*toTlmyfTnz=l4l?)~ToxuaGcsdQ4F>%VsvjjLN61brrn%p``_3bIxs0JzS%o)tD5MmDp!Wvg}o>*mpqTyBHFs0 zm&ce$I-kAF;x7CCfyn)-8v`Z2O?dqBA{| z!RNcj)Bf^kV#V`cX$013e8;divOMolgVPaMc^!;Hpz#P|)_M zcIfiKE=yR%*;6ZyPTQ?6!y|=@q_>>vm$dKQQ!uvUpLUKJ8DTsB?ilMa$9Aze=aIXu z(wmP}RK%b4KJrF8FR3V)$YLt*YHQ!-46JWyV6|B!p2lP#G&x6VW9=q2e7)lE_jna` z&CNM7?#0~WZSb2LGLRFDK!|b$;4?F=wK5P5W|*THz&E1Rv;?4|WJALNjM(1ZMw};K z7FsI2H|y%^3X4$VY{wrRY)hYgsutX);}e z`2sn_c#n^dFPj5Z+qP_#S$rzJ?RWjuw7e(gGplB$4t%UXWX{ z!6{R=Xjkey-RyT&zcg-l!Sn6PiV?X%166`fuQxTcDQ)(1VA0`}U3psl?nbX48TTzDv~B*4BL{uq{>YTcY7ni=dQv4`DXtrUApz)T{ZRu`=-_^X`!L)%$sG zfn8rgnnb)ncvHA8_El#WkM)FmH?Y zWS+U%FYmBqdj0GV8Fi+(5z|@c(aE{rW^+RyPPdN_KC5>5YBv8{M_v7#k+_?g zuau`Vm(9W6o6anJ=Ex_~o-hT)w%6o<9y`(`1$b88G7aZ)GY&WUUI;b5)`6Ok1(MKU zCV11j^X)zzJ$;ytab+xiX2#lQZ*(_36F(9yBPEw~rv?_1=L+)N6GDQNYuPt9r?b;6yWXo$z(!CaC*jkK0+ioC`?D_XV9v>SWF^g@*H z=mY+;IX0Bk_Axk4m%HuFd~JHNxw0$?3qazNOG{(AvU--XvvMtcLnT)(QfzNak_K6( zJ2Q?Utxu8%JhY!Ak)Gb$RN2_r=&|;h3)B~lo{^!T}lzR(Fr zH#MYhXX#!OoBsL9%v_LRXINb?t_Zb!*P-Vz zqV~0$c9jWaV!O|$LfoW)Y6^dGPq2YHU^B&_D5tZjq2W=lRa%}+kKIIbJYhk~^Wx1X zhpL{B$(6b-Hbx=QA9b=WFuL56xg=`+8g8es2A41~q*YYVR5%GIMK4Mob8rrE{uH1p zW^M7xUoR~Mo$PBWP_f4g1T&?$_-#!8gUan-)v4akuSutXA&vi0Vjcw*|0y|Hs^a;E zEOcOxVS2Rlub8DiGfq^Dd;sMY;ACcAlE89RX6&2swsAF?+`+!?T{A*faVMvs*PjHt_cYB3_-iGen zI1B0G_tN#tQ-c}o0F8Ja?bOeYsyTrfe}7q1E<7RyFqx5^{b+YX%XOaE0+e{FyWk9s zbgbH=M~7pJo~Bq~In9D`*Wb~gF8<(v{te`7t8Hl0q3zA2kQ?y`(PPpZy0(gg<_$7Q z8|6|<_T0>Y^#S-|9bY*S!RR0F$fh21b7G(B&KHLc3r@8-wa|qjZh_&JtlSE@8lsA8cw~DjxF<|; zM=+GuuC>PK;_`?8fV0xg4iXgpto}I{y3|%nd;jXuA9rTb~ZC)Lloa`vHy-dc` zD&G~W5N3DU=u&ZJ&pD(tvqXYoP~>&zDTnfnO;LvB4dv&~iz?7jK!}Vlu#nzM2QbSE zMw^Z8vS`}R!Cri}rlWX6(Hvj&;tN4MK8>^|NkJ z*8p8E-Xxxw9x9^;5k49$I{ate?T(hQF$&bKJ9H5Di9Y%##Bzp`k`gm-5v$Wpk-5Lg z+(kEOge?^n`<(^n6KNX4@C`5K#31$jDu&V+G)X~@k$78{9@PZ_Uc1TZ_3~4Kg~oxD?FI4m!0W5Jx~IZx8VKT7nPo)utyB zIHcBtQtYSOA>##ARSn<#_O05JZ!q;2jmo-4EV6NNwKVEv_2oTcXTMVp#t^HF3=VyA zZ{6OPi!0uurm-vT`kH%)F z3j34oJ{r`m1cy;h(AW&pdtM=uD>knc9(K(LKOHI!#-o*&wwwEs+mUu9+s`o#wBQFZ zb{A2>$iZVUy(nrYskFXWS7|B z%ITpXO%`u8aImVYd?-!pyj7qcK@yBXPaUskr<_b`Gi?y4pTq?DcYL)n1!8#@*czyf zqsP}wJC#_pjf`5%qm1j(qoF{Wu^_h1d-Jdvy#{h96lJzS0okL0_C|m?=odV{_Ubs3 zqAuw`=~tPahPd0p&p!d1pFoan=-$&Z(ou&hs^{gsYSt8K`*g54n!VEXqGiI3_ZZ4~ zAc0;Y4uX~m9+#jMn>Jv~uih_yd%4o{T3VbJ8((Cu8Swqd1gGOw9qLla@Ve6=+Z zpe;Q7E3}UGVh2-{xWh>rE)-`3O6b``-yO#OSW2*BGPISRZ_J@!xWSi&6O(VBkj9C{ zmX?aQi&-6K?1D;4``aK?@9F5QVm*CYbD2F3?Qb5etvMxk@Q}>|by1%Hej{<#>EGZp z9lVQ+WAt$y4sDAn18z??YoiP$3E^nwhGvU zbd*==kedW8@UC$mCuojTGrZy1%iCc-g+ zNrbh=OSG3bDS_Lj+8x4_qPF!^5cYXQNoG%4QIYKQ?`%rEtn{WbA1YnP8x;BB$;W{$ z?6-lQ5lT+(Fc{6Sw+<80)%6bwL5PwGz1Go2GeNMZI*K*ffg^meXh|tejk!%Vt*)ub zx-Mehr=p`n_v-k$EAc2zfiUG?lfR7B5+oylv9>2EiSk#N-GVj^o1d3k6BLQj(Q0P~ zJCDrN6g3sBrcvbMKW#~wmYLS)%Tt4K>rn^*PYhkX9VT*gCR!ofI2OcQ`^ zFzW^X35Y+C4cGbp>I2uvh_J3ybw2RL`#_x}NO?BQK$hUW%iQQOnNWk(*z?D?nbv-G zTQvSpW-vckf`y{c*3qds(3-xf-|)|q!E|b1x^#TH`Bji;pu2L1slnjGb`XkR;11?k zH+st4G-5<-ivS}=hkOtWnH?Id>xc13@`1V$}&hQmD3Tm9yof$$OZd&nz=a=TJ z#1krgfB;7MxE^iFATo!DxoOq>ay#gL3MzKKf z3E09ug8S>UAM#tY>iJs$HM02mUeGi26D(C=La)&uOH2K^1B<~qCtF#kSjjr!@2wt^ z)1v;h=%;=85g<9lU1sWEB!wVAa{gUS+y2R`#`)oDoQXi7>sZ{*f~hcPLrAC~!@f;7 zo&($P3m_cQ1Az+9^($e@aABS|phZE>*7RA%*UDg_FH3GcIZh=VBcOf%{^lI}yHpI;`1f@3VUhCY|J5 zhx&Vif4@4x_gx}2|0%XY)Xe#{8lY}cC8FW1H{qGMwrB#nCxKw?xDnV9&P=<7?nxi`k2 z;Q7ZKCHco4JjkD?ZigljZ`3iiv>n|QatG7iyCrxHGC1JzR*sI2IGOgG& zBc;UL-BOp?AFBjXuo-Vdv}K%1Du4A)q&5(WahSmPHvNH$j*Bp$9#9d1j}A$9_SPe+ zuo+G00+#`i(?O?8_IKSna*PaA+#((K-Ez&x8@s&34}@lB1Q{Kb1^6qrj{HYZn2(PS zu8vB0Ob`f8V_^YKt@>%J8xrjLU)_1&RE;Ln_>K6LK zwHR$p&F>P6@ zWc`J=q5%Yww>7#Jp`!zx6(m0S&5Q2r2S-jpdFoqICYd5nEmp_`f;9lqo~n_N|DBqe zv(gh!HoCG6=yh`pTP$g~uwZ+0*QY@+c@@lo{1r#C959AR^f-$U`ky1y&Vfhu%Pg26 zM_|Kdu!{;r1qQ0{Gx{)-+Xi^srZZm&9)D`8K6?_FAAEg+0+$pNh$UbNBxLOcs`Myv zD{g%dKc9*Eia7uJpW@%M zlY%ZYXW^Q1Wvg6dY3(&t1h4Yt)l}N5m()SiS27ljx-n*`+&z#}rnU<|$uc}QOl`&PCMv+5%VE1=|F0|CEpkEy8?Cid7wQ{r2 ze&9Q(%3HI|9ozx}X~SVAqmV6pG$`V~YEp>_jumkj{9Jc;aWS{5q1a)RXQ%{gBJCv; z92n#=))Ok^xgZHlLJe@v{oqYiTcwdff1wrmiGg|=jlD1+7La#3ZRn>7IdwCFK6Re^CtJN8ZUqD&qRdEO2ukvBvrr5p-!8_h|!0qn+ zyRtq$%qO&TjZr?v(ufz_AQA&e1`y4WGXrS))bcNZ&1r(02e~OmM_(UFs|Zpba(KYD zl)Q9w;tzRBGssqG>FKdiQBA&<5dT3~9t<0n7O>or5Pk zD%HTG+p>g5NC+|{L5K}UW+!p4l3(dT#L5Mq_IS;nUK=NSV9+aNG5>u?>%UK-{r~bz c-o60WFC~`rM0YoMQt}W|5v7nL|M2Di0V!)P%>V!Z literal 0 HcmV?d00001 From 2cbedcd29c361e0f05021ef9fdae7c43d236b529 Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Thu, 19 May 2022 07:54:36 -0600 Subject: [PATCH 055/150] [Observability] Use Observability rule type registry for list of rule types (#132484) --- .../observability/public/hooks/use_fetch_rules.ts | 14 ++++++++------ .../alerts/containers/alerts_page/alerts_page.tsx | 5 ++--- .../observability/public/pages/rules/config.ts | 14 -------------- .../create_observability_rule_type_registry.ts | 1 + 4 files changed, 11 insertions(+), 23 deletions(-) diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts index 229a54c754e4f..b8c3445fffabc 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts @@ -10,8 +10,8 @@ import { isEmpty } from 'lodash'; import { loadRules, loadRuleTags } from '@kbn/triggers-actions-ui-plugin/public'; import { RULES_LOAD_ERROR, RULE_TAGS_LOAD_ERROR } from '../pages/rules/translations'; import { FetchRulesProps, RuleState, TagsState } from '../pages/rules/types'; -import { OBSERVABILITY_RULE_TYPES } from '../pages/rules/config'; import { useKibana } from '../utils/kibana_react'; +import { usePluginContext } from './use_plugin_context'; export function useFetchRules({ searchText, @@ -24,6 +24,7 @@ export function useFetchRules({ sort, }: FetchRulesProps) { const { http } = useKibana().services; + const { observabilityRuleTypeRegistry } = usePluginContext(); const [rulesState, setRulesState] = useState({ isLoading: false, @@ -60,7 +61,7 @@ export function useFetchRules({ http, page, searchText, - typesFilter: typesFilter.length > 0 ? typesFilter : OBSERVABILITY_RULE_TYPES, + typesFilter: typesFilter.length > 0 ? typesFilter : observabilityRuleTypeRegistry.list(), tagsFilter, ruleExecutionStatusesFilter: ruleLastResponseFilter, ruleStatusesFilter, @@ -93,14 +94,15 @@ export function useFetchRules({ }, [ http, page, - setPage, searchText, - ruleLastResponseFilter, + typesFilter, + observabilityRuleTypeRegistry, tagsFilter, - loadRuleTagsAggs, + ruleLastResponseFilter, ruleStatusesFilter, - typesFilter, sort, + loadRuleTagsAggs, + setPage, ]); useEffect(() => { fetchRules(); diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index 8838ccd2ac56f..f51d00787c822 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -38,7 +38,6 @@ import './styles.scss'; import { AlertsStatusFilter, AlertsDisclaimer, AlertsSearchBar } from '../../components'; import { renderRuleStats } from '../../components/rule_stats'; import { ObservabilityAppServices } from '../../../../application/types'; -import { OBSERVABILITY_RULE_TYPES } from '../../../rules/config'; interface RuleStatsState { total: number; @@ -69,7 +68,7 @@ const ALERT_STATUS_REGEX = new RegExp( const ALERT_TABLE_STATE_STORAGE_KEY = 'xpack.observability.alert.tableState'; function AlertsPage() { - const { ObservabilityPageTemplate, config } = usePluginContext(); + const { ObservabilityPageTemplate, config, observabilityRuleTypeRegistry } = usePluginContext(); const [alertFilterStatus, setAlertFilterStatus] = useState('' as AlertStatusFilterButton); const refetch = useRef<() => void>(); const timefilterService = useTimefilterService(); @@ -110,7 +109,7 @@ function AlertsPage() { try { const response = await loadRuleAggregations({ http, - typesFilter: OBSERVABILITY_RULE_TYPES, + typesFilter: observabilityRuleTypeRegistry.list(), }); const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus, ruleSnoozedStatus } = response; diff --git a/x-pack/plugins/observability/public/pages/rules/config.ts b/x-pack/plugins/observability/public/pages/rules/config.ts index 4e7b9e83d5ab1..de3ef1219fde7 100644 --- a/x-pack/plugins/observability/public/pages/rules/config.ts +++ b/x-pack/plugins/observability/public/pages/rules/config.ts @@ -42,20 +42,6 @@ export const rulesStatusesTranslationsMapping = { warning: RULE_STATUS_WARNING, }; -export const OBSERVABILITY_RULE_TYPES = [ - 'xpack.uptime.alerts.monitorStatus', - 'xpack.uptime.alerts.tls', - 'xpack.uptime.alerts.tlsCertificate', - 'xpack.uptime.alerts.durationAnomaly', - 'apm.error_rate', - 'apm.transaction_error_rate', - 'apm.anomaly', - 'apm.transaction_duration', - 'metrics.alert.inventory.threshold', - 'metrics.alert.threshold', - 'logs.alert.document.count', -]; - export const OBSERVABILITY_SOLUTIONS = ['logs', 'uptime', 'infrastructure', 'apm']; export type InitialRule = Partial & diff --git a/x-pack/plugins/observability/public/rules/create_observability_rule_type_registry.ts b/x-pack/plugins/observability/public/rules/create_observability_rule_type_registry.ts index 5612601ebd803..021203e832441 100644 --- a/x-pack/plugins/observability/public/rules/create_observability_rule_type_registry.ts +++ b/x-pack/plugins/observability/public/rules/create_observability_rule_type_registry.ts @@ -35,6 +35,7 @@ export function createObservabilityRuleTypeRegistry(ruleTypeRegistry: RuleTypeRe getFormatter: (typeId: string) => { return formatters.find((formatter) => formatter.typeId === typeId)?.fn; }, + list: () => formatters.map((formatter) => formatter.typeId), }; } From 51acefc2e2ce8fdfcc26376143c9cbf263f54734 Mon Sep 17 00:00:00 2001 From: Caroline Horn <549577+cchaos@users.noreply.github.com> Date: Thu, 19 May 2022 10:01:13 -0400 Subject: [PATCH 056/150] [KibanaPageTemplateSolutionNavAvatar] Increase specificity of styles (#132448) --- .../public/page_template/solution_nav/solution_nav_avatar.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav_avatar.scss b/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav_avatar.scss index 4b47fefc65891..73b4241c8a18b 100644 --- a/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav_avatar.scss +++ b/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav_avatar.scss @@ -1,7 +1,7 @@ .kbnPageTemplateSolutionNavAvatar { @include euiBottomShadowSmall; - &--xxl { + &.kbnPageTemplateSolutionNavAvatar--xxl { @include euiBottomShadowMedium; @include size(100px); line-height: 100px; From d2b61738e2b086a62944ee822670821220b3ad81 Mon Sep 17 00:00:00 2001 From: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com> Date: Thu, 19 May 2022 15:09:31 +0100 Subject: [PATCH 057/150] [Security solution]Dynamic split of cypress tests (#125986) - adds `parallelism: 4` for security_solution cypress buildkite pipeline - added parsing /integrations folder with cypress tests, to retrieve paths to individual test files using `glob` utility - list of test files split equally between agents(there are approx 70+ tests files, split ~20 per job with **parallelism=4**) - small refactoring of existing cypress runners for `security_solution` Old metrics(before @MadameSheema https://github.com/elastic/kibana/pull/127558 performance improvements): before split: average time of completion ~ 1h 40m for tests, 1h 55m for Kibana build after split in 4 chunks: chunk completion between 20m - 30m, Kibana build 1h 20m **Current metrics:** before split: average time of completion ~ 1h for tests, 1h 10m for Kibana build after split in 4 chunks: each chunk completion between 10m - 20m, 1h Kibana build 1h --- .buildkite/ftr_configs.yml | 1 + .../pull_request/security_solution.yml | 1 + .../steps/functional/security_solution.sh | 6 +- .../security_solution/cypress/README.md | 12 ++ .../integration/users/user_details.spec.ts | 5 +- x-pack/plugins/security_solution/package.json | 3 +- .../cli_config_parallel.ts | 25 ++++ .../test/security_solution_cypress/runner.ts | 140 ++++++------------ 8 files changed, 90 insertions(+), 103 deletions(-) create mode 100644 x-pack/test/security_solution_cypress/cli_config_parallel.ts diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index f07ac997e31c2..e070baa844ea9 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -26,6 +26,7 @@ disabled: - x-pack/test/security_solution_cypress/cases_cli_config.ts - x-pack/test/security_solution_cypress/ccs_config.ts - x-pack/test/security_solution_cypress/cli_config.ts + - x-pack/test/security_solution_cypress/cli_config_parallel.ts - x-pack/test/security_solution_cypress/config.firefox.ts - x-pack/test/security_solution_cypress/config.ts - x-pack/test/security_solution_cypress/response_ops_cli_config.ts diff --git a/.buildkite/pipelines/pull_request/security_solution.yml b/.buildkite/pipelines/pull_request/security_solution.yml index 974469a700715..5903aac568a83 100644 --- a/.buildkite/pipelines/pull_request/security_solution.yml +++ b/.buildkite/pipelines/pull_request/security_solution.yml @@ -5,6 +5,7 @@ steps: queue: ci-group-6 depends_on: build timeout_in_minutes: 120 + parallelism: 4 retry: automatic: - exit_status: '*' diff --git a/.buildkite/scripts/steps/functional/security_solution.sh b/.buildkite/scripts/steps/functional/security_solution.sh index ae81eaa4f48e2..5e3b1513826f9 100755 --- a/.buildkite/scripts/steps/functional/security_solution.sh +++ b/.buildkite/scripts/steps/functional/security_solution.sh @@ -5,11 +5,13 @@ set -euo pipefail source .buildkite/scripts/steps/functional/common.sh export JOB=kibana-security-solution-chrome +export CLI_NUMBER=$((BUILDKITE_PARALLEL_JOB+1)) +export CLI_COUNT=$BUILDKITE_PARALLEL_JOB_COUNT echo "--- Security Solution tests (Chrome)" -checks-reporter-with-killswitch "Security Solution Cypress Tests (Chrome)" \ +checks-reporter-with-killswitch "Security Solution Cypress Tests (Chrome) $CLI_NUMBER" \ node scripts/functional_tests \ --debug --bail \ --kibana-install-dir "$KIBANA_BUILD_LOCATION" \ - --config x-pack/test/security_solution_cypress/cli_config.ts + --config x-pack/test/security_solution_cypress/cli_config_parallel.ts diff --git a/x-pack/plugins/security_solution/cypress/README.md b/x-pack/plugins/security_solution/cypress/README.md index e0430ea332e99..620a2148f6cf7 100644 --- a/x-pack/plugins/security_solution/cypress/README.md +++ b/x-pack/plugins/security_solution/cypress/README.md @@ -64,6 +64,18 @@ A headless browser is a browser simulation program that does not have a user int This is the configuration used by CI. It uses the FTR to spawn both a Kibana instance (http://localhost:5620) and an Elasticsearch instance (http://localhost:9220) with a preloaded minimum set of data (see preceding "Test data" section), and then executes cypress against this stack. You can find this configuration in `x-pack/test/security_solution_cypress` +Tests run on buildkite PR pipeline is parallelized(current value = 4 parallel jobs). It can be configured in [.buildkite/pipelines/pull_request/security_solution.yml](https://github.com/elastic/kibana/blob/main/.buildkite/pipelines/pull_request/security_solution.yml) with property `parallelism` + +```yml + ... + agents: + queue: ci-group-6 + depends_on: build + timeout_in_minutes: 120 + parallelism: 4 + ... +``` + #### Custom Targets This configuration runs cypress tests against an arbitrary host. diff --git a/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts b/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts index c1b4a81e14d0a..83eae1d259b2c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts @@ -24,13 +24,14 @@ describe('user details flyout', () => { before(() => { cleanKibana(); login(); + }); + + it('shows user detail flyout from alert table', () => { visitWithoutDateRange(ALERTS_URL); createCustomRuleEnabled({ ...getNewRule(), customQuery: 'user.name:*' }); refreshPage(); waitForAlertsToPopulate(); - }); - it('shows user detail flyout from alert table', () => { scrollAlertTableColumnIntoView(USER_COLUMN); expandAlertTableCellValue(USER_COLUMN); openUserDetailsFlyout(); diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index b62b6d08fd892..8853cb9aa582c 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -13,12 +13,13 @@ "cypress:open-as-ci": "node ../../../scripts/functional_tests --config ../../test/security_solution_cypress/visual_config.ts", "cypress:open:upgrade": "yarn cypress:open --config integrationFolder=./cypress/upgrade_integration", "cypress:run": "yarn cypress:run:reporter --browser chrome --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", + "cypress:run:spec": "yarn cypress:run:reporter --browser chrome --spec ${SPEC_LIST:-'./cypress/integration/**/*.spec.ts'}; status=$?; yarn junit:merge && exit $status", "cypress:run:cases": "yarn cypress:run:reporter --browser chrome --spec './cypress/integration/cases/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:firefox": "yarn cypress:run:reporter --browser firefox --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:reporter": "yarn cypress run --config-file ./cypress/cypress.json --reporter ../../../node_modules/cypress-multi-reporters --reporter-options configFile=./cypress/reporter_config.json", "cypress:run:respops": "yarn cypress:run:reporter --browser chrome --spec ./cypress/integration/detection_alerts/*.spec.ts,./cypress/integration/detection_rules/*.spec.ts,./cypress/integration/exceptions/*.spec.ts; status=$?; yarn junit:merge && exit $status", "cypress:run:ccs": "yarn cypress:run:reporter --browser chrome --config integrationFolder=./cypress/ccs_integration; status=$?; yarn junit:merge && exit $status", - "cypress:run-as-ci": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/cli_config.ts", + "cypress:run-as-ci": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/cli_config_parallel.ts", "cypress:run-as-ci:firefox": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/config.firefox.ts", "cypress:run:upgrade": "yarn cypress:run:reporter --browser chrome --config integrationFolder=./cypress/upgrade_integration", "cypress:run:upgrade:old": "yarn cypress:run:reporter --browser chrome --config integrationFolder=./cypress/upgrade_integration --spec ./cypress/upgrade_integration/threat_hunting/**/*.spec.ts,./cypress/upgrade_integration/detections/**/custom_query_rule.spec.ts; status=$?; yarn junit:merge && exit $status", diff --git a/x-pack/test/security_solution_cypress/cli_config_parallel.ts b/x-pack/test/security_solution_cypress/cli_config_parallel.ts new file mode 100644 index 0000000000000..20abaed99a1b9 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cli_config_parallel.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; +import { FtrProviderContext } from './ftr_provider_context'; + +import { SecuritySolutionCypressCliTestRunnerCI } from './runner'; + +const cliNumber = parseInt(process.env.CLI_NUMBER ?? '1', 10); +const cliCount = parseInt(process.env.CLI_COUNT ?? '1', 10); + +// eslint-disable-next-line import/no-default-export +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const securitySolutionCypressConfig = await readConfigFile(require.resolve('./config.ts')); + return { + ...securitySolutionCypressConfig.getAll(), + + testRunner: (context: FtrProviderContext) => + SecuritySolutionCypressCliTestRunnerCI(context, cliCount, cliNumber), + }; +} diff --git a/x-pack/test/security_solution_cypress/runner.ts b/x-pack/test/security_solution_cypress/runner.ts index 2c4b69799f1cc..2f4f76de53ced 100644 --- a/x-pack/test/security_solution_cypress/runner.ts +++ b/x-pack/test/security_solution_cypress/runner.ts @@ -5,7 +5,10 @@ * 2.0. */ +import { chunk } from 'lodash'; import { resolve } from 'path'; +import glob from 'glob'; + import Url from 'url'; import { withProcRunner } from '@kbn/dev-proc-runner'; @@ -13,7 +16,22 @@ import { withProcRunner } from '@kbn/dev-proc-runner'; import semver from 'semver'; import { FtrProviderContext } from './ftr_provider_context'; -export async function SecuritySolutionCypressCliTestRunner({ getService }: FtrProviderContext) { +const retrieveIntegrations = (chunksTotal: number, chunkIndex: number) => { + const pattern = resolve( + __dirname, + '../../plugins/security_solution/cypress/integration/**/*.spec.ts' + ); + const integrationsPaths = glob.sync(pattern); + const chunkSize = Math.ceil(integrationsPaths.length / chunksTotal); + + return chunk(integrationsPaths, chunkSize)[chunkIndex - 1]; +}; + +export async function SecuritySolutionConfigurableCypressTestRunner( + { getService }: FtrProviderContext, + command: string, + envVars?: Record +) { const log = getService('log'); const config = getService('config'); const esArchiver = getService('esArchiver'); @@ -23,7 +41,7 @@ export async function SecuritySolutionCypressCliTestRunner({ getService }: FtrPr await withProcRunner(log, async (procs) => { await procs.run('cypress', { cmd: 'yarn', - args: ['cypress:run'], + args: [command], cwd: resolve(__dirname, '../../plugins/security_solution'), env: { FORCE_COLOR: '1', @@ -32,91 +50,42 @@ export async function SecuritySolutionCypressCliTestRunner({ getService }: FtrPr CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), ...process.env, + ...envVars, }, wait: true, }); }); } -export async function SecuritySolutionCypressCliResponseOpsTestRunner({ - getService, -}: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const esArchiver = getService('esArchiver'); - - await esArchiver.load('x-pack/test/security_solution_cypress/es_archives/auditbeat'); - - await withProcRunner(log, async (procs) => { - await procs.run('cypress', { - cmd: 'yarn', - args: ['cypress:run:respops'], - cwd: resolve(__dirname, '../../plugins/security_solution'), - env: { - FORCE_COLOR: '1', - CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), - CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), - CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), - CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - ...process.env, - }, - wait: true, - }); +export async function SecuritySolutionCypressCliTestRunnerCI( + context: FtrProviderContext, + totalCiJobs: number, + ciJobNumber: number +) { + const integrations = retrieveIntegrations(totalCiJobs, ciJobNumber); + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run:spec', { + SPEC_LIST: integrations.join(','), }); } -export async function SecuritySolutionCypressCliCasesTestRunner({ - getService, -}: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const esArchiver = getService('esArchiver'); - - await esArchiver.load('x-pack/test/security_solution_cypress/es_archives/auditbeat'); +export async function SecuritySolutionCypressCliResponseOpsTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run:respops'); +} - await withProcRunner(log, async (procs) => { - await procs.run('cypress', { - cmd: 'yarn', - args: ['cypress:run:cases'], - cwd: resolve(__dirname, '../../plugins/security_solution'), - env: { - FORCE_COLOR: '1', - CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), - CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), - CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), - CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - ...process.env, - }, - wait: true, - }); - }); +export async function SecuritySolutionCypressCliCasesTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run:cases'); } -export async function SecuritySolutionCypressCliFirefoxTestRunner({ - getService, -}: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const esArchiver = getService('esArchiver'); +export async function SecuritySolutionCypressCliTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run'); +} - await esArchiver.load('x-pack/test/security_solution_cypress/es_archives/auditbeat'); +export async function SecuritySolutionCypressCliFirefoxTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run:firefox'); +} - await withProcRunner(log, async (procs) => { - await procs.run('cypress', { - cmd: 'yarn', - args: ['cypress:run:firefox'], - cwd: resolve(__dirname, '../../plugins/security_solution'), - env: { - FORCE_COLOR: '1', - CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), - CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), - CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), - CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - ...process.env, - }, - wait: true, - }); - }); +export async function SecuritySolutionCypressVisualTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:open'); } export async function SecuritySolutionCypressCcsTestRunner({ getService }: FtrProviderContext) { @@ -143,31 +112,6 @@ export async function SecuritySolutionCypressCcsTestRunner({ getService }: FtrPr }); } -export async function SecuritySolutionCypressVisualTestRunner({ getService }: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const esArchiver = getService('esArchiver'); - - await esArchiver.load('x-pack/test/security_solution_cypress/es_archives/auditbeat'); - - await withProcRunner(log, async (procs) => { - await procs.run('cypress', { - cmd: 'yarn', - args: ['cypress:open'], - cwd: resolve(__dirname, '../../plugins/security_solution'), - env: { - FORCE_COLOR: '1', - CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), - CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), - CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), - CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - ...process.env, - }, - wait: true, - }); - }); -} - export async function SecuritySolutionCypressUpgradeCliTestRunner({ getService, }: FtrProviderContext) { From 14a8997a80613ade9b97e848f3db7ae35e9bc321 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Thu, 19 May 2022 10:13:54 -0400 Subject: [PATCH 058/150] [Response Ops] Use `active/new/recovered` alert counts in event log `execute` doc to populate exec log (#131187) * Using new metrics in event log execute * Returning version from event log docs and updating cell value based on version * Fixing types * Cleanup * Using updated event log fields * importing specific semver function * Moving to library function * Cleanup Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../alerting/common/execution_log_types.ts | 4 + .../lib/get_execution_log_aggregation.test.ts | 248 +++++++++--------- .../lib/get_execution_log_aggregation.ts | 55 ++-- .../routes/get_rule_execution_log.test.ts | 2 + .../server/routes/get_rule_execution_log.ts | 3 + .../tests/get_execution_log.test.ts | 56 ++-- .../public/application/constants/index.ts | 6 + .../components/rule_event_log_data_grid.tsx | 2 + .../components/rule_event_log_list.test.tsx | 4 + ...rule_event_log_list_cell_renderer.test.tsx | 8 + .../rule_event_log_list_cell_renderer.tsx | 9 +- .../lib/format_rule_alert_count.test.ts | 34 +++ .../common/lib/format_rule_alert_count.ts | 23 ++ 13 files changed, 277 insertions(+), 177 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.ts diff --git a/x-pack/plugins/alerting/common/execution_log_types.ts b/x-pack/plugins/alerting/common/execution_log_types.ts index 4fff1f14ca5bd..cdfc7601190dd 100644 --- a/x-pack/plugins/alerting/common/execution_log_types.ts +++ b/x-pack/plugins/alerting/common/execution_log_types.ts @@ -13,6 +13,9 @@ export const executionLogSortableColumns = [ 'schedule_delay', 'num_triggered_actions', 'num_generated_actions', + 'num_active_alerts', + 'num_recovered_alerts', + 'num_new_alerts', ] as const; export type ExecutionLogSortFields = typeof executionLogSortableColumns[number]; @@ -23,6 +26,7 @@ export interface IExecutionLog { duration_ms: number; status: string; message: string; + version: string; num_active_alerts: number; num_new_alerts: number; num_recovered_alerts: number; diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts index 6927ef86dd47c..f5be4f0fcd34e 100644 --- a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts @@ -83,7 +83,7 @@ describe('getExecutionLogAggregation', () => { sort: [{ notsortable: { order: 'asc' } }], }); }).toThrowErrorMatchingInlineSnapshot( - `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions]"` + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions,num_active_alerts,num_recovered_alerts,num_new_alerts]"` ); }); @@ -95,7 +95,7 @@ describe('getExecutionLogAggregation', () => { sort: [{ notsortable: { order: 'asc' } }, { timestamp: { order: 'asc' } }], }); }).toThrowErrorMatchingInlineSnapshot( - `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions]"` + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions,num_active_alerts,num_recovered_alerts,num_new_alerts]"` ); }); @@ -164,15 +164,6 @@ describe('getExecutionLogAggregation', () => { gap_policy: 'insert_zeros', }, }, - alertCounts: { - filters: { - filters: { - newAlerts: { match: { 'event.action': 'new-instance' } }, - activeAlerts: { match: { 'event.action': 'active-instance' } }, - recoveredAlerts: { match: { 'event.action': 'recovered-instance' } }, - }, - }, - }, actionExecution: { filter: { bool: { @@ -216,11 +207,28 @@ describe('getExecutionLogAggregation', () => { field: 'kibana.alert.rule.execution.metrics.number_of_generated_actions', }, }, + numActiveAlerts: { + max: { + field: 'kibana.alert.rule.execution.metrics.alert_counts.active', + }, + }, + numRecoveredAlerts: { + max: { + field: 'kibana.alert.rule.execution.metrics.alert_counts.recovered', + }, + }, + numNewAlerts: { + max: { + field: 'kibana.alert.rule.execution.metrics.alert_counts.new', + }, + }, executionDuration: { max: { field: 'event.duration' } }, outcomeAndMessage: { top_hits: { size: 1, - _source: { includes: ['event.outcome', 'message', 'error.message'] }, + _source: { + includes: ['event.outcome', 'message', 'error.message', 'kibana.version'], + }, }, }, }, @@ -278,20 +286,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -301,6 +295,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -317,6 +320,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -363,20 +369,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -386,6 +378,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -402,6 +403,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -459,6 +463,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -478,6 +483,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -512,20 +518,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -535,6 +527,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -551,6 +552,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'failure', }, + kibana: { + version: '8.2.0', + }, message: "rule execution failure: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", error: { @@ -600,20 +604,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -623,6 +613,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -639,6 +638,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -696,6 +698,7 @@ describe('formatExecutionLogResult', () => { status: 'failure', message: "rule execution failure: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule' - I am erroring in rule execution!!", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -715,6 +718,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -749,20 +753,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 1, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 0, - }, - newAlerts: { - doc_count: 0, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -772,6 +762,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 0.0, }, + numActiveAlerts: { + value: 0.0, + }, + numNewAlerts: { + value: 0.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -788,6 +787,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -829,20 +831,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -852,6 +840,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -868,6 +865,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -925,6 +925,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 0, num_new_alerts: 0, num_recovered_alerts: 0, @@ -944,6 +945,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -978,20 +980,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -1001,6 +989,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -1017,6 +1014,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -1063,20 +1063,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -1086,6 +1072,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -1102,6 +1097,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -1159,6 +1157,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -1178,6 +1177,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts index 03e1077b02eda..aa8a7f6de88cf 100644 --- a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts @@ -20,6 +20,7 @@ const ACTION_FIELD = 'event.action'; const OUTCOME_FIELD = 'event.outcome'; const DURATION_FIELD = 'event.duration'; const MESSAGE_FIELD = 'message'; +const VERSION_FIELD = 'kibana.version'; const ERROR_MESSAGE_FIELD = 'error.message'; const SCHEDULE_DELAY_FIELD = 'kibana.task.schedule_delay'; const ES_SEARCH_DURATION_FIELD = 'kibana.alert.rule.execution.metrics.es_search_duration_ms'; @@ -28,6 +29,10 @@ const NUMBER_OF_TRIGGERED_ACTIONS_FIELD = 'kibana.alert.rule.execution.metrics.number_of_triggered_actions'; const NUMBER_OF_GENERATED_ACTIONS_FIELD = 'kibana.alert.rule.execution.metrics.number_of_generated_actions'; +const NUMBER_OF_ACTIVE_ALERTS_FIELD = 'kibana.alert.rule.execution.metrics.alert_counts.active'; +const NUMBER_OF_NEW_ALERTS_FIELD = 'kibana.alert.rule.execution.metrics.alert_counts.new'; +const NUMBER_OF_RECOVERED_ALERTS_FIELD = + 'kibana.alert.rule.execution.metrics.alert_counts.recovered'; const EXECUTION_UUID_FIELD = 'kibana.alert.rule.execution.uuid'; const Millis2Nanos = 1000 * 1000; @@ -37,14 +42,6 @@ export const EMPTY_EXECUTION_LOG_RESULT = { data: [], }; -interface IAlertCounts extends estypes.AggregationsMultiBucketAggregateBase { - buckets: { - activeAlerts: estypes.AggregationsSingleBucketAggregateBase; - newAlerts: estypes.AggregationsSingleBucketAggregateBase; - recoveredAlerts: estypes.AggregationsSingleBucketAggregateBase; - }; -} - interface IActionExecution extends estypes.AggregationsTermsAggregateBase<{ key: string; doc_count: number }> { buckets: Array<{ key: string; doc_count: number }>; @@ -60,9 +57,11 @@ interface IExecutionUuidAggBucket extends estypes.AggregationsStringTermsBucketK totalSearchDuration: estypes.AggregationsMaxAggregate; numTriggeredActions: estypes.AggregationsMaxAggregate; numGeneratedActions: estypes.AggregationsMaxAggregate; + numActiveAlerts: estypes.AggregationsMaxAggregate; + numRecoveredAlerts: estypes.AggregationsMaxAggregate; + numNewAlerts: estypes.AggregationsMaxAggregate; outcomeAndMessage: estypes.AggregationsTopHitsAggregate; }; - alertCounts: IAlertCounts; actionExecution: { actionOutcomes: IActionExecution; }; @@ -91,6 +90,9 @@ const ExecutionLogSortFields: Record = { schedule_delay: 'ruleExecution>scheduleDelay', num_triggered_actions: 'ruleExecution>numTriggeredActions', num_generated_actions: 'ruleExecution>numGeneratedActions', + num_active_alerts: 'ruleExecution>numActiveAlerts', + num_recovered_alerts: 'ruleExecution>numRecoveredAlerts', + num_new_alerts: 'ruleExecution>numNewAlerts', }; export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLogAggOptions) { @@ -153,16 +155,6 @@ export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLo gap_policy: 'insert_zeros' as estypes.AggregationsGapPolicy, }, }, - // Get counts for types of alerts and whether there was an execution timeout - alertCounts: { - filters: { - filters: { - newAlerts: { match: { [ACTION_FIELD]: 'new-instance' } }, - activeAlerts: { match: { [ACTION_FIELD]: 'active-instance' } }, - recoveredAlerts: { match: { [ACTION_FIELD]: 'recovered-instance' } }, - }, - }, - }, // Filter by action execute doc and get information from this event actionExecution: { filter: getProviderAndActionFilter('actions', 'execute'), @@ -209,6 +201,21 @@ export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLo field: NUMBER_OF_GENERATED_ACTIONS_FIELD, }, }, + numActiveAlerts: { + max: { + field: NUMBER_OF_ACTIVE_ALERTS_FIELD, + }, + }, + numRecoveredAlerts: { + max: { + field: NUMBER_OF_RECOVERED_ALERTS_FIELD, + }, + }, + numNewAlerts: { + max: { + field: NUMBER_OF_NEW_ALERTS_FIELD, + }, + }, executionDuration: { max: { field: DURATION_FIELD, @@ -218,7 +225,7 @@ export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLo top_hits: { size: 1, _source: { - includes: [OUTCOME_FIELD, MESSAGE_FIELD, ERROR_MESSAGE_FIELD], + includes: [OUTCOME_FIELD, MESSAGE_FIELD, ERROR_MESSAGE_FIELD, VERSION_FIELD], }, }, }, @@ -275,15 +282,17 @@ function formatExecutionLogAggBucket(bucket: IExecutionUuidAggBucket): IExecutio status === 'failure' ? `${outcomeAndMessage?.message ?? ''} - ${outcomeAndMessage?.error?.message ?? ''}` : outcomeAndMessage?.message ?? ''; + const version = outcomeAndMessage ? outcomeAndMessage?.kibana?.version ?? '' : ''; return { id: bucket?.key ?? '', timestamp: bucket?.ruleExecution?.executeStartTime.value_as_string ?? '', duration_ms: durationUs / Millis2Nanos, status, message, - num_active_alerts: bucket?.alertCounts?.buckets?.activeAlerts?.doc_count ?? 0, - num_new_alerts: bucket?.alertCounts?.buckets?.newAlerts?.doc_count ?? 0, - num_recovered_alerts: bucket?.alertCounts?.buckets?.recoveredAlerts?.doc_count ?? 0, + version, + num_active_alerts: bucket?.ruleExecution?.numActiveAlerts?.value ?? 0, + num_new_alerts: bucket?.ruleExecution?.numNewAlerts?.value ?? 0, + num_recovered_alerts: bucket?.ruleExecution?.numRecoveredAlerts?.value ?? 0, num_triggered_actions: bucket?.ruleExecution?.numTriggeredActions?.value ?? 0, num_generated_actions: bucket?.ruleExecution?.numGeneratedActions?.value ?? 0, num_succeeded_actions: actionExecutionSuccess, diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts index cbcff65cdbdca..4a67404ab232e 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts @@ -34,6 +34,7 @@ describe('getRuleExecutionLogRoute', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -53,6 +54,7 @@ describe('getRuleExecutionLogRoute', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts index 650bdd83a0a83..4a8a91089203d 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts @@ -26,6 +26,9 @@ const sortFieldSchema = schema.oneOf([ schema.object({ schedule_delay: schema.object({ order: sortOrderSchema }) }), schema.object({ num_triggered_actions: schema.object({ order: sortOrderSchema }) }), schema.object({ num_generated_actions: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_active_alerts: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_recovered_alerts: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_new_alerts: schema.object({ order: sortOrderSchema }) }), ]); const sortFieldsSchema = schema.arrayOf(sortFieldSchema, { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts index 541e55f5c8d90..04653d491f28b 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts @@ -111,20 +111,6 @@ const aggregateResults = { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -134,6 +120,15 @@ const aggregateResults = { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -150,6 +145,9 @@ const aggregateResults = { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -196,20 +194,6 @@ const aggregateResults = { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -219,6 +203,15 @@ const aggregateResults = { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -235,6 +228,9 @@ const aggregateResults = { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -631,6 +627,7 @@ describe('getExecutionLogForRule()', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -650,6 +647,7 @@ describe('getExecutionLogForRule()', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -929,7 +927,7 @@ describe('getExecutionLogForRule()', () => { getExecutionLogByIdParams({ sort: [{ foo: { order: 'desc' } }] }) ) ).rejects.toMatchInlineSnapshot( - `[Error: Invalid sort field "foo" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions]]` + `[Error: Invalid sort field "foo" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions,num_active_alerts,num_recovered_alerts,num_new_alerts]]` ); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts index 99c115def07e6..a416eb18b5a52 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts @@ -67,6 +67,12 @@ export const RULE_EXECUTION_LOG_DURATION_COLUMNS = [ 'schedule_delay', ]; +export const RULE_EXECUTION_LOG_ALERT_COUNT_COLUMNS = [ + 'num_new_alerts', + 'num_active_alerts', + 'num_recovered_alerts', +]; + export const RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS = [ 'timestamp', 'execution_duration', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_data_grid.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_data_grid.tsx index 7c2f5518c5c45..6f166af876004 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_data_grid.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_data_grid.tsx @@ -258,10 +258,12 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => { const pagedRowIndex = rowIndex - pageIndex * pageSize; const value = logs[pagedRowIndex]?.[columnId as keyof IExecutionLog] as string; + const version = logs?.[pagedRowIndex]?.version; return ( ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx index 0284ab14f6ce0..7bf2c05b843dc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx @@ -29,6 +29,7 @@ const mockLogResponse: any = { duration: 5000000, status: 'success', message: 'rule execution #1', + version: '8.2.0', num_active_alerts: 2, num_new_alerts: 4, num_recovered_alerts: 3, @@ -46,6 +47,7 @@ const mockLogResponse: any = { duration: 6000000, status: 'success', message: 'rule execution #2', + version: '8.2.0', num_active_alerts: 4, num_new_alerts: 2, num_recovered_alerts: 4, @@ -63,6 +65,7 @@ const mockLogResponse: any = { duration: 340000, status: 'failure', message: 'rule execution #3', + version: '8.2.0', num_active_alerts: 8, num_new_alerts: 5, num_recovered_alerts: 0, @@ -80,6 +83,7 @@ const mockLogResponse: any = { duration: 3000000, status: 'unknown', message: 'rule execution #4', + version: '8.2.0', num_active_alerts: 4, num_new_alerts: 4, num_recovered_alerts: 4, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx index a33bdf7e25916..e38e57f61878b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx @@ -38,6 +38,14 @@ describe('rule_event_log_list_cell_renderer', () => { expect(wrapper.find(RuleDurationFormat).props().duration).toEqual(100000); }); + it('renders alert count correctly', () => { + const wrapper = shallow( + + ); + + expect(wrapper.text()).toEqual('3'); + }); + it('renders timestamps correctly', () => { const time = '2022-03-20T07:40:44-07:00'; const wrapper = shallow(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx index 20e9274f2d73e..84fc3404f228e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx @@ -9,11 +9,13 @@ import React from 'react'; import moment from 'moment'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { EcsEventOutcome } from '@kbn/core/server'; +import { formatRuleAlertCount } from '../../../../common/lib/format_rule_alert_count'; import { RuleEventLogListStatus } from './rule_event_log_list_status'; import { RuleDurationFormat } from '../../rules_list/components/rule_duration_format'; import { RULE_EXECUTION_LOG_COLUMN_IDS, RULE_EXECUTION_LOG_DURATION_COLUMNS, + RULE_EXECUTION_LOG_ALERT_COUNT_COLUMNS, } from '../../../constants'; export const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; @@ -22,12 +24,13 @@ export type ColumnId = typeof RULE_EXECUTION_LOG_COLUMN_IDS[number]; interface RuleEventLogListCellRendererProps { columnId: ColumnId; + version?: string; value?: string; dateFormat?: string; } export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRendererProps) => { - const { columnId, value, dateFormat = DEFAULT_DATE_FORMAT } = props; + const { columnId, value, version, dateFormat = DEFAULT_DATE_FORMAT } = props; if (typeof value === 'undefined') { return null; @@ -41,6 +44,10 @@ export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRenderer return <>{moment(value).format(dateFormat)}; } + if (RULE_EXECUTION_LOG_ALERT_COUNT_COLUMNS.includes(columnId)) { + return <>{formatRuleAlertCount(value, version)}; + } + if (RULE_EXECUTION_LOG_DURATION_COLUMNS.includes(columnId)) { return ; } diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.test.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.test.ts new file mode 100644 index 0000000000000..99da6c01e66aa --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { formatRuleAlertCount } from './format_rule_alert_count'; + +describe('formatRuleAlertCount', () => { + it('returns value if version is undefined', () => { + expect(formatRuleAlertCount('0')).toEqual('0'); + }); + + it('renders zero value if version is greater than or equal to 8.3.0', () => { + expect(formatRuleAlertCount('0', '8.3.0')).toEqual('0'); + }); + + it('renders non-zero value if version is greater than or equal to 8.3.0', () => { + expect(formatRuleAlertCount('4', '8.3.0')).toEqual('4'); + }); + + it('renders dashes for zero value if version is less than 8.3.0', () => { + expect(formatRuleAlertCount('0', '8.2.9')).toEqual('--'); + }); + + it('renders non-zero value event if version is less than to 8.3.0', () => { + expect(formatRuleAlertCount('5', '8.2.9')).toEqual('5'); + }); + + it('renders as is if value is unexpectedly not an integer', () => { + expect(formatRuleAlertCount('yo', '8.2.9')).toEqual('yo'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.ts new file mode 100644 index 0000000000000..10ceb40ce19b0 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.ts @@ -0,0 +1,23 @@ +/* + * 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 semverLt from 'semver/functions/lt'; + +export const formatRuleAlertCount = (value: string, version?: string): string => { + if (version) { + try { + const intValue = parseInt(value, 10); + if (intValue === 0 && semverLt(version, '8.3.0')) { + return '--'; + } + } catch (err) { + return value; + } + } + + return value; +}; From a7012a319b9eca89f362e262667c8491232e8739 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Thu, 19 May 2022 15:22:08 +0100 Subject: [PATCH 059/150] [ML] Creating anomaly detection jobs from Lens visualizations (#129762) * [ML] Lens to ML ON week experiment * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * adding enabled check * refactor * type clean up * type updates * adding error text * variable rename * refactoring url generation * query refactor * translations * refactoring create job code * tiny refactor * adding getSavedVis function * adding undefined check * improving isCompatible check * improving field extraction * improving date parsing * code clean up * adding check for filter and timeShift * changing case of menu item * improving ml link generation * adding check for multiple split fields * adding layer types * renaming things * fixing queries and field type checks * using default bucket span * using locator * fixing query merging * fixing from and to string decoding * adding layer selection flyout * error tranlations and improving error reporting * removing annotatio and reference line layers * moving popout button * adding tick icon * tiny code clean up * removing commented code * using full labels * fixing full label selection * changing style of layer panels * fixing error text * adjusting split card border * style changes * removing border color * removing split card border * adding create job permission check Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../lens/public/embeddable/embeddable.tsx | 4 + x-pack/plugins/lens/public/index.ts | 4 +- x-pack/plugins/ml/common/constants/locator.ts | 1 + x-pack/plugins/ml/common/types/locator.ts | 1 + x-pack/plugins/ml/common/util/date_utils.ts | 11 + x-pack/plugins/ml/kibana.json | 2 + .../job_creator/advanced_job_creator.ts | 14 - .../new_job/common/job_creator/job_creator.ts | 14 + .../common/job_creator/util/general.ts | 9 +- .../convert_lens_to_job_action.tsx | 34 ++ .../jobs/new_job/job_from_lens/create_job.ts | 401 ++++++++++++++++++ .../jobs/new_job/job_from_lens/index.ts | 12 + .../new_job/job_from_lens/route_resolver.ts | 92 ++++ .../jobs/new_job/job_from_lens/utils.ts | 176 ++++++++ .../components/split_cards/split_cards.tsx | 5 +- .../components/split_cards/style.scss | 4 + .../jobs/new_job/pages/new_job/page.tsx | 9 +- .../jobs/new_job/utils/new_job_utils.ts | 117 +++-- .../routing/routes/new_job/from_lens.tsx | 36 ++ .../routing/routes/new_job/index.ts | 1 + .../application/services/job_service.d.ts | 1 + .../application/services/job_service.js | 1 + .../ml/public/embeddables/lens/index.ts | 8 + .../flyout.tsx | 80 ++++ .../flyout_body.tsx | 144 +++++++ .../lens_vis_layer_selection_flyout/index.ts | 8 + .../style.scss | 3 + .../public/embeddables/lens/show_flyout.tsx | 87 ++++ .../plugins/ml/public/locator/ml_locator.ts | 1 + x-pack/plugins/ml/public/plugin.ts | 3 + x-pack/plugins/ml/public/ui_actions/index.ts | 4 + .../ui_actions/open_lens_vis_in_ml_action.tsx | 64 +++ 32 files changed, 1283 insertions(+), 68 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/convert_lens_to_job_action.tsx create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/create_job.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/index.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/utils.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/style.scss create mode 100644 x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/lens/index.ts create mode 100644 x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout_body.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/index.ts create mode 100644 x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/style.scss create mode 100644 x-pack/plugins/ml/public/embeddables/lens/show_flyout.tsx create mode 100644 x-pack/plugins/ml/public/ui_actions/open_lens_vis_in_ml_action.tsx diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 7ca68c5ca5d21..bc7770e815ba6 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -800,6 +800,10 @@ export class Embeddable return this.savedVis && this.savedVis.description; } + public getSavedVis(): Readonly { + return this.savedVis; + } + destroy() { super.destroy(); this.isDestroyed = true; diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index edf57ba703a2e..caa08ee9cc418 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -68,6 +68,7 @@ export type { FormulaPublicApi, StaticValueIndexPatternColumn, TimeScaleIndexPatternColumn, + IndexPatternLayer, } from './indexpattern_datasource/types'; export type { XYArgs, @@ -103,7 +104,8 @@ export type { LabelsOrientationConfigResult, AxisTitlesVisibilityConfigResult, } from '@kbn/expression-xy-plugin/common'; -export type { LensEmbeddableInput } from './embeddable'; +export type { LensEmbeddableInput, LensSavedObjectAttributes, Embeddable } from './embeddable'; + export { layerTypes } from '../common'; export type { LensPublicStart, LensPublicSetup } from './plugin'; diff --git a/x-pack/plugins/ml/common/constants/locator.ts b/x-pack/plugins/ml/common/constants/locator.ts index 0c19c5b59766c..7b98eefe0ab24 100644 --- a/x-pack/plugins/ml/common/constants/locator.ts +++ b/x-pack/plugins/ml/common/constants/locator.ts @@ -42,6 +42,7 @@ export const ML_PAGES = { ANOMALY_DETECTION_CREATE_JOB_ADVANCED: `jobs/new_job/advanced`, ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE: `jobs/new_job/step/job_type`, ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX: `jobs/new_job/step/index_or_search`, + ANOMALY_DETECTION_CREATE_JOB_FROM_LENS: `jobs/new_job/from_lens`, SETTINGS: 'settings', CALENDARS_MANAGE: 'settings/calendars_list', CALENDARS_NEW: 'settings/calendars_list/new_calendar', diff --git a/x-pack/plugins/ml/common/types/locator.ts b/x-pack/plugins/ml/common/types/locator.ts index a440aaa349bcc..0d5cb7aeddd81 100644 --- a/x-pack/plugins/ml/common/types/locator.ts +++ b/x-pack/plugins/ml/common/types/locator.ts @@ -48,6 +48,7 @@ export type MlGenericUrlState = MLPageState< | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_RECOGNIZER | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_ADVANCED + | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_LENS | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX | typeof ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB diff --git a/x-pack/plugins/ml/common/util/date_utils.ts b/x-pack/plugins/ml/common/util/date_utils.ts index c5f5fdaabf388..d6605e5856d8b 100644 --- a/x-pack/plugins/ml/common/util/date_utils.ts +++ b/x-pack/plugins/ml/common/util/date_utils.ts @@ -31,6 +31,17 @@ export function validateTimeRange(time?: TimeRange): boolean { return !!(momentDateFrom && momentDateFrom.isValid() && momentDateTo && momentDateTo.isValid()); } +export function createAbsoluteTimeRange(time: TimeRange) { + if (validateTimeRange(time) === false) { + return null; + } + + return { + to: dateMath.parse(time.to)?.valueOf(), + from: dateMath.parse(time.from)?.valueOf(), + }; +} + export const timeFormatter = (value: number) => { return formatDate(value, TIME_FORMAT); }; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index f62cec0ec0fca..fd105b98805ac 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -28,6 +28,7 @@ "charts", "dashboard", "home", + "lens", "licenseManagement", "management", "maps", @@ -44,6 +45,7 @@ "fieldFormats", "kibanaReact", "kibanaUtils", + "lens", "maps", "savedObjects", "usageCollection", diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts index ebf3a43626c99..d6e7a8c3b21e2 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts @@ -21,7 +21,6 @@ import { createBasicDetector } from './util/default_configs'; import { JOB_TYPE } from '../../../../../../common/constants/new_job'; import { getRichDetectors } from './util/general'; import { isValidJson } from '../../../../../../common/util/validation_utils'; -import { ml } from '../../../../services/ml_api_service'; export interface RichDetector { agg: Aggregation | null; @@ -181,19 +180,6 @@ export class AdvancedJobCreator extends JobCreator { return isValidJson(this._queryString); } - // load the start and end times for the selected index - // and apply them to the job creator - public async autoSetTimeRange() { - const { start, end } = await ml.getTimeFieldRange({ - index: this._indexPatternTitle, - timeFieldName: this.timeFieldName, - query: this.query, - runtimeMappings: this.datafeedConfig.runtime_mappings, - indicesOptions: this.datafeedConfig.indices_options, - }); - this.setTimeRange(start.epoch, end.epoch); - } - public cloneFromExistingJob(job: Job, datafeed: Datafeed) { this._overrideConfigs(job, datafeed); const detectors = getRichDetectors(job, datafeed, this.additionalFields, true); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts index 750669a794bd8..4e0ed5f3bdf92 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts @@ -43,6 +43,7 @@ import { Calendar } from '../../../../../../common/types/calendars'; import { mlCalendarService } from '../../../../services/calendar_service'; import { getDatafeedAggregations } from '../../../../../../common/util/datafeed_utils'; import { getFirstKeyInObject } from '../../../../../../common/util/object_utils'; +import { ml } from '../../../../services/ml_api_service'; export class JobCreator { protected _type: JOB_TYPE = JOB_TYPE.SINGLE_METRIC; @@ -762,6 +763,19 @@ export class JobCreator { } } + // load the start and end times for the selected index + // and apply them to the job creator + public async autoSetTimeRange() { + const { start, end } = await ml.getTimeFieldRange({ + index: this._indexPatternTitle, + timeFieldName: this.timeFieldName, + query: this.query, + runtimeMappings: this.datafeedConfig.runtime_mappings, + indicesOptions: this.datafeedConfig.indices_options, + }); + this.setTimeRange(start.epoch, end.epoch); + } + protected _overrideConfigs(job: Job, datafeed: Datafeed) { this._job_config = job; this._datafeed_config = datafeed; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index 8f7b66b35ec4f..bd7b6277a542d 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -230,10 +230,11 @@ export function isSparseDataJob(job: Job, datafeed: Datafeed): boolean { return false; } -function stashJobForCloning( +export function stashJobForCloning( jobCreator: JobCreatorType, skipTimeRangeStep: boolean = false, - includeTimeRange: boolean = false + includeTimeRange: boolean = false, + autoSetTimeRange: boolean = false ) { mlJobService.tempJobCloningObjects.job = jobCreator.jobConfig; mlJobService.tempJobCloningObjects.datafeed = jobCreator.datafeedConfig; @@ -242,10 +243,12 @@ function stashJobForCloning( // skip over the time picker step of the wizard mlJobService.tempJobCloningObjects.skipTimeRangeStep = skipTimeRangeStep; - if (includeTimeRange === true) { + if (includeTimeRange === true && autoSetTimeRange === false) { // auto select the start and end dates of the time picker mlJobService.tempJobCloningObjects.start = jobCreator.start; mlJobService.tempJobCloningObjects.end = jobCreator.end; + } else if (autoSetTimeRange === true) { + mlJobService.tempJobCloningObjects.autoSetTimeRange = true; } mlJobService.tempJobCloningObjects.calendars = jobCreator.calendars; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/convert_lens_to_job_action.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/convert_lens_to_job_action.tsx new file mode 100644 index 0000000000000..ab00fa7e2d474 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/convert_lens_to_job_action.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import { getJobsItemsFromEmbeddable } from './utils'; +import { ML_PAGES, ML_APP_LOCATOR } from '../../../../../common/constants/locator'; + +export async function convertLensToADJob( + embeddable: Embeddable, + share: SharePluginStart, + layerIndex?: number +) { + const { query, filters, to, from, vis } = getJobsItemsFromEmbeddable(embeddable); + const locator = share.url.locators.get(ML_APP_LOCATOR); + + const url = await locator?.getUrl({ + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_LENS, + pageState: { + vis: vis as any, + from, + to, + query, + filters, + layerIndex, + }, + }); + + window.open(url, '_blank'); +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/create_job.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/create_job.ts new file mode 100644 index 0000000000000..7abc30c9f924e --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/create_job.ts @@ -0,0 +1,401 @@ +/* + * 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 { mergeWith } from 'lodash'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { IUiSettingsClient, SavedObjectReference } from '@kbn/core/public'; +import type { DataViewsContract } from '@kbn/data-views-plugin/public'; + +import { Filter, Query, DataViewBase } from '@kbn/es-query'; + +import type { + LensPublicStart, + LensSavedObjectAttributes, + FieldBasedIndexPatternColumn, + XYDataLayerConfig, + IndexPatternPersistedState, + IndexPatternLayer, + XYLayerConfig, +} from '@kbn/lens-plugin/public'; +import { layerTypes } from '@kbn/lens-plugin/public'; +import type { TimefilterContract } from '@kbn/data-plugin/public'; + +import { i18n } from '@kbn/i18n'; + +import type { JobCreatorType } from '../common/job_creator'; +import { createEmptyJob, createEmptyDatafeed } from '../common/job_creator/util/default_configs'; +import { stashJobForCloning } from '../common/job_creator/util/general'; +import { CREATED_BY_LABEL, DEFAULT_BUCKET_SPAN } from '../../../../../common/constants/new_job'; +import { ErrorType } from '../../../../../common/util/errors'; +import { createQueries } from '../utils/new_job_utils'; +import { + getVisTypeFactory, + isCompatibleLayer, + hasIncompatibleProperties, + hasSourceField, + isTermsField, + isStringField, + getMlFunction, +} from './utils'; + +type VisualizationType = Awaited>[number]; + +export interface LayerResult { + id: string; + layerType: typeof layerTypes[keyof typeof layerTypes]; + label: string; + icon: VisualizationType['icon']; + isCompatible: boolean; + jobWizardType: CREATED_BY_LABEL | null; + error?: ErrorType; +} + +export async function canCreateAndStashADJob( + vis: LensSavedObjectAttributes, + startString: string, + endString: string, + query: Query, + filters: Filter[], + dataViewClient: DataViewsContract, + kibanaConfig: IUiSettingsClient, + timeFilter: TimefilterContract, + layerIndex: number | undefined +) { + try { + const { jobConfig, datafeedConfig, createdBy } = await createADJobFromLensSavedObject( + vis, + query, + filters, + dataViewClient, + kibanaConfig, + layerIndex + ); + + let start: number | undefined; + let end: number | undefined; + let includeTimeRange = true; + + try { + // attempt to parse the start and end dates. + // if start and end values cannot be determined + // instruct the job cloning code to auto-select the + // full time range for the index. + const { min, max } = timeFilter.calculateBounds({ to: endString, from: startString }); + start = min?.valueOf(); + end = max?.valueOf(); + + if (start === undefined || end === undefined || isNaN(start) || isNaN(end)) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.timeRange', { + defaultMessage: 'Incompatible time range', + }) + ); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + includeTimeRange = false; + start = undefined; + end = undefined; + } + + // add job config and start and end dates to the + // job cloning stash, so they can be used + // by the new job wizards + stashJobForCloning( + { + jobConfig, + datafeedConfig, + createdBy, + start, + end, + } as JobCreatorType, + true, + includeTimeRange, + !includeTimeRange + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } +} +export async function getLayers( + vis: LensSavedObjectAttributes, + dataViewClient: DataViewsContract, + lens: LensPublicStart +): Promise { + const visualization = vis.state.visualization as { layers: XYLayerConfig[] }; + const getVisType = await getVisTypeFactory(lens); + + const layers: LayerResult[] = await Promise.all( + visualization.layers + .filter(({ layerType }) => layerType === layerTypes.DATA) // remove non chart layers + .map(async (layer) => { + const { icon, label } = getVisType(layer); + try { + const { fields, splitField } = await extractFields(layer, vis, dataViewClient); + const detectors = createDetectors(fields, splitField); + const createdBy = + splitField || detectors.length > 1 + ? CREATED_BY_LABEL.MULTI_METRIC + : CREATED_BY_LABEL.SINGLE_METRIC; + + return { + id: layer.layerId, + layerType: layer.layerType, + label, + icon, + jobWizardType: createdBy, + isCompatible: true, + }; + } catch (error) { + return { + id: layer.layerId, + layerType: layer.layerType, + label, + icon, + jobWizardType: null, + isCompatible: false, + error, + }; + } + }) + ); + + return layers; +} + +async function createADJobFromLensSavedObject( + vis: LensSavedObjectAttributes, + query: Query, + filters: Filter[], + dataViewClient: DataViewsContract, + kibanaConfig: IUiSettingsClient, + layerIndex?: number +) { + const visualization = vis.state.visualization as { layers: XYDataLayerConfig[] }; + + const compatibleLayers = visualization.layers.filter(isCompatibleLayer); + + const selectedLayer = + layerIndex !== undefined ? visualization.layers[layerIndex] : compatibleLayers[0]; + + const { fields, timeField, splitField, dataView } = await extractFields( + selectedLayer, + vis, + dataViewClient + ); + + const jobConfig = createEmptyJob(); + const datafeedConfig = createEmptyDatafeed(dataView.title); + + const combinedFiltersAndQueries = combineQueriesAndFilters( + { query, filters }, + { query: vis.state.query, filters: vis.state.filters }, + dataView, + kibanaConfig + ); + + datafeedConfig.query = combinedFiltersAndQueries; + + jobConfig.analysis_config.detectors = createDetectors(fields, splitField); + + jobConfig.data_description.time_field = timeField.sourceField; + jobConfig.analysis_config.bucket_span = DEFAULT_BUCKET_SPAN; + if (splitField) { + jobConfig.analysis_config.influencers = [splitField.sourceField]; + } + + const createdBy = + splitField || jobConfig.analysis_config.detectors.length > 1 + ? CREATED_BY_LABEL.MULTI_METRIC + : CREATED_BY_LABEL.SINGLE_METRIC; + + return { + jobConfig, + datafeedConfig, + createdBy, + }; +} + +async function extractFields( + layer: XYLayerConfig, + vis: LensSavedObjectAttributes, + dataViewClient: DataViewsContract +) { + if (!isCompatibleLayer(layer)) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.incompatibleLayerType', { + defaultMessage: 'Layer is incompatible. Only chart layers can be used.', + }) + ); + } + + const indexpattern = vis.state.datasourceStates.indexpattern as IndexPatternPersistedState; + const compatibleIndexPatternLayer = Object.entries(indexpattern.layers).find( + ([id]) => layer.layerId === id + ); + if (compatibleIndexPatternLayer === undefined) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.noCompatibleLayers', { + defaultMessage: + 'Visualization does not contain any layers which can be used for creating an anomaly detection job.', + }) + ); + } + + const [layerId, columnsLayer] = compatibleIndexPatternLayer; + + const columns = getColumns(columnsLayer, layer); + const timeField = Object.values(columns).find(({ dataType }) => dataType === 'date'); + if (timeField === undefined) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.noDateField', { + defaultMessage: 'Cannot find a date field.', + }) + ); + } + + const fields = layer.accessors.map((a) => columns[a]); + + const splitField = layer.splitAccessor ? columns[layer.splitAccessor] : null; + + if ( + splitField !== null && + isTermsField(splitField) && + splitField.params.secondaryFields?.length + ) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.splitFieldHasMultipleFields', { + defaultMessage: 'Selected split field contains more than one field.', + }) + ); + } + + if (splitField !== null && isStringField(splitField) === false) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.splitFieldMustBeString', { + defaultMessage: 'Selected split field type must be string.', + }) + ); + } + + const dataView = await getDataViewFromLens(vis.references, layerId, dataViewClient); + if (dataView === null) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.noDataViews', { + defaultMessage: 'No data views can be found in the visualization.', + }) + ); + } + + if (timeField.sourceField !== dataView.timeFieldName) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.timeFieldNotInDataView', { + defaultMessage: + 'Selected time field must be the default time field configured for data view.', + }) + ); + } + + return { fields, timeField, splitField, dataView }; +} + +function createDetectors( + fields: FieldBasedIndexPatternColumn[], + splitField: FieldBasedIndexPatternColumn | null +) { + return fields.map(({ operationType, sourceField }) => { + return { + function: getMlFunction(operationType), + field_name: sourceField, + ...(splitField ? { partition_field_name: splitField.sourceField } : {}), + }; + }); +} + +async function getDataViewFromLens( + references: SavedObjectReference[], + layerId: string, + dataViewClient: DataViewsContract +) { + const dv = references.find( + (r) => r.type === 'index-pattern' && r.name === `indexpattern-datasource-layer-${layerId}` + ); + if (!dv) { + return null; + } + return dataViewClient.get(dv.id); +} + +function getColumns( + { columns }: Omit, + layer: XYDataLayerConfig +) { + layer.accessors.forEach((a) => { + const col = columns[a]; + // fail early if any of the cols being used as accessors + // contain functions we don't support + return col.dataType !== 'date' && getMlFunction(col.operationType); + }); + + if (Object.values(columns).some((c) => hasSourceField(c) === false)) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.colsNoSourceField', { + defaultMessage: 'Some columns do not contain a source field.', + }) + ); + } + + if (Object.values(columns).some((c) => hasIncompatibleProperties(c) === true)) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.colsUsingFilterTimeSift', { + defaultMessage: + 'Columns contain settings which are incompatible with ML detectors, time shift and filter by are not supported.', + }) + ); + } + + return columns as Record; +} + +function combineQueriesAndFilters( + dashboard: { query: Query; filters: Filter[] }, + vis: { query: Query; filters: Filter[] }, + dataView: DataViewBase, + kibanaConfig: IUiSettingsClient +): estypes.QueryDslQueryContainer { + const { combinedQuery: dashboardQueries } = createQueries( + { + query: dashboard.query, + filter: dashboard.filters, + }, + dataView, + kibanaConfig + ); + + const { combinedQuery: visQueries } = createQueries( + { + query: vis.query, + filter: vis.filters, + }, + dataView, + kibanaConfig + ); + + const mergedQueries = mergeWith( + dashboardQueries, + visQueries, + (objValue: estypes.QueryDslQueryContainer, srcValue: estypes.QueryDslQueryContainer) => { + if (Array.isArray(objValue)) { + return objValue.concat(srcValue); + } + } + ); + + return mergedQueries; +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/index.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/index.ts new file mode 100644 index 0000000000000..911595f9673da --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { LayerResult } from './create_job'; +export { resolver } from './route_resolver'; +export { getLayers } from './create_job'; +export { convertLensToADJob } from './convert_lens_to_job_action'; +export { getJobsItemsFromEmbeddable, isCompatibleVisualizationType } from './utils'; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts new file mode 100644 index 0000000000000..b305c69c47d87 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import rison from 'rison-node'; +import { Query } from '@kbn/data-plugin/public'; +import { Filter } from '@kbn/es-query'; +import type { LensSavedObjectAttributes } from '@kbn/lens-plugin/public'; +import { canCreateAndStashADJob } from './create_job'; +import { + getUiSettings, + getDataViews, + getSavedObjectsClient, + getTimefilter, +} from '../../../util/dependency_cache'; +import { getDefaultQuery } from '../utils/new_job_utils'; + +export async function resolver( + lensSavedObjectId: string | undefined, + lensSavedObjectRisonString: string | undefined, + fromRisonStrong: string, + toRisonStrong: string, + queryRisonString: string, + filtersRisonString: string, + layerIndexRisonString: string +) { + let vis: LensSavedObjectAttributes; + if (lensSavedObjectId) { + vis = await getLensSavedObject(lensSavedObjectId); + } else if (lensSavedObjectRisonString) { + vis = rison.decode(lensSavedObjectRisonString) as unknown as LensSavedObjectAttributes; + } else { + throw new Error('Cannot create visualization'); + } + + let query: Query; + let filters: Filter[]; + try { + query = rison.decode(queryRisonString) as Query; + } catch (error) { + query = getDefaultQuery(); + } + try { + filters = rison.decode(filtersRisonString) as Filter[]; + } catch (error) { + filters = []; + } + + let from: string; + let to: string; + try { + from = rison.decode(fromRisonStrong) as string; + } catch (error) { + from = ''; + } + try { + to = rison.decode(toRisonStrong) as string; + } catch (error) { + to = ''; + } + let layerIndex: number | undefined; + try { + layerIndex = rison.decode(layerIndexRisonString) as number; + } catch (error) { + layerIndex = undefined; + } + + const dataViewClient = getDataViews(); + const kibanaConfig = getUiSettings(); + const timeFilter = getTimefilter(); + + await canCreateAndStashADJob( + vis, + from, + to, + query, + filters, + dataViewClient, + kibanaConfig, + timeFilter, + layerIndex + ); +} + +async function getLensSavedObject(id: string) { + const savedObjectClient = getSavedObjectsClient(); + const so = await savedObjectClient.get('lens', id); + return so.attributes; +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/utils.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/utils.ts new file mode 100644 index 0000000000000..e4b2ae91b3ba2 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/utils.ts @@ -0,0 +1,176 @@ +/* + * 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 type { + Embeddable, + LensPublicStart, + LensSavedObjectAttributes, + FieldBasedIndexPatternColumn, + XYDataLayerConfig, + GenericIndexPatternColumn, + TermsIndexPatternColumn, + SeriesType, + XYLayerConfig, +} from '@kbn/lens-plugin/public'; +import { layerTypes } from '@kbn/lens-plugin/public'; + +export const COMPATIBLE_SERIES_TYPES: SeriesType[] = [ + 'line', + 'bar', + 'bar_stacked', + 'bar_percentage_stacked', + 'bar_horizontal', + 'bar_horizontal_stacked', + 'area', + 'area_stacked', + 'area_percentage_stacked', +]; + +export const COMPATIBLE_LAYER_TYPE: XYDataLayerConfig['layerType'] = layerTypes.DATA; + +export const COMPATIBLE_VISUALIZATION = 'lnsXY'; + +export function getJobsItemsFromEmbeddable(embeddable: Embeddable) { + const { query, filters, timeRange } = embeddable.getInput(); + + if (timeRange === undefined) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.noTimeRange', { + defaultMessage: 'Time range not specified.', + }) + ); + } + const { to, from } = timeRange; + + const vis = embeddable.getSavedVis(); + if (vis === undefined) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.visNotFound', { + defaultMessage: 'Visualization cannot be found.', + }) + ); + } + + return { + vis, + from, + to, + query, + filters, + }; +} + +export function lensOperationToMlFunction(operationType: string) { + switch (operationType) { + case 'average': + return 'mean'; + case 'count': + return 'count'; + case 'max': + return 'max'; + case 'median': + return 'median'; + case 'min': + return 'min'; + case 'sum': + return 'sum'; + case 'unique_count': + return 'distinct_count'; + + default: + return null; + } +} + +export function getMlFunction(operationType: string) { + const func = lensOperationToMlFunction(operationType); + if (func === null) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.incorrectFunction', { + defaultMessage: + 'Selected function {operationType} is not supported by anomaly detection detectors', + values: { operationType }, + }) + ); + } + return func; +} + +export async function getVisTypeFactory(lens: LensPublicStart) { + const visTypes = await lens.getXyVisTypes(); + return (layer: XYLayerConfig) => { + switch (layer.layerType) { + case layerTypes.DATA: + const type = visTypes.find((t) => t.id === layer.seriesType); + return { + label: type?.fullLabel || type?.label || layer.layerType, + icon: type?.icon ?? '', + }; + case layerTypes.ANNOTATIONS: + // Annotation and Reference line layers are not displayed. + // but for consistency leave the labels in, in case we decide + // to display these layers in the future + return { + label: i18n.translate('xpack.ml.newJob.fromLens.createJob.VisType.annotations', { + defaultMessage: 'Annotations', + }), + icon: '', + }; + case layerTypes.REFERENCELINE: + return { + label: i18n.translate('xpack.ml.newJob.fromLens.createJob.VisType.referenceLine', { + defaultMessage: 'Reference line', + }), + icon: '', + }; + default: + return { + // @ts-expect-error just in case a new layer type appears in the future + label: layer.layerType, + icon: '', + }; + } + }; +} + +export async function isCompatibleVisualizationType(savedObject: LensSavedObjectAttributes) { + const visualization = savedObject.state.visualization as { layers: XYLayerConfig[] }; + return ( + savedObject.visualizationType === COMPATIBLE_VISUALIZATION && + visualization.layers.some((l) => l.layerType === layerTypes.DATA) + ); +} + +export function isCompatibleLayer(layer: XYLayerConfig): layer is XYDataLayerConfig { + return ( + isDataLayer(layer) && + layer.layerType === COMPATIBLE_LAYER_TYPE && + COMPATIBLE_SERIES_TYPES.includes(layer.seriesType) + ); +} + +export function isDataLayer(layer: XYLayerConfig): layer is XYDataLayerConfig { + return 'seriesType' in layer; +} +export function hasSourceField( + column: GenericIndexPatternColumn +): column is FieldBasedIndexPatternColumn { + return 'sourceField' in column; +} + +export function isTermsField(column: GenericIndexPatternColumn): column is TermsIndexPatternColumn { + return column.operationType === 'terms' && 'params' in column; +} + +export function isStringField(column: GenericIndexPatternColumn) { + return column.dataType === 'string'; +} + +export function hasIncompatibleProperties(column: GenericIndexPatternColumn) { + return 'timeShift' in column || 'filter' in column; +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx index 67b411ebc628e..597645d2fa87e 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx @@ -11,6 +11,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiHorizontalRule, EuiSpacer } fro import { SplitField } from '../../../../../../../../../common/types/fields'; import { JOB_TYPE } from '../../../../../../../../../common/constants/new_job'; +import './style.scss'; interface Props { fieldValues: string[]; @@ -72,7 +73,7 @@ export const SplitCards: FC = memo(
storePanels(ref, marginBottom)} style={style}>
= memo( {getBackPanels()}
= ({ existingJobsAndGroups, jobType }) => { ? WIZARD_STEPS.ADVANCED_CONFIGURE_DATAFEED : WIZARD_STEPS.TIME_RANGE; - let autoSetTimeRange = false; + let autoSetTimeRange = mlJobService.tempJobCloningObjects.autoSetTimeRange; + mlJobService.tempJobCloningObjects.autoSetTimeRange = false; if ( mlJobService.tempJobCloningObjects.job !== undefined && @@ -106,7 +107,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { } else { // if not start and end times are set and this is an advanced job, // auto set the time range based on the index - autoSetTimeRange = isAdvancedJobCreator(jobCreator); + autoSetTimeRange = autoSetTimeRange || isAdvancedJobCreator(jobCreator); } if (mlJobService.tempJobCloningObjects.calendars) { @@ -148,7 +149,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { } } - if (autoSetTimeRange && isAdvancedJobCreator(jobCreator)) { + if (autoSetTimeRange) { // for advanced jobs, load the full time range start and end times // so they can be used for job validation and bucket span estimation jobCreator.autoSetTimeRange().catch((error) => { @@ -183,7 +184,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { chartInterval.setInterval('auto'); const chartLoader = useMemo( - () => new ChartLoader(mlContext.currentDataView, mlContext.combinedQuery), + () => new ChartLoader(mlContext.currentDataView, jobCreator.query), [] ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts index 69bdfc666b06a..1f0be5bdb0516 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { cloneDeep } from 'lodash'; import { Query, @@ -14,6 +15,7 @@ import { buildQueryFromFilters, DataViewBase, } from '@kbn/es-query'; +import { Filter } from '@kbn/es-query'; import { IUiSettingsClient } from '@kbn/core/public'; import { getEsQueryConfig } from '@kbn/data-plugin/public'; import { SEARCH_QUERY_LANGUAGE } from '../../../../../common/constants/search'; @@ -22,7 +24,7 @@ import { getQueryFromSavedSearchObject } from '../../../util/index_utils'; // Provider for creating the items used for searching and job creation. -const DEFAULT_QUERY = { +const DEFAULT_DSL_QUERY: estypes.QueryDslQueryContainer = { bool: { must: [ { @@ -32,7 +34,16 @@ const DEFAULT_QUERY = { }, }; +export const DEFAULT_QUERY: Query = { + query: '', + language: 'lucene', +}; + export function getDefaultDatafeedQuery() { + return cloneDeep(DEFAULT_DSL_QUERY); +} + +export function getDefaultQuery() { return cloneDeep(DEFAULT_QUERY); } @@ -45,57 +56,75 @@ export function createSearchItems( // a lucene query_string. // Using a blank query will cause match_all:{} to be used // when passed through luceneStringToDsl - let query: Query = { - query: '', - language: 'lucene', - }; - - let combinedQuery: any = getDefaultDatafeedQuery(); - if (savedSearch !== null) { - const data = getQueryFromSavedSearchObject(savedSearch); + if (savedSearch === null) { + return { + query: getDefaultQuery(), + combinedQuery: getDefaultDatafeedQuery(), + }; + } - query = data.query; - const filter = data.filter; + const data = getQueryFromSavedSearchObject(savedSearch); + return createQueries(data, indexPattern, kibanaConfig); +} - const filters = Array.isArray(filter) ? filter : []; +export function createQueries( + data: { query: Query; filter: Filter[] }, + dataView: DataViewBase | undefined, + kibanaConfig: IUiSettingsClient +) { + let query = getDefaultQuery(); + let combinedQuery: estypes.QueryDslQueryContainer = getDefaultDatafeedQuery(); - if (query.language === SEARCH_QUERY_LANGUAGE.KUERY) { - const ast = fromKueryExpression(query.query); - if (query.query !== '') { - combinedQuery = toElasticsearchQuery(ast, indexPattern); - } - const filterQuery = buildQueryFromFilters(filters, indexPattern); - - if (combinedQuery.bool === undefined) { - combinedQuery.bool = {}; - // toElasticsearchQuery may add a single multi_match item to the - // root of its returned query, rather than putting it inside - // a bool.should - // in this case, move it to a bool.should - if (combinedQuery.multi_match !== undefined) { - combinedQuery.bool.should = { - multi_match: combinedQuery.multi_match, - }; - delete combinedQuery.multi_match; - } - } + query = data.query; + const filter = data.filter; + const filters = Array.isArray(filter) ? filter : []; - if (Array.isArray(combinedQuery.bool.filter) === false) { - combinedQuery.bool.filter = - combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter]; + if (query.language === SEARCH_QUERY_LANGUAGE.KUERY) { + const ast = fromKueryExpression(query.query); + if (query.query !== '') { + combinedQuery = toElasticsearchQuery(ast, dataView); + } + const filterQuery = buildQueryFromFilters(filters, dataView); + + if (combinedQuery.bool === undefined) { + combinedQuery.bool = {}; + // toElasticsearchQuery may add a single multi_match item to the + // root of its returned query, rather than putting it inside + // a bool.should + // in this case, move it to a bool.should + if (combinedQuery.multi_match !== undefined) { + combinedQuery.bool.should = { + multi_match: combinedQuery.multi_match, + }; + delete combinedQuery.multi_match; } + } - if (Array.isArray(combinedQuery.bool.must_not) === false) { - combinedQuery.bool.must_not = - combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not]; - } + if (Array.isArray(combinedQuery.bool.filter) === false) { + combinedQuery.bool.filter = + combinedQuery.bool.filter === undefined + ? [] + : [combinedQuery.bool.filter as estypes.QueryDslQueryContainer]; + } - combinedQuery.bool.filter = [...combinedQuery.bool.filter, ...filterQuery.filter]; - combinedQuery.bool.must_not = [...combinedQuery.bool.must_not, ...filterQuery.must_not]; - } else { - const esQueryConfigs = getEsQueryConfig(kibanaConfig); - combinedQuery = buildEsQuery(indexPattern, [query], filters, esQueryConfigs); + if (Array.isArray(combinedQuery.bool.must_not) === false) { + combinedQuery.bool.must_not = + combinedQuery.bool.must_not === undefined + ? [] + : [combinedQuery.bool.must_not as estypes.QueryDslQueryContainer]; } + + combinedQuery.bool.filter = [ + ...(combinedQuery.bool.filter as estypes.QueryDslQueryContainer[]), + ...filterQuery.filter, + ]; + combinedQuery.bool.must_not = [ + ...(combinedQuery.bool.must_not as estypes.QueryDslQueryContainer[]), + ...filterQuery.must_not, + ]; + } else { + const esQueryConfigs = getEsQueryConfig(kibanaConfig); + combinedQuery = buildEsQuery(dataView, [query], filters, esQueryConfigs); } return { diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx new file mode 100644 index 0000000000000..ad24bcfba89a9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx @@ -0,0 +1,36 @@ +/* + * 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 { Redirect } from 'react-router-dom'; +import { parse } from 'query-string'; + +import { MlRoute, PageLoader, PageProps } from '../../router'; +import { useResolver } from '../../use_resolver'; + +import { resolver } from '../../../jobs/new_job/job_from_lens'; + +export const fromLensRouteFactory = (): MlRoute => ({ + path: '/jobs/new_job/from_lens', + render: (props, deps) => , + breadcrumbs: [], +}); + +const PageWrapper: FC = ({ location, deps }) => { + const { lensId, vis, from, to, query, filters, layerIndex }: Record = parse( + location.search, + { + sort: false, + } + ); + + const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { + redirect: () => resolver(lensId, vis, from, to, query, filters, layerIndex), + }); + return {}; +}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts b/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts index b76a1b45588de..d02d4b16264c6 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts @@ -10,3 +10,4 @@ export * from './job_type'; export * from './new_job'; export * from './wizard'; export * from './recognize'; +export * from './from_lens'; diff --git a/x-pack/plugins/ml/public/application/services/job_service.d.ts b/x-pack/plugins/ml/public/application/services/job_service.d.ts index be0f035786923..465e4528bd9c5 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/job_service.d.ts @@ -25,6 +25,7 @@ declare interface JobService { start?: number; end?: number; calendars: Calendar[] | undefined; + autoSetTimeRange?: boolean; }; skipTimeRangeStep: boolean; saveNewJob(job: Job): Promise; diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index ebb89b84dd638..32cd957ff0f20 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -35,6 +35,7 @@ class JobService { start: undefined, end: undefined, calendars: undefined, + autoSetTimeRange: false, }; this.jobs = []; diff --git a/x-pack/plugins/ml/public/embeddables/lens/index.ts b/x-pack/plugins/ml/public/embeddables/lens/index.ts new file mode 100644 index 0000000000000..ad44424293dbb --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { showLensVisToADJobFlyout } from './show_flyout'; diff --git a/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout.tsx b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout.tsx new file mode 100644 index 0000000000000..edb882390e1ed --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiFlyoutBody, + EuiTitle, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import { FlyoutBody } from './flyout_body'; + +interface Props { + embeddable: Embeddable; + data: DataPublicPluginStart; + share: SharePluginStart; + lens: LensPublicStart; + onClose: () => void; +} + +export const LensLayerSelectionFlyout: FC = ({ onClose, embeddable, data, share, lens }) => { + return ( + <> + + +

+ +

+
+ + + + +
+ + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout_body.tsx b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout_body.tsx new file mode 100644 index 0000000000000..fbda903daa7e7 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout_body.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useState, useEffect, useMemo } from 'react'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import './style.scss'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiSpacer, + EuiIcon, + EuiText, + EuiSplitPanel, + EuiHorizontalRule, +} from '@elastic/eui'; + +import { + getLayers, + getJobsItemsFromEmbeddable, + convertLensToADJob, +} from '../../../application/jobs/new_job/job_from_lens'; +import type { LayerResult } from '../../../application/jobs/new_job/job_from_lens'; +import { CREATED_BY_LABEL } from '../../../../common/constants/new_job'; +import { extractErrorMessage } from '../../../../common/util/errors'; + +interface Props { + embeddable: Embeddable; + data: DataPublicPluginStart; + share: SharePluginStart; + lens: LensPublicStart; + onClose: () => void; +} + +export const FlyoutBody: FC = ({ onClose, embeddable, data, share, lens }) => { + const embeddableItems = useMemo(() => getJobsItemsFromEmbeddable(embeddable), [embeddable]); + + const [layerResult, setLayerResults] = useState([]); + + useEffect(() => { + const { vis } = embeddableItems; + + getLayers(vis, data.dataViews, lens).then((layers) => { + setLayerResults(layers); + }); + }, []); + + function createADJob(layerIndex: number) { + convertLensToADJob(embeddable, share, layerIndex); + } + + return ( + <> + {layerResult.map((layer, i) => ( + <> + + + + {layer.icon && ( + + + + )} + + +
{layer.label}
+
+
+
+
+ + + {layer.isCompatible ? ( + <> + + + + + + + + + + + + + + + {' '} + + + + ) : ( + <> + + + + + + + + + {layer.error ? ( + extractErrorMessage(layer.error) + ) : ( + + )} + + + + + )} + +
+ + + ))} + + ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/index.ts b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/index.ts new file mode 100644 index 0000000000000..4fa9391434162 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { LensLayerSelectionFlyout } from './flyout'; diff --git a/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/style.scss b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/style.scss new file mode 100644 index 0000000000000..0da0eb92c9637 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/style.scss @@ -0,0 +1,3 @@ +.mlLensToJobFlyoutBody { + background-color: $euiColorLightestShade; +} diff --git a/x-pack/plugins/ml/public/embeddables/lens/show_flyout.tsx b/x-pack/plugins/ml/public/embeddables/lens/show_flyout.tsx new file mode 100644 index 0000000000000..525b7aa74cbc7 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/show_flyout.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { takeUntil } from 'rxjs/operators'; +import { from } from 'rxjs'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import type { CoreStart } from '@kbn/core/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; + +import { + toMountPoint, + wrapWithTheme, + KibanaContextProvider, +} from '@kbn/kibana-react-plugin/public'; +import { DashboardConstants } from '@kbn/dashboard-plugin/public'; +import { getMlGlobalServices } from '../../application/app'; +import { LensLayerSelectionFlyout } from './lens_vis_layer_selection_flyout'; + +export async function showLensVisToADJobFlyout( + embeddable: Embeddable, + coreStart: CoreStart, + share: SharePluginStart, + data: DataPublicPluginStart, + lens: LensPublicStart +): Promise { + const { + http, + theme: { theme$ }, + overlays, + application: { currentAppId$ }, + } = coreStart; + + return new Promise(async (resolve, reject) => { + try { + const onFlyoutClose = () => { + flyoutSession.close(); + resolve(); + }; + + const flyoutSession = overlays.openFlyout( + toMountPoint( + wrapWithTheme( + + { + onFlyoutClose(); + resolve(); + }} + data={data} + share={share} + lens={lens} + /> + , + theme$ + ) + ), + { + 'data-test-subj': 'mlFlyoutJobSelector', + ownFocus: true, + closeButtonAriaLabel: 'jobSelectorFlyout', + onClose: onFlyoutClose, + // @ts-expect-error should take any number/string compatible with the CSS width attribute + size: '35vw', + } + ); + + // Close the flyout when user navigates out of the dashboard plugin + currentAppId$.pipe(takeUntil(from(flyoutSession.onClose))).subscribe((appId) => { + if (appId !== DashboardConstants.DASHBOARDS_ID) { + flyoutSession.close(); + } + }); + } catch (error) { + reject(error); + } + }); +} diff --git a/x-pack/plugins/ml/public/locator/ml_locator.ts b/x-pack/plugins/ml/public/locator/ml_locator.ts index 01d63aa0ebf3f..295dbaebbbae6 100644 --- a/x-pack/plugins/ml/public/locator/ml_locator.ts +++ b/x-pack/plugins/ml/public/locator/ml_locator.ts @@ -79,6 +79,7 @@ export class MlLocatorDefinition implements LocatorDefinition { case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB: case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_RECOGNIZER: case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_ADVANCED: + case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_LENS: case ML_PAGES.DATA_VISUALIZER: case ML_PAGES.DATA_VISUALIZER_FILE: case ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER: diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 79f386d521da1..7a3d605a1e8cf 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -23,6 +23,7 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import type { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; import { AppStatus, AppUpdater, DEFAULT_APP_CATEGORIES } from '@kbn/core/public'; import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; @@ -64,6 +65,7 @@ export interface MlStartDependencies { fieldFormats: FieldFormatsStart; dashboard: DashboardStart; charts: ChartsPluginStart; + lens?: LensPublicStart; } export interface MlSetupDependencies { @@ -130,6 +132,7 @@ export class MlPlugin implements Plugin { aiops: pluginsStart.aiops, usageCollection: pluginsSetup.usageCollection, fieldFormats: pluginsStart.fieldFormats, + lens: pluginsStart.lens, }, params ); diff --git a/x-pack/plugins/ml/public/ui_actions/index.ts b/x-pack/plugins/ml/public/ui_actions/index.ts index a663fa0e2fa01..4aac7c46b70ac 100644 --- a/x-pack/plugins/ml/public/ui_actions/index.ts +++ b/x-pack/plugins/ml/public/ui_actions/index.ts @@ -10,6 +10,7 @@ import { UiActionsSetup } from '@kbn/ui-actions-plugin/public'; import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public'; import { createEditSwimlanePanelAction } from './edit_swimlane_panel_action'; import { createOpenInExplorerAction } from './open_in_anomaly_explorer_action'; +import { createLensVisToADJobAction } from './open_lens_vis_in_ml_action'; import { MlPluginStart, MlStartDependencies } from '../plugin'; import { createApplyInfluencerFiltersAction } from './apply_influencer_filters_action'; import { @@ -26,6 +27,7 @@ export { APPLY_TIME_RANGE_SELECTION_ACTION } from './apply_time_range_action'; export { EDIT_SWIMLANE_PANEL_ACTION } from './edit_swimlane_panel_action'; export { APPLY_INFLUENCER_FILTERS_ACTION } from './apply_influencer_filters_action'; export { OPEN_IN_ANOMALY_EXPLORER_ACTION } from './open_in_anomaly_explorer_action'; +export { CREATE_LENS_VIS_TO_ML_AD_JOB_ACTION } from './open_lens_vis_in_ml_action'; export { SWIM_LANE_SELECTION_TRIGGER }; /** * Register ML UI actions @@ -42,6 +44,7 @@ export function registerMlUiActions( const applyTimeRangeSelectionAction = createApplyTimeRangeSelectionAction(core.getStartServices); const clearSelectionAction = createClearSelectionAction(core.getStartServices); const editExplorerPanelAction = createEditAnomalyChartsPanelAction(core.getStartServices); + const lensVisToADJobAction = createLensVisToADJobAction(core.getStartServices); // Register actions uiActions.registerAction(editSwimlanePanelAction); @@ -65,4 +68,5 @@ export function registerMlUiActions( uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, openInExplorerAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, clearSelectionAction); uiActions.addTriggerAction(EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER, applyEntityFieldFilterAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, lensVisToADJobAction); } diff --git a/x-pack/plugins/ml/public/ui_actions/open_lens_vis_in_ml_action.tsx b/x-pack/plugins/ml/public/ui_actions/open_lens_vis_in_ml_action.tsx new file mode 100644 index 0000000000000..692f0e2ac5f9b --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/open_lens_vis_in_ml_action.tsx @@ -0,0 +1,64 @@ +/* + * 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 type { Embeddable } from '@kbn/lens-plugin/public'; +import { createAction } from '@kbn/ui-actions-plugin/public'; +import { MlCoreSetup } from '../plugin'; + +export const CREATE_LENS_VIS_TO_ML_AD_JOB_ACTION = 'createMLADJobAction'; + +export function createLensVisToADJobAction(getStartServices: MlCoreSetup['getStartServices']) { + return createAction<{ embeddable: Embeddable }>({ + id: 'create-ml-ad-job-action', + type: CREATE_LENS_VIS_TO_ML_AD_JOB_ACTION, + getIconType(context): string { + return 'machineLearningApp'; + }, + getDisplayName: () => + i18n.translate('xpack.ml.actions.createADJobFromLens', { + defaultMessage: 'Create anomaly detection job', + }), + async execute({ embeddable }) { + if (!embeddable) { + throw new Error('Not possible to execute an action without the embeddable context'); + } + + try { + const [{ showLensVisToADJobFlyout }, [coreStart, { share, data, lens }]] = + await Promise.all([import('../embeddables/lens'), getStartServices()]); + if (lens === undefined) { + return; + } + await showLensVisToADJobFlyout(embeddable, coreStart, share, data, lens); + } catch (e) { + return Promise.reject(); + } + }, + async isCompatible(context: { embeddable: Embeddable }) { + if (context.embeddable.type !== 'lens') { + return false; + } + + const [{ getJobsItemsFromEmbeddable, isCompatibleVisualizationType }, [coreStart]] = + await Promise.all([ + import('../application/jobs/new_job/job_from_lens'), + getStartServices(), + ]); + + if ( + !coreStart.application.capabilities.ml?.canCreateJob || + !coreStart.application.capabilities.ml?.canStartStopDatafeed + ) { + return false; + } + + const { vis } = getJobsItemsFromEmbeddable(context.embeddable); + return isCompatibleVisualizationType(vis); + }, + }); +} From 3effa893da12cd968cc3e34bdab1391857b5b445 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 19 May 2022 07:28:47 -0700 Subject: [PATCH 060/150] [ci] always supply defaults for parallelism vars (#132520) --- .buildkite/scripts/steps/code_coverage/jest_parallel.sh | 6 +++--- .buildkite/scripts/steps/test/ftr_configs.sh | 4 ++-- .buildkite/scripts/steps/test/jest.sh | 4 +++- .buildkite/scripts/steps/test/jest_integration.sh | 4 +++- .buildkite/scripts/steps/test/jest_parallel.sh | 2 +- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.buildkite/scripts/steps/code_coverage/jest_parallel.sh b/.buildkite/scripts/steps/code_coverage/jest_parallel.sh index dc8a67320c5ed..44ea80bf95257 100755 --- a/.buildkite/scripts/steps/code_coverage/jest_parallel.sh +++ b/.buildkite/scripts/steps/code_coverage/jest_parallel.sh @@ -2,8 +2,8 @@ set -uo pipefail -JOB=$BUILDKITE_PARALLEL_JOB -JOB_COUNT=$BUILDKITE_PARALLEL_JOB_COUNT +JOB=${BUILDKITE_PARALLEL_JOB:-0} +JOB_COUNT=${BUILDKITE_PARALLEL_JOB_COUNT:-1} # a jest failure will result in the script returning an exit code of 10 @@ -35,4 +35,4 @@ while read -r config; do # uses heredoc to avoid the while loop being in a sub-shell thus unable to overwrite exitCode done <<< "$(find src x-pack packages -name jest.config.js -not -path "*/__fixtures__/*" | sort)" -exit $exitCode \ No newline at end of file +exit $exitCode diff --git a/.buildkite/scripts/steps/test/ftr_configs.sh b/.buildkite/scripts/steps/test/ftr_configs.sh index 244b108a269f8..447dc5bca9e6b 100755 --- a/.buildkite/scripts/steps/test/ftr_configs.sh +++ b/.buildkite/scripts/steps/test/ftr_configs.sh @@ -4,10 +4,10 @@ set -euo pipefail source .buildkite/scripts/steps/functional/common.sh -export JOB_NUM=$BUILDKITE_PARALLEL_JOB +export JOB_NUM=${BUILDKITE_PARALLEL_JOB:-0} export JOB=ftr-configs-${JOB_NUM} -FAILED_CONFIGS_KEY="${BUILDKITE_STEP_ID}${BUILDKITE_PARALLEL_JOB:-0}" +FAILED_CONFIGS_KEY="${BUILDKITE_STEP_ID}${JOB_NUM}" # a FTR failure will result in the script returning an exit code of 10 exitCode=0 diff --git a/.buildkite/scripts/steps/test/jest.sh b/.buildkite/scripts/steps/test/jest.sh index cbf8bce703cc6..7b09c3f0d788a 100755 --- a/.buildkite/scripts/steps/test/jest.sh +++ b/.buildkite/scripts/steps/test/jest.sh @@ -8,6 +8,8 @@ is_test_execution_step .buildkite/scripts/bootstrap.sh +JOB=${BUILDKITE_PARALLEL_JOB:-0} + echo '--- Jest' -checks-reporter-with-killswitch "Jest Unit Tests $((BUILDKITE_PARALLEL_JOB+1))" \ +checks-reporter-with-killswitch "Jest Unit Tests $((JOB+1))" \ .buildkite/scripts/steps/test/jest_parallel.sh jest.config.js diff --git a/.buildkite/scripts/steps/test/jest_integration.sh b/.buildkite/scripts/steps/test/jest_integration.sh index 13412881cb6fa..2dce8fec0f26c 100755 --- a/.buildkite/scripts/steps/test/jest_integration.sh +++ b/.buildkite/scripts/steps/test/jest_integration.sh @@ -8,6 +8,8 @@ is_test_execution_step .buildkite/scripts/bootstrap.sh +JOB=${BUILDKITE_PARALLEL_JOB:-0} + echo '--- Jest Integration Tests' -checks-reporter-with-killswitch "Jest Integration Tests $((BUILDKITE_PARALLEL_JOB+1))" \ +checks-reporter-with-killswitch "Jest Integration Tests $((JOB+1))" \ .buildkite/scripts/steps/test/jest_parallel.sh jest.integration.config.js diff --git a/.buildkite/scripts/steps/test/jest_parallel.sh b/.buildkite/scripts/steps/test/jest_parallel.sh index 71ecf7a853d4a..8ca025a3e6516 100755 --- a/.buildkite/scripts/steps/test/jest_parallel.sh +++ b/.buildkite/scripts/steps/test/jest_parallel.sh @@ -2,7 +2,7 @@ set -euo pipefail -export JOB=$BUILDKITE_PARALLEL_JOB +export JOB=${BUILDKITE_PARALLEL_JOB:-0} # a jest failure will result in the script returning an exit code of 10 exitCode=0 From 0c2d06dd816780b3aaa3c19bc1f953eda4ae8c39 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 19 May 2022 10:55:09 -0400 Subject: [PATCH 061/150] [Spacetime] [Maps] Localized basemaps (#130930) --- package.json | 2 +- src/dev/license_checker/config.ts | 2 +- x-pack/plugins/maps/common/constants.ts | 2 + .../layer_descriptor_types.ts | 1 + .../maps/public/actions/layer_actions.ts | 9 +++ .../create_basemap_layer_descriptor.test.ts | 3 +- .../layers/create_basemap_layer_descriptor.ts | 2 + .../ems_vector_tile_layer.test.ts | 20 ++++++ .../ems_vector_tile_layer.tsx | 42 ++++++++++++- .../maps/public/classes/layers/layer.tsx | 10 +++ .../edit_layer_panel/layer_settings/index.tsx | 2 + .../layer_settings/layer_settings.tsx | 62 ++++++++++++++++++- yarn.lock | 9 +-- 13 files changed, 156 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 7e4e2ea78175a..84f9be547e7a1 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@elastic/charts": "46.0.1", "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.2.0-canary.2", - "@elastic/ems-client": "8.3.0", + "@elastic/ems-client": "8.3.2", "@elastic/eui": "55.1.2", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index 0ccab6fcf1b24..f10fb0231352d 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -76,7 +76,7 @@ export const DEV_ONLY_LICENSE_ALLOWED = ['MPL-2.0']; export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint - '@elastic/ems-client@8.3.0': ['Elastic License 2.0'], + '@elastic/ems-client@8.3.2': ['Elastic License 2.0'], '@elastic/eui@55.1.2': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index b51259307f3a1..53660c5256497 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -298,6 +298,8 @@ export const MAPS_NEW_VECTOR_LAYER_META_CREATED_BY = 'maps-new-vector-layer'; export const MAX_DRAWING_SIZE_BYTES = 10485760; // 10MB +export const NO_EMS_LOCALE = 'none'; +export const AUTOSELECT_EMS_LOCALE = 'autoselect'; export const emsWorldLayerId = 'world_countries'; export enum WIZARD_ID { diff --git a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts index 996e3d7303b82..5aba9ba06dc48 100644 --- a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts @@ -61,6 +61,7 @@ export type LayerDescriptor = { attribution?: Attribution; id: string; label?: string | null; + locale?: string | null; areLabelsOnTop?: boolean; minZoom?: number; maxZoom?: number; diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index 257b27e422e2f..6ffd9d59b1434 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -472,6 +472,15 @@ export function updateLayerLabel(id: string, newLabel: string) { }; } +export function updateLayerLocale(id: string, locale: string) { + return { + type: UPDATE_LAYER_PROP, + id, + propName: 'locale', + newValue: locale, + }; +} + export function setLayerAttribution(id: string, attribution: Attribution) { return { type: UPDATE_LAYER_PROP, diff --git a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.test.ts b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.test.ts index eded70a75e4ac..9c81b4c3aa72f 100644 --- a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.test.ts @@ -80,10 +80,11 @@ describe('EMS is enabled', () => { id: '12345', includeInFitToBounds: true, label: null, + locale: 'autoselect', maxZoom: 24, minZoom: 0, - source: undefined, sourceDescriptor: { + id: undefined, isAutoSelect: true, lightModeDefault: 'road_map_desaturated', type: 'EMS_TMS', diff --git a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts index e104261f90847..dd569951f90e4 100644 --- a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts @@ -14,6 +14,7 @@ import { KibanaTilemapSource } from '../sources/kibana_tilemap_source'; import { RasterTileLayer } from './raster_tile_layer/raster_tile_layer'; import { EmsVectorTileLayer } from './ems_vector_tile_layer/ems_vector_tile_layer'; import { EMSTMSSource } from '../sources/ems_tms_source'; +import { AUTOSELECT_EMS_LOCALE } from '../../../common/constants'; export function createBasemapLayerDescriptor(): LayerDescriptor | null { const tilemapSourceFromKibana = getKibanaTileMap(); @@ -27,6 +28,7 @@ export function createBasemapLayerDescriptor(): LayerDescriptor | null { const isEmsEnabled = getEMSSettings()!.isEMSEnabled(); if (isEmsEnabled) { const layerDescriptor = EmsVectorTileLayer.createDescriptor({ + locale: AUTOSELECT_EMS_LOCALE, sourceDescriptor: EMSTMSSource.createDescriptor({ isAutoSelect: true }), }); return layerDescriptor; diff --git a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.test.ts b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.test.ts index 21c9c1f79d970..5f12f4cbc2b61 100644 --- a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.test.ts @@ -55,6 +55,26 @@ describe('EmsVectorTileLayer', () => { expect(actualErrorMessage).toStrictEqual('network error'); }); + describe('getLocale', () => { + test('should set locale to none for existing layers where locale is not defined', () => { + const layer = new EmsVectorTileLayer({ + source: {} as unknown as EMSTMSSource, + layerDescriptor: {} as unknown as LayerDescriptor, + }); + expect(layer.getLocale()).toBe('none'); + }); + + test('should set locale for new layers', () => { + const layer = new EmsVectorTileLayer({ + source: {} as unknown as EMSTMSSource, + layerDescriptor: { + locale: 'xx', + } as unknown as LayerDescriptor, + }); + expect(layer.getLocale()).toBe('xx'); + }); + }); + describe('isInitialDataLoadComplete', () => { test('should return false when tile loading has not started', () => { const layer = new EmsVectorTileLayer({ diff --git a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx index 646ccb3c09acd..6f8bc3470d792 100644 --- a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx @@ -6,11 +6,19 @@ */ import type { Map as MbMap, LayerSpecification, StyleSpecification } from '@kbn/mapbox-gl'; +import { TMSService } from '@elastic/ems-client'; +import { i18n } from '@kbn/i18n'; import _ from 'lodash'; // @ts-expect-error import { RGBAImage } from './image_utils'; import { AbstractLayer } from '../layer'; -import { SOURCE_DATA_REQUEST_ID, LAYER_TYPE, LAYER_STYLE_TYPE } from '../../../../common/constants'; +import { + AUTOSELECT_EMS_LOCALE, + NO_EMS_LOCALE, + SOURCE_DATA_REQUEST_ID, + LAYER_TYPE, + LAYER_STYLE_TYPE, +} from '../../../../common/constants'; import { LayerDescriptor } from '../../../../common/descriptor_types'; import { DataRequest } from '../../util/data_request'; import { isRetina } from '../../../util'; @@ -50,6 +58,7 @@ export class EmsVectorTileLayer extends AbstractLayer { const tileLayerDescriptor = super.createDescriptor(options); tileLayerDescriptor.type = LAYER_TYPE.EMS_VECTOR_TILE; tileLayerDescriptor.alpha = _.get(options, 'alpha', 1); + tileLayerDescriptor.locale = _.get(options, 'locale', AUTOSELECT_EMS_LOCALE); tileLayerDescriptor.style = { type: LAYER_STYLE_TYPE.TILE }; return tileLayerDescriptor; } @@ -87,6 +96,10 @@ export class EmsVectorTileLayer extends AbstractLayer { return this._style; } + getLocale() { + return this._descriptor.locale ?? NO_EMS_LOCALE; + } + _canSkipSync({ prevDataRequest, nextMeta, @@ -309,7 +322,6 @@ export class EmsVectorTileLayer extends AbstractLayer { return; } this._addSpriteSheetToMapFromImageData(newJson, imageData, mbMap); - // sync layers const layers = vectorStyle.layers ? vectorStyle.layers : []; layers.forEach((layer) => { @@ -391,6 +403,27 @@ export class EmsVectorTileLayer extends AbstractLayer { }); } + _setLanguage(mbMap: MbMap, mbLayer: LayerSpecification, mbLayerId: string) { + const locale = this.getLocale(); + if (locale === null || locale === NO_EMS_LOCALE) { + if (mbLayer.type !== 'symbol') return; + + const textProperty = mbLayer.layout?.['text-field']; + if (mbLayer.layout && textProperty) { + mbMap.setLayoutProperty(mbLayerId, 'text-field', textProperty); + } + return; + } + + const textProperty = + locale === AUTOSELECT_EMS_LOCALE + ? TMSService.transformLanguageProperty(mbLayer, i18n.getLocale()) + : TMSService.transformLanguageProperty(mbLayer, locale); + if (textProperty !== undefined) { + mbMap.setLayoutProperty(mbLayerId, 'text-field', textProperty); + } + } + _setLayerZoomRange(mbMap: MbMap, mbLayer: LayerSpecification, mbLayerId: string) { let minZoom = this.getMinZoom(); if (typeof mbLayer.minzoom === 'number') { @@ -414,6 +447,7 @@ export class EmsVectorTileLayer extends AbstractLayer { this.syncVisibilityWithMb(mbMap, mbLayerId); this._setLayerZoomRange(mbMap, mbLayer, mbLayerId); this._setOpacityForType(mbMap, mbLayer, mbLayerId); + this._setLanguage(mbMap, mbLayer, mbLayerId); }); } @@ -425,6 +459,10 @@ export class EmsVectorTileLayer extends AbstractLayer { return true; } + supportsLabelLocales(): boolean { + return true; + } + async getLicensedFeatures() { return this._source.getLicensedFeatures(); } diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index 29aa19103e511..369f3a0099d66 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -53,6 +53,7 @@ export interface ILayer { supportsFitToBounds(): Promise; getAttributions(): Promise; getLabel(): string; + getLocale(): string | null; hasLegendDetails(): Promise; renderLegendDetails(): ReactElement | null; showAtZoomLevel(zoom: number): boolean; @@ -101,6 +102,7 @@ export interface ILayer { isPreviewLayer: () => boolean; areLabelsOnTop: () => boolean; supportsLabelsOnTop: () => boolean; + supportsLabelLocales: () => boolean; isFittable(): Promise; isIncludeInFitToBounds(): boolean; getLicensedFeatures(): Promise; @@ -250,6 +252,10 @@ export class AbstractLayer implements ILayer { return this._descriptor.label ? this._descriptor.label : ''; } + getLocale(): string | null { + return null; + } + getLayerIcon(isTocIcon: boolean): LayerIcon { return { icon: , @@ -461,6 +467,10 @@ export class AbstractLayer implements ILayer { return false; } + supportsLabelLocales(): boolean { + return false; + } + async getLicensedFeatures(): Promise { return []; } diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/index.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/index.tsx index 931557a3febe8..44336a5bbaf56 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/index.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/index.tsx @@ -12,6 +12,7 @@ import { clearLayerAttribution, setLayerAttribution, updateLayerLabel, + updateLayerLocale, updateLayerMaxZoom, updateLayerMinZoom, updateLayerAlpha, @@ -26,6 +27,7 @@ function mapDispatchToProps(dispatch: Dispatch) { setLayerAttribution: (id: string, attribution: Attribution) => dispatch(setLayerAttribution(id, attribution)), updateLabel: (id: string, label: string) => dispatch(updateLayerLabel(id, label)), + updateLocale: (id: string, locale: string) => dispatch(updateLayerLocale(id, locale)), updateMinZoom: (id: string, minZoom: number) => dispatch(updateLayerMinZoom(id, minZoom)), updateMaxZoom: (id: string, maxZoom: number) => dispatch(updateLayerMaxZoom(id, maxZoom)), updateAlpha: (id: string, alpha: number) => dispatch(updateLayerAlpha(id, alpha)), diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx index e975834f2cf50..4ae95b9dc5c48 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx @@ -11,6 +11,7 @@ import { EuiPanel, EuiFormRow, EuiFieldText, + EuiSelect, EuiSpacer, EuiSwitch, EuiSwitchEvent, @@ -20,7 +21,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { ValidatedDualRange } from '@kbn/kibana-react-plugin/public'; import { Attribution } from '../../../../common/descriptor_types'; -import { MAX_ZOOM } from '../../../../common/constants'; +import { AUTOSELECT_EMS_LOCALE, NO_EMS_LOCALE, MAX_ZOOM } from '../../../../common/constants'; import { AlphaSlider } from '../../../components/alpha_slider'; import { ILayer } from '../../../classes/layers/layer'; import { AttributionFormRow } from './attribution_form_row'; @@ -30,6 +31,7 @@ export interface Props { clearLayerAttribution: (layerId: string) => void; setLayerAttribution: (id: string, attribution: Attribution) => void; updateLabel: (layerId: string, label: string) => void; + updateLocale: (layerId: string, locale: string) => void; updateMinZoom: (layerId: string, minZoom: number) => void; updateMaxZoom: (layerId: string, maxZoom: number) => void; updateAlpha: (layerId: string, alpha: number) => void; @@ -48,6 +50,11 @@ export function LayerSettings(props: Props) { props.updateLabel(layerId, label); }; + const onLocaleChange = (event: ChangeEvent) => { + const { value } = event.target; + if (value) props.updateLocale(layerId, value); + }; + const onZoomChange = (value: [string, string]) => { props.updateMinZoom(layerId, Math.max(minVisibilityZoom, parseInt(value[0], 10))); props.updateMaxZoom(layerId, Math.min(maxVisibilityZoom, parseInt(value[1], 10))); @@ -155,6 +162,58 @@ export function LayerSettings(props: Props) { ); }; + const renderShowLocaleSelector = () => { + if (!props.layer.supportsLabelLocales()) { + return null; + } + + const options = [ + { + text: i18n.translate( + 'xpack.maps.layerPanel.settingsPanel.labelLanguageAutoselectDropDown', + { + defaultMessage: 'Autoselect based on Kibana locale', + } + ), + value: AUTOSELECT_EMS_LOCALE, + }, + { value: 'ar', text: 'العربية' }, + { value: 'de', text: 'Deutsch' }, + { value: 'en', text: 'English' }, + { value: 'es', text: 'Español' }, + { value: 'fr-fr', text: 'Français' }, + { value: 'hi-in', text: 'हिन्दी' }, + { value: 'it', text: 'Italiano' }, + { value: 'ja-jp', text: '日本語' }, + { value: 'ko', text: '한국어' }, + { value: 'pt-pt', text: 'Português' }, + { value: 'ru-ru', text: 'русский' }, + { value: 'zh-cn', text: '简体中文' }, + { + text: i18n.translate('xpack.maps.layerPanel.settingsPanel.labelLanguageNoneDropDown', { + defaultMessage: 'None', + }), + value: NO_EMS_LOCALE, + }, + ]; + + return ( + + + + ); + }; + return ( @@ -172,6 +231,7 @@ export function LayerSettings(props: Props) { {renderZoomSliders()} {renderShowLabelsOnTop()} + {renderShowLocaleSelector()} {renderIncludeInFitToBounds()} diff --git a/yarn.lock b/yarn.lock index ec5afced2df22..ef1d5d849ca75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1483,15 +1483,16 @@ "@elastic/transport" "^8.0.2" tslib "^2.3.0" -"@elastic/ems-client@8.3.0": - version "8.3.0" - resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-8.3.0.tgz#9d40c02e33c407d433b8e509d83c5edec24c4902" - integrity sha512-DlJDyUQzNrxGbS0AWxGiBNfq1hPQUP3Ib/Zyotgv7+VGGklb0mBwppde7WLVvuj0E+CYc6E63TJsoD8KNUO0MQ== +"@elastic/ems-client@8.3.2": + version "8.3.2" + resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-8.3.2.tgz#a12eafcfd9ac8d3068da78a5a77503ea8a89f67c" + integrity sha512-81u+Z7+4Y2Fu+sTl9QOKdG3SVeCzzpfyCsHFR8X0V2WFCpQa+SU4sSN9WhdLHz/pe9oi6Gtt5eFMF90TOO/ckg== dependencies: "@types/geojson" "^7946.0.7" "@types/lru-cache" "^5.1.0" "@types/topojson-client" "^3.0.0" "@types/topojson-specification" "^1.0.1" + chroma-js "^2.1.0" lodash "^4.17.15" lru-cache "^6.0.0" semver "^7.3.2" From d12156ec22324b882ff6aa97bc044537d1f44393 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Thu, 19 May 2022 08:06:32 -0700 Subject: [PATCH 062/150] [DOCS] Add severity field to case APIs (#132289) --- docs/api/cases/cases-api-add-comment.asciidoc | 1 + docs/api/cases/cases-api-create.asciidoc | 5 + docs/api/cases/cases-api-find-cases.asciidoc | 5 + .../cases-api-get-case-activity.asciidoc | 402 +++--------------- docs/api/cases/cases-api-get-case.asciidoc | 1 + docs/api/cases/cases-api-push.asciidoc | 1 + .../cases/cases-api-update-comment.asciidoc | 1 + docs/api/cases/cases-api-update.asciidoc | 5 + .../plugins/cases/docs/openapi/bundled.json | 37 ++ .../plugins/cases/docs/openapi/bundled.yaml | 27 ++ .../examples/create_case_response.yaml | 1 + .../examples/update_case_response.yaml | 1 + .../schemas/case_response_properties.yaml | 2 + .../openapi/components/schemas/severity.yaml | 8 + .../cases/docs/openapi/paths/api@cases.yaml | 4 + .../openapi/paths/s@{spaceid}@api@cases.yaml | 4 + 16 files changed, 151 insertions(+), 354 deletions(-) create mode 100644 x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml diff --git a/docs/api/cases/cases-api-add-comment.asciidoc b/docs/api/cases/cases-api-add-comment.asciidoc index 203492d6aa632..b179c9ac2e4fb 100644 --- a/docs/api/cases/cases-api-add-comment.asciidoc +++ b/docs/api/cases/cases-api-add-comment.asciidoc @@ -120,6 +120,7 @@ The API returns details about the case and its comments. For example: }, "owner": "cases", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-03-24T00:37:03.906Z", diff --git a/docs/api/cases/cases-api-create.asciidoc b/docs/api/cases/cases-api-create.asciidoc index 73c89937466b3..b39125cf7538e 100644 --- a/docs/api/cases/cases-api-create.asciidoc +++ b/docs/api/cases/cases-api-create.asciidoc @@ -140,6 +140,10 @@ An object that contains the case settings. (Required, boolean) Turns alert syncing on or off. ==== +`severity`:: +(Optional,string) The severity of the case. Valid values are: `critical`, `high`, +`low`, and `medium`. + `tags`:: (Required, string array) The words and phrases that help categorize cases. It can be an empty array. @@ -206,6 +210,7 @@ the case identifier, version, and creation time. For example: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/docs/api/cases/cases-api-find-cases.asciidoc b/docs/api/cases/cases-api-find-cases.asciidoc index 3e94dd56ffa36..92b23a4aafb8d 100644 --- a/docs/api/cases/cases-api-find-cases.asciidoc +++ b/docs/api/cases/cases-api-find-cases.asciidoc @@ -62,6 +62,10 @@ filters the objects in the response. (Optional, string or array of strings) The fields to perform the `simple_query_string` parsed query against. +`severity`:: +(Optional,string) The severity of the case. Valid values are: `critical`, `high`, +`low`, and `medium`. + `sortField`:: (Optional, string) Determines which field is used to sort the results, `createdAt` or `updatedAt`. Defaults to `createdAt`. @@ -126,6 +130,7 @@ The API returns a JSON object listing the retrieved cases. For example: }, "owner": "securitySolution", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-03-29T13:03:23.533Z", diff --git a/docs/api/cases/cases-api-get-case-activity.asciidoc b/docs/api/cases/cases-api-get-case-activity.asciidoc index 25d102dc11ee7..0f931965df248 100644 --- a/docs/api/cases/cases-api-get-case-activity.asciidoc +++ b/docs/api/cases/cases-api-get-case-activity.asciidoc @@ -51,362 +51,56 @@ The API returns a JSON object with all the activity for the case. For example: [source,json] -------------------------------------------------- [ - { - "action": "create", - "action_id": "5275af50-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:34:48.709Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": null, - "id": "none", - "name": "none", - "type": ".none" - }, - "description": "migrating user actions", - "settings": { - "syncAlerts": true - }, - "status": "open", - "tags": [ - "user", - "actions" - ], - "title": "User actions", - "owner": "securitySolution" - }, - "sub_case_id": "", - "type": "create_case" - }, - { - "action": "create", - "action_id": "72e73240-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": "72a03e30-5e7d-11ec-9ee9-cd64f0b77b3c", - "created_at": "2021-12-16T14:35:42.872Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "comment": { - "comment": "a comment", - "owner": "securitySolution", - "type": "user" - } - }, - "sub_case_id": "", - "type": "comment" - }, - { - "action": "update", - "action_id": "7685b5c0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:35:48.826Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "title": "User actions!" - }, - "sub_case_id": "", - "type": "title" - }, - { - "action": "update", - "action_id": "7a2d8810-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:35:55.421Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "description": "migrating user actions and update!" - }, - "sub_case_id": "", - "type": "description" - }, - { - "action": "update", - "action_id": "7f942160-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": "72a03e30-5e7d-11ec-9ee9-cd64f0b77b3c", - "created_at": "2021-12-16T14:36:04.120Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "comment": { - "comment": "a comment updated!", - "owner": "securitySolution", - "type": "user" - } - }, - "sub_case_id": "", - "type": "comment" - }, - { - "action": "add", - "action_id": "8591a380-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:13.840Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "tags": [ - "migration" - ] - }, - "sub_case_id": "", - "type": "tags" - }, - { - "action": "delete", - "action_id": "8591a381-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:13.840Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "tags": [ - "user" - ] - }, - "sub_case_id": "", - "type": "tags" + { + "created_at": "2022-12-16T14:34:48.709Z", + "created_by": { + "email": "", + "full_name": "", + "username": "elastic" }, - { - "action": "update", - "action_id": "87fadb50-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:17.764Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "settings": { - "syncAlerts": false - } - }, - "sub_case_id": "", - "type": "settings" - }, - { - "action": "update", - "action_id": "89ca4420-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:21.509Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "status": "in-progress" - }, - "sub_case_id": "", - "type": "status" - }, - { - "action": "update", - "action_id": "9060aae0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:32.716Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": { - "issueType": "10001", - "parent": null, - "priority": "High" - }, - "id": "6773fba0-5e7d-11ec-9ee9-cd64f0b77b3c", - "name": "Jira", - "type": ".jira" - } - }, - "sub_case_id": "", - "type": "connector" - }, - { - "action": "push_to_service", - "action_id": "988579d0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:46.443Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "externalService": { - "connector_id": "6773fba0-5e7d-11ec-9ee9-cd64f0b77b3c", - "connector_name": "Jira", - "external_id": "26225", - "external_title": "CASES-229", - "external_url": "https://example.com/browse/CASES-229", - "pushed_at": "2021-12-16T14:36:46.443Z", - "pushed_by": { - "email": "", - "full_name": "", - "username": "elastic" - } - } - }, - "sub_case_id": "", - "type": "pushed" + "owner": "securitySolution", + "action": "create", + "payload": { + "title": "User actions", + "tags": [ + "user", + "actions" + ], + "connector": { + "fields": null, + "id": "none", + "name": "none", + "type": ".none" + }, + "settings": { + "syncAlerts": true + }, + "owner": "cases", + "severity": "low", + "description": "migrating user actions", + "status": "open" }, - { - "action": "update", - "action_id": "bcb76020-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:37:46.863Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": { - "incidentTypes": [ - "17", - "4" - ], - "severityCode": "5" - }, - "id": "b3214df0-5e7d-11ec-9ee9-cd64f0b77b3c", - "name": "IBM", - "type": ".resilient" - } - }, - "sub_case_id": "", - "type": "connector" - }, - { - "action": "push_to_service", - "action_id": "c0338e90-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:37:53.016Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "externalService": { - "connector_id": "b3214df0-5e7d-11ec-9ee9-cd64f0b77b3c", - "connector_name": "IBM", - "external_id": "17574", - "external_title": "17574", - "external_url": "https://example.com/#incidents/17574", - "pushed_at": "2021-12-16T14:37:53.016Z", - "pushed_by": { - "email": "", - "full_name": "", - "username": "elastic" - } - } - }, - "sub_case_id": "", - "type": "pushed" + "type": "create_case", + "action_id": "5275af50-5e7d-11ec-9ee9-cd64f0b77b3c", + "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", + "comment_id": null + }, + { + "created_at": "2022-12-16T14:35:42.872Z", + "created_by": { + "email": "", + "full_name": "", + "username": "elastic" }, - { - "action": "update", - "action_id": "c5b6d7a0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:38:01.895Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": { - "issueType": "10001", - "parent": null, - "priority": "Lowest" - }, - "id": "6773fba0-5e7d-11ec-9ee9-cd64f0b77b3c", - "name": "Jira", - "type": ".jira" - } - }, - "sub_case_id": "", - "type": "connector" + "owner": "cases", + "action": "add", + "payload": { + "tags": ["bubblegum"] }, - { - "action": "create", - "action_id": "ca8f61c0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": "ca1d17f0-5e7d-11ec-9ee9-cd64f0b77b3c", - "created_at": "2021-12-16T14:38:09.649Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "comment": { - "comment": "and another comment!", - "owner": "securitySolution", - "type": "user" - } - }, - "sub_case_id": "", - "type": "comment" - } - ] + "type": "tags", + "action_id": "72e73240-5e7d-11ec-9ee9-cd64f0b77b3c", + "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", + "comment_id": null + } +] -------------------------------------------------- \ No newline at end of file diff --git a/docs/api/cases/cases-api-get-case.asciidoc b/docs/api/cases/cases-api-get-case.asciidoc index 42cf0672065e7..a3adc90fe09bf 100644 --- a/docs/api/cases/cases-api-get-case.asciidoc +++ b/docs/api/cases/cases-api-get-case.asciidoc @@ -91,6 +91,7 @@ The API returns a JSON object with the retrieved case. For example: "syncAlerts": true }, "owner": "securitySolution", + "severity": "low", "duration": null, "tags": [ "phishing", diff --git a/docs/api/cases/cases-api-push.asciidoc b/docs/api/cases/cases-api-push.asciidoc index 16c411104caed..46dbc1110d589 100644 --- a/docs/api/cases/cases-api-push.asciidoc +++ b/docs/api/cases/cases-api-push.asciidoc @@ -68,6 +68,7 @@ The API returns a JSON object representing the pushed case. For example: "syncAlerts": true }, "owner": "securitySolution", + "severity": "low", "duration": null, "closed_at": null, "closed_by": null, diff --git a/docs/api/cases/cases-api-update-comment.asciidoc b/docs/api/cases/cases-api-update-comment.asciidoc index d00d1eb66ea7c..a4ea53ec19468 100644 --- a/docs/api/cases/cases-api-update-comment.asciidoc +++ b/docs/api/cases/cases-api-update-comment.asciidoc @@ -135,6 +135,7 @@ The API returns details about the case and its comments. For example: "settings": {"syncAlerts":false}, "owner": "cases", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-03-24T00:37:03.906Z", diff --git a/docs/api/cases/cases-api-update.asciidoc b/docs/api/cases/cases-api-update.asciidoc index ebad2feaedff4..ea33394a6ee63 100644 --- a/docs/api/cases/cases-api-update.asciidoc +++ b/docs/api/cases/cases-api-update.asciidoc @@ -144,6 +144,10 @@ An object that contains the case settings. (Required, boolean) Turn on or off synching with alerts. ===== +`severity`:: +(Optional,string) The severity of the case. Valid values are: `critical`, `high`, +`low`, and `medium`. + `status`:: (Optional, string) The case status. Valid values are: `closed`, `in-progress`, and `open`. @@ -227,6 +231,7 @@ The API returns the updated case with a new `version` value. For example: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json index 0cb084b5beb7c..d673f470de740 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.json +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -157,6 +157,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "tags": { "description": "The words and phrases that help categorize cases. It can be an empty array.", "type": "array", @@ -402,6 +405,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -636,6 +642,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -887,6 +896,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1093,6 +1105,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "tags": { "description": "The words and phrases that help categorize cases. It can be an empty array.", "type": "array", @@ -1338,6 +1353,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1578,6 +1596,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1829,6 +1850,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1959,6 +1983,17 @@ "securitySolution" ] }, + "severity": { + "type": "string", + "description": "The severity of the case.", + "enum": [ + "critical", + "high", + "low", + "medium" + ], + "default": "low" + }, "status": { "type": "string", "description": "The status of the case.", @@ -2015,6 +2050,7 @@ "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", @@ -2090,6 +2126,7 @@ "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml index 083aef3c25ad2..6dcde228ebd7c 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.yaml +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -147,6 +147,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' tags: description: >- The words and phrases that help categorize cases. It can be @@ -363,6 +365,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -569,6 +573,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -784,6 +790,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -960,6 +968,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' tags: description: >- The words and phrases that help categorize cases. It can be @@ -1176,6 +1186,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -1384,6 +1396,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -1599,6 +1613,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -1686,6 +1702,15 @@ components: - cases - observability - securitySolution + severity: + type: string + description: The severity of the case. + enum: + - critical + - high + - low + - medium + default: low status: type: string description: The status of the case. @@ -1738,6 +1763,7 @@ components: cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active duration: null + severity: low closed_at: null closed_by: null created_at: '2022-05-13T09:16:17.416Z' @@ -1804,6 +1830,7 @@ components: cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active! duration: null + severity: low closed_at: null closed_by: null created_at: '2022-05-13T09:16:17.416Z' diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml index bc5fa1f5bc049..9646425bca0fe 100644 --- a/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml @@ -18,6 +18,7 @@ value: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml index 114669b893651..c7b02cd47deaa 100644 --- a/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml @@ -19,6 +19,7 @@ value: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml index 6a2c3c3963c3c..53f1fd3910224 100644 --- a/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml @@ -84,6 +84,8 @@ settings: syncAlerts: type: boolean example: true +severity: + $ref: 'severity.yaml' status: $ref: 'status.yaml' tags: diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml new file mode 100644 index 0000000000000..cf5967f8f012e --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml @@ -0,0 +1,8 @@ +type: string +description: The severity of the case. +enum: + - critical + - high + - low + - medium +default: low \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml b/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml index c37bb3ecef645..62816ae2767cc 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml @@ -30,6 +30,8 @@ post: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' tags: description: The words and phrases that help categorize cases. It can be an empty array. type: array @@ -123,6 +125,8 @@ patch: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' status: $ref: '../components/schemas/status.yaml' tags: diff --git a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml index c03ea64a53538..b2c2a8e4e11f1 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml @@ -31,6 +31,8 @@ post: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' tags: description: The words and phrases that help categorize cases. It can be an empty array. type: array @@ -126,6 +128,8 @@ patch: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' status: $ref: '../components/schemas/status.yaml' tags: From dd6dacf0035e959885b04296444603492d6b0c71 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 19 May 2022 08:21:20 -0700 Subject: [PATCH 063/150] [jest/ci-stats] when jest fails to execute a test file, report it as a failure (#132527) --- packages/kbn-test/src/jest/ci_stats_jest_reporter.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts b/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts index 3ac4a64c1f3f7..6cf979eb46a26 100644 --- a/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts +++ b/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts @@ -41,6 +41,7 @@ export default class CiStatsJestReporter extends BaseReporter { private startTime: number | undefined; private passCount = 0; private failCount = 0; + private testExecErrorCount = 0; private group: CiStatsReportTestsOptions['group'] | undefined; private readonly testRuns: CiStatsReportTestsOptions['testRuns'] = []; @@ -90,6 +91,10 @@ export default class CiStatsJestReporter extends BaseReporter { return; } + if (testResult.testExecError) { + this.testExecErrorCount += 1; + } + let elapsedTime = 0; for (const t of testResult.testResults) { const result = t.status === 'failed' ? 'fail' : t.status === 'passed' ? 'pass' : 'skip'; @@ -123,7 +128,8 @@ export default class CiStatsJestReporter extends BaseReporter { } this.group.durationMs = Date.now() - this.startTime; - this.group.result = this.failCount ? 'fail' : this.passCount ? 'pass' : 'skip'; + this.group.result = + this.failCount || this.testExecErrorCount ? 'fail' : this.passCount ? 'pass' : 'skip'; await this.reporter.reportTests({ group: this.group, From 75941b1eaaa862ce9525037f99e44302a675b633 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Thu, 19 May 2022 18:01:54 +0200 Subject: [PATCH 064/150] Prevent react event pooling to clear data when used (#132419) --- .../operations/definitions/date_histogram.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx index 3b6d75879640d..3bbd329a39396 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx @@ -197,12 +197,15 @@ export const dateHistogramOperation: OperationDefinition< const onChangeDropPartialBuckets = useCallback( (ev: EuiSwitchEvent) => { + // updateColumnParam will be called async + // store the checked value before the event pooling clears it + const value = ev.target.checked; updateLayer((newLayer) => updateColumnParam({ layer: newLayer, columnId, paramName: 'dropPartials', - value: ev.target.checked, + value, }) ); }, From e2827350e97804601905add05debe2a7ea9690dc Mon Sep 17 00:00:00 2001 From: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> Date: Thu, 19 May 2022 18:02:42 +0200 Subject: [PATCH 065/150] [Security Solution][Endpoint][EventFilters] Port Event Filters to use `ArtifactListPage` component (#130995) * Delete redundant files fixes elastic/security-team/issues/3093 * Make the event filter form work fixes elastic/security-team/issues/3093 * Update event_filters_list.test.tsx fixes elastic/security-team/issues/3093 * update form tests fixes elastic/security-team/issues/3093 * update event filter flyout fixes elastic/security-team/issues/3093 * Show apt copy when OS options are not visible * update tests fixes elastic/security-team/issues/3093 * extract static OS options review changes * test for each type of artifact list review changes * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * update test mocks * update form review changes * update state handler name review changes * extract test id prefix Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../public/common/store/actions.ts | 7 +- .../timeline_actions/alert_context_menu.tsx | 2 +- .../management/pages/event_filters/index.tsx | 4 +- ...nt_filters_api_client.ts => api_client.ts} | 0 .../pages/event_filters/store/action.ts | 92 --- .../pages/event_filters/store/builders.ts | 38 -- .../event_filters/store/middleware.test.ts | 387 ------------ .../pages/event_filters/store/middleware.ts | 342 ----------- .../pages/event_filters/store/reducer.test.ts | 221 ------- .../pages/event_filters/store/reducer.ts | 271 --------- .../pages/event_filters/store/selector.ts | 224 ------- .../event_filters/store/selectors.test.ts | 391 ------------ .../pages/event_filters/test_utils/index.ts | 19 +- .../management/pages/event_filters/types.ts | 29 - .../view/components/empty/index.tsx | 64 -- .../event_filter_delete_modal.test.tsx | 177 ------ .../components/event_filter_delete_modal.tsx | 159 ----- .../components/event_filters_flyout.test.tsx | 222 +++++++ .../view/components/event_filters_flyout.tsx | 239 ++++++++ .../view/components/flyout/index.test.tsx | 287 --------- .../view/components/flyout/index.tsx | 302 ---------- .../view/components/form.test.tsx | 468 +++++++++++++++ .../event_filters/view/components/form.tsx | 558 ++++++++++++++++++ .../view/components/form/index.test.tsx | 338 ----------- .../view/components/form/index.tsx | 487 --------------- .../view/components/form/translations.ts | 44 -- .../view/event_filters_list.test.tsx | 57 ++ .../event_filters/view/event_filters_list.tsx | 150 +++++ .../view/event_filters_list_page.test.tsx | 247 -------- .../view/event_filters_list_page.tsx | 339 ----------- .../pages/event_filters/view/hooks.ts | 78 --- .../pages/event_filters/view/translations.ts | 47 +- .../use_event_filters_notification.test.tsx | 230 -------- .../event_filters/{store => view}/utils.ts | 0 .../policy_artifacts_delete_modal.test.tsx | 48 +- .../flyout/policy_artifacts_flyout.test.tsx | 2 +- .../layout/policy_artifacts_layout.test.tsx | 2 +- .../list/policy_artifacts_list.test.tsx | 2 +- .../components/fleet_artifacts_card.test.tsx | 2 +- .../fleet_integration_artifacts_card.test.tsx | 2 +- .../endpoint_package_custom_extension.tsx | 2 +- .../endpoint_policy_edit_extension.tsx | 2 +- .../pages/policy/view/tabs/policy_tabs.tsx | 2 +- .../public/management/store/middleware.ts | 7 - .../public/management/store/reducer.ts | 5 - .../public/management/types.ts | 2 - .../side_panel/event_details/footer.tsx | 2 +- .../translations/translations/fr-FR.json | 30 - .../translations/translations/ja-JP.json | 30 - .../translations/translations/zh-CN.json | 30 - 50 files changed, 1754 insertions(+), 4936 deletions(-) rename x-pack/plugins/security_solution/public/management/pages/event_filters/service/{event_filters_api_client.ts => api_client.ts} (100%) delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/use_event_filters_notification.test.tsx rename x-pack/plugins/security_solution/public/management/pages/event_filters/{store => view}/utils.ts (100%) diff --git a/x-pack/plugins/security_solution/public/common/store/actions.ts b/x-pack/plugins/security_solution/public/common/store/actions.ts index 585fdb98a0323..f1d5e51e172ba 100644 --- a/x-pack/plugins/security_solution/public/common/store/actions.ts +++ b/x-pack/plugins/security_solution/public/common/store/actions.ts @@ -7,7 +7,6 @@ import { EndpointAction } from '../../management/pages/endpoint_hosts/store/action'; import { PolicyDetailsAction } from '../../management/pages/policy/store/policy_details'; -import { EventFiltersPageAction } from '../../management/pages/event_filters/store/action'; export { appActions } from './app'; export { dragAndDropActions } from './drag_and_drop'; @@ -15,8 +14,4 @@ export { inputsActions } from './inputs'; export { sourcererActions } from './sourcerer'; import { RoutingAction } from './routing'; -export type AppAction = - | EndpointAction - | RoutingAction - | PolicyDetailsAction - | EventFiltersPageAction; +export type AppAction = EndpointAction | RoutingAction | PolicyDetailsAction; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 160252f4d11c1..05a91f094ed38 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -28,7 +28,7 @@ import { TimelineId } from '../../../../../common/types'; import { AlertData, EcsHit } from '../../../../common/components/exceptions/types'; import { useQueryAlerts } from '../../../containers/detection_engine/alerts/use_query'; import { useSignalIndex } from '../../../containers/detection_engine/alerts/use_signal_index'; -import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/flyout'; +import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/event_filters_flyout'; import { useAlertsActions } from './use_alerts_actions'; import { useExceptionFlyout } from './use_add_exception_flyout'; import { useExceptionActions } from './use_add_exception_actions'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/index.tsx index 86c2f2364961d..54d18f85b739a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/index.tsx @@ -9,12 +9,12 @@ import { Route, Switch } from 'react-router-dom'; import React from 'react'; import { NotFoundPage } from '../../../app/404'; import { MANAGEMENT_ROUTING_EVENT_FILTERS_PATH } from '../../common/constants'; -import { EventFiltersListPage } from './view/event_filters_list_page'; +import { EventFiltersList } from './view/event_filters_list'; export const EventFiltersContainer = () => { return ( - + ); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/event_filters_api_client.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/api_client.ts similarity index 100% rename from x-pack/plugins/security_solution/public/management/pages/event_filters/service/event_filters_api_client.ts rename to x-pack/plugins/security_solution/public/management/pages/event_filters/service/api_client.ts diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts deleted file mode 100644 index 4325c4d90951a..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Action } from 'redux'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, - UpdateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { AsyncResourceState } from '../../../state/async_resource_state'; -import { EventFiltersListPageState } from '../types'; - -export type EventFiltersListPageDataChanged = Action<'eventFiltersListPageDataChanged'> & { - payload: EventFiltersListPageState['listPage']['data']; -}; - -export type EventFiltersListPageDataExistsChanged = - Action<'eventFiltersListPageDataExistsChanged'> & { - payload: EventFiltersListPageState['listPage']['dataExist']; - }; - -export type EventFilterForDeletion = Action<'eventFilterForDeletion'> & { - payload: ExceptionListItemSchema; -}; - -export type EventFilterDeletionReset = Action<'eventFilterDeletionReset'>; - -export type EventFilterDeleteSubmit = Action<'eventFilterDeleteSubmit'>; - -export type EventFilterDeleteStatusChanged = Action<'eventFilterDeleteStatusChanged'> & { - payload: EventFiltersListPageState['listPage']['deletion']['status']; -}; - -export type EventFiltersInitForm = Action<'eventFiltersInitForm'> & { - payload: { - entry: UpdateExceptionListItemSchema | CreateExceptionListItemSchema; - }; -}; - -export type EventFiltersInitFromId = Action<'eventFiltersInitFromId'> & { - payload: { - id: string; - }; -}; - -export type EventFiltersChangeForm = Action<'eventFiltersChangeForm'> & { - payload: { - entry?: UpdateExceptionListItemSchema | CreateExceptionListItemSchema; - hasNameError?: boolean; - hasItemsError?: boolean; - hasOSError?: boolean; - newComment?: string; - }; -}; - -export type EventFiltersUpdateStart = Action<'eventFiltersUpdateStart'>; -export type EventFiltersUpdateSuccess = Action<'eventFiltersUpdateSuccess'>; -export type EventFiltersCreateStart = Action<'eventFiltersCreateStart'>; -export type EventFiltersCreateSuccess = Action<'eventFiltersCreateSuccess'>; -export type EventFiltersCreateError = Action<'eventFiltersCreateError'>; - -export type EventFiltersFormStateChanged = Action<'eventFiltersFormStateChanged'> & { - payload: AsyncResourceState; -}; - -export type EventFiltersForceRefresh = Action<'eventFiltersForceRefresh'> & { - payload: { - forceRefresh: boolean; - }; -}; - -export type EventFiltersPageAction = - | EventFiltersListPageDataChanged - | EventFiltersListPageDataExistsChanged - | EventFiltersInitForm - | EventFiltersInitFromId - | EventFiltersChangeForm - | EventFiltersUpdateStart - | EventFiltersUpdateSuccess - | EventFiltersCreateStart - | EventFiltersCreateSuccess - | EventFiltersCreateError - | EventFiltersFormStateChanged - | EventFilterForDeletion - | EventFilterDeletionReset - | EventFilterDeleteSubmit - | EventFilterDeleteStatusChanged - | EventFiltersForceRefresh; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts deleted file mode 100644 index 397a7c2ae1e79..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../../common/constants'; -import { EventFiltersListPageState } from '../types'; -import { createUninitialisedResourceState } from '../../../state'; - -export const initialEventFiltersPageState = (): EventFiltersListPageState => ({ - entries: [], - form: { - entry: undefined, - hasNameError: false, - hasItemsError: false, - hasOSError: false, - newComment: '', - submissionResourceState: createUninitialisedResourceState(), - }, - location: { - page_index: MANAGEMENT_DEFAULT_PAGE, - page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, - filter: '', - included_policies: '', - }, - listPage: { - active: false, - forceRefresh: false, - data: createUninitialisedResourceState(), - dataExist: createUninitialisedResourceState(), - deletion: { - item: undefined, - status: createUninitialisedResourceState(), - }, - }, -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts deleted file mode 100644 index 9ec7e84d693fd..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts +++ /dev/null @@ -1,387 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { applyMiddleware, createStore, Store } from 'redux'; - -import { - createSpyMiddleware, - MiddlewareActionSpyHelper, -} from '../../../../common/store/test_utils'; -import { AppAction } from '../../../../common/store/actions'; -import { createEventFiltersPageMiddleware } from './middleware'; -import { eventFiltersPageReducer } from './reducer'; - -import { initialEventFiltersPageState } from './builders'; -import { getInitialExceptionFromEvent } from './utils'; -import { createdEventFilterEntryMock, ecsEventMock } from '../test_utils'; -import { EventFiltersListPageState, EventFiltersService } from '../types'; -import { getFoundExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/found_exception_list_item_schema.mock'; -import { isFailedResourceState, isLoadedResourceState } from '../../../state'; -import { getListFetchError } from './selector'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { Immutable } from '../../../../../common/endpoint/types'; -import { parsePoliciesAndFilterToKql } from '../../../common/utils'; - -const createEventFiltersServiceMock = (): jest.Mocked => ({ - addEventFilters: jest.fn(), - getList: jest.fn(), - getOne: jest.fn(), - updateOne: jest.fn(), - deleteOne: jest.fn(), - getSummary: jest.fn(), -}); - -const createStoreSetup = (eventFiltersService: EventFiltersService) => { - const spyMiddleware = createSpyMiddleware(); - - return { - spyMiddleware, - store: createStore( - eventFiltersPageReducer, - applyMiddleware( - createEventFiltersPageMiddleware(eventFiltersService), - spyMiddleware.actionSpyMiddleware - ) - ), - }; -}; - -describe('Event filters middleware', () => { - let service: jest.Mocked; - let store: Store; - let spyMiddleware: MiddlewareActionSpyHelper; - let initialState: EventFiltersListPageState; - - beforeEach(() => { - initialState = initialEventFiltersPageState(); - service = createEventFiltersServiceMock(); - - const storeSetup = createStoreSetup(service); - - store = storeSetup.store as Store; - spyMiddleware = storeSetup.spyMiddleware; - }); - - describe('initial state', () => { - it('sets initial state properly', async () => { - expect(createStoreSetup(createEventFiltersServiceMock()).store.getState()).toStrictEqual( - initialState - ); - }); - }); - - describe('when on the List page', () => { - const changeUrl = (searchParams: string = '') => { - store.dispatch({ - type: 'userChangedUrl', - payload: { - pathname: '/administration/event_filters', - search: searchParams, - hash: '', - key: 'ylsd7h', - }, - }); - }; - - beforeEach(() => { - service.getList.mockResolvedValue(getFoundExceptionListItemSchemaMock()); - }); - - it.each([ - [undefined, undefined, undefined], - [3, 50, ['1', '2']], - ])( - 'should trigger api call to retrieve event filters with url params page_index[%s] page_size[%s] included_policies[%s]', - async (pageIndex, perPage, policies) => { - const dataLoaded = spyMiddleware.waitForAction('eventFiltersListPageDataChanged', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }); - - changeUrl( - (pageIndex && - perPage && - `?page_index=${pageIndex}&page_size=${perPage}&included_policies=${policies}`) || - '' - ); - await dataLoaded; - - expect(service.getList).toHaveBeenCalledWith({ - page: (pageIndex ?? 0) + 1, - perPage: perPage ?? 10, - sortField: 'created_at', - sortOrder: 'desc', - filter: policies ? parsePoliciesAndFilterToKql({ policies }) : undefined, - }); - } - ); - - it('should not refresh the list if nothing in the query has changed', async () => { - const dataLoaded = spyMiddleware.waitForAction('eventFiltersListPageDataChanged', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }); - - changeUrl(); - await dataLoaded; - const getListCallCount = service.getList.mock.calls.length; - changeUrl('&show=create'); - - expect(service.getList.mock.calls.length).toBe(getListCallCount); - }); - - it('should trigger second api call to check if data exists if first returned no records', async () => { - const dataLoaded = spyMiddleware.waitForAction('eventFiltersListPageDataExistsChanged', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }); - - service.getList.mockResolvedValue({ - data: [], - total: 0, - page: 1, - per_page: 10, - }); - - changeUrl(); - await dataLoaded; - - expect(service.getList).toHaveBeenCalledTimes(2); - expect(service.getList).toHaveBeenNthCalledWith(2, { - page: 1, - perPage: 1, - }); - }); - - it('should dispatch a Failure if an API error was encountered', async () => { - const dataLoaded = spyMiddleware.waitForAction('eventFiltersListPageDataChanged', { - validate({ payload }) { - return isFailedResourceState(payload); - }, - }); - - service.getList.mockRejectedValue({ - body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, - }); - - changeUrl(); - await dataLoaded; - - expect(getListFetchError(store.getState())).toEqual({ - message: 'error message', - statusCode: 500, - error: 'Internal Server Error', - }); - }); - }); - - describe('submit creation event filter', () => { - it('does not submit when entry is undefined', async () => { - store.dispatch({ type: 'eventFiltersCreateStart' }); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { type: 'UninitialisedResourceState' }, - }, - }); - }); - - it('does submit when entry is not undefined', async () => { - service.addEventFilters.mockResolvedValue(createdEventFilterEntryMock()); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - store.dispatch({ type: 'eventFiltersCreateStart' }); - - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }, - }); - }); - - it('does submit when entry has empty comments with white spaces', async () => { - service.addEventFilters.mockImplementation( - async (exception: Immutable) => { - expect(exception.comments).toStrictEqual(createdEventFilterEntryMock().comments); - return createdEventFilterEntryMock(); - } - ); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - store.dispatch({ - type: 'eventFiltersChangeForm', - payload: { newComment: ' ', entry }, - }); - - store.dispatch({ type: 'eventFiltersCreateStart' }); - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }, - }); - }); - - it('does throw error when creating', async () => { - service.addEventFilters.mockRejectedValue({ - body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, - }); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - store.dispatch({ type: 'eventFiltersCreateStart' }); - - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'FailedResourceState', - lastLoadedState: undefined, - error: { - error: 'Internal Server Error', - message: 'error message', - statusCode: 500, - }, - }, - }, - }); - }); - }); - describe('load event filterby id', () => { - it('init form with an entry loaded by id from API', async () => { - service.getOne.mockResolvedValue(createdEventFilterEntryMock()); - store.dispatch({ type: 'eventFiltersInitFromId', payload: { id: 'id' } }); - await spyMiddleware.waitForAction('eventFiltersInitForm'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - entry: createdEventFilterEntryMock(), - }, - }); - }); - - it('does throw error when getting by id', async () => { - service.getOne.mockRejectedValue({ - body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, - }); - store.dispatch({ type: 'eventFiltersInitFromId', payload: { id: 'id' } }); - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'FailedResourceState', - lastLoadedState: undefined, - error: { - error: 'Internal Server Error', - message: 'error message', - statusCode: 500, - }, - }, - }, - }); - }); - }); - describe('submit update event filter', () => { - it('does not submit when entry is undefined', async () => { - store.dispatch({ type: 'eventFiltersUpdateStart' }); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { type: 'UninitialisedResourceState' }, - }, - }); - }); - - it('does submit when entry is not undefined', async () => { - service.updateOne.mockResolvedValue(createdEventFilterEntryMock()); - - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: createdEventFilterEntryMock() }, - }); - - store.dispatch({ type: 'eventFiltersUpdateStart' }); - - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }, - }); - }); - - it('does throw error when creating', async () => { - service.updateOne.mockRejectedValue({ - body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, - }); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - store.dispatch({ type: 'eventFiltersUpdateStart' }); - - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'FailedResourceState', - lastLoadedState: undefined, - error: { - error: 'Internal Server Error', - message: 'error message', - statusCode: 500, - }, - }, - }, - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts deleted file mode 100644 index a8bf725e61b2a..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts +++ /dev/null @@ -1,342 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { - CreateExceptionListItemSchema, - ExceptionListItemSchema, - UpdateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { transformNewItemOutput, transformOutput } from '@kbn/securitysolution-list-hooks'; -import { AppAction } from '../../../../common/store/actions'; -import { - ImmutableMiddleware, - ImmutableMiddlewareAPI, - ImmutableMiddlewareFactory, -} from '../../../../common/store'; - -import { EventFiltersHttpService } from '../service'; - -import { - getCurrentListPageDataState, - getCurrentLocation, - getListIsLoading, - getListPageDataExistsState, - getListPageIsActive, - listDataNeedsRefresh, - getFormEntry, - getSubmissionResource, - getNewComment, - isDeletionInProgress, - getItemToDelete, - getDeletionState, -} from './selector'; - -import { parseQueryFilterToKQL, parsePoliciesAndFilterToKql } from '../../../common/utils'; -import { SEARCHABLE_FIELDS } from '../constants'; -import { - EventFiltersListPageData, - EventFiltersListPageState, - EventFiltersService, - EventFiltersServiceGetListOptions, -} from '../types'; -import { - asStaleResourceState, - createFailedResourceState, - createLoadedResourceState, - createLoadingResourceState, - getLastLoadedResourceState, -} from '../../../state'; -import { ServerApiError } from '../../../../common/types'; - -const addNewComments = ( - entry: UpdateExceptionListItemSchema | CreateExceptionListItemSchema, - newComment: string -): UpdateExceptionListItemSchema | CreateExceptionListItemSchema => { - if (newComment) { - if (!entry.comments) entry.comments = []; - const trimmedComment = newComment.trim(); - if (trimmedComment) entry.comments.push({ comment: trimmedComment }); - } - return entry; -}; - -type MiddlewareActionHandler = ( - store: ImmutableMiddlewareAPI, - eventFiltersService: EventFiltersService -) => Promise; - -const eventFiltersCreate: MiddlewareActionHandler = async (store, eventFiltersService) => { - const submissionResourceState = store.getState().form.submissionResourceState; - try { - const formEntry = getFormEntry(store.getState()); - if (!formEntry) return; - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: createLoadingResourceState({ - type: 'UninitialisedResourceState', - }), - }); - - const sanitizedEntry = transformNewItemOutput(formEntry as CreateExceptionListItemSchema); - const updatedCommentsEntry = addNewComments( - sanitizedEntry, - getNewComment(store.getState()) - ) as CreateExceptionListItemSchema; - - const exception = await eventFiltersService.addEventFilters(updatedCommentsEntry); - - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: exception, - }, - }); - store.dispatch({ - type: 'eventFiltersCreateSuccess', - }); - } catch (error) { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: error.body || error, - lastLoadedState: getLastLoadedResourceState(submissionResourceState), - }, - }); - } -}; - -const eventFiltersUpdate = async ( - store: ImmutableMiddlewareAPI, - eventFiltersService: EventFiltersService -) => { - const submissionResourceState = getSubmissionResource(store.getState()); - try { - const formEntry = getFormEntry(store.getState()); - if (!formEntry) return; - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadingResourceState', - previousState: { type: 'UninitialisedResourceState' }, - }, - }); - - const sanitizedEntry: UpdateExceptionListItemSchema = transformOutput( - formEntry as UpdateExceptionListItemSchema - ); - const updatedCommentsEntry = addNewComments( - sanitizedEntry, - getNewComment(store.getState()) - ) as UpdateExceptionListItemSchema; - - const exception = await eventFiltersService.updateOne(updatedCommentsEntry); - store.dispatch({ - type: 'eventFiltersUpdateSuccess', - }); - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: createLoadedResourceState(exception), - }); - } catch (error) { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: createFailedResourceState( - error.body ?? error, - getLastLoadedResourceState(submissionResourceState) - ), - }); - } -}; - -const eventFiltersLoadById = async ( - store: ImmutableMiddlewareAPI, - eventFiltersService: EventFiltersService, - id: string -) => { - const submissionResourceState = getSubmissionResource(store.getState()); - try { - const entry = await eventFiltersService.getOne(id); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - } catch (error) { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: error.body || error, - lastLoadedState: getLastLoadedResourceState(submissionResourceState), - }, - }); - } -}; - -const checkIfEventFilterDataExist: MiddlewareActionHandler = async ( - { dispatch, getState }, - eventFiltersService: EventFiltersService -) => { - dispatch({ - type: 'eventFiltersListPageDataExistsChanged', - payload: createLoadingResourceState( - asStaleResourceState(getListPageDataExistsState(getState())) - ), - }); - - try { - const anythingInListResults = await eventFiltersService.getList({ perPage: 1, page: 1 }); - - dispatch({ - type: 'eventFiltersListPageDataExistsChanged', - payload: createLoadedResourceState(Boolean(anythingInListResults.total)), - }); - } catch (error) { - dispatch({ - type: 'eventFiltersListPageDataExistsChanged', - payload: createFailedResourceState(error.body ?? error), - }); - } -}; - -const refreshListDataIfNeeded: MiddlewareActionHandler = async (store, eventFiltersService) => { - const { dispatch, getState } = store; - const state = getState(); - const isLoading = getListIsLoading(state); - - if (!isLoading && listDataNeedsRefresh(state)) { - dispatch({ - type: 'eventFiltersListPageDataChanged', - payload: { - type: 'LoadingResourceState', - previousState: asStaleResourceState(getCurrentListPageDataState(state)), - }, - }); - - const { - page_size: pageSize, - page_index: pageIndex, - filter, - included_policies: includedPolicies, - } = getCurrentLocation(state); - - const kuery = parseQueryFilterToKQL(filter, SEARCHABLE_FIELDS) || undefined; - - const query: EventFiltersServiceGetListOptions = { - page: pageIndex + 1, - perPage: pageSize, - sortField: 'created_at', - sortOrder: 'desc', - filter: parsePoliciesAndFilterToKql({ - kuery, - policies: includedPolicies ? includedPolicies.split(',') : [], - }), - }; - - try { - const results = await eventFiltersService.getList(query); - - dispatch({ - type: 'eventFiltersListPageDataChanged', - payload: createLoadedResourceState({ - query: { ...query, filter }, - content: results, - }), - }); - - // If no results were returned, then just check to make sure data actually exists for - // event filters. This is used to drive the UI between showing "empty state" and "no items found" - // messages to the user - if (results.total === 0) { - await checkIfEventFilterDataExist(store, eventFiltersService); - } else { - dispatch({ - type: 'eventFiltersListPageDataExistsChanged', - payload: { - type: 'LoadedResourceState', - data: Boolean(results.total), - }, - }); - } - } catch (error) { - dispatch({ - type: 'eventFiltersListPageDataChanged', - payload: createFailedResourceState(error.body ?? error), - }); - } - } -}; - -const eventFilterDeleteEntry: MiddlewareActionHandler = async ( - { getState, dispatch }, - eventFiltersService -) => { - const state = getState(); - - if (isDeletionInProgress(state)) { - return; - } - - const itemId = getItemToDelete(state)?.id; - - if (!itemId) { - return; - } - - dispatch({ - type: 'eventFilterDeleteStatusChanged', - payload: createLoadingResourceState(asStaleResourceState(getDeletionState(state).status)), - }); - - try { - const response = await eventFiltersService.deleteOne(itemId); - dispatch({ - type: 'eventFilterDeleteStatusChanged', - payload: createLoadedResourceState(response), - }); - } catch (e) { - dispatch({ - type: 'eventFilterDeleteStatusChanged', - payload: createFailedResourceState(e.body ?? e), - }); - } -}; - -export const createEventFiltersPageMiddleware = ( - eventFiltersService: EventFiltersService -): ImmutableMiddleware => { - return (store) => (next) => async (action) => { - next(action); - - if (action.type === 'eventFiltersCreateStart') { - await eventFiltersCreate(store, eventFiltersService); - } else if (action.type === 'eventFiltersInitFromId') { - await eventFiltersLoadById(store, eventFiltersService, action.payload.id); - } else if (action.type === 'eventFiltersUpdateStart') { - await eventFiltersUpdate(store, eventFiltersService); - } - - // Middleware that only applies to the List Page for Event Filters - if (getListPageIsActive(store.getState())) { - if ( - action.type === 'userChangedUrl' || - action.type === 'eventFiltersCreateSuccess' || - action.type === 'eventFiltersUpdateSuccess' || - action.type === 'eventFilterDeleteStatusChanged' - ) { - refreshListDataIfNeeded(store, eventFiltersService); - } else if (action.type === 'eventFilterDeleteSubmit') { - eventFilterDeleteEntry(store, eventFiltersService); - } - } - }; -}; - -export const eventFiltersPageMiddlewareFactory: ImmutableMiddlewareFactory< - EventFiltersListPageState -> = (coreStart) => createEventFiltersPageMiddleware(new EventFiltersHttpService(coreStart.http)); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts deleted file mode 100644 index 0deb7cb51c850..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { initialEventFiltersPageState } from './builders'; -import { eventFiltersPageReducer } from './reducer'; -import { getInitialExceptionFromEvent } from './utils'; -import { createdEventFilterEntryMock, ecsEventMock } from '../test_utils'; -import { UserChangedUrl } from '../../../../common/store/routing/action'; -import { getListPageIsActive } from './selector'; -import { EventFiltersListPageState } from '../types'; - -describe('event filters reducer', () => { - let initialState: EventFiltersListPageState; - - beforeEach(() => { - initialState = initialEventFiltersPageState(); - }); - - describe('EventFiltersForm', () => { - it('sets the initial form values', () => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - expect(result).toStrictEqual({ - ...initialState, - form: { - ...initialState.form, - entry, - hasNameError: !entry.name, - submissionResourceState: { - type: 'UninitialisedResourceState', - }, - }, - }); - }); - - it('change form values', () => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - const nameChanged = 'name changed'; - const newComment = 'new comment'; - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersChangeForm', - payload: { entry: { ...entry, name: nameChanged }, newComment }, - }); - - expect(result).toStrictEqual({ - ...initialState, - form: { - ...initialState.form, - entry: { - ...entry, - name: nameChanged, - }, - newComment, - hasNameError: false, - submissionResourceState: { - type: 'UninitialisedResourceState', - }, - }, - }); - }); - - it('change form values without entry', () => { - const newComment = 'new comment'; - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersChangeForm', - payload: { newComment }, - }); - - expect(result).toStrictEqual({ - ...initialState, - form: { - ...initialState.form, - newComment, - submissionResourceState: { - type: 'UninitialisedResourceState', - }, - }, - }); - }); - - it('change form status', () => { - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }); - - expect(result).toStrictEqual({ - ...initialState, - form: { - ...initialState.form, - submissionResourceState: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }, - }); - }); - - it('clean form after change form status', () => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - const nameChanged = 'name changed'; - const newComment = 'new comment'; - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersChangeForm', - payload: { entry: { ...entry, name: nameChanged }, newComment }, - }); - const cleanState = eventFiltersPageReducer(result, { - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - expect(cleanState).toStrictEqual({ - ...initialState, - form: { ...initialState.form, entry, hasNameError: true, newComment: '' }, - }); - }); - - it('create is success and force list refresh', () => { - const initialStateWithListPageActive = { - ...initialState, - listPage: { ...initialState.listPage, active: true }, - }; - const result = eventFiltersPageReducer(initialStateWithListPageActive, { - type: 'eventFiltersCreateSuccess', - }); - - expect(result).toStrictEqual({ - ...initialStateWithListPageActive, - listPage: { - ...initialStateWithListPageActive.listPage, - forceRefresh: true, - }, - }); - }); - }); - describe('UserChangedUrl', () => { - const userChangedUrlAction = ( - search: string = '', - pathname = '/administration/event_filters' - ): UserChangedUrl => ({ - type: 'userChangedUrl', - payload: { search, pathname, hash: '' }, - }); - - describe('When url is the Event List page', () => { - it('should mark page active when on the list url', () => { - const result = eventFiltersPageReducer(initialState, userChangedUrlAction()); - expect(getListPageIsActive(result)).toBe(true); - }); - - it('should mark page not active when not on the list url', () => { - const result = eventFiltersPageReducer( - initialState, - userChangedUrlAction('', '/some-other-page') - ); - expect(getListPageIsActive(result)).toBe(false); - }); - }); - - describe('When `show=create`', () => { - it('receives a url change with show=create', () => { - const result = eventFiltersPageReducer(initialState, userChangedUrlAction('?show=create')); - - expect(result).toStrictEqual({ - ...initialState, - location: { - ...initialState.location, - id: undefined, - show: 'create', - }, - listPage: { - ...initialState.listPage, - active: true, - }, - }); - }); - }); - }); - - describe('ForceRefresh', () => { - it('sets the force refresh state to true', () => { - const result = eventFiltersPageReducer( - { - ...initialState, - listPage: { ...initialState.listPage, forceRefresh: false }, - }, - { type: 'eventFiltersForceRefresh', payload: { forceRefresh: true } } - ); - - expect(result).toStrictEqual({ - ...initialState, - listPage: { ...initialState.listPage, forceRefresh: true }, - }); - }); - it('sets the force refresh state to false', () => { - const result = eventFiltersPageReducer( - { - ...initialState, - listPage: { ...initialState.listPage, forceRefresh: true }, - }, - { type: 'eventFiltersForceRefresh', payload: { forceRefresh: false } } - ); - - expect(result).toStrictEqual({ - ...initialState, - listPage: { ...initialState.listPage, forceRefresh: false }, - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts deleted file mode 100644 index 95b0078f80f8b..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts +++ /dev/null @@ -1,271 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// eslint-disable-next-line import/no-nodejs-modules -import { parse } from 'querystring'; -import { matchPath } from 'react-router-dom'; -import { ImmutableReducer } from '../../../../common/store'; -import { AppAction } from '../../../../common/store/actions'; -import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; -import { UserChangedUrl } from '../../../../common/store/routing/action'; -import { MANAGEMENT_ROUTING_EVENT_FILTERS_PATH } from '../../../common/constants'; -import { extractEventFiltersPageLocation } from '../../../common/routing'; -import { - isLoadedResourceState, - isUninitialisedResourceState, -} from '../../../state/async_resource_state'; - -import { - EventFiltersInitForm, - EventFiltersChangeForm, - EventFiltersFormStateChanged, - EventFiltersCreateSuccess, - EventFiltersUpdateSuccess, - EventFiltersListPageDataChanged, - EventFiltersListPageDataExistsChanged, - EventFilterForDeletion, - EventFilterDeletionReset, - EventFilterDeleteStatusChanged, - EventFiltersForceRefresh, -} from './action'; - -import { initialEventFiltersPageState } from './builders'; -import { getListPageIsActive } from './selector'; -import { EventFiltersListPageState } from '../types'; - -type StateReducer = ImmutableReducer; -type CaseReducer = ( - state: Immutable, - action: Immutable -) => Immutable; - -const isEventFiltersPageLocation = (location: Immutable) => { - return ( - matchPath(location.pathname ?? '', { - path: MANAGEMENT_ROUTING_EVENT_FILTERS_PATH, - exact: true, - }) !== null - ); -}; - -const handleEventFiltersListPageDataChanges: CaseReducer = ( - state, - action -) => { - return { - ...state, - listPage: { - ...state.listPage, - forceRefresh: false, - data: action.payload, - }, - }; -}; - -const handleEventFiltersListPageDataExistChanges: CaseReducer< - EventFiltersListPageDataExistsChanged -> = (state, action) => { - return { - ...state, - listPage: { - ...state.listPage, - dataExist: action.payload, - }, - }; -}; - -const eventFiltersInitForm: CaseReducer = (state, action) => { - return { - ...state, - form: { - ...state.form, - entry: action.payload.entry, - hasNameError: !action.payload.entry.name, - hasOSError: !action.payload.entry.os_types?.length, - newComment: '', - submissionResourceState: { - type: 'UninitialisedResourceState', - }, - }, - }; -}; - -const eventFiltersChangeForm: CaseReducer = (state, action) => { - return { - ...state, - form: { - ...state.form, - entry: action.payload.entry !== undefined ? action.payload.entry : state.form.entry, - hasItemsError: - action.payload.hasItemsError !== undefined - ? action.payload.hasItemsError - : state.form.hasItemsError, - hasNameError: - action.payload.hasNameError !== undefined - ? action.payload.hasNameError - : state.form.hasNameError, - hasOSError: - action.payload.hasOSError !== undefined ? action.payload.hasOSError : state.form.hasOSError, - newComment: - action.payload.newComment !== undefined ? action.payload.newComment : state.form.newComment, - }, - }; -}; - -const eventFiltersFormStateChanged: CaseReducer = (state, action) => { - return { - ...state, - form: { - ...state.form, - entry: isUninitialisedResourceState(action.payload) ? undefined : state.form.entry, - newComment: isUninitialisedResourceState(action.payload) ? '' : state.form.newComment, - submissionResourceState: action.payload, - }, - }; -}; - -const eventFiltersCreateSuccess: CaseReducer = (state, action) => { - return { - ...state, - // If we are on the List page, then force a refresh of data - listPage: getListPageIsActive(state) - ? { - ...state.listPage, - forceRefresh: true, - } - : state.listPage, - }; -}; - -const eventFiltersUpdateSuccess: CaseReducer = (state, action) => { - return { - ...state, - // If we are on the List page, then force a refresh of data - listPage: getListPageIsActive(state) - ? { - ...state.listPage, - forceRefresh: true, - } - : state.listPage, - }; -}; - -const userChangedUrl: CaseReducer = (state, action) => { - if (isEventFiltersPageLocation(action.payload)) { - const location = extractEventFiltersPageLocation(parse(action.payload.search.slice(1))); - return { - ...state, - location, - listPage: { - ...state.listPage, - active: true, - }, - }; - } else { - // Reset the list page state if needed - if (state.listPage.active) { - const { listPage } = initialEventFiltersPageState(); - - return { - ...state, - listPage, - }; - } - - return state; - } -}; - -const handleEventFilterForDeletion: CaseReducer = (state, action) => { - return { - ...state, - listPage: { - ...state.listPage, - deletion: { - ...state.listPage.deletion, - item: action.payload, - }, - }, - }; -}; - -const handleEventFilterDeletionReset: CaseReducer = (state) => { - return { - ...state, - listPage: { - ...state.listPage, - deletion: initialEventFiltersPageState().listPage.deletion, - }, - }; -}; - -const handleEventFilterDeleteStatusChanges: CaseReducer = ( - state, - action -) => { - return { - ...state, - listPage: { - ...state.listPage, - forceRefresh: isLoadedResourceState(action.payload) ? true : state.listPage.forceRefresh, - deletion: { - ...state.listPage.deletion, - status: action.payload, - }, - }, - }; -}; - -const handleEventFilterForceRefresh: CaseReducer = (state, action) => { - return { - ...state, - listPage: { - ...state.listPage, - forceRefresh: action.payload.forceRefresh, - }, - }; -}; - -export const eventFiltersPageReducer: StateReducer = ( - state = initialEventFiltersPageState(), - action -) => { - switch (action.type) { - case 'eventFiltersInitForm': - return eventFiltersInitForm(state, action); - case 'eventFiltersChangeForm': - return eventFiltersChangeForm(state, action); - case 'eventFiltersFormStateChanged': - return eventFiltersFormStateChanged(state, action); - case 'eventFiltersCreateSuccess': - return eventFiltersCreateSuccess(state, action); - case 'eventFiltersUpdateSuccess': - return eventFiltersUpdateSuccess(state, action); - case 'userChangedUrl': - return userChangedUrl(state, action); - case 'eventFiltersForceRefresh': - return handleEventFilterForceRefresh(state, action); - } - - // actions only handled if we're on the List Page - if (getListPageIsActive(state)) { - switch (action.type) { - case 'eventFiltersListPageDataChanged': - return handleEventFiltersListPageDataChanges(state, action); - case 'eventFiltersListPageDataExistsChanged': - return handleEventFiltersListPageDataExistChanges(state, action); - case 'eventFilterForDeletion': - return handleEventFilterForDeletion(state, action); - case 'eventFilterDeletionReset': - return handleEventFilterDeletionReset(state, action); - case 'eventFilterDeleteStatusChanged': - return handleEventFilterDeleteStatusChanges(state, action); - } - } - - return state; -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts deleted file mode 100644 index 9e5eb5c531b6e..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { createSelector } from 'reselect'; -import { Pagination } from '@elastic/eui'; - -import type { - ExceptionListItemSchema, - FoundExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { EventFiltersListPageState, EventFiltersServiceGetListOptions } from '../types'; - -import { ServerApiError } from '../../../../common/types'; -import { - isLoadingResourceState, - isLoadedResourceState, - isFailedResourceState, - isUninitialisedResourceState, - getLastLoadedResourceState, -} from '../../../state/async_resource_state'; -import { - MANAGEMENT_DEFAULT_PAGE_SIZE, - MANAGEMENT_PAGE_SIZE_OPTIONS, -} from '../../../common/constants'; -import { Immutable } from '../../../../../common/endpoint/types'; - -type StoreState = Immutable; -type EventFiltersSelector = (state: StoreState) => T; - -export const getCurrentListPageState: EventFiltersSelector = (state) => { - return state.listPage; -}; - -export const getListPageIsActive: EventFiltersSelector = createSelector( - getCurrentListPageState, - (listPage) => listPage.active -); - -export const getCurrentListPageDataState: EventFiltersSelector = ( - state -) => state.listPage.data; - -/** - * Will return the API response with event filters. If the current state is attempting to load a new - * page of content, then return the previous API response if we have one - */ -export const getListApiSuccessResponse: EventFiltersSelector< - Immutable | undefined -> = createSelector(getCurrentListPageDataState, (listPageData) => { - return getLastLoadedResourceState(listPageData)?.data.content; -}); - -export const getListItems: EventFiltersSelector> = - createSelector(getListApiSuccessResponse, (apiResponseData) => { - return apiResponseData?.data || []; - }); - -export const getTotalCountListItems: EventFiltersSelector> = createSelector( - getListApiSuccessResponse, - (apiResponseData) => { - return apiResponseData?.total || 0; - } -); - -/** - * Will return the query that was used with the currently displayed list of content. If a new page - * of content is being loaded, this selector will then attempt to use the previousState to return - * the query used. - */ -export const getCurrentListItemsQuery: EventFiltersSelector = - createSelector(getCurrentListPageDataState, (pageDataState) => { - return getLastLoadedResourceState(pageDataState)?.data.query ?? {}; - }); - -export const getListPagination: EventFiltersSelector = createSelector( - getListApiSuccessResponse, - // memoized via `reselect` until the API response changes - (response) => { - return { - totalItemCount: response?.total ?? 0, - pageSize: response?.per_page ?? MANAGEMENT_DEFAULT_PAGE_SIZE, - pageSizeOptions: [...MANAGEMENT_PAGE_SIZE_OPTIONS], - pageIndex: (response?.page ?? 1) - 1, - }; - } -); - -export const getListFetchError: EventFiltersSelector | undefined> = - createSelector(getCurrentListPageDataState, (listPageDataState) => { - return (isFailedResourceState(listPageDataState) && listPageDataState.error) || undefined; - }); - -export const getListPageDataExistsState: EventFiltersSelector< - StoreState['listPage']['dataExist'] -> = ({ listPage: { dataExist } }) => dataExist; - -export const getListIsLoading: EventFiltersSelector = createSelector( - getCurrentListPageDataState, - getListPageDataExistsState, - (listDataState, dataExists) => - isLoadingResourceState(listDataState) || isLoadingResourceState(dataExists) -); - -export const getListPageDoesDataExist: EventFiltersSelector = createSelector( - getListPageDataExistsState, - (dataExistsState) => { - return !!getLastLoadedResourceState(dataExistsState)?.data; - } -); - -export const getFormEntryState: EventFiltersSelector = (state) => { - return state.form.entry; -}; -// Needed for form component as we modify the existing entry on exceptuionBuilder component -export const getFormEntryStateMutable = ( - state: EventFiltersListPageState -): EventFiltersListPageState['form']['entry'] => { - return state.form.entry; -}; - -export const getFormEntry = createSelector(getFormEntryState, (entry) => entry); - -export const getNewCommentState: EventFiltersSelector = ( - state -) => { - return state.form.newComment; -}; - -export const getNewComment = createSelector(getNewCommentState, (newComment) => newComment); - -export const getHasNameError = (state: EventFiltersListPageState): boolean => { - return state.form.hasNameError; -}; - -export const getFormHasError = (state: EventFiltersListPageState): boolean => { - return state.form.hasItemsError || state.form.hasNameError || state.form.hasOSError; -}; - -export const isCreationInProgress = (state: EventFiltersListPageState): boolean => { - return isLoadingResourceState(state.form.submissionResourceState); -}; - -export const isCreationSuccessful = (state: EventFiltersListPageState): boolean => { - return isLoadedResourceState(state.form.submissionResourceState); -}; - -export const isUninitialisedForm = (state: EventFiltersListPageState): boolean => { - return isUninitialisedResourceState(state.form.submissionResourceState); -}; - -export const getActionError = (state: EventFiltersListPageState): ServerApiError | undefined => { - const submissionResourceState = state.form.submissionResourceState; - - return isFailedResourceState(submissionResourceState) ? submissionResourceState.error : undefined; -}; - -export const getSubmissionResourceState: EventFiltersSelector< - StoreState['form']['submissionResourceState'] -> = (state) => { - return state.form.submissionResourceState; -}; - -export const getSubmissionResource = createSelector( - getSubmissionResourceState, - (submissionResourceState) => submissionResourceState -); - -export const getCurrentLocation: EventFiltersSelector = (state) => - state.location; - -/** Compares the URL param values to the values used in the last data query */ -export const listDataNeedsRefresh: EventFiltersSelector = createSelector( - getCurrentLocation, - getCurrentListItemsQuery, - (state) => state.listPage.forceRefresh, - (location, currentQuery, forceRefresh) => { - return ( - forceRefresh || - location.page_index + 1 !== currentQuery.page || - location.page_size !== currentQuery.perPage - ); - } -); - -export const getDeletionState = createSelector( - getCurrentListPageState, - (listState) => listState.deletion -); - -export const showDeleteModal: EventFiltersSelector = createSelector( - getDeletionState, - ({ item }) => { - return Boolean(item); - } -); - -export const getItemToDelete: EventFiltersSelector = - createSelector(getDeletionState, ({ item }) => item); - -export const isDeletionInProgress: EventFiltersSelector = createSelector( - getDeletionState, - ({ status }) => { - return isLoadingResourceState(status); - } -); - -export const wasDeletionSuccessful: EventFiltersSelector = createSelector( - getDeletionState, - ({ status }) => { - return isLoadedResourceState(status); - } -); - -export const getDeleteError: EventFiltersSelector = createSelector( - getDeletionState, - ({ status }) => { - if (isFailedResourceState(status)) { - return status.error; - } - } -); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts deleted file mode 100644 index fa3a519bc1908..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { initialEventFiltersPageState } from './builders'; -import { - getFormEntry, - getFormHasError, - getCurrentLocation, - getNewComment, - getHasNameError, - getCurrentListPageState, - getListPageIsActive, - getCurrentListPageDataState, - getListApiSuccessResponse, - getListItems, - getTotalCountListItems, - getCurrentListItemsQuery, - getListPagination, - getListFetchError, - getListIsLoading, - getListPageDoesDataExist, - listDataNeedsRefresh, -} from './selector'; -import { ecsEventMock } from '../test_utils'; -import { getInitialExceptionFromEvent } from './utils'; -import { EventFiltersListPageState, EventFiltersPageLocation } from '../types'; -import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../../common/constants'; -import { getFoundExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/found_exception_list_item_schema.mock'; -import { - asStaleResourceState, - createFailedResourceState, - createLoadedResourceState, - createLoadingResourceState, - createUninitialisedResourceState, - getLastLoadedResourceState, -} from '../../../state'; - -describe('event filters selectors', () => { - let initialState: EventFiltersListPageState; - - // When `setToLoadingState()` is called, this variable will hold the prevousState in order to - // avoid ts-ignores due to know issues (#830) around the LoadingResourceState - let previousStateWhileLoading: EventFiltersListPageState['listPage']['data'] | undefined; - - const setToLoadedState = () => { - initialState.listPage.data = createLoadedResourceState({ - query: { page: 2, perPage: 10, filter: '' }, - content: getFoundExceptionListItemSchemaMock(), - }); - }; - - const setToLoadingState = ( - previousState: EventFiltersListPageState['listPage']['data'] = createLoadedResourceState({ - query: { page: 5, perPage: 50, filter: '' }, - content: getFoundExceptionListItemSchemaMock(), - }) - ) => { - previousStateWhileLoading = previousState; - - initialState.listPage.data = createLoadingResourceState(asStaleResourceState(previousState)); - }; - - beforeEach(() => { - initialState = initialEventFiltersPageState(); - }); - - describe('getCurrentListPageState()', () => { - it('should retrieve list page state', () => { - expect(getCurrentListPageState(initialState)).toEqual(initialState.listPage); - }); - }); - - describe('getListPageIsActive()', () => { - it('should return active state', () => { - expect(getListPageIsActive(initialState)).toBe(false); - }); - }); - - describe('getCurrentListPageDataState()', () => { - it('should return list data state', () => { - expect(getCurrentListPageDataState(initialState)).toEqual(initialState.listPage.data); - }); - }); - - describe('getListApiSuccessResponse()', () => { - it('should return api response', () => { - setToLoadedState(); - expect(getListApiSuccessResponse(initialState)).toEqual( - getLastLoadedResourceState(initialState.listPage.data)?.data.content - ); - }); - - it('should return undefined if not available', () => { - setToLoadingState(createUninitialisedResourceState()); - expect(getListApiSuccessResponse(initialState)).toBeUndefined(); - }); - - it('should return previous success response if currently loading', () => { - setToLoadingState(); - expect(getListApiSuccessResponse(initialState)).toEqual( - getLastLoadedResourceState(previousStateWhileLoading!)?.data.content - ); - }); - }); - - describe('getListItems()', () => { - it('should return the list items from api response', () => { - setToLoadedState(); - expect(getListItems(initialState)).toEqual( - getLastLoadedResourceState(initialState.listPage.data)?.data.content.data - ); - }); - - it('should return empty array if no api response', () => { - expect(getListItems(initialState)).toEqual([]); - }); - }); - - describe('getTotalCountListItems()', () => { - it('should return the list items from api response', () => { - setToLoadedState(); - expect(getTotalCountListItems(initialState)).toEqual( - getLastLoadedResourceState(initialState.listPage.data)?.data.content.total - ); - }); - - it('should return empty array if no api response', () => { - expect(getTotalCountListItems(initialState)).toEqual(0); - }); - }); - - describe('getCurrentListItemsQuery()', () => { - it('should return empty object if Uninitialized', () => { - expect(getCurrentListItemsQuery(initialState)).toEqual({}); - }); - - it('should return query from current loaded state', () => { - setToLoadedState(); - expect(getCurrentListItemsQuery(initialState)).toEqual({ page: 2, perPage: 10, filter: '' }); - }); - - it('should return query from previous state while Loading new page', () => { - setToLoadingState(); - expect(getCurrentListItemsQuery(initialState)).toEqual({ page: 5, perPage: 50, filter: '' }); - }); - }); - - describe('getListPagination()', () => { - it('should return pagination defaults if no API response is available', () => { - expect(getListPagination(initialState)).toEqual({ - totalItemCount: 0, - pageSize: 10, - pageSizeOptions: [10, 20, 50], - pageIndex: 0, - }); - }); - - it('should return pagination based on API response', () => { - setToLoadedState(); - expect(getListPagination(initialState)).toEqual({ - totalItemCount: 1, - pageSize: 1, - pageSizeOptions: [10, 20, 50], - pageIndex: 0, - }); - }); - }); - - describe('getListFetchError()', () => { - it('should return undefined if no error exists', () => { - expect(getListFetchError(initialState)).toBeUndefined(); - }); - - it('should return the API error', () => { - const error = { - statusCode: 500, - error: 'Internal Server Error', - message: 'Something is not right', - }; - - initialState.listPage.data = createFailedResourceState(error); - expect(getListFetchError(initialState)).toBe(error); - }); - }); - - describe('getListIsLoading()', () => { - it('should return false if not in a Loading state', () => { - expect(getListIsLoading(initialState)).toBe(false); - }); - - it('should return true if in a Loading state', () => { - setToLoadingState(); - expect(getListIsLoading(initialState)).toBe(true); - }); - }); - - describe('getListPageDoesDataExist()', () => { - it('should return false (default) until we get a Loaded Resource state', () => { - expect(getListPageDoesDataExist(initialState)).toBe(false); - - // Set DataExists to Loading - initialState.listPage.dataExist = createLoadingResourceState( - asStaleResourceState(initialState.listPage.dataExist) - ); - expect(getListPageDoesDataExist(initialState)).toBe(false); - - // Set DataExists to Failure - initialState.listPage.dataExist = createFailedResourceState({ - statusCode: 500, - error: 'Internal Server Error', - message: 'Something is not right', - }); - expect(getListPageDoesDataExist(initialState)).toBe(false); - }); - - it('should return false if no data exists', () => { - initialState.listPage.dataExist = createLoadedResourceState(false); - expect(getListPageDoesDataExist(initialState)).toBe(false); - }); - }); - - describe('listDataNeedsRefresh()', () => { - beforeEach(() => { - setToLoadedState(); - - initialState.location = { - page_index: 1, - page_size: 10, - filter: '', - id: '', - show: undefined, - included_policies: '', - }; - }); - - it('should return false if location url params match those that were used in api call', () => { - expect(listDataNeedsRefresh(initialState)).toBe(false); - }); - - it('should return true if `forceRefresh` is set', () => { - initialState.listPage.forceRefresh = true; - expect(listDataNeedsRefresh(initialState)).toBe(true); - }); - - it('should should return true if any of the url params differ from last api call', () => { - initialState.location.page_index = 10; - expect(listDataNeedsRefresh(initialState)).toBe(true); - }); - }); - - describe('getFormEntry()', () => { - it('returns undefined when there is no entry', () => { - expect(getFormEntry(initialState)).toBe(undefined); - }); - it('returns entry when there is an entry on form', () => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - const state = { - ...initialState, - form: { - ...initialState.form, - entry, - }, - }; - expect(getFormEntry(state)).toBe(entry); - }); - }); - describe('getHasNameError()', () => { - it('returns false when there is no entry', () => { - expect(getHasNameError(initialState)).toBeFalsy(); - }); - it('returns true when entry with name error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasNameError: true, - }, - }; - expect(getHasNameError(state)).toBeTruthy(); - }); - it('returns false when entry with no name error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasNameError: false, - }, - }; - expect(getHasNameError(state)).toBeFalsy(); - }); - }); - describe('getFormHasError()', () => { - it('returns false when there is no entry', () => { - expect(getFormHasError(initialState)).toBeFalsy(); - }); - it('returns true when entry with name error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasNameError: true, - }, - }; - expect(getFormHasError(state)).toBeTruthy(); - }); - it('returns true when entry with item error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasItemsError: true, - }, - }; - expect(getFormHasError(state)).toBeTruthy(); - }); - it('returns true when entry with os error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasOSError: true, - }, - }; - expect(getFormHasError(state)).toBeTruthy(); - }); - it('returns true when entry with item error, name error and os error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasItemsError: true, - hasNameError: true, - hasOSError: true, - }, - }; - expect(getFormHasError(state)).toBeTruthy(); - }); - - it('returns false when entry without errors', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasItemsError: false, - hasNameError: false, - hasOSError: false, - }, - }; - expect(getFormHasError(state)).toBeFalsy(); - }); - }); - describe('getCurrentLocation()', () => { - it('returns current locations', () => { - const expectedLocation: EventFiltersPageLocation = { - show: 'create', - page_index: MANAGEMENT_DEFAULT_PAGE, - page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, - filter: 'filter', - included_policies: '1', - }; - const state = { - ...initialState, - location: expectedLocation, - }; - expect(getCurrentLocation(state)).toBe(expectedLocation); - }); - }); - describe('getNewComment()', () => { - it('returns new comment', () => { - const newComment = 'this is a new comment'; - const state = { - ...initialState, - form: { - ...initialState.form, - newComment, - }, - }; - expect(getNewComment(state)).toBe(newComment); - }); - it('returns empty comment', () => { - const state = { - ...initialState, - }; - expect(getNewComment(state)).toBe(''); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts index 398b3d9fa6d37..6edff2d89c416 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { combineReducers, createStore } from 'redux'; import type { FoundExceptionListItemSchema, ExceptionListItemSchema, @@ -17,27 +16,11 @@ import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas import { getSummaryExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_summary_schema.mock'; import { Ecs } from '../../../../../common/ecs'; -import { - MANAGEMENT_STORE_GLOBAL_NAMESPACE, - MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE, -} from '../../../common/constants'; - -import { eventFiltersPageReducer } from '../store/reducer'; import { httpHandlerMockFactory, ResponseProvidersInterface, } from '../../../../common/mock/endpoint/http_handler_mock_factory'; -export const createGlobalNoMiddlewareStore = () => { - return createStore( - combineReducers({ - [MANAGEMENT_STORE_GLOBAL_NAMESPACE]: combineReducers({ - [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: eventFiltersPageReducer, - }), - }) - ); -}; - export const ecsEventMock = (): Ecs => ({ _id: 'unLfz3gB2mJZsMY3ytx3', timestamp: '2021-04-14T15:34:15.330Z', @@ -206,6 +189,8 @@ export const esResponseData = () => ({ ], }, }, + indexFields: [], + indicesExist: [], isPartial: false, isRunning: false, total: 1, diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/types.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/types.ts index f15bd47e0f3e7..b6a7c3b555daa 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/types.ts @@ -12,7 +12,6 @@ import type { UpdateExceptionListItemSchema, ExceptionListSummarySchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { AsyncResourceState } from '../../state/async_resource_state'; import { Immutable } from '../../../../common/endpoint/types'; export interface EventFiltersPageLocation { @@ -25,15 +24,6 @@ export interface EventFiltersPageLocation { included_policies: string; } -export interface EventFiltersForm { - entry: UpdateExceptionListItemSchema | CreateExceptionListItemSchema | undefined; - newComment: string; - hasNameError: boolean; - hasItemsError: boolean; - hasOSError: boolean; - submissionResourceState: AsyncResourceState; -} - export type EventFiltersServiceGetListOptions = Partial<{ page: number; perPage: number; @@ -60,22 +50,3 @@ export interface EventFiltersListPageData { /** The data retrieved from the API */ content: FoundExceptionListItemSchema; } - -export interface EventFiltersListPageState { - entries: ExceptionListItemSchema[]; - form: EventFiltersForm; - location: EventFiltersPageLocation; - /** State for the Event Filters List page */ - listPage: { - active: boolean; - forceRefresh: boolean; - data: AsyncResourceState; - /** tracks if the overall list (not filtered or with invalid page numbers) contains data */ - dataExist: AsyncResourceState; - /** state for deletion of items from the list */ - deletion: { - item: ExceptionListItemSchema | undefined; - status: AsyncResourceState; - }; - }; -} diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx deleted file mode 100644 index e48d4f8fb4d21..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo } from 'react'; -import styled, { css } from 'styled-components'; -import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { ManagementEmptyStateWrapper } from '../../../../../components/management_empty_state_wrapper'; - -const EmptyPrompt = styled(EuiEmptyPrompt)` - ${() => css` - max-width: 100%; - `} -`; - -export const EventFiltersListEmptyState = memo<{ - onAdd: () => void; - /** Should the Add button be disabled */ - isAddDisabled?: boolean; - backComponent?: React.ReactNode; -}>(({ onAdd, isAddDisabled = false, backComponent }) => { - return ( - - - - - } - body={ - - } - actions={[ - - - , - ...(backComponent ? [backComponent] : []), - ]} - /> - - ); -}); - -EventFiltersListEmptyState.displayName = 'EventFiltersListEmptyState'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.test.tsx deleted file mode 100644 index 9e245e5c8214e..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.test.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../common/mock/endpoint'; -import { act } from '@testing-library/react'; -import { fireEvent } from '@testing-library/dom'; -import { EventFilterDeleteModal } from './event_filter_delete_modal'; -import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import { showDeleteModal } from '../../store/selector'; -import { isFailedResourceState, isLoadedResourceState } from '../../../../state'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; - -describe('When event filters delete modal is shown', () => { - let renderAndSetup: ( - customEventFilterProps?: Partial - ) => Promise>; - let renderResult: ReturnType; - let coreStart: AppContextTestRender['coreStart']; - let history: AppContextTestRender['history']; - let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; - let store: AppContextTestRender['store']; - - const getConfirmButton = () => - renderResult.baseElement.querySelector( - '[data-test-subj="eventFilterDeleteModalConfirmButton"]' - ) as HTMLButtonElement; - - const getCancelButton = () => - renderResult.baseElement.querySelector( - '[data-test-subj="eventFilterDeleteModalCancelButton"]' - ) as HTMLButtonElement; - - const getCurrentState = () => store.getState().management.eventFilters; - - beforeEach(() => { - const mockedContext = createAppRootMockRenderer(); - - ({ history, store, coreStart } = mockedContext); - renderAndSetup = async (customEventFilterProps) => { - renderResult = mockedContext.render(); - - await act(async () => { - history.push('/administration/event_filters'); - - await waitForAction('userChangedUrl'); - - mockedContext.store.dispatch({ - type: 'eventFilterForDeletion', - payload: getExceptionListItemSchemaMock({ - id: '123', - name: 'tic-tac-toe', - tags: [], - ...(customEventFilterProps ? customEventFilterProps : {}), - }), - }); - }); - - return renderResult; - }; - - waitForAction = mockedContext.middlewareSpy.waitForAction; - }); - - it("should display calllout when it's assigned to one policy", async () => { - await renderAndSetup({ tags: ['policy:1'] }); - expect(renderResult.getByTestId('eventFilterDeleteModalCalloutMessage').textContent).toMatch( - /Deleting this entry will remove it from 1 associated policy./ - ); - }); - - it("should display calllout when it's assigned to more than one policy", async () => { - await renderAndSetup({ tags: ['policy:1', 'policy:2', 'policy:3'] }); - expect(renderResult.getByTestId('eventFilterDeleteModalCalloutMessage').textContent).toMatch( - /Deleting this entry will remove it from 3 associated policies./ - ); - }); - - it("should display calllout when it's assigned globally", async () => { - await renderAndSetup({ tags: ['policy:all'] }); - expect(renderResult.getByTestId('eventFilterDeleteModalCalloutMessage').textContent).toMatch( - /Deleting this entry will remove it from all associated policies./ - ); - }); - - it("should display calllout when it's unassigned", async () => { - await renderAndSetup({ tags: [] }); - expect(renderResult.getByTestId('eventFilterDeleteModalCalloutMessage').textContent).toMatch( - /Deleting this entry will remove it from 0 associated policies./ - ); - }); - - it('should close dialog if cancel button is clicked', async () => { - await renderAndSetup(); - act(() => { - fireEvent.click(getCancelButton()); - }); - - expect(showDeleteModal(getCurrentState())).toBe(false); - }); - - it('should close dialog if the close X button is clicked', async () => { - await renderAndSetup(); - const dialogCloseButton = renderResult.baseElement.querySelector( - '[aria-label="Closes this modal window"]' - )!; - act(() => { - fireEvent.click(dialogCloseButton); - }); - - expect(showDeleteModal(getCurrentState())).toBe(false); - }); - - it('should disable action buttons when confirmed', async () => { - await renderAndSetup(); - act(() => { - fireEvent.click(getConfirmButton()); - }); - - expect(getCancelButton().disabled).toBe(true); - expect(getConfirmButton().disabled).toBe(true); - }); - - it('should set confirm button to loading', async () => { - await renderAndSetup(); - act(() => { - fireEvent.click(getConfirmButton()); - }); - - expect(getConfirmButton().querySelector('.euiLoadingSpinner')).not.toBeNull(); - }); - - it('should show success toast', async () => { - await renderAndSetup(); - const updateCompleted = waitForAction('eventFilterDeleteStatusChanged', { - validate(action) { - return isLoadedResourceState(action.payload); - }, - }); - - await act(async () => { - fireEvent.click(getConfirmButton()); - await updateCompleted; - }); - - expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( - '"tic-tac-toe" has been removed from the event filters list.' - ); - }); - - it('should show error toast if error is countered', async () => { - coreStart.http.delete.mockRejectedValue(new Error('oh oh')); - await renderAndSetup(); - const updateFailure = waitForAction('eventFilterDeleteStatusChanged', { - validate(action) { - return isFailedResourceState(action.payload); - }, - }); - - await act(async () => { - fireEvent.click(getConfirmButton()); - await updateFailure; - }); - - expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( - 'Unable to remove "tic-tac-toe" from the event filters list. Reason: oh oh' - ); - expect(showDeleteModal(getCurrentState())).toBe(true); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.tsx deleted file mode 100644 index 75e49bf270bab..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.tsx +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - EuiButtonEmpty, - EuiCallOut, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React, { memo, useCallback, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; -import { AutoFocusButton } from '../../../../../common/components/autofocus_button/autofocus_button'; -import { useToasts } from '../../../../../common/lib/kibana'; -import { AppAction } from '../../../../../common/store/actions'; -import { - getArtifactPoliciesIdByTag, - isGlobalPolicyEffected, -} from '../../../../components/effected_policy_select/utils'; -import { - getDeleteError, - getItemToDelete, - isDeletionInProgress, - wasDeletionSuccessful, -} from '../../store/selector'; -import { useEventFiltersSelector } from '../hooks'; - -export const EventFilterDeleteModal = memo<{}>(() => { - const dispatch = useDispatch>(); - const toasts = useToasts(); - - const isDeleting = useEventFiltersSelector(isDeletionInProgress); - const eventFilter = useEventFiltersSelector(getItemToDelete); - const wasDeleted = useEventFiltersSelector(wasDeletionSuccessful); - const deleteError = useEventFiltersSelector(getDeleteError); - - const onCancel = useCallback(() => { - dispatch({ type: 'eventFilterDeletionReset' }); - }, [dispatch]); - - const onConfirm = useCallback(() => { - dispatch({ type: 'eventFilterDeleteSubmit' }); - }, [dispatch]); - - // Show toast for success - useEffect(() => { - if (wasDeleted) { - toasts.addSuccess( - i18n.translate('xpack.securitySolution.eventFilters.deletionDialog.deleteSuccess', { - defaultMessage: '"{name}" has been removed from the event filters list.', - values: { name: eventFilter?.name }, - }) - ); - - dispatch({ type: 'eventFilterDeletionReset' }); - } - }, [dispatch, eventFilter?.name, toasts, wasDeleted]); - - // show toast for failures - useEffect(() => { - if (deleteError) { - toasts.addDanger( - i18n.translate('xpack.securitySolution.eventFilters.deletionDialog.deleteFailure', { - defaultMessage: - 'Unable to remove "{name}" from the event filters list. Reason: {message}', - values: { name: eventFilter?.name, message: deleteError.message }, - }) - ); - } - }, [deleteError, eventFilter?.name, toasts]); - - return ( - - - - {eventFilter?.name ?? ''} }} - /> - - - - - - -

- -

-
- -

- -

-
-
- - - - - - - - - - -
- ); -}); - -EventFilterDeleteModal.displayName = 'EventFilterDeleteModal'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.test.tsx new file mode 100644 index 0000000000000..21bd1fa655c2e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.test.tsx @@ -0,0 +1,222 @@ +/* + * 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 { EventFiltersFlyout, EventFiltersFlyoutProps } from './event_filters_flyout'; +import { act, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; + +import { getInitialExceptionFromEvent } from '../utils'; +import { useCreateArtifact } from '../../../../hooks/artifacts/use_create_artifact'; +import { useGetEndpointSpecificPolicies } from '../../../../services/policies/hooks'; +import { ecsEventMock, esResponseData } from '../../test_utils'; + +import { useKibana, useToasts } from '../../../../../common/lib/kibana'; +import { of } from 'rxjs'; +import { ExceptionsListItemGenerator } from '../../../../../../common/endpoint/data_generators/exceptions_list_item_generator'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +// mocked modules +jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../services/policies/hooks'); +jest.mock('../../../../services/policies/policies'); +jest.mock('../../../../hooks/artifacts/use_create_artifact'); +jest.mock('../utils'); + +let mockedContext: AppContextTestRender; +let render: ( + props?: Partial +) => ReturnType; +let renderResult: ReturnType; +let onCancelMock: jest.Mock; +const exceptionsGenerator = new ExceptionsListItemGenerator(); + +describe('Event filter flyout', () => { + beforeEach(async () => { + mockedContext = createAppRootMockRenderer(); + onCancelMock = jest.fn(); + + (useKibana as jest.Mock).mockReturnValue({ + services: { + docLinks: { + links: { + securitySolution: { + eventFilters: '', + }, + }, + }, + http: {}, + data: { + search: { + search: jest.fn().mockImplementation(() => of(esResponseData())), + }, + }, + notifications: {}, + unifiedSearch: {}, + }, + }); + (useToasts as jest.Mock).mockReturnValue({ + addSuccess: jest.fn(), + addError: jest.fn(), + addWarning: jest.fn(), + remove: jest.fn(), + }); + + (useCreateArtifact as jest.Mock).mockImplementation(() => { + return { + isLoading: false, + mutateAsync: jest.fn(), + }; + }); + + (useGetEndpointSpecificPolicies as jest.Mock).mockImplementation(() => { + return { isLoading: false, isRefetching: false }; + }); + + render = (props) => { + renderResult = mockedContext.render( + + ); + return renderResult; + }; + }); + + afterEach(() => { + cleanup(); + }); + + describe('On initial render', () => { + const exception = exceptionsGenerator.generateEventFilterForCreate({ + meta: {}, + entries: [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: 'a', + }, + { + field: 'process.executable', + operator: 'included', + type: 'match', + value: 'b', + }, + ], + name: '', + }); + beforeEach(() => { + (getInitialExceptionFromEvent as jest.Mock).mockImplementation(() => { + return exception; + }); + }); + it('should render correctly without data ', () => { + render(); + expect(renderResult.getAllByText('Add event filter')).not.toBeNull(); + expect(renderResult.getByText('Cancel')).not.toBeNull(); + }); + + it('should render correctly with data ', () => { + act(() => { + render({ data: ecsEventMock() }); + }); + expect(renderResult.getAllByText('Add endpoint event filter')).not.toBeNull(); + expect(renderResult.getByText('Cancel')).not.toBeNull(); + }); + + it('should start with "add event filter" button disabled', () => { + render(); + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton.hasAttribute('disabled')).toBeTruthy(); + }); + + it('should close when click on cancel button', () => { + render(); + const cancelButton = renderResult.getByTestId('cancelExceptionAddButton'); + expect(onCancelMock).toHaveBeenCalledTimes(0); + + userEvent.click(cancelButton); + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('When valid form state', () => { + const exceptionOptions: Partial = { + meta: {}, + entries: [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: 'a', + }, + { + field: 'process.executable', + operator: 'included', + type: 'match', + value: 'b', + }, + ], + name: 'some name', + }; + beforeEach(() => { + const exception = exceptionsGenerator.generateEventFilterForCreate(exceptionOptions); + (getInitialExceptionFromEvent as jest.Mock).mockImplementation(() => { + return exception; + }); + }); + it('should change to "add event filter" button enabled', () => { + render(); + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton.hasAttribute('disabled')).toBeFalsy(); + }); + it('should prevent close when submitting data', () => { + (useCreateArtifact as jest.Mock).mockImplementation(() => { + return { isLoading: true, mutateAsync: jest.fn() }; + }); + render(); + const cancelButton = renderResult.getByTestId('cancelExceptionAddButton'); + expect(onCancelMock).toHaveBeenCalledTimes(0); + + userEvent.click(cancelButton); + expect(onCancelMock).toHaveBeenCalledTimes(0); + }); + + it('should close when exception has been submitted successfully and close flyout', () => { + // mock submit query + (useCreateArtifact as jest.Mock).mockImplementation(() => { + return { + isLoading: false, + mutateAsync: ( + _: Parameters['mutateAsync']>[0], + options: Parameters['mutateAsync']>[1] + ) => { + if (!options) return; + if (!options.onSuccess) return; + const exception = exceptionsGenerator.generateEventFilter(exceptionOptions); + + options.onSuccess(exception, exception, () => null); + }, + }; + }); + + render(); + + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton.hasAttribute('disabled')).toBeFalsy(); + expect(onCancelMock).toHaveBeenCalledTimes(0); + userEvent.click(confirmButton); + + expect(useToasts().addSuccess).toHaveBeenCalled(); + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.tsx new file mode 100644 index 0000000000000..c370f548e6812 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.tsx @@ -0,0 +1,239 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo, useEffect, useCallback, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiTextColor, +} from '@elastic/eui'; +import { lastValueFrom } from 'rxjs'; + +import { useWithArtifactSubmitData } from '../../../../components/artifact_list_page/hooks/use_with_artifact_submit_data'; +import { + ArtifactFormComponentOnChangeCallbackProps, + ArtifactFormComponentProps, +} from '../../../../components/artifact_list_page/types'; +import { EventFiltersForm } from './form'; + +import { getInitialExceptionFromEvent } from '../utils'; +import { Ecs } from '../../../../../../common/ecs'; +import { useHttp, useKibana, useToasts } from '../../../../../common/lib/kibana'; +import { useGetEndpointSpecificPolicies } from '../../../../services/policies/hooks'; +import { getLoadPoliciesError } from '../../../../common/translations'; + +import { EventFiltersApiClient } from '../../service/api_client'; +import { getCreationSuccessMessage, getCreationErrorMessage } from '../translations'; +export interface EventFiltersFlyoutProps { + data?: Ecs; + onCancel(): void; + maskProps?: { + style?: string; + }; +} + +export const EventFiltersFlyout: React.FC = memo( + ({ onCancel: onClose, data, ...flyoutProps }) => { + const toasts = useToasts(); + const http = useHttp(); + + const { isLoading: isSubmittingData, mutateAsync: submitData } = useWithArtifactSubmitData( + EventFiltersApiClient.getInstance(http), + 'create' + ); + + const [enrichedData, setEnrichedData] = useState(); + const [isFormValid, setIsFormValid] = useState(false); + const { + data: { search }, + } = useKibana().services; + + // load the list of policies> + const policiesRequest = useGetEndpointSpecificPolicies({ + perPage: 1000, + onError: (error) => { + toasts.addWarning(getLoadPoliciesError(error)); + }, + }); + + const [exception, setException] = useState( + getInitialExceptionFromEvent(data) + ); + + const policiesIsLoading = useMemo( + () => policiesRequest.isLoading || policiesRequest.isRefetching, + [policiesRequest] + ); + + useEffect(() => { + const enrichEvent = async () => { + if (!data || !data._index) return; + const searchResponse = await lastValueFrom( + search.search({ + params: { + index: data._index, + body: { + query: { + match: { + _id: data._id, + }, + }, + }, + }, + }) + ); + setEnrichedData({ + ...data, + host: { + ...data.host, + os: { + ...(data?.host?.os || {}), + name: [searchResponse.rawResponse.hits.hits[0]._source.host.os.name], + }, + }, + }); + }; + + if (data) { + enrichEvent(); + } + + return () => { + setException(getInitialExceptionFromEvent()); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleOnClose = useCallback(() => { + if (policiesIsLoading || isSubmittingData) return; + onClose(); + }, [isSubmittingData, policiesIsLoading, onClose]); + + const handleOnSubmit = useCallback(() => { + return submitData(exception, { + onSuccess: (result) => { + toasts.addSuccess(getCreationSuccessMessage(result)); + onClose(); + }, + onError: (error) => { + toasts.addError(error, getCreationErrorMessage(error)); + }, + }); + }, [exception, onClose, submitData, toasts]); + + const confirmButtonMemo = useMemo( + () => ( + + {data ? ( + + ) : ( + + )} + + ), + [data, enrichedData, handleOnSubmit, isFormValid, isSubmittingData, policiesIsLoading] + ); + + // update flyout state with form state + const onChange = useCallback((formState?: ArtifactFormComponentOnChangeCallbackProps) => { + if (!formState) return; + setIsFormValid(formState.isValid); + setException(formState.item); + }, []); + + return ( + + + +

+ {data ? ( + + ) : ( + + )} +

+
+ {data ? ( + + + + ) : null} +
+ + + + + + + + + + + + + {confirmButtonMemo} + + +
+ ); + } +); + +EventFiltersFlyout.displayName = 'EventFiltersFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx deleted file mode 100644 index 0ba0a3385dcb6..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx +++ /dev/null @@ -1,287 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React from 'react'; -import { EventFiltersFlyout, EventFiltersFlyoutProps } from '.'; -import * as reactTestingLibrary from '@testing-library/react'; -import { fireEvent } from '@testing-library/dom'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { MiddlewareActionSpyHelper } from '../../../../../../common/store/test_utils'; -import { sendGetEndpointSpecificPackagePolicies } from '../../../../../services/policies/policies'; -import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../../../services/policies/test_mock_utils'; -import type { - CreateExceptionListItemSchema, - ExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { ecsEventMock, esResponseData, eventFiltersListQueryHttpMock } from '../../../test_utils'; -import { getFormEntryState, isUninitialisedForm } from '../../../store/selector'; -import { EventFiltersListPageState } from '../../../types'; -import { useKibana } from '../../../../../../common/lib/kibana'; -import { licenseService } from '../../../../../../common/hooks/use_license'; -import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import { of } from 'rxjs'; - -jest.mock('../../../../../../common/lib/kibana'); -jest.mock('../form'); -jest.mock('../../../../../services/policies/policies'); - -jest.mock('../../hooks', () => { - const originalModule = jest.requireActual('../../hooks'); - const useEventFiltersNotification = jest.fn().mockImplementation(() => {}); - - return { - ...originalModule, - useEventFiltersNotification, - }; -}); - -jest.mock('../../../../../../common/hooks/use_license', () => { - const licenseServiceInstance = { - isPlatinumPlus: jest.fn(), - }; - return { - licenseService: licenseServiceInstance, - useLicense: () => { - return licenseServiceInstance; - }, - }; -}); - -(sendGetEndpointSpecificPackagePolicies as jest.Mock).mockImplementation( - sendGetEndpointSpecificPackagePoliciesMock -); - -let component: reactTestingLibrary.RenderResult; -let mockedContext: AppContextTestRender; -let waitForAction: MiddlewareActionSpyHelper['waitForAction']; -let render: ( - props?: Partial -) => ReturnType; -const act = reactTestingLibrary.act; -let onCancelMock: jest.Mock; -let getState: () => EventFiltersListPageState; -let mockedApi: ReturnType; - -describe('Event filter flyout', () => { - beforeEach(() => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); - mockedContext = createAppRootMockRenderer(); - waitForAction = mockedContext.middlewareSpy.waitForAction; - onCancelMock = jest.fn(); - getState = () => mockedContext.store.getState().management.eventFilters; - mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); - - render = (props) => { - return mockedContext.render(); - }; - - (useKibana as jest.Mock).mockReturnValue({ - services: { - docLinks: { - links: { - securitySolution: { - eventFilters: '', - }, - }, - }, - http: {}, - data: { - search: { - search: jest.fn().mockImplementation(() => of(esResponseData())), - }, - }, - notifications: {}, - }, - }); - }); - - afterEach(() => reactTestingLibrary.cleanup()); - - it('should renders correctly', () => { - component = render(); - expect(component.getAllByText('Add event filter')).not.toBeNull(); - expect(component.getByText('Cancel')).not.toBeNull(); - }); - - it('should renders correctly with data ', async () => { - await act(async () => { - component = render({ data: ecsEventMock() }); - await waitForAction('eventFiltersInitForm'); - }); - expect(component.getAllByText('Add endpoint event filter')).not.toBeNull(); - expect(component.getByText('Cancel')).not.toBeNull(); - }); - - it('should dispatch action to init form store on mount', async () => { - await act(async () => { - render(); - await waitForAction('eventFiltersInitForm'); - }); - - expect(getFormEntryState(getState())).not.toBeUndefined(); - expect(getFormEntryState(getState())?.entries[0].field).toBe(''); - }); - - it('should confirm form when button is disabled', () => { - component = render(); - const confirmButton = component.getByTestId('add-exception-confirm-button'); - act(() => { - fireEvent.click(confirmButton); - }); - expect(isUninitialisedForm(getState())).toBeTruthy(); - }); - - it('should confirm form when button is enabled', async () => { - component = render(); - - mockedContext.store.dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { - ...(getState().form?.entry as CreateExceptionListItemSchema), - name: 'test', - os_types: ['windows'], - }, - hasNameError: false, - hasOSError: false, - }, - }); - await reactTestingLibrary.waitFor(() => { - expect(sendGetEndpointSpecificPackagePolicies).toHaveBeenCalled(); - }); - const confirmButton = component.getByTestId('add-exception-confirm-button'); - - await act(async () => { - fireEvent.click(confirmButton); - await waitForAction('eventFiltersCreateSuccess'); - }); - expect(isUninitialisedForm(getState())).toBeTruthy(); - expect(confirmButton.hasAttribute('disabled')).toBeFalsy(); - }); - - it('should close when exception has been submitted correctly', () => { - render(); - expect(onCancelMock).toHaveBeenCalledTimes(0); - - act(() => { - mockedContext.store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: getState().form?.entry as ExceptionListItemSchema, - }, - }); - }); - - expect(onCancelMock).toHaveBeenCalledTimes(1); - }); - - it('should close when click on cancel button', () => { - component = render(); - const cancelButton = component.getByText('Cancel'); - expect(onCancelMock).toHaveBeenCalledTimes(0); - - act(() => { - fireEvent.click(cancelButton); - }); - - expect(onCancelMock).toHaveBeenCalledTimes(1); - }); - - it('should close when close flyout', () => { - component = render(); - const flyoutCloseButton = component.getByTestId('euiFlyoutCloseButton'); - expect(onCancelMock).toHaveBeenCalledTimes(0); - - act(() => { - fireEvent.click(flyoutCloseButton); - }); - - expect(onCancelMock).toHaveBeenCalledTimes(1); - }); - - it('should prevent close when is loading action', () => { - component = render(); - act(() => { - mockedContext.store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadingResourceState', - previousState: { type: 'UninitialisedResourceState' }, - }, - }); - }); - - const cancelButton = component.getByText('Cancel'); - expect(onCancelMock).toHaveBeenCalledTimes(0); - - act(() => { - fireEvent.click(cancelButton); - }); - - expect(onCancelMock).toHaveBeenCalledTimes(0); - }); - - it('should renders correctly when id and edit type', () => { - component = render({ id: 'fakeId', type: 'edit' }); - - expect(component.getAllByText('Update event filter')).not.toBeNull(); - expect(component.getByText('Cancel')).not.toBeNull(); - }); - - it('should dispatch action to init form store on mount with id', async () => { - await act(async () => { - render({ id: 'fakeId', type: 'edit' }); - await waitForAction('eventFiltersInitFromId'); - }); - - expect(getFormEntryState(getState())).not.toBeUndefined(); - expect(getFormEntryState(getState())?.item_id).toBe( - mockedApi.responseProvider.eventFiltersGetOne.getMockImplementation()!().item_id - ); - }); - - it('should not display banner when platinum license', async () => { - await act(async () => { - component = render({ id: 'fakeId', type: 'edit' }); - await waitForAction('eventFiltersInitFromId'); - }); - - expect(component.queryByTestId('expired-license-callout')).toBeNull(); - }); - - it('should not display banner when under platinum license and create mode', async () => { - component = render(); - expect(component.queryByTestId('expired-license-callout')).toBeNull(); - }); - - it('should not display banner when under platinum license and edit mode with global assignment', async () => { - mockedApi.responseProvider.eventFiltersGetOne.mockReturnValue({ - ...getExceptionListItemSchemaMock(), - tags: ['policy:all'], - }); - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - await act(async () => { - component = render({ id: 'fakeId', type: 'edit' }); - await waitForAction('eventFiltersInitFromId'); - }); - - expect(component.queryByTestId('expired-license-callout')).toBeNull(); - }); - - it('should display banner when under platinum license and edit mode with by policy assignment', async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - await act(async () => { - component = render({ id: 'fakeId', type: 'edit' }); - await waitForAction('eventFiltersInitFromId'); - }); - - expect(component.queryByTestId('expired-license-callout')).not.toBeNull(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx deleted file mode 100644 index ed4e0e11975c7..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx +++ /dev/null @@ -1,302 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo, useEffect, useCallback, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; - -import { FormattedMessage } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; -import { - EuiFlyout, - EuiFlyoutHeader, - EuiTitle, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiTextColor, - EuiCallOut, - EuiLink, -} from '@elastic/eui'; -import { lastValueFrom } from 'rxjs'; -import { AppAction } from '../../../../../../common/store/actions'; -import { EventFiltersForm } from '../form'; -import { useEventFiltersSelector, useEventFiltersNotification } from '../../hooks'; -import { - getFormEntryStateMutable, - getFormHasError, - isCreationInProgress, - isCreationSuccessful, -} from '../../../store/selector'; -import { getInitialExceptionFromEvent } from '../../../store/utils'; -import { Ecs } from '../../../../../../../common/ecs'; -import { useKibana, useToasts } from '../../../../../../common/lib/kibana'; -import { useGetEndpointSpecificPolicies } from '../../../../../services/policies/hooks'; -import { getLoadPoliciesError } from '../../../../../common/translations'; -import { useLicense } from '../../../../../../common/hooks/use_license'; -import { isGlobalPolicyEffected } from '../../../../../components/effected_policy_select/utils'; - -export interface EventFiltersFlyoutProps { - type?: 'create' | 'edit'; - id?: string; - data?: Ecs; - onCancel(): void; - maskProps?: { - style?: string; - }; -} - -export const EventFiltersFlyout: React.FC = memo( - ({ onCancel, id, type = 'create', data, ...flyoutProps }) => { - useEventFiltersNotification(); - const [enrichedData, setEnrichedData] = useState(); - const toasts = useToasts(); - const dispatch = useDispatch>(); - const formHasError = useEventFiltersSelector(getFormHasError); - const creationInProgress = useEventFiltersSelector(isCreationInProgress); - const creationSuccessful = useEventFiltersSelector(isCreationSuccessful); - const exception = useEventFiltersSelector(getFormEntryStateMutable); - const { - data: { search }, - docLinks, - } = useKibana().services; - - // load the list of policies> - const policiesRequest = useGetEndpointSpecificPolicies({ - perPage: 1000, - onError: (error) => { - toasts.addWarning(getLoadPoliciesError(error)); - }, - }); - - const isPlatinumPlus = useLicense().isPlatinumPlus(); - const isEditMode = useMemo(() => type === 'edit' && !!id, [type, id]); - const [wasByPolicy, setWasByPolicy] = useState(undefined); - - const showExpiredLicenseBanner = useMemo(() => { - return !isPlatinumPlus && isEditMode && wasByPolicy; - }, [isPlatinumPlus, isEditMode, wasByPolicy]); - - useEffect(() => { - if (exception && wasByPolicy === undefined) { - setWasByPolicy(!isGlobalPolicyEffected(exception?.tags)); - } - }, [exception, wasByPolicy]); - - useEffect(() => { - if (creationSuccessful) { - onCancel(); - dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'UninitialisedResourceState', - }, - }); - } - }, [creationSuccessful, onCancel, dispatch]); - - // Initialize the store with the id passed as prop to allow render the form. It acts as componentDidMount - useEffect(() => { - const enrichEvent = async () => { - if (!data || !data._index) return; - const searchResponse = await lastValueFrom( - search.search({ - params: { - index: data._index, - body: { - query: { - match: { - _id: data._id, - }, - }, - }, - }, - }) - ); - - setEnrichedData({ - ...data, - host: { - ...data.host, - os: { - ...(data?.host?.os || {}), - name: [searchResponse.rawResponse.hits.hits[0]._source.host.os.name], - }, - }, - }); - }; - - if (type === 'edit' && !!id) { - dispatch({ - type: 'eventFiltersInitFromId', - payload: { id }, - }); - } else if (data) { - enrichEvent(); - } else { - dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: getInitialExceptionFromEvent() }, - }); - } - - return () => { - dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'UninitialisedResourceState', - }, - }); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Initialize the store with the enriched event to allow render the form - useEffect(() => { - if (enrichedData) { - dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: getInitialExceptionFromEvent(enrichedData) }, - }); - } - }, [dispatch, enrichedData]); - - const handleOnCancel = useCallback(() => { - if (creationInProgress) return; - onCancel(); - }, [creationInProgress, onCancel]); - - const confirmButtonMemo = useMemo( - () => ( - - id - ? dispatch({ type: 'eventFiltersUpdateStart' }) - : dispatch({ type: 'eventFiltersCreateStart' }) - } - isLoading={creationInProgress} - > - {id ? ( - - ) : data ? ( - - ) : ( - - )} - - ), - [formHasError, creationInProgress, data, enrichedData, id, dispatch, policiesRequest] - ); - - return ( - - - -

- {id ? ( - - ) : data ? ( - - ) : ( - - )} -

-
- {data ? ( - - - - ) : null} -
- - {showExpiredLicenseBanner && ( - - - - - - - )} - - - - - - - - - - - - - {confirmButtonMemo} - - -
- ); - } -); - -EventFiltersFlyout.displayName = 'EventFiltersFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx new file mode 100644 index 0000000000000..e20abb2f93264 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx @@ -0,0 +1,468 @@ +/* + * 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 { act, cleanup } from '@testing-library/react'; +import { fireEvent } from '@testing-library/dom'; +import { stubIndexPattern } from '@kbn/data-plugin/common/stubs'; +import { useFetchIndex } from '../../../../../common/containers/source'; +import { NAME_ERROR } from '../event_filters_list'; +import { useCurrentUser, useKibana } from '../../../../../common/lib/kibana'; +import { licenseService } from '../../../../../common/hooks/use_license'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; +import userEvent from '@testing-library/user-event'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { + ArtifactFormComponentOnChangeCallbackProps, + ArtifactFormComponentProps, +} from '../../../../components/artifact_list_page'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { EventFiltersForm } from './form'; +import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data'; +import { PolicyData } from '../../../../../../common/endpoint/types'; + +jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../../common/containers/source'); +jest.mock('../../../../../common/hooks/use_license', () => { + const licenseServiceInstance = { + isPlatinumPlus: jest.fn(), + isGoldPlus: jest.fn(), + }; + return { + licenseService: licenseServiceInstance, + useLicense: () => { + return licenseServiceInstance; + }, + }; +}); + +describe('Event filter form', () => { + const formPrefix = 'eventFilters-form'; + const generator = new EndpointDocGenerator('effected-policy-select'); + + let formProps: jest.Mocked; + let mockedContext: AppContextTestRender; + let renderResult: ReturnType; + let latestUpdatedItem: ArtifactFormComponentProps['item']; + + const getUI = () => ; + const render = () => { + return (renderResult = mockedContext.render(getUI())); + }; + const rerender = () => renderResult.rerender(getUI()); + const rerenderWithLatestProps = () => { + formProps.item = latestUpdatedItem; + rerender(); + }; + + function createEntry( + overrides?: ExceptionListItemSchema['entries'][number] + ): ExceptionListItemSchema['entries'][number] { + const defaultEntry: ExceptionListItemSchema['entries'][number] = { + field: '', + operator: 'included', + type: 'match', + value: '', + }; + + return { + ...defaultEntry, + ...overrides, + }; + } + + function createItem( + overrides: Partial = {} + ): ArtifactFormComponentProps['item'] { + const defaults: ArtifactFormComponentProps['item'] = { + id: 'some_item_id', + list_id: ENDPOINT_EVENT_FILTERS_LIST_ID, + name: '', + description: '', + os_types: [OperatingSystem.WINDOWS], + entries: [createEntry()], + type: 'simple', + tags: ['policy:all'], + }; + return { + ...defaults, + ...overrides, + }; + } + + function createOnChangeArgs( + overrides: Partial + ): ArtifactFormComponentOnChangeCallbackProps { + const defaults = { + item: createItem(), + isValid: false, + }; + return { + ...defaults, + ...overrides, + }; + } + + function createPolicies(): PolicyData[] { + const policies = [ + generator.generatePolicyPackagePolicy(), + generator.generatePolicyPackagePolicy(), + ]; + policies.map((p, i) => { + p.id = `id-${i}`; + p.name = `some-policy-${Math.random().toString(36).split('.').pop()}`; + return p; + }); + return policies; + } + + beforeEach(async () => { + (useCurrentUser as jest.Mock).mockReturnValue({ username: 'test-username' }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + http: {}, + data: {}, + unifiedSearch: {}, + notifications: {}, + }, + }); + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); + mockedContext = createAppRootMockRenderer(); + latestUpdatedItem = createItem(); + (useFetchIndex as jest.Mock).mockImplementation(() => [ + false, + { + indexPatterns: stubIndexPattern, + }, + ]); + + formProps = { + item: latestUpdatedItem, + mode: 'create', + disabled: false, + error: undefined, + policiesIsLoading: false, + onChange: jest.fn((updates) => { + latestUpdatedItem = updates.item; + }), + policies: [], + }; + }); + + afterEach(() => { + cleanup(); + }); + + describe('Details and Conditions', () => { + it('should render correctly without data', () => { + formProps.policies = createPolicies(); + formProps.policiesIsLoading = true; + formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]]; + formProps.item.entries = []; + render(); + expect(renderResult.getByTestId('loading-spinner')).not.toBeNull(); + }); + + it('should render correctly with data', async () => { + formProps.policies = createPolicies(); + render(); + expect(renderResult.queryByTestId('loading-spinner')).toBeNull(); + expect(renderResult.getByTestId('exceptionsBuilderWrapper')).not.toBeNull(); + }); + + it('should display sections', async () => { + render(); + expect(renderResult.queryByText('Details')).not.toBeNull(); + expect(renderResult.queryByText('Conditions')).not.toBeNull(); + expect(renderResult.queryByText('Comments')).not.toBeNull(); + }); + + it('should display name error only when on blur and empty name', async () => { + render(); + expect(renderResult.queryByText(NAME_ERROR)).toBeNull(); + const nameInput = renderResult.getByTestId(`${formPrefix}-name-input`); + act(() => { + fireEvent.blur(nameInput); + }); + rerenderWithLatestProps(); + expect(renderResult.queryByText(NAME_ERROR)).not.toBeNull(); + }); + + it('should change name', async () => { + render(); + const nameInput = renderResult.getByTestId(`${formPrefix}-name-input`); + + act(() => { + fireEvent.change(nameInput, { + target: { + value: 'Exception name', + }, + }); + fireEvent.blur(nameInput); + }); + rerenderWithLatestProps(); + + expect(formProps.item?.name).toBe('Exception name'); + expect(renderResult.queryByText(NAME_ERROR)).toBeNull(); + }); + + it('should change name with a white space still shows an error', async () => { + render(); + const nameInput = renderResult.getByTestId(`${formPrefix}-name-input`); + + act(() => { + fireEvent.change(nameInput, { + target: { + value: ' ', + }, + }); + fireEvent.blur(nameInput); + }); + rerenderWithLatestProps(); + + expect(formProps.item.name).toBe(''); + expect(renderResult.queryByText(NAME_ERROR)).not.toBeNull(); + }); + + it('should change description', async () => { + render(); + const nameInput = renderResult.getByTestId(`${formPrefix}-description-input`); + + act(() => { + fireEvent.change(nameInput, { + target: { + value: 'Exception description', + }, + }); + fireEvent.blur(nameInput); + }); + rerenderWithLatestProps(); + + expect(formProps.item.description).toBe('Exception description'); + }); + + it('should change comments', async () => { + render(); + const commentInput = renderResult.getByLabelText('Comment Input'); + + act(() => { + fireEvent.change(commentInput, { + target: { + value: 'Exception comment', + }, + }); + fireEvent.blur(commentInput); + }); + rerenderWithLatestProps(); + + expect(formProps.item.comments).toEqual([{ comment: 'Exception comment' }]); + }); + }); + + describe('Policy section', () => { + beforeEach(() => { + formProps.policies = createPolicies(); + }); + + afterEach(() => { + cleanup(); + }); + + it('should display loader when policies are still loading', () => { + formProps.policiesIsLoading = true; + formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]]; + render(); + expect(renderResult.getByTestId('loading-spinner')).not.toBeNull(); + }); + + it('should display the policy list when "per policy" is selected', async () => { + render(); + userEvent.click(renderResult.getByTestId('perPolicy')); + rerenderWithLatestProps(); + // policy selector should show up + expect(renderResult.getByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); + }); + + it('should call onChange when a policy is selected from the policy selection', async () => { + formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]]; + render(); + const policyId = formProps.policies[0].id; + userEvent.click(renderResult.getByTestId('effectedPolicies-select-perPolicy')); + userEvent.click(renderResult.getByTestId(`policy-${policyId}`)); + formProps.item.tags = formProps.onChange.mock.calls[0][0].item.tags; + rerender(); + const expected = createOnChangeArgs({ + item: { + ...formProps.item, + tags: [`policy:${policyId}`], + }, + }); + expect(formProps.onChange).toHaveBeenCalledWith(expected); + }); + + it('should have global policy by default', async () => { + render(); + expect(renderResult.getByTestId('globalPolicy')).toBeChecked(); + expect(renderResult.getByTestId('perPolicy')).not.toBeChecked(); + }); + + it('should retain the previous policy selection when switching from per-policy to global', async () => { + formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]]; + render(); + const policyId = formProps.policies[0].id; + + // move to per-policy and select the first + userEvent.click(renderResult.getByTestId('perPolicy')); + userEvent.click(renderResult.getByTestId(`policy-${policyId}`)); + formProps.item.tags = formProps.onChange.mock.calls[0][0].item.tags; + rerender(); + expect(renderResult.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); + expect(formProps.item.tags).toEqual([`policy:${policyId}`]); + + // move back to global + userEvent.click(renderResult.getByTestId('globalPolicy')); + formProps.item.tags = ['policy:all']; + rerenderWithLatestProps(); + expect(formProps.item.tags).toEqual(['policy:all']); + expect(renderResult.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeFalsy(); + + // move back to per-policy + userEvent.click(renderResult.getByTestId('perPolicy')); + formProps.item.tags = [`policy:${policyId}`]; + rerender(); + // on change called with the previous policy + expect(formProps.item.tags).toEqual([`policy:${policyId}`]); + // the previous selected policy should be selected + // expect(renderResult.getByTestId(`policy-${policyId}`)).toHaveAttribute( + // 'data-test-selected', + // 'true' + // ); + }); + }); + + describe('Policy section with downgraded license', () => { + beforeEach(() => { + const policies = createPolicies(); + formProps.policies = policies; + formProps.item.tags = [policies.map((p) => `policy:${p.id}`)[0]]; + formProps.mode = 'edit'; + // downgrade license + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); + }); + + it('should hide assignment section when no license', () => { + render(); + formProps.item.tags = ['policy:all']; + rerender(); + expect(renderResult.queryByTestId('effectedPolicies-select')).toBeNull(); + }); + + it('should hide assignment section when create mode and no license even with by policy', () => { + render(); + formProps.mode = 'create'; + rerender(); + expect(renderResult.queryByTestId('effectedPolicies-select')).toBeNull(); + }); + + it('should show disabled assignment section when edit mode and no license with by policy', async () => { + render(); + formProps.item.tags = ['policy:id-0']; + rerender(); + + expect(renderResult.queryByTestId('perPolicy')).not.toBeNull(); + expect(renderResult.getByTestId('policy-id-0').getAttribute('aria-disabled')).toBe('true'); + }); + + it("allows the user to set the event filter entry to 'Global' in the edit option", () => { + render(); + const globalButtonInput = renderResult.getByTestId('globalPolicy') as HTMLButtonElement; + userEvent.click(globalButtonInput); + formProps.item.tags = ['policy:all']; + rerender(); + const expected = createOnChangeArgs({ + item: { + ...formProps.item, + tags: ['policy:all'], + }, + }); + expect(formProps.onChange).toHaveBeenCalledWith(expected); + + const policyItem = formProps.onChange.mock.calls[0][0].item.tags + ? formProps.onChange.mock.calls[0][0].item.tags[0] + : ''; + + expect(policyItem).toBe('policy:all'); + }); + }); + + describe('Warnings', () => { + beforeEach(() => { + render(); + }); + + it('should not show warning text when unique fields are added', async () => { + formProps.item.entries = [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: 'some value', + }, + { + field: 'file.name', + operator: 'excluded', + type: 'match', + value: 'some other value', + }, + ]; + rerender(); + expect(renderResult.queryByTestId('duplicate-fields-warning-message')).toBeNull(); + }); + + it('should not show warning text when field values are not added', async () => { + formProps.item.entries = [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: '', + }, + { + field: 'event.category', + operator: 'excluded', + type: 'match', + value: '', + }, + ]; + rerender(); + expect(renderResult.queryByTestId('duplicate-fields-warning-message')).toBeNull(); + }); + + it('should show warning text when duplicate fields are added with values', async () => { + formProps.item.entries = [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: 'some value', + }, + { + field: 'event.category', + operator: 'excluded', + type: 'match', + value: 'some other value', + }, + ]; + rerender(); + expect(renderResult.findByTestId('duplicate-fields-warning-message')).not.toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx new file mode 100644 index 0000000000000..4e021d12dac36 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx @@ -0,0 +1,558 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo, useCallback, useState, useEffect } from 'react'; + +import { isEqual } from 'lodash'; +import { + EuiFieldText, + EuiSpacer, + EuiForm, + EuiFormRow, + EuiSuperSelect, + EuiSuperSelectOption, + EuiText, + EuiHorizontalRule, + EuiTextArea, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { EVENT_FILTERS_OPERATORS } from '@kbn/securitysolution-list-utils'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; + +import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; +import type { OnChangeProps } from '@kbn/lists-plugin/public'; +import { useTestIdGenerator } from '../../../../components/hooks/use_test_id_generator'; +import { PolicyData } from '../../../../../../common/endpoint/types'; +import { AddExceptionComments } from '../../../../../common/components/exceptions/add_exception_comments'; +import { useFetchIndex } from '../../../../../common/containers/source'; +import { Loader } from '../../../../../common/components/loader'; +import { useLicense } from '../../../../../common/hooks/use_license'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { ArtifactFormComponentProps } from '../../../../components/artifact_list_page'; +import { filterIndexPatterns } from '../../../../../common/components/exceptions/helpers'; +import { + isArtifactGlobal, + getPolicyIdsFromArtifact, + GLOBAL_ARTIFACT_TAG, + BY_POLICY_ARTIFACT_TAG_PREFIX, +} from '../../../../../../common/endpoint/service/artifacts'; + +import { + ABOUT_EVENT_FILTERS, + NAME_LABEL, + NAME_ERROR, + DESCRIPTION_LABEL, + OS_LABEL, + RULE_NAME, +} from '../event_filters_list'; +import { OS_TITLES } from '../../../../common/translations'; +import { ENDPOINT_EVENT_FILTERS_LIST_ID, EVENT_FILTER_LIST_TYPE } from '../../constants'; + +import { + EffectedPolicySelect, + EffectedPolicySelection, +} from '../../../../components/effected_policy_select'; +import { isGlobalPolicyEffected } from '../../../../components/effected_policy_select/utils'; + +const OPERATING_SYSTEMS: readonly OperatingSystem[] = [ + OperatingSystem.MAC, + OperatingSystem.WINDOWS, + OperatingSystem.LINUX, +]; + +// OS options +const osOptions: Array> = OPERATING_SYSTEMS.map((os) => ({ + value: os, + inputDisplay: OS_TITLES[os], +})); + +const getAddedFieldsCounts = (formFields: string[]): { [k: string]: number } => + formFields.reduce<{ [k: string]: number }>((allFields, field) => { + if (field in allFields) { + allFields[field]++; + } else { + allFields[field] = 1; + } + return allFields; + }, {}); + +const computeHasDuplicateFields = (formFieldsList: Record): boolean => + Object.values(formFieldsList).some((e) => e > 1); + +const defaultConditionEntry = (): ExceptionListItemSchema['entries'] => [ + { + field: '', + operator: 'included', + type: 'match', + value: '', + }, +]; + +const cleanupEntries = ( + item: ArtifactFormComponentProps['item'] +): ArtifactFormComponentProps['item']['entries'] => { + return item.entries.map( + (e: ArtifactFormComponentProps['item']['entries'][number] & { id?: string }) => { + delete e.id; + return e; + } + ); +}; + +type EventFilterItemEntries = Array<{ + field: string; + value: string; + operator: 'included' | 'excluded'; + type: Exclude; +}>; + +export const EventFiltersForm: React.FC = + memo(({ allowSelectOs = true, item: exception, policies, policiesIsLoading, onChange, mode }) => { + const getTestId = useTestIdGenerator('eventFilters-form'); + const { http, unifiedSearch } = useKibana().services; + + const [hasFormChanged, setHasFormChanged] = useState(false); + const [hasNameError, toggleHasNameError] = useState(!exception.name); + const [newComment, setNewComment] = useState(''); + const [hasBeenInputNameVisited, setHasBeenInputNameVisited] = useState(false); + const [selectedPolicies, setSelectedPolicies] = useState([]); + const isPlatinumPlus = useLicense().isPlatinumPlus(); + const isGlobal = useMemo( + () => isArtifactGlobal(exception as ExceptionListItemSchema), + [exception] + ); + const [wasByPolicy, setWasByPolicy] = useState(!isGlobalPolicyEffected(exception?.tags)); + + const [hasDuplicateFields, setHasDuplicateFields] = useState(false); + // This value has to be memoized to avoid infinite useEffect loop on useFetchIndex + const indexNames = useMemo(() => ['logs-endpoint.events.*'], []); + const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex(indexNames); + const [areConditionsValid, setAreConditionsValid] = useState( + !!exception.entries.length || false + ); + // compute this for initial render only + const existingComments = useMemo( + () => (exception as ExceptionListItemSchema)?.comments, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const showAssignmentSection = useMemo(() => { + return ( + isPlatinumPlus || + (mode === 'edit' && (!isGlobal || (wasByPolicy && isGlobal && hasFormChanged))) + ); + }, [mode, isGlobal, hasFormChanged, isPlatinumPlus, wasByPolicy]); + + const isFormValid = useMemo(() => { + // verify that it has legit entries + // and not just default entry without values + return ( + !hasNameError && + !!exception.entries.length && + (exception.entries as EventFilterItemEntries).some((e) => e.value !== '' || e.value.length) + ); + }, [hasNameError, exception.entries]); + + const processChanged = useCallback( + (updatedItem?: Partial) => { + const item = updatedItem + ? { + ...exception, + ...updatedItem, + } + : exception; + cleanupEntries(item); + onChange({ + item, + isValid: isFormValid && areConditionsValid, + }); + }, + [areConditionsValid, exception, isFormValid, onChange] + ); + + // set initial state of `wasByPolicy` that checks + // if the initial state of the exception was by policy or not + useEffect(() => { + if (!hasFormChanged && exception.tags) { + setWasByPolicy(!isGlobalPolicyEffected(exception.tags)); + } + }, [exception.tags, hasFormChanged]); + + // select policies if editing + useEffect(() => { + if (hasFormChanged) return; + const policyIds = exception.tags ? getPolicyIdsFromArtifact({ tags: exception.tags }) : []; + + if (!policyIds.length) return; + const policiesData = policies.filter((policy) => policyIds.includes(policy.id)); + setSelectedPolicies(policiesData); + }, [hasFormChanged, exception, policies]); + + const eventFilterItem = useMemo(() => { + const ef: ArtifactFormComponentProps['item'] = exception; + ef.entries = exception.entries.length + ? (exception.entries as ExceptionListItemSchema['entries']) + : defaultConditionEntry(); + + // TODO: `id` gets added to the exception.entries item + // Is there a simpler way to this? + cleanupEntries(ef); + + setAreConditionsValid(!!exception.entries.length); + return ef; + }, [exception]); + + // name and handler + const handleOnChangeName = useCallback( + (event: React.ChangeEvent) => { + if (!exception) return; + const name = event.target.value.trim(); + toggleHasNameError(!name); + processChanged({ name }); + if (!hasFormChanged) setHasFormChanged(true); + }, + [exception, hasFormChanged, processChanged] + ); + + const nameInputMemo = useMemo( + () => ( + + !hasBeenInputNameVisited && setHasBeenInputNameVisited(true)} + /> + + ), + [getTestId, hasNameError, handleOnChangeName, hasBeenInputNameVisited, exception?.name] + ); + + // description and handler + const handleOnDescriptionChange = useCallback( + (event: React.ChangeEvent) => { + if (!exception) return; + if (!hasFormChanged) setHasFormChanged(true); + processChanged({ description: event.target.value.toString().trim() }); + }, + [exception, hasFormChanged, processChanged] + ); + const descriptionInputMemo = useMemo( + () => ( + + + + ), + [exception?.description, getTestId, handleOnDescriptionChange] + ); + + // selected OS and handler + const selectedOs = useMemo((): OperatingSystem => { + if (!exception?.os_types?.length) { + return OperatingSystem.WINDOWS; + } + return exception.os_types[0] as OperatingSystem; + }, [exception?.os_types]); + + const handleOnOsChange = useCallback( + (os: OperatingSystem) => { + if (!exception) return; + processChanged({ + os_types: [os], + entries: exception.entries, + }); + if (!hasFormChanged) setHasFormChanged(true); + }, + [exception, hasFormChanged, processChanged] + ); + + const osInputMemo = useMemo( + () => ( + + + + ), + [handleOnOsChange, selectedOs] + ); + + // comments and handler + const handleOnChangeComment = useCallback( + (value: string) => { + if (!exception) return; + setNewComment(value); + processChanged({ comments: [{ comment: value }] }); + if (!hasFormChanged) setHasFormChanged(true); + }, + [exception, hasFormChanged, processChanged] + ); + const commentsInputMemo = useMemo( + () => ( + + ), + [existingComments, handleOnChangeComment, newComment] + ); + + // comments + const commentsSection = useMemo( + () => ( + <> + +

+ +

+
+ + +

+ +

+
+ + {commentsInputMemo} + + ), + [commentsInputMemo] + ); + + // details + const detailsSection = useMemo( + () => ( + <> + +

+ +

+
+ + +

{ABOUT_EVENT_FILTERS}

+
+ + {nameInputMemo} + {descriptionInputMemo} + + ), + [nameInputMemo, descriptionInputMemo] + ); + + // conditions and handler + const handleOnBuilderChange = useCallback( + (arg: OnChangeProps) => { + const hasDuplicates = + (!hasFormChanged && arg.exceptionItems[0] === undefined) || + isEqual(arg.exceptionItems[0]?.entries, exception?.entries); + if (hasDuplicates) { + const addedFields = arg.exceptionItems[0]?.entries.map((e) => e.field) || ['']; + setHasDuplicateFields(computeHasDuplicateFields(getAddedFieldsCounts(addedFields))); + if (!hasFormChanged) setHasFormChanged(true); + return; + } + const updatedItem: Partial = + arg.exceptionItems[0] !== undefined + ? { + ...arg.exceptionItems[0], + name: exception?.name ?? '', + description: exception?.description ?? '', + comments: exception?.comments ?? [], + os_types: exception?.os_types ?? [OperatingSystem.WINDOWS], + tags: exception?.tags ?? [], + } + : exception; + const hasValidConditions = + arg.exceptionItems[0] !== undefined + ? !(arg.errorExists && !arg.exceptionItems[0]?.entries?.length) + : false; + + setAreConditionsValid(hasValidConditions); + processChanged(updatedItem); + if (!hasFormChanged) setHasFormChanged(true); + }, + [exception, hasFormChanged, processChanged] + ); + const exceptionBuilderComponentMemo = useMemo( + () => + getExceptionBuilderComponentLazy({ + allowLargeValueLists: false, + httpService: http, + autocompleteService: unifiedSearch.autocomplete, + exceptionListItems: [eventFilterItem as ExceptionListItemSchema], + listType: EVENT_FILTER_LIST_TYPE, + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + listNamespaceType: 'agnostic', + ruleName: RULE_NAME, + indexPatterns, + isOrDisabled: true, + isOrHidden: true, + isAndDisabled: false, + isNestedDisabled: false, + dataTestSubj: 'alert-exception-builder', + idAria: 'alert-exception-builder', + onChange: handleOnBuilderChange, + listTypeSpecificIndexPatternFilter: filterIndexPatterns, + operatorsList: EVENT_FILTERS_OPERATORS, + osTypes: exception.os_types, + }), + [unifiedSearch, handleOnBuilderChange, http, indexPatterns, exception, eventFilterItem] + ); + + // conditions + const criteriaSection = useMemo( + () => ( + <> + +

+ +

+
+ + +

+ {allowSelectOs ? ( + + ) : ( + + )} +

+
+ + {allowSelectOs ? ( + <> + {osInputMemo} + + + ) : null} + {exceptionBuilderComponentMemo} + + ), + [allowSelectOs, exceptionBuilderComponentMemo, osInputMemo] + ); + + // policy and handler + const handleOnPolicyChange = useCallback( + (change: EffectedPolicySelection) => { + const tags = change.isGlobal + ? [GLOBAL_ARTIFACT_TAG] + : change.selected.map((policy) => `${BY_POLICY_ARTIFACT_TAG_PREFIX}${policy.id}`); + + // Preserve old selected policies when switching to global + if (!change.isGlobal) { + setSelectedPolicies(change.selected); + } + processChanged({ tags }); + if (!hasFormChanged) setHasFormChanged(true); + }, + [processChanged, hasFormChanged, setSelectedPolicies] + ); + + const policiesSection = useMemo( + () => ( + + ), + [ + policies, + selectedPolicies, + isGlobal, + isPlatinumPlus, + handleOnPolicyChange, + policiesIsLoading, + ] + ); + + useEffect(() => { + processChanged(); + }, [processChanged]); + + if (isIndexPatternLoading || !exception) { + return ; + } + + return ( + + {detailsSection} + + {criteriaSection} + {hasDuplicateFields && ( + <> + + + + + + )} + {showAssignmentSection && ( + <> + + {policiesSection} + + )} + + {commentsSection} + + ); + }); + +EventFiltersForm.displayName = 'EventFiltersForm'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.test.tsx deleted file mode 100644 index f0589099a8077..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.test.tsx +++ /dev/null @@ -1,338 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React from 'react'; -import { EventFiltersForm } from '.'; -import { RenderResult, act } from '@testing-library/react'; -import { fireEvent, waitFor } from '@testing-library/dom'; -import { stubIndexPattern } from '@kbn/data-plugin/common/stubs'; -import { getInitialExceptionFromEvent } from '../../../store/utils'; -import { useFetchIndex } from '../../../../../../common/containers/source'; -import { ecsEventMock } from '../../../test_utils'; -import { NAME_ERROR, NAME_PLACEHOLDER } from './translations'; -import { useCurrentUser, useKibana } from '../../../../../../common/lib/kibana'; -import { licenseService } from '../../../../../../common/hooks/use_license'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { EventFiltersListPageState } from '../../../types'; -import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../../../services/policies/test_mock_utils'; -import { GetPolicyListResponse } from '../../../../policy/types'; -import userEvent from '@testing-library/user-event'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; - -jest.mock('../../../../../../common/lib/kibana'); -jest.mock('../../../../../../common/containers/source'); -jest.mock('../../../../../../common/hooks/use_license', () => { - const licenseServiceInstance = { - isPlatinumPlus: jest.fn(), - }; - return { - licenseService: licenseServiceInstance, - useLicense: () => { - return licenseServiceInstance; - }, - }; -}); - -describe('Event filter form', () => { - let component: RenderResult; - let mockedContext: AppContextTestRender; - let render: ( - props?: Partial> - ) => ReturnType; - let renderWithData: ( - customEventFilterProps?: Partial - ) => Promise>; - let getState: () => EventFiltersListPageState; - let policiesRequest: GetPolicyListResponse; - - beforeEach(async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); - mockedContext = createAppRootMockRenderer(); - policiesRequest = await sendGetEndpointSpecificPackagePoliciesMock(); - getState = () => mockedContext.store.getState().management.eventFilters; - render = (props) => - mockedContext.render( - - ); - renderWithData = async (customEventFilterProps = {}) => { - const renderResult = render(); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - - act(() => { - mockedContext.store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: { ...entry, ...customEventFilterProps } }, - }); - }); - await waitFor(() => { - expect(renderResult.getByTestId('exceptionsBuilderWrapper')).toBeInTheDocument(); - }); - return renderResult; - }; - - (useFetchIndex as jest.Mock).mockImplementation(() => [ - false, - { - indexPatterns: stubIndexPattern, - }, - ]); - (useCurrentUser as jest.Mock).mockReturnValue({ username: 'test-username' }); - (useKibana as jest.Mock).mockReturnValue({ - services: { - http: {}, - data: {}, - unifiedSearch: {}, - notifications: {}, - }, - }); - }); - - it('should renders correctly without data', () => { - component = render(); - expect(component.getByTestId('loading-spinner')).not.toBeNull(); - }); - - it('should renders correctly with data', async () => { - component = await renderWithData(); - - expect(component.getByTestId('exceptionsBuilderWrapper')).not.toBeNull(); - }); - - it('should displays loader when policies are still loading', () => { - component = render({ arePoliciesLoading: true }); - - expect(component.queryByTestId('exceptionsBuilderWrapper')).toBeNull(); - expect(component.getByTestId('loading-spinner')).not.toBeNull(); - }); - - it('should display sections', async () => { - component = await renderWithData(); - - expect(component.queryByText('Details')).not.toBeNull(); - expect(component.queryByText('Conditions')).not.toBeNull(); - expect(component.queryByText('Comments')).not.toBeNull(); - }); - - it('should display name error only when on blur and empty name', async () => { - component = await renderWithData(); - expect(component.queryByText(NAME_ERROR)).toBeNull(); - const nameInput = component.getByPlaceholderText(NAME_PLACEHOLDER); - act(() => { - fireEvent.blur(nameInput); - }); - expect(component.queryByText(NAME_ERROR)).not.toBeNull(); - }); - - it('should change name', async () => { - component = await renderWithData(); - - const nameInput = component.getByPlaceholderText(NAME_PLACEHOLDER); - - act(() => { - fireEvent.change(nameInput, { - target: { - value: 'Exception name', - }, - }); - }); - - expect(getState().form.entry?.name).toBe('Exception name'); - expect(getState().form.hasNameError).toBeFalsy(); - }); - - it('should change name with a white space still shows an error', async () => { - component = await renderWithData(); - - const nameInput = component.getByPlaceholderText(NAME_PLACEHOLDER); - - act(() => { - fireEvent.change(nameInput, { - target: { - value: ' ', - }, - }); - }); - - expect(getState().form.entry?.name).toBe(''); - expect(getState().form.hasNameError).toBeTruthy(); - }); - - it('should change description', async () => { - component = await renderWithData(); - - const nameInput = component.getByTestId('eventFilters-form-description-input'); - - act(() => { - fireEvent.change(nameInput, { - target: { - value: 'Exception description', - }, - }); - }); - - expect(getState().form.entry?.description).toBe('Exception description'); - }); - - it('should change comments', async () => { - component = await renderWithData(); - - const commentInput = component.getByPlaceholderText('Add a new comment...'); - - act(() => { - fireEvent.change(commentInput, { - target: { - value: 'Exception comment', - }, - }); - }); - - expect(getState().form.newComment).toBe('Exception comment'); - }); - - it('should display the policy list when "per policy" is selected', async () => { - component = await renderWithData(); - userEvent.click(component.getByTestId('perPolicy')); - - // policy selector should show up - expect(component.getByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); - }); - - it('should call onChange when a policy is selected from the policy selection', async () => { - component = await renderWithData(); - - const policyId = policiesRequest.items[0].id; - userEvent.click(component.getByTestId('perPolicy')); - userEvent.click(component.getByTestId(`policy-${policyId}`)); - expect(getState().form.entry?.tags).toEqual([`policy:${policyId}`]); - }); - - it('should have global policy by default', async () => { - component = await renderWithData(); - - expect(component.getByTestId('globalPolicy')).toBeChecked(); - expect(component.getByTestId('perPolicy')).not.toBeChecked(); - }); - - it('should retain the previous policy selection when switching from per-policy to global', async () => { - const policyId = policiesRequest.items[0].id; - - component = await renderWithData(); - - // move to per-policy and select the first - userEvent.click(component.getByTestId('perPolicy')); - userEvent.click(component.getByTestId(`policy-${policyId}`)); - expect(component.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); - expect(getState().form.entry?.tags).toEqual([`policy:${policyId}`]); - - // move back to global - userEvent.click(component.getByTestId('globalPolicy')); - expect(component.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeFalsy(); - expect(getState().form.entry?.tags).toEqual([`policy:all`]); - - // move back to per-policy - userEvent.click(component.getByTestId('perPolicy')); - // the previous selected policy should be selected - expect(component.getByTestId(`policy-${policyId}`)).toHaveAttribute( - 'data-test-selected', - 'true' - ); - // on change called with the previous policy - expect(getState().form.entry?.tags).toEqual([`policy:${policyId}`]); - }); - - it('should hide assignment section when no license', async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - component = await renderWithData(); - expect(component.queryByTestId('perPolicy')).toBeNull(); - }); - - it('should hide assignment section when create mode and no license even with by policy', async () => { - const policyId = policiesRequest.items[0].id; - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - component = await renderWithData({ tags: [`policy:${policyId}`] }); - expect(component.queryByTestId('perPolicy')).toBeNull(); - }); - - it('should show disabled assignment section when edit mode and no license with by policy', async () => { - const policyId = policiesRequest.items[0].id; - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - component = await renderWithData({ tags: [`policy:${policyId}`], item_id: '1' }); - expect(component.queryByTestId('perPolicy')).not.toBeNull(); - expect(component.getByTestId(`policy-${policyId}`).getAttribute('aria-disabled')).toBe('true'); - }); - - it('should change from by policy to global when edit mode and no license with by policy', async () => { - const policyId = policiesRequest.items[0].id; - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - component = await renderWithData({ tags: [`policy:${policyId}`], item_id: '1' }); - userEvent.click(component.getByTestId('globalPolicy')); - expect(component.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeFalsy(); - expect(getState().form.entry?.tags).toEqual([`policy:all`]); - }); - - it('should not show warning text when unique fields are added', async () => { - component = await renderWithData({ - entries: [ - { - field: 'event.category', - operator: 'included', - type: 'match', - value: 'some value', - }, - { - field: 'file.name', - operator: 'excluded', - type: 'match', - value: 'some other value', - }, - ], - }); - expect(component.queryByTestId('duplicate-fields-warning-message')).toBeNull(); - }); - - it('should not show warning text when field values are not added', async () => { - component = await renderWithData({ - entries: [ - { - field: 'event.category', - operator: 'included', - type: 'match', - value: '', - }, - { - field: 'event.category', - operator: 'excluded', - type: 'match', - value: '', - }, - ], - }); - expect(component.queryByTestId('duplicate-fields-warning-message')).toBeNull(); - }); - - it('should show warning text when duplicate fields are added with values', async () => { - component = await renderWithData({ - entries: [ - { - field: 'event.category', - operator: 'included', - type: 'match', - value: 'some value', - }, - { - field: 'event.category', - operator: 'excluded', - type: 'match', - value: 'some other value', - }, - ], - }); - expect(component.queryByTestId('duplicate-fields-warning-message')).not.toBeNull(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx deleted file mode 100644 index 11d1af0a5a2e9..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx +++ /dev/null @@ -1,487 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo, useCallback, useState, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; - -import { isEqual } from 'lodash'; -import { - EuiFieldText, - EuiSpacer, - EuiForm, - EuiFormRow, - EuiSuperSelect, - EuiSuperSelectOption, - EuiText, - EuiHorizontalRule, - EuiTextArea, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { EVENT_FILTERS_OPERATORS } from '@kbn/securitysolution-list-utils'; -import { OperatingSystem } from '@kbn/securitysolution-utils'; - -import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; -import type { OnChangeProps } from '@kbn/lists-plugin/public'; -import { PolicyData } from '../../../../../../../common/endpoint/types'; -import { AddExceptionComments } from '../../../../../../common/components/exceptions/add_exception_comments'; -import { filterIndexPatterns } from '../../../../../../common/components/exceptions/helpers'; -import { Loader } from '../../../../../../common/components/loader'; -import { useKibana } from '../../../../../../common/lib/kibana'; -import { useFetchIndex } from '../../../../../../common/containers/source'; -import { AppAction } from '../../../../../../common/store/actions'; -import { useEventFiltersSelector } from '../../hooks'; -import { getFormEntryStateMutable, getHasNameError, getNewComment } from '../../../store/selector'; -import { - NAME_LABEL, - NAME_ERROR, - DESCRIPTION_LABEL, - DESCRIPTION_PLACEHOLDER, - NAME_PLACEHOLDER, - OS_LABEL, - RULE_NAME, -} from './translations'; -import { OS_TITLES } from '../../../../../common/translations'; -import { ENDPOINT_EVENT_FILTERS_LIST_ID, EVENT_FILTER_LIST_TYPE } from '../../../constants'; -import { ABOUT_EVENT_FILTERS } from '../../translations'; -import { - EffectedPolicySelect, - EffectedPolicySelection, - EffectedPolicySelectProps, -} from '../../../../../components/effected_policy_select'; -import { - getArtifactTagsByEffectedPolicySelection, - getArtifactTagsWithoutPolicies, - getEffectedPolicySelectionByTags, - isGlobalPolicyEffected, -} from '../../../../../components/effected_policy_select/utils'; -import { useLicense } from '../../../../../../common/hooks/use_license'; - -const OPERATING_SYSTEMS: readonly OperatingSystem[] = [ - OperatingSystem.MAC, - OperatingSystem.WINDOWS, - OperatingSystem.LINUX, -]; - -const getAddedFieldsCounts = (formFields: string[]): { [k: string]: number } => - formFields.reduce<{ [k: string]: number }>((allFields, field) => { - if (field in allFields) { - allFields[field]++; - } else { - allFields[field] = 1; - } - return allFields; - }, {}); - -const computeHasDuplicateFields = (formFieldsList: Record): boolean => - Object.values(formFieldsList).some((e) => e > 1); -interface EventFiltersFormProps { - allowSelectOs?: boolean; - policies: PolicyData[]; - arePoliciesLoading: boolean; -} -export const EventFiltersForm: React.FC = memo( - ({ allowSelectOs = false, policies, arePoliciesLoading }) => { - const { http, unifiedSearch } = useKibana().services; - - const dispatch = useDispatch>(); - const exception = useEventFiltersSelector(getFormEntryStateMutable); - const hasNameError = useEventFiltersSelector(getHasNameError); - const newComment = useEventFiltersSelector(getNewComment); - const [hasBeenInputNameVisited, setHasBeenInputNameVisited] = useState(false); - const isPlatinumPlus = useLicense().isPlatinumPlus(); - const [hasFormChanged, setHasFormChanged] = useState(false); - const [hasDuplicateFields, setHasDuplicateFields] = useState(false); - - // This value has to be memoized to avoid infinite useEffect loop on useFetchIndex - const indexNames = useMemo(() => ['logs-endpoint.events.*'], []); - const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex(indexNames); - - const [selection, setSelection] = useState({ - selected: [], - isGlobal: isGlobalPolicyEffected(exception?.tags), - }); - - const isEditMode = useMemo(() => !!exception?.item_id, [exception?.item_id]); - const [wasByPolicy, setWasByPolicy] = useState(!isGlobalPolicyEffected(exception?.tags)); - - const showAssignmentSection = useMemo(() => { - return ( - isPlatinumPlus || - (isEditMode && - (!selection.isGlobal || (wasByPolicy && selection.isGlobal && hasFormChanged))) - ); - }, [isEditMode, selection.isGlobal, hasFormChanged, isPlatinumPlus, wasByPolicy]); - - // set current policies if not previously selected - useEffect(() => { - if (selection.selected.length === 0 && exception?.tags) { - setSelection(getEffectedPolicySelectionByTags(exception.tags, policies)); - } - }, [exception?.tags, policies, selection.selected.length]); - - // set initial state of `wasByPolicy` that checks if the initial state of the exception was by policy or not - useEffect(() => { - if (!hasFormChanged && exception?.tags) { - setWasByPolicy(!isGlobalPolicyEffected(exception?.tags)); - } - }, [exception?.tags, hasFormChanged]); - - const osOptions: Array> = useMemo( - () => OPERATING_SYSTEMS.map((os) => ({ value: os, inputDisplay: OS_TITLES[os] })), - [] - ); - - const handleOnBuilderChange = useCallback( - (arg: OnChangeProps) => { - if ( - (!hasFormChanged && arg.exceptionItems[0] === undefined) || - isEqual(arg.exceptionItems[0]?.entries, exception?.entries) - ) { - const addedFields = arg.exceptionItems[0]?.entries.map((e) => e.field) || ['']; - setHasDuplicateFields(computeHasDuplicateFields(getAddedFieldsCounts(addedFields))); - setHasFormChanged(true); - return; - } - setHasFormChanged(true); - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - ...(arg.exceptionItems[0] !== undefined - ? { - entry: { - ...arg.exceptionItems[0], - name: exception?.name ?? '', - description: exception?.description ?? '', - comments: exception?.comments ?? [], - os_types: exception?.os_types ?? [OperatingSystem.WINDOWS], - tags: exception?.tags ?? [], - }, - hasItemsError: arg.errorExists || !arg.exceptionItems[0]?.entries?.length, - } - : { - hasItemsError: true, - }), - }, - }); - }, - [dispatch, exception, hasFormChanged] - ); - - const handleOnChangeName = useCallback( - (e: React.ChangeEvent) => { - if (!exception) return; - setHasFormChanged(true); - const name = e.target.value.toString().trim(); - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { ...exception, name }, - hasNameError: !name, - }, - }); - }, - [dispatch, exception] - ); - - const handleOnDescriptionChange = useCallback( - (e: React.ChangeEvent) => { - if (!exception) return; - setHasFormChanged(true); - const description = e.target.value.toString().trim(); - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { ...exception, description }, - }, - }); - }, - [dispatch, exception] - ); - - const handleOnChangeComment = useCallback( - (value: string) => { - if (!exception) return; - setHasFormChanged(true); - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: exception, - newComment: value, - }, - }); - }, - [dispatch, exception] - ); - - const exceptionBuilderComponentMemo = useMemo( - () => - getExceptionBuilderComponentLazy({ - allowLargeValueLists: false, - httpService: http, - autocompleteService: unifiedSearch.autocomplete, - exceptionListItems: [exception as ExceptionListItemSchema], - listType: EVENT_FILTER_LIST_TYPE, - listId: ENDPOINT_EVENT_FILTERS_LIST_ID, - listNamespaceType: 'agnostic', - ruleName: RULE_NAME, - indexPatterns, - isOrDisabled: true, - isOrHidden: true, - isAndDisabled: false, - isNestedDisabled: false, - dataTestSubj: 'alert-exception-builder', - idAria: 'alert-exception-builder', - onChange: handleOnBuilderChange, - listTypeSpecificIndexPatternFilter: filterIndexPatterns, - operatorsList: EVENT_FILTERS_OPERATORS, - osTypes: exception?.os_types, - }), - [unifiedSearch, handleOnBuilderChange, http, indexPatterns, exception] - ); - - const nameInputMemo = useMemo( - () => ( - - !hasBeenInputNameVisited && setHasBeenInputNameVisited(true)} - /> - - ), - [hasNameError, exception?.name, handleOnChangeName, hasBeenInputNameVisited] - ); - - const descriptionInputMemo = useMemo( - () => ( - - - - ), - [exception?.description, handleOnDescriptionChange] - ); - - const osInputMemo = useMemo( - () => ( - - { - if (!exception) return; - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { - ...exception, - os_types: [value as 'windows' | 'linux' | 'macos'], - }, - }, - }); - }} - /> - - ), - [dispatch, exception, osOptions] - ); - - const commentsInputMemo = useMemo( - () => ( - - ), - [exception, handleOnChangeComment, newComment] - ); - - const detailsSection = useMemo( - () => ( - <> - -

- -

-
- - -

{ABOUT_EVENT_FILTERS}

-
- - {nameInputMemo} - {descriptionInputMemo} - - ), - [nameInputMemo, descriptionInputMemo] - ); - - const criteriaSection = useMemo( - () => ( - <> - -

- -

-
- - -

- -

-
- - {allowSelectOs ? ( - <> - {osInputMemo} - - - ) : null} - {exceptionBuilderComponentMemo} - - ), - [allowSelectOs, exceptionBuilderComponentMemo, osInputMemo] - ); - - const handleOnChangeEffectScope: EffectedPolicySelectProps['onChange'] = useCallback( - (currentSelection) => { - if (currentSelection.isGlobal) { - // Preserve last selection inputs - setSelection({ ...selection, isGlobal: true }); - } else { - setSelection(currentSelection); - } - - if (!exception) return; - setHasFormChanged(true); - - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { - ...exception, - tags: getArtifactTagsByEffectedPolicySelection( - currentSelection, - getArtifactTagsWithoutPolicies(exception?.tags ?? []) - ), - }, - }, - }); - }, - [dispatch, exception, selection] - ); - const policiesSection = useMemo( - () => ( - - ), - [policies, selection, isPlatinumPlus, handleOnChangeEffectScope, arePoliciesLoading] - ); - - const commentsSection = useMemo( - () => ( - <> - -

- -

-
- - -

- -

-
- - {commentsInputMemo} - - ), - [commentsInputMemo] - ); - - if (isIndexPatternLoading || !exception) { - return ; - } - - return ( - - {detailsSection} - - {criteriaSection} - {hasDuplicateFields && ( - <> - - - - - - )} - {showAssignmentSection && ( - <> - {policiesSection} - - )} - - {commentsSection} - - ); - } -); - -EventFiltersForm.displayName = 'EventFiltersForm'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts deleted file mode 100644 index 20bdde0364e2c..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const NAME_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.eventFilter.form.name.placeholder', - { - defaultMessage: 'Event filter name', - } -); - -export const NAME_LABEL = i18n.translate('xpack.securitySolution.eventFilter.form.name.label', { - defaultMessage: 'Name your event filter', -}); -export const DESCRIPTION_LABEL = i18n.translate( - 'xpack.securitySolution.eventFilter.form.description.placeholder', - { - defaultMessage: 'Description', - } -); - -export const DESCRIPTION_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.eventFilter.form.description.label', - { - defaultMessage: 'Describe your event filter', - } -); - -export const NAME_ERROR = i18n.translate('xpack.securitySolution.eventFilter.form.name.error', { - defaultMessage: "The name can't be empty", -}); - -export const OS_LABEL = i18n.translate('xpack.securitySolution.eventFilter.form.os.label', { - defaultMessage: 'Select operating system', -}); - -export const RULE_NAME = i18n.translate('xpack.securitySolution.eventFilter.form.rule.name', { - defaultMessage: 'Endpoint Event Filtering', -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.test.tsx new file mode 100644 index 0000000000000..79afbce97caf6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { EVENT_FILTERS_PATH } from '../../../../../common/constants'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; +import { EventFiltersList } from './event_filters_list'; +import { exceptionsListAllHttpMocks } from '../../mocks/exceptions_list_http_mocks'; +import { SEARCHABLE_FIELDS } from '../constants'; +import { parseQueryFilterToKQL } from '../../../common/utils'; + +describe('When on the Event Filters list page', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let history: AppContextTestRender['history']; + let mockedContext: AppContextTestRender; + let apiMocks: ReturnType; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + ({ history } = mockedContext); + render = () => (renderResult = mockedContext.render()); + apiMocks = exceptionsListAllHttpMocks(mockedContext.coreStart.http); + act(() => { + history.push(EVENT_FILTERS_PATH); + }); + }); + + it('should search using expected exception item fields', async () => { + const expectedFilterString = parseQueryFilterToKQL('fooFooFoo', SEARCHABLE_FIELDS); + const { findAllByTestId } = render(); + await waitFor(async () => { + await expect(findAllByTestId('EventFiltersListPage-card')).resolves.toHaveLength(10); + }); + + apiMocks.responseProvider.exceptionsFind.mockClear(); + userEvent.type(renderResult.getByTestId('searchField'), 'fooFooFoo'); + userEvent.click(renderResult.getByTestId('searchButton')); + await waitFor(() => { + expect(apiMocks.responseProvider.exceptionsFind).toHaveBeenCalled(); + }); + + expect(apiMocks.responseProvider.exceptionsFind).toHaveBeenLastCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + filter: expectedFilterString, + }), + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx new file mode 100644 index 0000000000000..f303987e1acab --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { DocLinks } from '@kbn/doc-links'; +import { EuiLink } from '@elastic/eui'; + +import { useHttp } from '../../../../common/lib/kibana'; +import { ArtifactListPage, ArtifactListPageProps } from '../../../components/artifact_list_page'; +import { EventFiltersApiClient } from '../service/api_client'; +import { EventFiltersForm } from './components/form'; +import { SEARCHABLE_FIELDS } from '../constants'; + +export const ABOUT_EVENT_FILTERS = i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { + defaultMessage: + 'Event filters exclude high volume or unwanted events from being written to Elasticsearch.', +}); + +export const NAME_LABEL = i18n.translate('xpack.securitySolution.eventFilter.form.name.label', { + defaultMessage: 'Name', +}); +export const DESCRIPTION_LABEL = i18n.translate( + 'xpack.securitySolution.eventFilter.form.description.placeholder', + { + defaultMessage: 'Description', + } +); + +export const NAME_ERROR = i18n.translate('xpack.securitySolution.eventFilter.form.name.error', { + defaultMessage: "The name can't be empty", +}); + +export const OS_LABEL = i18n.translate('xpack.securitySolution.eventFilter.form.os.label', { + defaultMessage: 'Select operating system', +}); + +export const RULE_NAME = i18n.translate('xpack.securitySolution.eventFilter.form.rule.name', { + defaultMessage: 'Endpoint Event Filtering', +}); + +const EVENT_FILTERS_PAGE_LABELS: ArtifactListPageProps['labels'] = { + pageTitle: i18n.translate('xpack.securitySolution.eventFilters.pageTitle', { + defaultMessage: 'Event Filters', + }), + pageAboutInfo: i18n.translate('xpack.securitySolution.eventFilters.pageAboutInfo', { + defaultMessage: + 'Event filters exclude high volume or unwanted events from being written to Elasticsearch.', + }), + pageAddButtonTitle: i18n.translate('xpack.securitySolution.eventFilters.pageAddButtonTitle', { + defaultMessage: 'Add event filter', + }), + getShowingCountLabel: (total) => + i18n.translate('xpack.securitySolution.eventFilters.showingTotal', { + defaultMessage: 'Showing {total} {total, plural, one {event filter} other {event filters}}', + values: { total }, + }), + cardActionEditLabel: i18n.translate('xpack.securitySolution.eventFilters.cardActionEditLabel', { + defaultMessage: 'Edit event filter', + }), + cardActionDeleteLabel: i18n.translate( + 'xpack.securitySolution.eventFilters.cardActionDeleteLabel', + { + defaultMessage: 'Delete event filter', + } + ), + flyoutCreateTitle: i18n.translate('xpack.securitySolution.eventFilters.flyoutCreateTitle', { + defaultMessage: 'Add event filter', + }), + flyoutEditTitle: i18n.translate('xpack.securitySolution.eventFilters.flyoutEditTitle', { + defaultMessage: 'Edit event filter', + }), + flyoutCreateSubmitButtonLabel: i18n.translate( + 'xpack.securitySolution.eventFilters.flyoutCreateSubmitButtonLabel', + { defaultMessage: 'Add event filter' } + ), + flyoutCreateSubmitSuccess: ({ name }) => + i18n.translate('xpack.securitySolution.eventFilters.flyoutCreateSubmitSuccess', { + defaultMessage: '"{name}" has been added to the event filters list.', + values: { name }, + }), + flyoutEditSubmitSuccess: ({ name }) => + i18n.translate('xpack.securitySolution.eventFilters.flyoutEditSubmitSuccess', { + defaultMessage: '"{name}" has been updated.', + values: { name }, + }), + flyoutDowngradedLicenseDocsInfo: ( + securitySolutionDocsLinks: DocLinks['securitySolution'] + ): React.ReactNode => { + return ( + <> + + + + + + ); + }, + deleteActionSuccess: (itemName) => + i18n.translate('xpack.securitySolution.eventFilters.deleteSuccess', { + defaultMessage: '"{itemName}" has been removed from event filters list.', + values: { itemName }, + }), + emptyStateTitle: i18n.translate('xpack.securitySolution.eventFilters.emptyStateTitle', { + defaultMessage: 'Add your first event filter', + }), + emptyStateInfo: i18n.translate('xpack.securitySolution.eventFilters.emptyStateInfo', { + defaultMessage: + 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch.', + }), + emptyStatePrimaryButtonLabel: i18n.translate( + 'xpack.securitySolution.eventFilters.emptyStatePrimaryButtonLabel', + { defaultMessage: 'Add event filter' } + ), + searchPlaceholderInfo: i18n.translate( + 'xpack.securitySolution.eventFilters.searchPlaceholderInfo', + { + defaultMessage: 'Search on the fields below: name, description, comments, value', + } + ), +}; + +export const EventFiltersList = memo(() => { + const http = useHttp(); + const eventFiltersApiClient = EventFiltersApiClient.getInstance(http); + + return ( + + ); +}); + +EventFiltersList.displayName = 'EventFiltersList'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx deleted file mode 100644 index ec0adf0c10a23..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx +++ /dev/null @@ -1,247 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; -import React from 'react'; -import { fireEvent, act, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { EventFiltersListPage } from './event_filters_list_page'; -import { eventFiltersListQueryHttpMock } from '../test_utils'; -import { isFailedResourceState, isLoadedResourceState } from '../../../state'; -import { sendGetEndpointSpecificPackagePolicies } from '../../../services/policies/policies'; -import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../services/policies/test_mock_utils'; - -// Needed to mock the data services used by the ExceptionItem component -jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../../common/components/user_privileges'); -jest.mock('../../../services/policies/policies'); - -(sendGetEndpointSpecificPackagePolicies as jest.Mock).mockImplementation( - sendGetEndpointSpecificPackagePoliciesMock -); - -describe('When on the Event Filters List Page', () => { - let render: () => ReturnType; - let renderResult: ReturnType; - let history: AppContextTestRender['history']; - let coreStart: AppContextTestRender['coreStart']; - let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; - let mockedApi: ReturnType; - - const dataReceived = () => - act(async () => { - await waitForAction('eventFiltersListPageDataChanged', { - validate(action) { - return isLoadedResourceState(action.payload); - }, - }); - }); - - beforeEach(() => { - const mockedContext = createAppRootMockRenderer(); - - ({ history, coreStart } = mockedContext); - render = () => (renderResult = mockedContext.render()); - mockedApi = eventFiltersListQueryHttpMock(coreStart.http); - waitForAction = mockedContext.middlewareSpy.waitForAction; - - act(() => { - history.push('/administration/event_filters'); - }); - }); - - describe('And no data exists', () => { - beforeEach(async () => { - mockedApi.responseProvider.eventFiltersList.mockReturnValue({ - data: [], - page: 1, - per_page: 10, - total: 0, - }); - - render(); - - await act(async () => { - await waitForAction('eventFiltersListPageDataExistsChanged', { - validate(action) { - return isLoadedResourceState(action.payload); - }, - }); - }); - }); - - it('should show the Empty message', () => { - expect(renderResult.getByTestId('eventFiltersEmpty')).toBeTruthy(); - expect(renderResult.getByTestId('eventFiltersListEmptyStateAddButton')).toBeTruthy(); - }); - - it('should open create flyout when add button in empty state is clicked', async () => { - act(() => { - fireEvent.click(renderResult.getByTestId('eventFiltersListEmptyStateAddButton')); - }); - - expect(renderResult.getByTestId('eventFiltersCreateEditFlyout')).toBeTruthy(); - expect(history.location.search).toEqual('?show=create'); - }); - }); - - describe('And data exists', () => { - it('should show loading indicator while retrieving data', async () => { - let releaseApiResponse: () => void; - - mockedApi.responseProvider.eventFiltersList.mockDelay.mockReturnValue( - new Promise((r) => (releaseApiResponse = r)) - ); - render(); - - expect(renderResult.getByTestId('eventFilterListLoader')).toBeTruthy(); - - const wasReceived = dataReceived(); - releaseApiResponse!(); - await wasReceived; - - expect(renderResult.container.querySelector('.euiProgress')).toBeNull(); - }); - - it('should show items on the list', async () => { - render(); - await dataReceived(); - - expect(renderResult.getByTestId('eventFilterCard')).toBeTruthy(); - }); - - it('should render expected fields on card', async () => { - render(); - await dataReceived(); - - [ - ['subHeader-touchedBy-createdBy-value', 'some user'], - ['subHeader-touchedBy-updatedBy-value', 'some user'], - ['header-created-value', '4/20/2020'], - ['header-updated-value', '4/20/2020'], - ].forEach(([suffix, value]) => - expect(renderResult.getByTestId(`eventFilterCard-${suffix}`).textContent).toEqual(value) - ); - }); - - it('should show API error if one is encountered', async () => { - mockedApi.responseProvider.eventFiltersList.mockImplementation(() => { - throw new Error('oh no'); - }); - render(); - await act(async () => { - await waitForAction('eventFiltersListPageDataChanged', { - validate(action) { - return isFailedResourceState(action.payload); - }, - }); - }); - - expect(renderResult.getByTestId('eventFiltersContent-error').textContent).toEqual(' oh no'); - }); - - it('should show modal when delete is clicked on a card', async () => { - render(); - await dataReceived(); - - await act(async () => { - (await renderResult.findAllByTestId('eventFilterCard-header-actions-button'))[0].click(); - }); - - await act(async () => { - (await renderResult.findByTestId('deleteEventFilterAction')).click(); - }); - - expect( - renderResult.baseElement.querySelector('[data-test-subj="eventFilterDeleteModalHeader"]') - ).not.toBeNull(); - }); - }); - - describe('And search is dispatched', () => { - beforeEach(async () => { - act(() => { - history.push('/administration/event_filters?filter=test'); - }); - renderResult = render(); - await act(async () => { - await waitForAction('eventFiltersListPageDataChanged'); - }); - }); - - it('search bar is filled with query params', () => { - expect(renderResult.getByDisplayValue('test')).not.toBeNull(); - }); - - it('search action is dispatched', async () => { - await act(async () => { - fireEvent.click(renderResult.getByTestId('searchButton')); - expect(await waitForAction('userChangedUrl')).not.toBeNull(); - }); - }); - }); - - describe('And policies select is dispatched', () => { - it('should apply policy filter', async () => { - const policies = await sendGetEndpointSpecificPackagePoliciesMock(); - (sendGetEndpointSpecificPackagePolicies as jest.Mock).mockResolvedValue(policies); - - renderResult = render(); - await waitFor(() => { - expect(sendGetEndpointSpecificPackagePolicies).toHaveBeenCalled(); - }); - - const firstPolicy = policies.items[0]; - - userEvent.click(renderResult.getByTestId('policiesSelectorButton')); - userEvent.click(renderResult.getByTestId(`policiesSelector-popover-items-${firstPolicy.id}`)); - await waitFor(() => expect(waitForAction('userChangedUrl')).not.toBeNull()); - }); - }); - - describe('and the back button is present', () => { - beforeEach(async () => { - renderResult = render(); - act(() => { - history.push('/administration/event_filters', { - onBackButtonNavigateTo: [{ appId: 'appId' }], - backButtonLabel: 'back to fleet', - backButtonUrl: '/fleet', - }); - }); - }); - - it('back button is present', () => { - const button = renderResult.queryByTestId('backToOrigin'); - expect(button).not.toBeNull(); - expect(button).toHaveAttribute('href', '/fleet'); - }); - - it('back button is still present after push history', () => { - act(() => { - history.push('/administration/event_filters'); - }); - const button = renderResult.queryByTestId('backToOrigin'); - expect(button).not.toBeNull(); - expect(button).toHaveAttribute('href', '/fleet'); - }); - }); - - describe('and the back button is not present', () => { - beforeEach(async () => { - renderResult = render(); - act(() => { - history.push('/administration/event_filters'); - }); - }); - - it('back button is not present when missing history params', () => { - const button = renderResult.queryByTestId('backToOrigin'); - expect(button).toBeNull(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx deleted file mode 100644 index b982c260f9ca8..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ /dev/null @@ -1,339 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useCallback, useMemo, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; -import { useHistory, useLocation } from 'react-router-dom'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui'; -import styled from 'styled-components'; - -import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { AppAction } from '../../../../common/store/actions'; -import { getEventFiltersListPath } from '../../../common/routing'; -import { AdministrationListPage as _AdministrationListPage } from '../../../components/administration_list_page'; - -import { EventFiltersListEmptyState } from './components/empty'; -import { useEventFiltersNavigateCallback, useEventFiltersSelector } from './hooks'; -import { EventFiltersFlyout } from './components/flyout'; -import { - getListFetchError, - getListIsLoading, - getListItems, - getListPagination, - getCurrentLocation, - getListPageDoesDataExist, - getActionError, - getFormEntry, - showDeleteModal, - getTotalCountListItems, -} from '../store/selector'; -import { PaginatedContent, PaginatedContentProps } from '../../../components/paginated_content'; -import { Immutable, ListPageRouteState } from '../../../../../common/endpoint/types'; -import { ExceptionItem } from '../../../../common/components/exceptions/viewer/exception_item'; -import { - AnyArtifact, - ArtifactEntryCard, - ArtifactEntryCardProps, -} from '../../../components/artifact_entry_card'; -import { EventFilterDeleteModal } from './components/event_filter_delete_modal'; - -import { SearchExceptions } from '../../../components/search_exceptions'; -import { BackToExternalAppSecondaryButton } from '../../../components/back_to_external_app_secondary_button'; -import { BackToExternalAppButton } from '../../../components/back_to_external_app_button'; -import { ABOUT_EVENT_FILTERS } from './translations'; -import { useGetEndpointSpecificPolicies } from '../../../services/policies/hooks'; -import { useToasts } from '../../../../common/lib/kibana'; -import { getLoadPoliciesError } from '../../../common/translations'; -import { useEndpointPoliciesToArtifactPolicies } from '../../../components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies'; -import { ManagementPageLoader } from '../../../components/management_page_loader'; -import { useMemoizedRouteState } from '../../../common/hooks'; - -type ArtifactEntryCardType = typeof ArtifactEntryCard; - -type EventListPaginatedContent = PaginatedContentProps< - Immutable, - typeof ExceptionItem ->; - -const AdministrationListPage = styled(_AdministrationListPage)` - .event-filter-container > * { - margin-bottom: ${({ theme }) => theme.eui.euiSizeL}; - - &:last-child { - margin-bottom: 0; - } - } -`; - -const EDIT_EVENT_FILTER_ACTION_LABEL = i18n.translate( - 'xpack.securitySolution.eventFilters.list.cardAction.edit', - { - defaultMessage: 'Edit event filter', - } -); - -const DELETE_EVENT_FILTER_ACTION_LABEL = i18n.translate( - 'xpack.securitySolution.eventFilters.list.cardAction.delete', - { - defaultMessage: 'Delete event filter', - } -); - -export const EventFiltersListPage = memo(() => { - const { state: routeState } = useLocation(); - const history = useHistory(); - const dispatch = useDispatch>(); - const toasts = useToasts(); - const isActionError = useEventFiltersSelector(getActionError); - const formEntry = useEventFiltersSelector(getFormEntry); - const listItems = useEventFiltersSelector(getListItems); - const totalCountListItems = useEventFiltersSelector(getTotalCountListItems); - const pagination = useEventFiltersSelector(getListPagination); - const isLoading = useEventFiltersSelector(getListIsLoading); - const fetchError = useEventFiltersSelector(getListFetchError); - const location = useEventFiltersSelector(getCurrentLocation); - const doesDataExist = useEventFiltersSelector(getListPageDoesDataExist); - const showDelete = useEventFiltersSelector(showDeleteModal); - - const navigateCallback = useEventFiltersNavigateCallback(); - const showFlyout = !!location.show; - - const memoizedRouteState = useMemoizedRouteState(routeState); - - const backButtonEmptyComponent = useMemo(() => { - if (memoizedRouteState && memoizedRouteState.onBackButtonNavigateTo) { - return ; - } - }, [memoizedRouteState]); - - const backButtonHeaderComponent = useMemo(() => { - if (memoizedRouteState && memoizedRouteState.onBackButtonNavigateTo) { - return ; - } - }, [memoizedRouteState]); - - // load the list of policies - const policiesRequest = useGetEndpointSpecificPolicies({ - perPage: 1000, - onError: (err) => { - toasts.addDanger(getLoadPoliciesError(err)); - }, - }); - - const artifactCardPolicies = useEndpointPoliciesToArtifactPolicies(policiesRequest.data?.items); - - // Clean url params if wrong - useEffect(() => { - if ((location.show === 'edit' && !location.id) || (location.show === 'create' && !!location.id)) - navigateCallback({ - show: 'create', - id: undefined, - }); - }, [location, navigateCallback]); - - // Catch fetch error -> actionError + empty entry in form - useEffect(() => { - if (isActionError && !formEntry) { - // Replace the current URL route so that user does not keep hitting this page via browser back/fwd buttons - history.replace( - getEventFiltersListPath({ - ...location, - show: undefined, - id: undefined, - }) - ); - dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'UninitialisedResourceState', - }, - }); - } - }, [dispatch, formEntry, history, isActionError, location, navigateCallback]); - - const handleAddButtonClick = useCallback( - () => - navigateCallback({ - show: 'create', - id: undefined, - }), - [navigateCallback] - ); - - const handleCancelButtonClick = useCallback( - () => - navigateCallback({ - show: undefined, - id: undefined, - }), - [navigateCallback] - ); - - const handlePaginatedContentChange: EventListPaginatedContent['onChange'] = useCallback( - ({ pageIndex, pageSize }) => { - navigateCallback({ - page_index: pageIndex, - page_size: pageSize, - }); - }, - [navigateCallback] - ); - - const handleOnSearch = useCallback( - (query: string, includedPolicies?: string) => { - dispatch({ type: 'eventFiltersForceRefresh', payload: { forceRefresh: true } }); - navigateCallback({ filter: query, included_policies: includedPolicies }); - }, - [navigateCallback, dispatch] - ); - - const artifactCardPropsPerItem = useMemo(() => { - const cachedCardProps: Record = {}; - - // Casting `listItems` below to remove the `Immutable<>` from it in order to prevent errors - // with common component's props - for (const eventFilter of listItems as ExceptionListItemSchema[]) { - cachedCardProps[eventFilter.id] = { - item: eventFilter as AnyArtifact, - policies: artifactCardPolicies, - 'data-test-subj': 'eventFilterCard', - actions: [ - { - icon: 'controlsHorizontal', - onClick: () => { - history.push( - getEventFiltersListPath({ - ...location, - show: 'edit', - id: eventFilter.id, - }) - ); - }, - 'data-test-subj': 'editEventFilterAction', - children: EDIT_EVENT_FILTER_ACTION_LABEL, - }, - { - icon: 'trash', - onClick: () => { - dispatch({ - type: 'eventFilterForDeletion', - payload: eventFilter, - }); - }, - 'data-test-subj': 'deleteEventFilterAction', - children: DELETE_EVENT_FILTER_ACTION_LABEL, - }, - ], - hideDescription: !eventFilter.description, - hideComments: !eventFilter.comments.length, - }; - } - - return cachedCardProps; - }, [artifactCardPolicies, dispatch, history, listItems, location]); - - const handleArtifactCardProps = useCallback( - (eventFilter: ExceptionListItemSchema) => { - return artifactCardPropsPerItem[eventFilter.id]; - }, - [artifactCardPropsPerItem] - ); - - if (isLoading && !doesDataExist) { - return ; - } - - return ( - - } - subtitle={ABOUT_EVENT_FILTERS} - actions={ - doesDataExist && ( - - - - ) - } - hideHeader={!doesDataExist} - > - {showFlyout && ( - - )} - - {showDelete && } - - {doesDataExist && ( - <> - - - - - - - - )} - - - items={listItems} - ItemComponent={ArtifactEntryCard} - itemComponentProps={handleArtifactCardProps} - onChange={handlePaginatedContentChange} - error={fetchError?.message} - loading={isLoading} - pagination={pagination} - contentClassName="event-filter-container" - data-test-subj="eventFiltersContent" - noItemsMessage={ - !doesDataExist && ( - - ) - } - /> - - ); -}); - -EventFiltersListPage.displayName = 'EventFiltersListPage'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts deleted file mode 100644 index e48f11c7f8bae..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useState, useCallback } from 'react'; -import { useSelector } from 'react-redux'; -import { useHistory } from 'react-router-dom'; - -import { - isCreationSuccessful, - getFormEntryStateMutable, - getActionError, - getCurrentLocation, -} from '../store/selector'; - -import { useToasts } from '../../../../common/lib/kibana'; -import { - getCreationSuccessMessage, - getUpdateSuccessMessage, - getCreationErrorMessage, - getUpdateErrorMessage, - getGetErrorMessage, -} from './translations'; - -import { State } from '../../../../common/store'; -import { EventFiltersListPageState, EventFiltersPageLocation } from '../types'; -import { getEventFiltersListPath } from '../../../common/routing'; - -import { - MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE as EVENT_FILTER_NS, - MANAGEMENT_STORE_GLOBAL_NAMESPACE as GLOBAL_NS, -} from '../../../common/constants'; - -export function useEventFiltersSelector(selector: (state: EventFiltersListPageState) => R): R { - return useSelector((state: State) => - selector(state[GLOBAL_NS][EVENT_FILTER_NS] as EventFiltersListPageState) - ); -} - -export const useEventFiltersNotification = () => { - const creationSuccessful = useEventFiltersSelector(isCreationSuccessful); - const actionError = useEventFiltersSelector(getActionError); - const formEntry = useEventFiltersSelector(getFormEntryStateMutable); - const toasts = useToasts(); - const [wasAlreadyHandled] = useState(new WeakSet()); - - if (creationSuccessful && formEntry && !wasAlreadyHandled.has(formEntry)) { - wasAlreadyHandled.add(formEntry); - if (formEntry.item_id) { - toasts.addSuccess(getUpdateSuccessMessage(formEntry)); - } else { - toasts.addSuccess(getCreationSuccessMessage(formEntry)); - } - } else if (actionError && !wasAlreadyHandled.has(actionError)) { - wasAlreadyHandled.add(actionError); - if (formEntry && formEntry.item_id) { - toasts.addDanger(getUpdateErrorMessage(actionError)); - } else if (formEntry) { - toasts.addDanger(getCreationErrorMessage(actionError)); - } else { - toasts.addWarning(getGetErrorMessage(actionError)); - } - } -}; - -export function useEventFiltersNavigateCallback() { - const location = useEventFiltersSelector(getCurrentLocation); - const history = useHistory(); - - return useCallback( - (args: Partial) => - history.push(getEventFiltersListPath({ ...location, ...args })), - [history, location] - ); -} diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts index 6177fb7822c92..db6908f2baa8d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts @@ -5,47 +5,20 @@ * 2.0. */ +import { HttpFetchError } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; +import { ArtifactFormComponentProps } from '../../../components/artifact_list_page'; -import { ServerApiError } from '../../../../common/types'; -import { EventFiltersForm } from '../types'; - -export const getCreationSuccessMessage = (entry: EventFiltersForm['entry']) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.creationSuccessToastTitle', { +export const getCreationSuccessMessage = (item: ArtifactFormComponentProps['item']) => { + return i18n.translate('xpack.securitySolution.eventFilter.flyoutForm.creationSuccessToastTitle', { defaultMessage: '"{name}" has been added to the event filters list.', - values: { name: entry?.name }, - }); -}; - -export const getUpdateSuccessMessage = (entry: EventFiltersForm['entry']) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.updateSuccessToastTitle', { - defaultMessage: '"{name}" has been updated successfully.', - values: { name: entry?.name }, - }); -}; - -export const getCreationErrorMessage = (creationError: ServerApiError) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.failedToastTitle.create', { - defaultMessage: 'There was an error creating the new event filter: "{error}"', - values: { error: creationError.message }, - }); -}; - -export const getUpdateErrorMessage = (updateError: ServerApiError) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.failedToastTitle.update', { - defaultMessage: 'There was an error updating the event filter: "{error}"', - values: { error: updateError.message }, + values: { name: item?.name }, }); }; -export const getGetErrorMessage = (getError: ServerApiError) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.failedToastTitle.get', { - defaultMessage: 'Unable to edit event filter: "{error}"', - values: { error: getError.message }, - }); +export const getCreationErrorMessage = (creationError: HttpFetchError) => { + return { + title: 'There was an error creating the new event filter: "{error}"', + message: { error: creationError.message }, + }; }; - -export const ABOUT_EVENT_FILTERS = i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { - defaultMessage: - 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch.', -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/use_event_filters_notification.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/use_event_filters_notification.test.tsx deleted file mode 100644 index 7643125c587e7..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/use_event_filters_notification.test.tsx +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { Provider } from 'react-redux'; -import { renderHook, act } from '@testing-library/react-hooks'; - -import { NotificationsStart } from '@kbn/core/public'; -import { coreMock } from '@kbn/core/public/mocks'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public/context'; -import type { - CreateExceptionListItemSchema, - ExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; - -import { - createdEventFilterEntryMock, - createGlobalNoMiddlewareStore, - ecsEventMock, -} from '../test_utils'; -import { useEventFiltersNotification } from './hooks'; -import { - getCreationErrorMessage, - getCreationSuccessMessage, - getGetErrorMessage, - getUpdateSuccessMessage, - getUpdateErrorMessage, -} from './translations'; -import { getInitialExceptionFromEvent } from '../store/utils'; -import { - getLastLoadedResourceState, - FailedResourceState, -} from '../../../state/async_resource_state'; - -const mockNotifications = () => coreMock.createStart({ basePath: '/mock' }).notifications; - -const renderNotifications = ( - store: ReturnType, - notifications: NotificationsStart -) => { - const Wrapper: React.FC = ({ children }) => ( - - {children} - - ); - return renderHook(useEventFiltersNotification, { wrapper: Wrapper }); -}; - -describe('EventFiltersNotification', () => { - it('renders correctly initially', () => { - const notifications = mockNotifications(); - - renderNotifications(createGlobalNoMiddlewareStore(), notifications); - - expect(notifications.toasts.addSuccess).not.toBeCalled(); - expect(notifications.toasts.addDanger).not.toBeCalled(); - }); - - it('shows success notification when creation successful', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - }); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: store.getState().management.eventFilters.form.entry as ExceptionListItemSchema, - }, - }); - }); - - expect(notifications.toasts.addSuccess).toBeCalledWith( - getCreationSuccessMessage( - store.getState().management.eventFilters.form.entry as CreateExceptionListItemSchema - ) - ); - expect(notifications.toasts.addDanger).not.toBeCalled(); - }); - - it('shows success notification when update successful', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: createdEventFilterEntryMock() }, - }); - }); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: store.getState().management.eventFilters.form.entry as ExceptionListItemSchema, - }, - }); - }); - - expect(notifications.toasts.addSuccess).toBeCalledWith( - getUpdateSuccessMessage( - store.getState().management.eventFilters.form.entry as CreateExceptionListItemSchema - ) - ); - expect(notifications.toasts.addDanger).not.toBeCalled(); - }); - - it('shows error notification when creation fails', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - }); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: { message: 'error message', statusCode: 500, error: 'error' }, - lastLoadedState: getLastLoadedResourceState( - store.getState().management.eventFilters.form.submissionResourceState - ), - }, - }); - }); - - expect(notifications.toasts.addSuccess).not.toBeCalled(); - expect(notifications.toasts.addDanger).toBeCalledWith( - getCreationErrorMessage( - ( - store.getState().management.eventFilters.form - .submissionResourceState as FailedResourceState - ).error - ) - ); - }); - - it('shows error notification when update fails', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: createdEventFilterEntryMock() }, - }); - }); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: { message: 'error message', statusCode: 500, error: 'error' }, - lastLoadedState: getLastLoadedResourceState( - store.getState().management.eventFilters.form.submissionResourceState - ), - }, - }); - }); - - expect(notifications.toasts.addSuccess).not.toBeCalled(); - expect(notifications.toasts.addDanger).toBeCalledWith( - getUpdateErrorMessage( - ( - store.getState().management.eventFilters.form - .submissionResourceState as FailedResourceState - ).error - ) - ); - }); - - it('shows error notification when get fails', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: { message: 'error message', statusCode: 500, error: 'error' }, - lastLoadedState: getLastLoadedResourceState( - store.getState().management.eventFilters.form.submissionResourceState - ), - }, - }); - }); - - expect(notifications.toasts.addSuccess).not.toBeCalled(); - expect(notifications.toasts.addWarning).toBeCalledWith( - getGetErrorMessage( - ( - store.getState().management.eventFilters.form - .submissionResourceState as FailedResourceState - ).error - ) - ); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/utils.ts similarity index 100% rename from x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts rename to x-pack/plugins/security_solution/public/management/pages/event_filters/view/utils.ts diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx index c30b5a8887338..11772324ff51c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx @@ -5,7 +5,10 @@ * 2.0. */ -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { + CreateExceptionListSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -16,17 +19,26 @@ import { createAppRootMockRenderer, } from '../../../../../../common/mock/endpoint'; import { PolicyArtifactsDeleteModal } from './policy_artifacts_delete_modal'; -import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_utils'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { exceptionsListAllHttpMocks } from '../../../../mocks/exceptions_list_http_mocks'; +import { ExceptionsListApiClient } from '../../../../../services/exceptions_list/exceptions_list_api_client'; import { POLICY_ARTIFACT_DELETE_MODAL_LABELS } from './translations'; -describe('Policy details artifacts delete modal', () => { +const listType: Array = [ + 'endpoint_events', + 'detection', + 'endpoint', + 'endpoint_trusted_apps', + 'endpoint_host_isolation_exceptions', + 'endpoint_blocklists', +]; + +describe.each(listType)('Policy details %s artifact delete modal', (type) => { let policyId: string; let render: () => Promise>; let renderResult: ReturnType; let mockedContext: AppContextTestRender; let exception: ExceptionListItemSchema; - let mockedApi: ReturnType; + let mockedApi: ReturnType; let onCloseMock: () => jest.Mock; beforeEach(() => { @@ -34,20 +46,30 @@ describe('Policy details artifacts delete modal', () => { mockedContext = createAppRootMockRenderer(); exception = getExceptionListItemSchemaMock(); onCloseMock = jest.fn(); - mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); + mockedApi = exceptionsListAllHttpMocks(mockedContext.coreStart.http); render = async () => { await act(async () => { renderResult = mockedContext.render( ); - await waitFor(mockedApi.responseProvider.eventFiltersList); + + mockedApi.responseProvider.exceptionsFind.mockReturnValue({ + data: [], + total: 0, + page: 1, + per_page: 10, + }); }); return renderResult; }; @@ -75,9 +97,9 @@ describe('Policy details artifacts delete modal', () => { const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); userEvent.click(confirmButton); await waitFor(() => { - expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenLastCalledWith({ + expect(mockedApi.responseProvider.exceptionUpdate).toHaveBeenLastCalledWith({ body: JSON.stringify( - EventFiltersApiClient.cleanExceptionsBeforeUpdate({ + ExceptionsListApiClient.cleanExceptionsBeforeUpdate({ ...exception, tags: ['policy:1234', 'policy:4321', 'not-a-policy-tag'], }) @@ -93,7 +115,7 @@ describe('Policy details artifacts delete modal', () => { userEvent.click(confirmButton); await waitFor(() => { - expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenCalled(); + expect(mockedApi.responseProvider.exceptionUpdate).toHaveBeenCalled(); }); expect(onCloseMock).toHaveBeenCalled(); @@ -102,7 +124,7 @@ describe('Policy details artifacts delete modal', () => { it('should show an error toast if the operation failed', async () => { const error = new Error('the server is too far away'); - mockedApi.responseProvider.eventFiltersUpdateOne.mockImplementation(() => { + mockedApi.responseProvider.exceptionUpdate.mockImplementation(() => { throw error; }); @@ -111,7 +133,7 @@ describe('Policy details artifacts delete modal', () => { userEvent.click(confirmButton); await waitFor(() => { - expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenCalled(); + expect(mockedApi.responseProvider.exceptionUpdate).toHaveBeenCalled(); }); expect(mockedContext.coreStart.notifications.toasts.addError).toHaveBeenCalledWith(error, { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx index edf9f5b21d8b4..056a8daa92d3a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx @@ -27,7 +27,7 @@ import { UpdateExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { cleanEventFilterToUpdate } from '../../../../event_filters/service/service_actions'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; import { POLICY_ARTIFACT_FLYOUT_LABELS } from './translations'; const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx index 453c84f63689e..67452fd11df53 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx @@ -24,7 +24,7 @@ import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_ut import { getFoundExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/found_exception_list_item_schema.mock'; import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; import { POLICY_ARTIFACT_EVENT_FILTERS_LABELS } from '../../tabs/event_filters_translations'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; import { SEARCHABLE_FIELDS as EVENT_FILTERS_SEARCHABLE_FIELDS } from '../../../../event_filters/constants'; import { FormattedMessage } from '@kbn/i18n-react'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx index de2f245a9c098..b3c104b27977f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx @@ -22,7 +22,7 @@ import { parseQueryFilterToKQL, parsePoliciesAndFilterToKql } from '../../../../ import { SEARCHABLE_FIELDS } from '../../../../event_filters/constants'; import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; import { POLICY_ARTIFACT_LIST_LABELS } from './translations'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; const endpointGenerator = new EndpointDocGenerator('seed'); const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx index 87860db1fe69d..16b5e9f975e22 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx @@ -15,7 +15,7 @@ import { getEventFiltersListPath } from '../../../../../../common/routing'; import { eventFiltersListQueryHttpMock } from '../../../../../event_filters/test_utils'; import { getEndpointPrivilegesInitialStateMock } from '../../../../../../../common/components/user_privileges/endpoint/mocks'; import { useToasts } from '../../../../../../../common/lib/kibana'; -import { EventFiltersApiClient } from '../../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../../event_filters/service/api_client'; import { FleetArtifactsCard } from './fleet_artifacts_card'; import { EVENT_FILTERS_LABELS } from '..'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx index c88f54f01fd2b..b8724850e1188 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx @@ -19,7 +19,7 @@ import { EndpointDocGenerator } from '../../../../../../../../common/endpoint/ge import { getPolicyEventFiltersPath } from '../../../../../../common/routing'; import { PolicyData } from '../../../../../../../../common/endpoint/types'; import { getSummaryExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_summary_schema.mock'; -import { EventFiltersApiClient } from '../../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../../event_filters/service/api_client'; import { SEARCHABLE_FIELDS } from '../../../../../event_filters/constants'; import { EVENT_FILTERS_LABELS } from '../../endpoint_policy_edit_extension'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/endpoint_package_custom_extension.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/endpoint_package_custom_extension.tsx index 72cc9852b0e7d..f1af7c3505297 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/endpoint_package_custom_extension.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/endpoint_package_custom_extension.tsx @@ -11,7 +11,7 @@ import { PackageCustomExtensionComponentProps } from '@kbn/fleet-plugin/public'; import { useHttp } from '../../../../../../common/lib/kibana'; import { useCanSeeHostIsolationExceptionsMenu } from '../../../../host_isolation_exceptions/view/hooks'; import { TrustedAppsApiClient } from '../../../../trusted_apps/service/api_client'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; import { HostIsolationExceptionsApiClient } from '../../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; import { BlocklistsApiClient } from '../../../../blocklist/services'; import { FleetArtifactsCard } from './components/fleet_artifacts_card'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx index dfb2677ecb594..9ac612aec05ed 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx @@ -35,7 +35,7 @@ import { useUserPrivileges } from '../../../../../common/components/user_privile import { FleetIntegrationArtifactsCard } from './endpoint_package_custom_extension/components/fleet_integration_artifacts_card'; import { BlocklistsApiClient } from '../../../blocklist/services'; import { HostIsolationExceptionsApiClient } from '../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; -import { EventFiltersApiClient } from '../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../event_filters/service/api_client'; import { TrustedAppsApiClient } from '../../../trusted_apps/service/api_client'; import { SEARCHABLE_FIELDS as BLOCKLIST_SEARCHABLE_FIELDS } from '../../../blocklist/constants'; import { SEARCHABLE_FIELDS as HOST_ISOLATION_EXCEPTIONS_SEARCHABLE_FIELDS } from '../../../host_isolation_exceptions/constants'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx index f3a20a1abfd66..f81b55b5e8a31 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx @@ -42,7 +42,7 @@ import { POLICY_ARTIFACT_TRUSTED_APPS_LABELS } from './trusted_apps_translations import { POLICY_ARTIFACT_HOST_ISOLATION_EXCEPTIONS_LABELS } from './host_isolation_exceptions_translations'; import { POLICY_ARTIFACT_BLOCKLISTS_LABELS } from './blocklists_translations'; import { TrustedAppsApiClient } from '../../../trusted_apps/service/api_client'; -import { EventFiltersApiClient } from '../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../event_filters/service/api_client'; import { BlocklistsApiClient } from '../../../blocklist/services/blocklists_api_client'; import { HostIsolationExceptionsApiClient } from '../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; import { SEARCHABLE_FIELDS as TRUSTED_APPS_SEARCHABLE_FIELDS } from '../../../trusted_apps/constants'; diff --git a/x-pack/plugins/security_solution/public/management/store/middleware.ts b/x-pack/plugins/security_solution/public/management/store/middleware.ts index 86a5ade340058..475fe0bc9bb7c 100644 --- a/x-pack/plugins/security_solution/public/management/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/store/middleware.ts @@ -14,11 +14,9 @@ import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_GLOBAL_NAMESPACE, MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, - MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE, } from '../common/constants'; import { policyDetailsMiddlewareFactory } from '../pages/policy/store/policy_details'; import { endpointMiddlewareFactory } from '../pages/endpoint_hosts/store/middleware'; -import { eventFiltersPageMiddlewareFactory } from '../pages/event_filters/store/middleware'; type ManagementSubStateKey = keyof State[typeof MANAGEMENT_STORE_GLOBAL_NAMESPACE]; @@ -40,10 +38,5 @@ export const managementMiddlewareFactory: SecuritySubPluginMiddlewareFactory = ( createSubStateSelector(MANAGEMENT_STORE_ENDPOINTS_NAMESPACE), endpointMiddlewareFactory(coreStart, depsStart) ), - - substateMiddlewareFactory( - createSubStateSelector(MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE), - eventFiltersPageMiddlewareFactory(coreStart, depsStart) - ), ]; }; diff --git a/x-pack/plugins/security_solution/public/management/store/reducer.ts b/x-pack/plugins/security_solution/public/management/store/reducer.ts index 2fd20129ddca8..678819a51d747 100644 --- a/x-pack/plugins/security_solution/public/management/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/store/reducer.ts @@ -13,14 +13,11 @@ import { import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, - MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE, } from '../common/constants'; import { ImmutableCombineReducers } from '../../common/store'; import { Immutable } from '../../../common/endpoint/types'; import { ManagementState } from '../types'; import { endpointListReducer } from '../pages/endpoint_hosts/store/reducer'; -import { initialEventFiltersPageState } from '../pages/event_filters/store/builders'; -import { eventFiltersPageReducer } from '../pages/event_filters/store/reducer'; import { initialEndpointPageState } from '../pages/endpoint_hosts/store/builders'; const immutableCombineReducers: ImmutableCombineReducers = combineReducers; @@ -31,7 +28,6 @@ const immutableCombineReducers: ImmutableCombineReducers = combineReducers; export const mockManagementState: Immutable = { [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: initialPolicyDetailsState(), [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointPageState(), - [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: initialEventFiltersPageState(), }; /** @@ -40,5 +36,4 @@ export const mockManagementState: Immutable = { export const managementReducer = immutableCombineReducers({ [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: policyDetailsReducer, [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: endpointListReducer, - [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: eventFiltersPageReducer, }); diff --git a/x-pack/plugins/security_solution/public/management/types.ts b/x-pack/plugins/security_solution/public/management/types.ts index 0ad0f2e757c00..f1cb7b2623b39 100644 --- a/x-pack/plugins/security_solution/public/management/types.ts +++ b/x-pack/plugins/security_solution/public/management/types.ts @@ -9,7 +9,6 @@ import { CombinedState } from 'redux'; import { SecurityPageName } from '../app/types'; import { PolicyDetailsState } from './pages/policy/types'; import { EndpointState } from './pages/endpoint_hosts/types'; -import { EventFiltersListPageState } from './pages/event_filters/types'; /** * The type for the management store global namespace. Used mostly internally to reference @@ -20,7 +19,6 @@ export type ManagementStoreGlobalNamespace = 'management'; export type ManagementState = CombinedState<{ policyDetails: PolicyDetailsState; endpoints: EndpointState; - eventFilters: EventFiltersListPageState; }>; /** diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx index 0f3bb6e7177bd..86a8047b3ad76 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx @@ -14,7 +14,7 @@ import type { TimelineEventsDetailsItem } from '../../../../../common/search_str import { TimelineId } from '../../../../../common/types'; import { useExceptionFlyout } from '../../../../detections/components/alerts_table/timeline_actions/use_add_exception_flyout'; import { AddExceptionFlyoutWrapper } from '../../../../detections/components/alerts_table/timeline_actions/alert_context_menu'; -import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/flyout'; +import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/event_filters_flyout'; import { useEventFilterModal } from '../../../../detections/components/alerts_table/timeline_actions/use_event_filter_modal'; import { getFieldValue } from '../../../../detections/components/host_isolation/helpers'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index a9d350146c0d9..70d3a81a2f808 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -25469,52 +25469,22 @@ "xpack.securitySolution.eventDetails.viewAllFields": "Afficher tous les champs dans le tableau", "xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel": "Afficher la colonne {field}", "xpack.securitySolution.eventDetails.viewRuleDetailPage": "Afficher la page Détails de la règle", - "xpack.securitySolution.eventFilter.form.creationSuccessToastTitle": "\"{name}\" a été ajouté à la liste de filtres d'événements.", - "xpack.securitySolution.eventFilter.form.description.label": "Décrivez votre filtre d'événement", "xpack.securitySolution.eventFilter.form.description.placeholder": "Description", - "xpack.securitySolution.eventFilter.form.failedToastTitle.create": "Une erreur est survenue lors de la création du nouveau filtre d'événement : \"{error}\"", - "xpack.securitySolution.eventFilter.form.failedToastTitle.get": "Impossible de modifier le filtre d'événement : \"{error}\"", - "xpack.securitySolution.eventFilter.form.failedToastTitle.update": "Une erreur est survenue lors de la mise à jour du filtre d'événement : \"{error}\"", "xpack.securitySolution.eventFilter.form.name.error": "Le nom doit être renseigné", "xpack.securitySolution.eventFilter.form.name.label": "Nommer votre filtre d'événement", - "xpack.securitySolution.eventFilter.form.name.placeholder": "Nom du filtre d'événement", "xpack.securitySolution.eventFilter.form.os.label": "Sélectionner un système d'exploitation", "xpack.securitySolution.eventFilter.form.rule.name": "Filtrage d'événement Endpoint", - "xpack.securitySolution.eventFilter.form.updateSuccessToastTitle": "{name} a été mis à jour avec succès.", - "xpack.securitySolution.eventFilter.search.placeholder": "Rechercher sur les champs ci-dessous : nom, description, commentaires, valeur", "xpack.securitySolution.eventFilters.aboutInfo": "Ajouter un filtre d'événement pour exclure les volumes importants ou les événements non souhaités de l'écriture dans Elasticsearch.", "xpack.securitySolution.eventFilters.commentsSectionDescription": "Ajouter un commentaire à votre filtre d'événement.", "xpack.securitySolution.eventFilters.commentsSectionTitle": "Commentaires", - "xpack.securitySolution.eventFilters.criteriaSectionDescription": "Sélectionnez un système d'exploitation et ajoutez des conditions.", "xpack.securitySolution.eventFilters.criteriaSectionTitle": "Conditions", - "xpack.securitySolution.eventFilters.deletionDialog.calloutMessage": "La suppression de cette entrée entraînera son retrait dans {count} {count, plural, one {politique associée} other {politiques associées}}.", - "xpack.securitySolution.eventFilters.deletionDialog.calloutTitle": "Avertissement", - "xpack.securitySolution.eventFilters.deletionDialog.cancelButton": "Annuler", - "xpack.securitySolution.eventFilters.deletionDialog.confirmButton": "Supprimer", - "xpack.securitySolution.eventFilters.deletionDialog.deleteFailure": "Impossible de retirer \"{name}\" de la liste de filtres d'événements. Raison : {message}", - "xpack.securitySolution.eventFilters.deletionDialog.deleteSuccess": "\"{name}\" a été retiré de la liste de filtres d'événements.", - "xpack.securitySolution.eventFilters.deletionDialog.subMessage": "Cette action ne peut pas être annulée. Voulez-vous vraiment continuer ?", - "xpack.securitySolution.eventFilters.deletionDialog.title": "Supprimer \"{name}\"", "xpack.securitySolution.eventFilters.detailsSectionTitle": "Détails", - "xpack.securitySolution.eventFilters.docsLink": "Documentation relative aux filtres d'événements.", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.cancel": "Annuler", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.create": "Ajouter un filtre d'événement", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update": "Enregistrer", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update.withData": "Ajouter un filtre d'événement de point de terminaison", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create": "Ajouter un filtre d'événement", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create.withData": "Endpoint Security", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update": "Mettre à jour le filtre d'événement", "xpack.securitySolution.eventFilters.eventFiltersFlyout.title.create.withData": "Ajouter un filtre d'événement de point de terminaison", - "xpack.securitySolution.eventFilters.expiredLicenseMessage": "Votre licence Kibana est passée à une version inférieure. Les futures configurations de politiques seront désormais globalement affectées à toutes les politiques. Pour en savoir plus, consultez notre ", - "xpack.securitySolution.eventFilters.expiredLicenseTitle": "Licence expirée", - "xpack.securitySolution.eventFilters.list.cardAction.delete": "Supprimer le filtre d'événement", - "xpack.securitySolution.eventFilters.list.cardAction.edit": "Modifier le filtre d'événement", - "xpack.securitySolution.eventFilters.list.pageAddButton": "Ajouter un filtre d'événement", - "xpack.securitySolution.eventFilters.list.pageTitle": "Filtres d'événements", - "xpack.securitySolution.eventFilters.list.totalCount": "Affichage de {total, plural, one {# filtre d'événement} other {# filtres d'événements}}", - "xpack.securitySolution.eventFilters.listEmpty.addButton": "Ajouter un filtre d'événement", - "xpack.securitySolution.eventFilters.listEmpty.message": "Ajouter un filtre d'événement pour exclure les volumes importants ou les événements non souhaités de l'écriture dans Elasticsearch.", - "xpack.securitySolution.eventFilters.listEmpty.title": "Ajouter votre premier filtre d'événement", "xpack.securitySolution.eventFiltersTab": "Filtres d'événements", "xpack.securitySolution.eventRenderers.alertsDescription": "Les alertes sont affichées lorsqu'un malware ou ransomware est bloqué ou détecté", "xpack.securitySolution.eventRenderers.alertsName": "Alertes", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 89813c1104606..a20feeeccdb1b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25619,52 +25619,22 @@ "xpack.securitySolution.eventDetails.viewAllFields": "テーブルのすべてのフィールドを表示", "xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel": "{field} 列を表示", "xpack.securitySolution.eventDetails.viewRuleDetailPage": "ルール詳細ページを表示", - "xpack.securitySolution.eventFilter.form.creationSuccessToastTitle": "\"{name}\"がイベントフィルターリストに追加されました。", - "xpack.securitySolution.eventFilter.form.description.label": "イベントフィルターの説明", "xpack.securitySolution.eventFilter.form.description.placeholder": "説明", - "xpack.securitySolution.eventFilter.form.failedToastTitle.create": "新しいイベントフィルターの作成中にエラーが発生しました:\"{error}\"", - "xpack.securitySolution.eventFilter.form.failedToastTitle.get": "イベントフィルターを編集できません:\"{error}\"", - "xpack.securitySolution.eventFilter.form.failedToastTitle.update": "イベントフィルターの更新中にエラーが発生しました:\"{error}\"", "xpack.securitySolution.eventFilter.form.name.error": "名前を空にすることはできません", "xpack.securitySolution.eventFilter.form.name.label": "イベントフィルターの名前を付ける", - "xpack.securitySolution.eventFilter.form.name.placeholder": "イベントフィルター名", "xpack.securitySolution.eventFilter.form.os.label": "オペレーティングシステムを選択", "xpack.securitySolution.eventFilter.form.rule.name": "エンドポイントイベントフィルター", - "xpack.securitySolution.eventFilter.form.updateSuccessToastTitle": "\"{name}\"が正常に更新されました", - "xpack.securitySolution.eventFilter.search.placeholder": "次のフィールドで検索:名前、説明、コメント、値", "xpack.securitySolution.eventFilters.aboutInfo": "イベントフィルターを追加して、大量のイベントや不要なイベントがElasticsearchに書き込まれないように除外します。", "xpack.securitySolution.eventFilters.commentsSectionDescription": "イベントフィルターにコメントを追加します。", "xpack.securitySolution.eventFilters.commentsSectionTitle": "コメント", - "xpack.securitySolution.eventFilters.criteriaSectionDescription": "オペレーティングシステムを選択して、条件を追加します。", "xpack.securitySolution.eventFilters.criteriaSectionTitle": "条件", - "xpack.securitySolution.eventFilters.deletionDialog.calloutMessage": "このエントリを削除すると、{count}個の関連付けられた{count, plural, other {ポリシー}}から削除されます。", - "xpack.securitySolution.eventFilters.deletionDialog.calloutTitle": "警告", - "xpack.securitySolution.eventFilters.deletionDialog.cancelButton": "キャンセル", - "xpack.securitySolution.eventFilters.deletionDialog.confirmButton": "削除", - "xpack.securitySolution.eventFilters.deletionDialog.deleteFailure": "イベントフィルターリストから\"{name}\"を削除できません。理由:{message}", - "xpack.securitySolution.eventFilters.deletionDialog.deleteSuccess": "\"{name}\"がイベントフィルターリストから削除されました。", - "xpack.securitySolution.eventFilters.deletionDialog.subMessage": "この操作は元に戻すことができません。続行していいですか?", - "xpack.securitySolution.eventFilters.deletionDialog.title": "\"{name}\"を削除", "xpack.securitySolution.eventFilters.detailsSectionTitle": "詳細", - "xpack.securitySolution.eventFilters.docsLink": "イベントフィルタードキュメント。", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.cancel": "キャンセル", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.create": "イベントフィルターを追加", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update": "保存", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update.withData": "エンドポイントイベントフィルターを追加", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create": "イベントフィルターを追加", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create.withData": "Endpoint Security", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update": "イベントフィルターを更新", "xpack.securitySolution.eventFilters.eventFiltersFlyout.title.create.withData": "エンドポイントイベントフィルターを追加", - "xpack.securitySolution.eventFilters.expiredLicenseMessage": "Kibanaライセンスがダウングレードされました。今後のポリシー構成はグローバルにすべてのポリシーに割り当てられます。詳細はご覧ください。 ", - "xpack.securitySolution.eventFilters.expiredLicenseTitle": "失効したライセンス", - "xpack.securitySolution.eventFilters.list.cardAction.delete": "イベントフィルターを削除", - "xpack.securitySolution.eventFilters.list.cardAction.edit": "イベントフィルターを編集", - "xpack.securitySolution.eventFilters.list.pageAddButton": "イベントフィルターを追加", - "xpack.securitySolution.eventFilters.list.pageTitle": "イベントフィルター", - "xpack.securitySolution.eventFilters.list.totalCount": "{total, plural, other {# 個のイベントフィルター}}を表示中", - "xpack.securitySolution.eventFilters.listEmpty.addButton": "イベントフィルターを追加", - "xpack.securitySolution.eventFilters.listEmpty.message": "イベントフィルターを追加して、大量のイベントや不要なイベントがElasticsearchに書き込まれないように除外します。", - "xpack.securitySolution.eventFilters.listEmpty.title": "最初のイベントフィルターを追加", "xpack.securitySolution.eventFilters.warningMessage.duplicateFields": "同じフィールド値の乗数を使用すると、エンドポイントパフォーマンスが劣化したり、効果的ではないルールが作成されたりすることがあります", "xpack.securitySolution.eventFiltersTab": "イベントフィルター", "xpack.securitySolution.eventRenderers.alertsDescription": "マルウェアまたはランサムウェアが防御、検出されたときにアラートが表示されます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a9278d13031f4..a2c33d9a1fae7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25652,52 +25652,22 @@ "xpack.securitySolution.eventDetails.viewAllFields": "查看表中的所有字段", "xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel": "查看 {field} 列", "xpack.securitySolution.eventDetails.viewRuleDetailPage": "查看规则详情页面", - "xpack.securitySolution.eventFilter.form.creationSuccessToastTitle": "“{name}”已添加到事件筛选列表。", - "xpack.securitySolution.eventFilter.form.description.label": "描述您的事件筛选", "xpack.securitySolution.eventFilter.form.description.placeholder": "描述", - "xpack.securitySolution.eventFilter.form.failedToastTitle.create": "创建新事件筛选时出错:“{error}”", - "xpack.securitySolution.eventFilter.form.failedToastTitle.get": "无法编辑事件筛选:“{error}”", - "xpack.securitySolution.eventFilter.form.failedToastTitle.update": "更新事件筛选时出错:“{error}”", "xpack.securitySolution.eventFilter.form.name.error": "名称不能为空", "xpack.securitySolution.eventFilter.form.name.label": "命名您的事件筛选", - "xpack.securitySolution.eventFilter.form.name.placeholder": "事件筛选名称", "xpack.securitySolution.eventFilter.form.os.label": "选择操作系统", "xpack.securitySolution.eventFilter.form.rule.name": "终端事件筛选", - "xpack.securitySolution.eventFilter.form.updateSuccessToastTitle": "“{name}”已成功更新。", - "xpack.securitySolution.eventFilter.search.placeholder": "搜索下面的字段:name、description、comments、value", "xpack.securitySolution.eventFilters.aboutInfo": "添加事件筛选以阻止高数目或非预期事件写入到 Elasticsearch。", "xpack.securitySolution.eventFilters.commentsSectionDescription": "将注释添加到事件筛选。", "xpack.securitySolution.eventFilters.commentsSectionTitle": "注释", - "xpack.securitySolution.eventFilters.criteriaSectionDescription": "选择操作系统,然后添加条件。", "xpack.securitySolution.eventFilters.criteriaSectionTitle": "条件", - "xpack.securitySolution.eventFilters.deletionDialog.calloutMessage": "删除此条目会将其从 {count} 个关联{count, plural, other {策略}}中移除。", - "xpack.securitySolution.eventFilters.deletionDialog.calloutTitle": "警告", - "xpack.securitySolution.eventFilters.deletionDialog.cancelButton": "取消", - "xpack.securitySolution.eventFilters.deletionDialog.confirmButton": "删除", - "xpack.securitySolution.eventFilters.deletionDialog.deleteFailure": "无法从事件筛选列表中移除“{name}”。原因:{message}", - "xpack.securitySolution.eventFilters.deletionDialog.deleteSuccess": "“{name}”已从事件筛选列表中移除。", - "xpack.securitySolution.eventFilters.deletionDialog.subMessage": "此操作无法撤消。是否确定要继续?", - "xpack.securitySolution.eventFilters.deletionDialog.title": "删除“{name}”", "xpack.securitySolution.eventFilters.detailsSectionTitle": "详情", - "xpack.securitySolution.eventFilters.docsLink": "事件筛选文档。", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.cancel": "取消", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.create": "添加事件筛选", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update": "保存", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update.withData": "添加终端事件筛选", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create": "添加事件筛选", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create.withData": "Endpoint Security", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update": "更新事件筛选", "xpack.securitySolution.eventFilters.eventFiltersFlyout.title.create.withData": "添加终端事件筛选", - "xpack.securitySolution.eventFilters.expiredLicenseMessage": "您的 Kibana 许可证已降级。现在会将未来的策略配置全局分配给所有策略。有关更多信息,请参见 ", - "xpack.securitySolution.eventFilters.expiredLicenseTitle": "已过期许可证", - "xpack.securitySolution.eventFilters.list.cardAction.delete": "删除事件筛选", - "xpack.securitySolution.eventFilters.list.cardAction.edit": "编辑事件筛选", - "xpack.securitySolution.eventFilters.list.pageAddButton": "添加事件筛选", - "xpack.securitySolution.eventFilters.list.pageTitle": "事件筛选", - "xpack.securitySolution.eventFilters.list.totalCount": "正在显示 {total, plural, other {# 个事件筛选}}", - "xpack.securitySolution.eventFilters.listEmpty.addButton": "添加事件筛选", - "xpack.securitySolution.eventFilters.listEmpty.message": "添加事件筛选以阻止高数目或非预期事件写入到 Elasticsearch。", - "xpack.securitySolution.eventFilters.listEmpty.title": "添加您的首个事件筛选", "xpack.securitySolution.eventFilters.warningMessage.duplicateFields": "使用相同提交值的倍数可能会降低终端性能和/或创建低效规则", "xpack.securitySolution.eventFiltersTab": "事件筛选", "xpack.securitySolution.eventRenderers.alertsDescription": "阻止或检测到恶意软件或勒索软件时,显示告警", From 8de3401dffbe2954b24fd749c34a2c92145a528f Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 19 May 2022 10:12:00 -0600 Subject: [PATCH 066/150] [Controls] Field first control creation (#131461) * Field first *creation* * Field first *editing* * Add support for custom control options * Add i18n * Make field picker accept predicate again + clean up imports * Fix functional tests * Attempt 1 at case sensitivity * Works both ways * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * Clean up code * Use React useMemo to calculate field registry * Fix functional tests * Fix default state + control settings label * Fix functional tests Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../control_types/options_list/types.ts | 1 - src/plugins/controls/common/types.ts | 2 + .../control_group/control_group_strings.ts | 20 + .../control_group/editor/control_editor.tsx | 345 +++++++++++------- .../control_group/editor/create_control.tsx | 28 +- .../control_group/editor/edit_control.tsx | 104 +++--- .../options_list/options_list_editor.tsx | 182 --------- .../options_list_editor_options.tsx | 54 +++ .../options_list/options_list_embeddable.tsx | 14 +- .../options_list_embeddable_factory.tsx | 15 +- .../range_slider/range_slider_editor.tsx | 111 ------ .../range_slider_embeddable_factory.tsx | 9 +- .../time_slider/time_slider_editor.tsx | 110 ------ .../time_slider_embeddable_factory.tsx | 9 +- src/plugins/controls/public/plugin.ts | 5 +- src/plugins/controls/public/types.ts | 29 +- .../controls/control_group_settings.ts | 4 +- .../controls/options_list.ts | 4 +- .../controls/range_slider.ts | 4 +- .../controls/replace_controls.ts | 22 +- .../page_objects/dashboard_page_controls.ts | 43 ++- 21 files changed, 479 insertions(+), 636 deletions(-) delete mode 100644 src/plugins/controls/public/control_types/options_list/options_list_editor.tsx create mode 100644 src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx delete mode 100644 src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx delete mode 100644 src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx diff --git a/src/plugins/controls/common/control_types/options_list/types.ts b/src/plugins/controls/common/control_types/options_list/types.ts index 7dfdfab742d1a..7ab1c3c4f67a0 100644 --- a/src/plugins/controls/common/control_types/options_list/types.ts +++ b/src/plugins/controls/common/control_types/options_list/types.ts @@ -17,7 +17,6 @@ export const OPTIONS_LIST_CONTROL = 'optionsListControl'; export interface OptionsListEmbeddableInput extends DataControlInput { selectedOptions?: string[]; runPastTimeout?: boolean; - textFieldName?: string; singleSelect?: boolean; loading?: boolean; } diff --git a/src/plugins/controls/common/types.ts b/src/plugins/controls/common/types.ts index 4108e886e757d..7d70f53c32933 100644 --- a/src/plugins/controls/common/types.ts +++ b/src/plugins/controls/common/types.ts @@ -30,5 +30,7 @@ export type ControlInput = EmbeddableInput & { export type DataControlInput = ControlInput & { fieldName: string; + parentFieldName?: string; + childFieldName?: string; dataViewId: string; }; diff --git a/src/plugins/controls/public/control_group/control_group_strings.ts b/src/plugins/controls/public/control_group/control_group_strings.ts index 58ef91ed28173..23be81f3585d3 100644 --- a/src/plugins/controls/public/control_group/control_group_strings.ts +++ b/src/plugins/controls/public/control_group/control_group_strings.ts @@ -44,6 +44,14 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.editFlyoutTitle', { defaultMessage: 'Edit control', }), + getDataViewTitle: () => + i18n.translate('controls.controlGroup.manageControl.dataViewTitle', { + defaultMessage: 'Data view', + }), + getFieldTitle: () => + i18n.translate('controls.controlGroup.manageControl.fielditle', { + defaultMessage: 'Field', + }), getTitleInputTitle: () => i18n.translate('controls.controlGroup.manageControl.titleInputTitle', { defaultMessage: 'Label', @@ -56,6 +64,10 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.widthInputTitle', { defaultMessage: 'Minimum width', }), + getControlSettingsTitle: () => + i18n.translate('controls.controlGroup.manageControl.controlSettingsTitle', { + defaultMessage: 'Additional settings', + }), getSaveChangesTitle: () => i18n.translate('controls.controlGroup.manageControl.saveChangesTitle', { defaultMessage: 'Save and close', @@ -64,6 +76,14 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.cancelTitle', { defaultMessage: 'Cancel', }), + getSelectFieldMessage: () => + i18n.translate('controls.controlGroup.manageControl.selectFieldMessage', { + defaultMessage: 'Please select a field', + }), + getSelectDataViewMessage: () => + i18n.translate('controls.controlGroup.manageControl.selectDataViewMessage', { + defaultMessage: 'Please select a data view', + }), getGrowSwitchTitle: () => i18n.translate('controls.controlGroup.manageControl.growSwitchTitle', { defaultMessage: 'Expand width to fit available space', diff --git a/src/plugins/controls/public/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx index fdf99dc0f9c48..4f52ef67ed7b1 100644 --- a/src/plugins/controls/public/control_group/editor/control_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_editor.tsx @@ -14,7 +14,9 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import useMount from 'react-use/lib/useMount'; + import { EuiFlyoutHeader, EuiButtonGroup, @@ -29,32 +31,35 @@ import { EuiForm, EuiButtonEmpty, EuiSpacer, - EuiKeyPadMenu, - EuiKeyPadMenuItem, EuiIcon, - EuiToolTip, EuiSwitch, + EuiTextColor, } from '@elastic/eui'; +import { DataViewListItem, DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import { IFieldSubTypeMulti } from '@kbn/es-query'; +import { + LazyDataViewPicker, + LazyFieldPicker, + withSuspense, +} from '@kbn/presentation-util-plugin/public'; -import { EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public'; import { ControlGroupStrings } from '../control_group_strings'; import { ControlEmbeddable, - ControlInput, ControlWidth, + DataControlFieldRegistry, DataControlInput, IEditableControlFactory, } from '../../types'; import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; import { pluginServices } from '../../services'; - interface EditControlProps { - embeddable?: ControlEmbeddable; + embeddable?: ControlEmbeddable; isCreate: boolean; title?: string; width: ControlWidth; + onSave: (type?: string) => void; grow: boolean; - onSave: (type: string) => void; onCancel: () => void; removeControl?: () => void; updateGrow?: (grow: boolean) => void; @@ -62,9 +67,18 @@ interface EditControlProps { updateWidth: (newWidth: ControlWidth) => void; getRelevantDataViewId?: () => string | undefined; setLastUsedDataViewId?: (newDataViewId: string) => void; - onTypeEditorChange: (partial: Partial) => void; + onTypeEditorChange: (partial: Partial) => void; } +interface ControlEditorState { + dataViewListItems: DataViewListItem[]; + selectedDataView?: DataView; + selectedField?: DataViewField; +} + +const FieldPicker = withSuspense(LazyFieldPicker, null); +const DataViewPicker = withSuspense(LazyDataViewPicker, null); + export const ControlEditor = ({ embeddable, isCreate, @@ -81,81 +95,104 @@ export const ControlEditor = ({ getRelevantDataViewId, setLastUsedDataViewId, }: EditControlProps) => { + const { dataViews } = pluginServices.getHooks(); + const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); + const { controls } = pluginServices.getServices(); const { getControlTypes, getControlFactory } = controls; + const [state, setState] = useState({ + dataViewListItems: [], + }); - const [selectedType, setSelectedType] = useState( - !isCreate && embeddable ? embeddable.type : getControlTypes()[0] - ); const [defaultTitle, setDefaultTitle] = useState(); const [currentTitle, setCurrentTitle] = useState(title); const [currentWidth, setCurrentWidth] = useState(width); const [currentGrow, setCurrentGrow] = useState(grow); const [controlEditorValid, setControlEditorValid] = useState(false); const [selectedField, setSelectedField] = useState( - embeddable - ? (embeddable.getInput() as DataControlInput).fieldName // CLEAN THIS ONCE OTHER PR GETS IN - : undefined + embeddable ? embeddable.getInput().fieldName : undefined ); - const getControlTypeEditor = (type: string) => { - const factory = getControlFactory(type); - const ControlTypeEditor = (factory as IEditableControlFactory).controlEditorComponent; - return ControlTypeEditor ? ( - { - if (!currentTitle || currentTitle === defaultTitle) { - setCurrentTitle(newDefaultTitle); - updateTitle(newDefaultTitle); - } - setDefaultTitle(newDefaultTitle); - }} - /> - ) : null; + const doubleLinkFields = (dataView: DataView) => { + // double link the parent-child relationship specifically for case-sensitivity support for options lists + const fieldRegistry: DataControlFieldRegistry = {}; + + for (const field of dataView.fields.getAll()) { + if (!fieldRegistry[field.name]) { + fieldRegistry[field.name] = { field, compatibleControlTypes: [] }; + } + const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; + if (parentFieldName) { + fieldRegistry[field.name].parentFieldName = parentFieldName; + + const parentField = dataView.getFieldByName(parentFieldName); + if (!fieldRegistry[parentFieldName] && parentField) { + fieldRegistry[parentFieldName] = { field: parentField, compatibleControlTypes: [] }; + } + fieldRegistry[parentFieldName].childFieldName = field.name; + } + } + return fieldRegistry; }; - const getTypeButtons = () => { - return getControlTypes().map((type) => { - const factory = getControlFactory(type); - const icon = (factory as EmbeddableFactoryDefinition).getIconType?.(); - const tooltip = (factory as EmbeddableFactoryDefinition).getDescription?.(); - const menuPadItem = ( - { - setSelectedType(type); - if (!isCreate) - setSelectedField( - embeddable && type === embeddable.type - ? (embeddable.getInput() as DataControlInput).fieldName - : undefined - ); - }} - > - - - ); + const fieldRegistry = useMemo(() => { + if (!state.selectedDataView) return; + const newFieldRegistry: DataControlFieldRegistry = doubleLinkFields(state.selectedDataView); - return tooltip ? ( - - {menuPadItem} - - ) : ( - menuPadItem - ); + const controlFactories = getControlTypes().map( + (controlType) => getControlFactory(controlType) as IEditableControlFactory + ); + state.selectedDataView.fields.map((dataViewField) => { + for (const factory of controlFactories) { + if (factory.isFieldCompatible) { + factory.isFieldCompatible(newFieldRegistry[dataViewField.name]); + } + } + + if (newFieldRegistry[dataViewField.name]?.compatibleControlTypes.length === 0) { + delete newFieldRegistry[dataViewField.name]; + } }); - }; + return newFieldRegistry; + }, [state.selectedDataView, getControlFactory, getControlTypes]); + + useMount(() => { + let mounted = true; + if (selectedField) setDefaultTitle(selectedField); + + (async () => { + const dataViewListItems = await getIdsWithTitle(); + const initialId = + embeddable?.getInput().dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); + let dataView: DataView | undefined; + if (initialId) { + onTypeEditorChange({ dataViewId: initialId }); + dataView = await get(initialId); + } + if (!mounted) return; + setState((s) => ({ + ...s, + selectedDataView: dataView, + dataViewListItems, + })); + })(); + return () => { + mounted = false; + }; + }); + + useEffect( + () => setControlEditorValid(Boolean(selectedField) && Boolean(state.selectedDataView)), + [selectedField, setControlEditorValid, state.selectedDataView] + ); + + const { selectedDataView: dataView } = state; + const controlType = + selectedField && fieldRegistry && fieldRegistry[selectedField].compatibleControlTypes[0]; + const factory = controlType && getControlFactory(controlType); + const CustomSettings = + factory && (factory as IEditableControlFactory).controlEditorOptionsComponent; return ( <> @@ -169,64 +206,124 @@ export const ControlEditor = ({ + + { + setLastUsedDataViewId?.(dataViewId); + if (dataViewId === dataView?.id) return; + + onTypeEditorChange({ dataViewId }); + setSelectedField(undefined); + get(dataViewId).then((newDataView) => { + setState((s) => ({ ...s, selectedDataView: newDataView })); + }); + }} + trigger={{ + label: + state.selectedDataView?.title ?? + ControlGroupStrings.manageControl.getSelectDataViewMessage(), + }} + /> + + + { + return Boolean(fieldRegistry?.[field.name]); + }} + selectedFieldName={selectedField} + dataView={dataView} + onSelectField={(field) => { + onTypeEditorChange({ + fieldName: field.name, + parentFieldName: fieldRegistry?.[field.name].parentFieldName, + childFieldName: fieldRegistry?.[field.name].childFieldName, + }); + + const newDefaultTitle = field.displayName ?? field.name; + setDefaultTitle(newDefaultTitle); + setSelectedField(field.name); + if (!currentTitle || currentTitle === defaultTitle) { + setCurrentTitle(newDefaultTitle); + updateTitle(newDefaultTitle); + } + }} + /> + - {getTypeButtons()} + {factory ? ( + + + + + + {factory.getDisplayName()} + + + ) : ( + + {ControlGroupStrings.manageControl.getSelectFieldMessage()} + + )} + + + { + updateTitle(e.target.value || defaultTitle); + setCurrentTitle(e.target.value); + }} + /> - {selectedType && ( + + { + setCurrentWidth(newWidth as ControlWidth); + updateWidth(newWidth as ControlWidth); + }} + /> + + {updateGrow ? ( + + { + setCurrentGrow(!currentGrow); + updateGrow(!currentGrow); + }} + data-test-subj="control-editor-grow-switch" + /> + + ) : null} + {CustomSettings && (factory as IEditableControlFactory).controlEditorOptionsComponent && ( + + + + )} + {removeControl && ( <> - {getControlTypeEditor(selectedType)} - - { - updateTitle(e.target.value || defaultTitle); - setCurrentTitle(e.target.value); - }} - /> - - - { - setCurrentWidth(newWidth as ControlWidth); - updateWidth(newWidth as ControlWidth); - }} - /> - - {updateGrow ? ( - - { - setCurrentGrow(!currentGrow); - updateGrow(!currentGrow); - }} - data-test-subj="control-editor-grow-switch" - /> - - ) : null} - {removeControl && ( - { - onCancel(); - removeControl(); - }} - > - {ControlGroupStrings.management.getDeleteButtonTitle()} - - )} + { + onCancel(); + removeControl(); + }} + > + {ControlGroupStrings.management.getDeleteButtonTitle()} + )} @@ -250,7 +347,7 @@ export const ControlEditor = ({ iconType="check" color="primary" disabled={!controlEditorValid} - onClick={() => onSave(selectedType)} + onClick={() => onSave(controlType)} > {ControlGroupStrings.manageControl.getSaveChangesTitle()} diff --git a/src/plugins/controls/public/control_group/editor/create_control.tsx b/src/plugins/controls/public/control_group/editor/create_control.tsx index 2f791ac74d3ae..a3da7071d7ceb 100644 --- a/src/plugins/controls/public/control_group/editor/create_control.tsx +++ b/src/plugins/controls/public/control_group/editor/create_control.tsx @@ -14,7 +14,7 @@ import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { pluginServices } from '../../services'; import { ControlEditor } from './control_editor'; import { ControlGroupStrings } from '../control_group_strings'; -import { ControlWidth, ControlInput, IEditableControlFactory } from '../../types'; +import { ControlWidth, ControlInput, IEditableControlFactory, DataControlInput } from '../../types'; import { DEFAULT_CONTROL_WIDTH, DEFAULT_CONTROL_GROW, @@ -59,7 +59,7 @@ export const CreateControlButton = ({ const PresentationUtilProvider = pluginServices.getContextProvider(); const initialInputPromise = new Promise((resolve, reject) => { - let inputToReturn: Partial = {}; + let inputToReturn: Partial = {}; const onCancel = (ref: OverlayRef) => { if (Object.keys(inputToReturn).length === 0) { @@ -80,6 +80,21 @@ export const CreateControlButton = ({ }); }; + const onSave = (ref: OverlayRef, type?: string) => { + if (!type) { + reject(); + ref.close(); + return; + } + + const factory = getControlFactory(type) as IEditableControlFactory; + if (factory.presaveTransformFunction) { + inputToReturn = factory.presaveTransformFunction(inputToReturn); + } + resolve({ type, controlInput: inputToReturn }); + ref.close(); + }; + const flyoutInstance = openFlyout( toMountPoint( @@ -92,14 +107,7 @@ export const CreateControlButton = ({ updateTitle={(newTitle) => (inputToReturn.title = newTitle)} updateWidth={updateDefaultWidth} updateGrow={updateDefaultGrow} - onSave={(type: string) => { - const factory = getControlFactory(type) as IEditableControlFactory; - if (factory.presaveTransformFunction) { - inputToReturn = factory.presaveTransformFunction(inputToReturn); - } - resolve({ type, controlInput: inputToReturn }); - flyoutInstance.close(); - }} + onSave={(type) => onSave(flyoutInstance, type)} onCancel={() => onCancel(flyoutInstance)} onTypeEditorChange={(partialInput) => (inputToReturn = { ...inputToReturn, ...partialInput }) diff --git a/src/plugins/controls/public/control_group/editor/edit_control.tsx b/src/plugins/controls/public/control_group/editor/edit_control.tsx index b3fa8834da5e0..370b4f7caa011 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control.tsx @@ -11,14 +11,19 @@ import { EuiButtonIcon } from '@elastic/eui'; import React, { useEffect, useRef } from 'react'; import { OverlayRef } from '@kbn/core/public'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { EmbeddableFactoryNotFoundError } from '@kbn/embeddable-plugin/public'; import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public'; import { ControlGroupInput } from '../types'; import { ControlEditor } from './control_editor'; import { pluginServices } from '../../services'; -import { forwardAllContext } from './forward_all_context'; import { ControlGroupStrings } from '../control_group_strings'; -import { IEditableControlFactory, ControlInput } from '../../types'; +import { + IEditableControlFactory, + ControlInput, + DataControlInput, + ControlEmbeddable, +} from '../../types'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlGroupContainer, setFlyoutRef } from '../embeddable/control_group_container'; @@ -56,15 +61,19 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }, [panels, embeddableId]); const editControl = async () => { - const panel = panels[embeddableId]; - let factory = getControlFactory(panel.type); - if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); - - const embeddable = await untilEmbeddableLoaded(embeddableId); - const controlGroup = embeddable.getRoot() as ControlGroupContainer; + const PresentationUtilProvider = pluginServices.getContextProvider(); + const embeddable = (await untilEmbeddableLoaded( + embeddableId + )) as ControlEmbeddable; const initialInputPromise = new Promise((resolve, reject) => { - let inputToReturn: Partial = {}; + const panel = panels[embeddableId]; + let factory = getControlFactory(panel.type); + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); + + const controlGroup = embeddable.getRoot() as ControlGroupContainer; + + let inputToReturn: Partial = {}; let removed = false; const onCancel = (ref: OverlayRef) => { @@ -94,7 +103,13 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }); }; - const onSave = (type: string, ref: OverlayRef) => { + const onSave = (ref: OverlayRef, type?: string) => { + if (!type) { + reject(); + ref.close(); + return; + } + // if the control now has a new type, need to replace the old factory with // one of the correct new type if (latestPanelState.current.type !== type) { @@ -110,44 +125,47 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }; const flyoutInstance = openFlyout( - forwardAllContext( - onCancel(flyoutInstance)} - updateTitle={(newTitle) => (inputToReturn.title = newTitle)} - setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} - updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))} - updateGrow={(grow) => dispatch(setControlGrow({ grow, embeddableId }))} - onTypeEditorChange={(partialInput) => { - inputToReturn = { ...inputToReturn, ...partialInput }; - }} - onSave={(type) => onSave(type, flyoutInstance)} - removeControl={() => { - openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), - cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), - title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), - buttonColor: 'danger', - }).then((confirmed) => { - if (confirmed) { - removeEmbeddable(embeddableId); - removed = true; - flyoutInstance.close(); - } - }); - }} - />, - reduxContainerContext + toMountPoint( + + onCancel(flyoutInstance)} + updateTitle={(newTitle) => (inputToReturn.title = newTitle)} + setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} + updateWidth={(newWidth) => + dispatch(setControlWidth({ width: newWidth, embeddableId })) + } + updateGrow={(grow) => dispatch(setControlGrow({ grow, embeddableId }))} + onTypeEditorChange={(partialInput) => { + inputToReturn = { ...inputToReturn, ...partialInput }; + }} + onSave={(type) => onSave(flyoutInstance, type)} + removeControl={() => { + openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), + title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + removeEmbeddable(embeddableId); + removed = true; + flyoutInstance.close(); + } + }); + }} + /> + ), { outsideClickCloses: false, onClose: (flyout) => { - setFlyoutRef(undefined); onCancel(flyout); + setFlyoutRef(undefined); }, } ); diff --git a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx b/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx deleted file mode 100644 index b6d5a0877d7ce..0000000000000 --- a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 useMount from 'react-use/lib/useMount'; -import React, { useEffect, useState } from 'react'; - -import { - LazyDataViewPicker, - LazyFieldPicker, - withSuspense, -} from '@kbn/presentation-util-plugin/public'; -import { IFieldSubTypeMulti } from '@kbn/es-query'; -import { EuiFormRow, EuiSwitch } from '@elastic/eui'; -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; - -import { pluginServices } from '../../services'; -import { ControlEditorProps } from '../../types'; -import { OptionsListStrings } from './options_list_strings'; -import { OptionsListEmbeddableInput, OptionsListField } from './types'; -interface OptionsListEditorState { - singleSelect?: boolean; - runPastTimeout?: boolean; - dataViewListItems: DataViewListItem[]; - fieldsMap?: { [key: string]: OptionsListField }; - dataView?: DataView; -} - -const FieldPicker = withSuspense(LazyFieldPicker, null); -const DataViewPicker = withSuspense(LazyDataViewPicker, null); - -export const OptionsListEditor = ({ - onChange, - initialInput, - setValidState, - setDefaultTitle, - getRelevantDataViewId, - setLastUsedDataViewId, - selectedField, - setSelectedField, -}: ControlEditorProps) => { - // Controls Services Context - const { dataViews } = pluginServices.getHooks(); - const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); - - const [state, setState] = useState({ - singleSelect: initialInput?.singleSelect, - runPastTimeout: initialInput?.runPastTimeout, - dataViewListItems: [], - }); - - useMount(() => { - let mounted = true; - if (selectedField) setDefaultTitle(selectedField); - (async () => { - const dataViewListItems = await getIdsWithTitle(); - const initialId = - initialInput?.dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); - let dataView: DataView | undefined; - if (initialId) { - onChange({ dataViewId: initialId }); - dataView = await get(initialId); - } - if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems, fieldsMap: {} })); - })(); - return () => { - mounted = false; - }; - }); - - useEffect(() => { - if (!state.dataView) return; - - // double link the parent-child relationship so that we can filter in fields which are multi-typed to text / keyword - const doubleLinkedFields: OptionsListField[] = state.dataView?.fields.getAll(); - for (const field of doubleLinkedFields) { - const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; - if (parentFieldName) { - (field as OptionsListField).parentFieldName = parentFieldName; - const parentField = state.dataView?.getFieldByName(parentFieldName); - (parentField as OptionsListField).childFieldName = field.name; - } - } - - const newFieldsMap: OptionsListEditorState['fieldsMap'] = {}; - for (const field of doubleLinkedFields) { - if (field.type === 'boolean') { - newFieldsMap[field.name] = field; - } - - // field type is keyword, check if this field is related to a text mapped field and include it. - else if (field.aggregatable && field.type === 'string') { - const childField = - (field.childFieldName && state.dataView?.fields.getByName(field.childFieldName)) || - undefined; - const parentField = - (field.parentFieldName && state.dataView?.fields.getByName(field.parentFieldName)) || - undefined; - - const textFieldName = childField?.esTypes?.includes('text') - ? childField.name - : parentField?.esTypes?.includes('text') - ? parentField.name - : undefined; - - newFieldsMap[field.name] = { ...field, textFieldName } as OptionsListField; - } - } - setState((s) => ({ ...s, fieldsMap: newFieldsMap })); - }, [state.dataView]); - - useEffect( - () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), - [selectedField, setValidState, state.dataView] - ); - - const { dataView } = state; - return ( - <> - - { - setLastUsedDataViewId?.(dataViewId); - if (dataViewId === dataView?.id) return; - - onChange({ dataViewId }); - setSelectedField(undefined); - get(dataViewId).then((newDataView) => { - setState((s) => ({ ...s, dataView: newDataView })); - }); - }} - trigger={{ - label: state.dataView?.title ?? OptionsListStrings.editor.getNoDataViewTitle(), - }} - /> - - - Boolean(state.fieldsMap?.[field.name])} - selectedFieldName={selectedField} - dataView={dataView} - onSelectField={(field) => { - setDefaultTitle(field.displayName ?? field.name); - const textFieldName = state.fieldsMap?.[field.name].textFieldName; - onChange({ - fieldName: field.name, - textFieldName, - }); - setSelectedField(field.name); - }} - /> - - - { - onChange({ singleSelect: !state.singleSelect }); - setState((s) => ({ ...s, singleSelect: !s.singleSelect })); - }} - /> - - - { - onChange({ runPastTimeout: !state.runPastTimeout }); - setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout })); - }} - /> - - - ); -}; diff --git a/src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx b/src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx new file mode 100644 index 0000000000000..e09d1887aac1f --- /dev/null +++ b/src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx @@ -0,0 +1,54 @@ +/* + * 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, { useState } from 'react'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; + +import { OptionsListEmbeddableInput } from './types'; +import { OptionsListStrings } from './options_list_strings'; +import { ControlEditorProps } from '../..'; + +interface OptionsListEditorState { + singleSelect?: boolean; + runPastTimeout?: boolean; +} + +export const OptionsListEditorOptions = ({ + initialInput, + onChange, +}: ControlEditorProps) => { + const [state, setState] = useState({ + singleSelect: initialInput?.singleSelect, + runPastTimeout: initialInput?.runPastTimeout, + }); + + return ( + <> + + { + onChange({ singleSelect: !state.singleSelect }); + setState((s) => ({ ...s, singleSelect: !s.singleSelect })); + }} + /> + + + { + onChange({ runPastTimeout: !state.runPastTimeout }); + setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout })); + }} + /> + + + ); +}; diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx index edf4cb6ddaff1..0376776121eea 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx @@ -179,7 +179,8 @@ export class OptionsListEmbeddable extends Embeddable => { - const { dataViewId, fieldName, textFieldName } = this.getInput(); + const { dataViewId, fieldName, parentFieldName, childFieldName } = this.getInput(); + if (!this.dataView || this.dataView.id !== dataViewId) { this.dataView = await this.dataViewsService.get(dataViewId); if (this.dataView === undefined) { @@ -192,6 +193,16 @@ export class OptionsListEmbeddable extends Embeddable { + if ( + (dataControlField.field.aggregatable && dataControlField.field.type === 'string') || + dataControlField.field.type === 'boolean' + ) { + dataControlField.compatibleControlTypes.push(this.type); + } + }; + + public controlEditorOptionsComponent = OptionsListEditorOptions; public isEditable = () => Promise.resolve(false); diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx deleted file mode 100644 index 13f688c5dd318..0000000000000 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 useMount from 'react-use/lib/useMount'; -import React, { useEffect, useState } from 'react'; -import { EuiFormRow } from '@elastic/eui'; - -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; -import { - LazyDataViewPicker, - LazyFieldPicker, - withSuspense, -} from '@kbn/presentation-util-plugin/public'; -import { pluginServices } from '../../services'; -import { ControlEditorProps } from '../../types'; -import { RangeSliderEmbeddableInput } from './types'; -import { RangeSliderStrings } from './range_slider_strings'; - -interface RangeSliderEditorState { - dataViewListItems: DataViewListItem[]; - dataView?: DataView; -} - -const FieldPicker = withSuspense(LazyFieldPicker, null); -const DataViewPicker = withSuspense(LazyDataViewPicker, null); - -export const RangeSliderEditor = ({ - onChange, - initialInput, - setValidState, - setDefaultTitle, - getRelevantDataViewId, - setLastUsedDataViewId, - selectedField, - setSelectedField, -}: ControlEditorProps) => { - // Controls Services Context - const { dataViews } = pluginServices.getHooks(); - const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); - - const [state, setState] = useState({ - dataViewListItems: [], - }); - - useMount(() => { - let mounted = true; - if (selectedField) setDefaultTitle(selectedField); - (async () => { - const dataViewListItems = await getIdsWithTitle(); - const initialId = - initialInput?.dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); - let dataView: DataView | undefined; - if (initialId) { - onChange({ dataViewId: initialId }); - dataView = await get(initialId); - } - if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems })); - })(); - return () => { - mounted = false; - }; - }); - - useEffect( - () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), - [selectedField, setValidState, state.dataView] - ); - - const { dataView } = state; - return ( - <> - - { - setLastUsedDataViewId?.(dataViewId); - if (dataViewId === dataView?.id) return; - - onChange({ dataViewId }); - setSelectedField(undefined); - get(dataViewId).then((newDataView) => { - setState((s) => ({ ...s, dataView: newDataView })); - }); - }} - trigger={{ - label: state.dataView?.title ?? RangeSliderStrings.editor.getNoDataViewTitle(), - }} - /> - - - field.aggregatable && field.type === 'number'} - selectedFieldName={selectedField} - dataView={dataView} - onSelectField={(field) => { - setDefaultTitle(field.displayName ?? field.name); - onChange({ fieldName: field.name }); - setSelectedField(field.name); - }} - /> - - - ); -}; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx index bd8b8a394988b..962937a8dc500 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx @@ -9,8 +9,7 @@ import deepEqual from 'fast-deep-equal'; import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public'; -import { RangeSliderEditor } from './range_slider_editor'; -import { ControlEmbeddable, IEditableControlFactory } from '../../types'; +import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; import { RangeSliderEmbeddableInput, RANGE_SLIDER_CONTROL } from './types'; import { createRangeSliderExtract, @@ -46,7 +45,11 @@ export class RangeSliderEmbeddableFactory return newInput; }; - public controlEditorComponent = RangeSliderEditor; + public isFieldCompatible = (dataControlField: DataControlField) => { + if (dataControlField.field.aggregatable && dataControlField.field.type === 'number') { + dataControlField.compatibleControlTypes.push(this.type); + } + }; public isEditable = () => Promise.resolve(false); diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx deleted file mode 100644 index d8f130661983f..0000000000000 --- a/src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 useMount from 'react-use/lib/useMount'; -import React, { useEffect, useState } from 'react'; -import { EuiFormRow } from '@elastic/eui'; - -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; -import { - LazyDataViewPicker, - LazyFieldPicker, - withSuspense, -} from '@kbn/presentation-util-plugin/public'; -import { pluginServices } from '../../services'; -import { ControlEditorProps } from '../../types'; -import { TimeSliderStrings } from './time_slider_strings'; - -interface TimeSliderEditorState { - dataViewListItems: DataViewListItem[]; - dataView?: DataView; -} - -const FieldPicker = withSuspense(LazyFieldPicker, null); -const DataViewPicker = withSuspense(LazyDataViewPicker, null); - -export const TimeSliderEditor = ({ - onChange, - initialInput, - setValidState, - setDefaultTitle, - getRelevantDataViewId, - setLastUsedDataViewId, - selectedField, - setSelectedField, -}: ControlEditorProps) => { - // Controls Services Context - const { dataViews } = pluginServices.getHooks(); - const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); - - const [state, setState] = useState({ - dataViewListItems: [], - }); - - useMount(() => { - let mounted = true; - if (selectedField) setDefaultTitle(selectedField); - (async () => { - const dataViewListItems = await getIdsWithTitle(); - const initialId = - initialInput?.dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); - let dataView: DataView | undefined; - if (initialId) { - onChange({ dataViewId: initialId }); - dataView = await get(initialId); - } - if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems })); - })(); - return () => { - mounted = false; - }; - }); - - useEffect( - () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), - [selectedField, setValidState, state.dataView] - ); - - const { dataView } = state; - return ( - <> - - { - setLastUsedDataViewId?.(dataViewId); - if (dataViewId === dataView?.id) return; - - onChange({ dataViewId }); - setSelectedField(undefined); - get(dataViewId).then((newDataView) => { - setState((s) => ({ ...s, dataView: newDataView })); - }); - }} - trigger={{ - label: state.dataView?.title ?? TimeSliderStrings.editor.getNoDataViewTitle(), - }} - /> - - - field.type === 'date'} - selectedFieldName={selectedField} - dataView={dataView} - onSelectField={(field) => { - setDefaultTitle(field.displayName ?? field.name); - onChange({ fieldName: field.name }); - setSelectedField(field.name); - }} - /> - - - ); -}; diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx index a49a0b85818f2..6fad0139b98e2 100644 --- a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx +++ b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx @@ -10,12 +10,11 @@ import deepEqual from 'fast-deep-equal'; import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public'; import { TIME_SLIDER_CONTROL } from '../..'; -import { ControlEmbeddable, IEditableControlFactory } from '../../types'; +import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; import { createOptionsListExtract, createOptionsListInject, } from '../../../common/control_types/options_list/options_list_persistable_state'; -import { TimeSliderEditor } from './time_slider_editor'; import { TimeSliderControlEmbeddableInput } from '../../../common/control_types/time_slider/types'; import { TimeSliderStrings } from './time_slider_strings'; @@ -48,7 +47,11 @@ export class TimesliderEmbeddableFactory return newInput; }; - public controlEditorComponent = TimeSliderEditor; + public isFieldCompatible = (dataControlField: DataControlField) => { + if (dataControlField.field.type === 'date') { + dataControlField.compatibleControlTypes.push(this.type); + } + }; public isEditable = () => Promise.resolve(false); diff --git a/src/plugins/controls/public/plugin.ts b/src/plugins/controls/public/plugin.ts index 9b0d754b3f150..352ed60b554a2 100644 --- a/src/plugins/controls/public/plugin.ts +++ b/src/plugins/controls/public/plugin.ts @@ -61,10 +61,11 @@ export class ControlsPlugin factoryDef: IEditableControlFactory, factory: EmbeddableFactory ) { - (factory as IEditableControlFactory).controlEditorComponent = - factoryDef.controlEditorComponent; + (factory as IEditableControlFactory).controlEditorOptionsComponent = + factoryDef.controlEditorOptionsComponent ?? undefined; (factory as IEditableControlFactory).presaveTransformFunction = factoryDef.presaveTransformFunction; + (factory as IEditableControlFactory).isFieldCompatible = factoryDef.isFieldCompatible; } public setup( diff --git a/src/plugins/controls/public/types.ts b/src/plugins/controls/public/types.ts index 4ab4db2eec037..71436fa9926e0 100644 --- a/src/plugins/controls/public/types.ts +++ b/src/plugins/controls/public/types.ts @@ -16,7 +16,7 @@ import { IEmbeddable, } from '@kbn/embeddable-plugin/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { DataView, DataViewField, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { ControlInput } from '../common/types'; import { ControlsService } from './services/controls'; @@ -28,7 +28,11 @@ export interface CommonControlOutput { export type ControlOutput = EmbeddableOutput & CommonControlOutput; -export type ControlFactory = EmbeddableFactory; +export type ControlFactory = EmbeddableFactory< + ControlInput, + ControlOutput, + ControlEmbeddable +>; export type ControlEmbeddable< TControlEmbeddableInput extends ControlInput = ControlInput, @@ -39,21 +43,28 @@ export type ControlEmbeddable< * Control embeddable editor types */ export interface IEditableControlFactory { - controlEditorComponent?: (props: ControlEditorProps) => JSX.Element; + controlEditorOptionsComponent?: (props: ControlEditorProps) => JSX.Element; presaveTransformFunction?: ( newState: Partial, embeddable?: ControlEmbeddable ) => Partial; + isFieldCompatible?: (dataControlField: DataControlField) => void; // reducer } + export interface ControlEditorProps { initialInput?: Partial; - getRelevantDataViewId?: () => string | undefined; - setLastUsedDataViewId?: (newId: string) => void; onChange: (partial: Partial) => void; - setValidState: (valid: boolean) => void; - setDefaultTitle: (defaultTitle: string) => void; - selectedField: string | undefined; - setSelectedField: (newField: string | undefined) => void; +} + +export interface DataControlField { + field: DataViewField; + parentFieldName?: string; + childFieldName?: string; + compatibleControlTypes: string[]; +} + +export interface DataControlFieldRegistry { + [fieldName: string]: DataControlField; } /** diff --git a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts index 23f44575ff45e..4648698ec0b5f 100644 --- a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts +++ b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts @@ -42,7 +42,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('apply new default width and grow', async () => { it('defaults to medium width and grow enabled', async () => { - await dashboardControls.openCreateControlFlyout(OPTIONS_LIST_CONTROL); + await dashboardControls.openCreateControlFlyout(); const mediumWidthButton = await testSubjects.find('control-editor-width-medium'); expect(await mediumWidthButton.elementHasClass('euiButtonGroupButton-isSelected')).to.be( true @@ -70,7 +70,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await secondControl.elementHasClass('controlFrameWrapper--small')).to.be(true); expect(await secondControl.elementHasClass('euiFlexItem--flexGrowZero')).to.be(true); - await dashboardControls.openCreateControlFlyout(OPTIONS_LIST_CONTROL); + await dashboardControls.openCreateControlFlyout(); const smallWidthButton = await testSubjects.find('control-editor-width-small'); expect(await smallWidthButton.elementHasClass('euiButtonGroupButton-isSelected')).to.be( true diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index 17a028a39464e..162444883873a 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -110,7 +110,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await saveButton.isEnabled()).to.be(true); await dashboardControls.controlsEditorSetDataView('animals-*'); expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield('animal.keyword'); + await dashboardControls.controlsEditorSetfield('animal.keyword', OPTIONS_LIST_CONTROL); await dashboardControls.controlEditorSave(); // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view @@ -129,7 +129,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.optionsListEnsurePopoverIsClosed(secondId); await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlsEditorSetfield('animal.keyword'); + await dashboardControls.controlsEditorSetfield('animal.keyword', OPTIONS_LIST_CONTROL); await dashboardControls.controlEditorSave(); const selectionString = await dashboardControls.optionsListGetSelectionsString(secondId); diff --git a/test/functional/apps/dashboard_elements/controls/range_slider.ts b/test/functional/apps/dashboard_elements/controls/range_slider.ts index a4b84206bde84..9cc390fbe405a 100644 --- a/test/functional/apps/dashboard_elements/controls/range_slider.ts +++ b/test/functional/apps/dashboard_elements/controls/range_slider.ts @@ -121,7 +121,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await saveButton.isEnabled()).to.be(true); await dashboardControls.controlsEditorSetDataView('kibana_sample_data_flights'); expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield('dayOfWeek'); + await dashboardControls.controlsEditorSetfield('dayOfWeek', RANGE_SLIDER_CONTROL); await dashboardControls.controlEditorSave(); await dashboardControls.rangeSliderWaitForLoading(); validateRange('placeholder', firstId, '0', '6'); @@ -164,7 +164,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('editing field clears selections', async () => { const secondId = (await dashboardControls.getAllControlIds())[1]; await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlsEditorSetfield('FlightDelayMin'); + await dashboardControls.controlsEditorSetfield('FlightDelayMin', RANGE_SLIDER_CONTROL); await dashboardControls.controlEditorSave(); await dashboardControls.rangeSliderWaitForLoading(); diff --git a/test/functional/apps/dashboard_elements/controls/replace_controls.ts b/test/functional/apps/dashboard_elements/controls/replace_controls.ts index f6af399905077..3697300e1b7d3 100644 --- a/test/functional/apps/dashboard_elements/controls/replace_controls.ts +++ b/test/functional/apps/dashboard_elements/controls/replace_controls.ts @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect'; - import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL, @@ -28,24 +26,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'header', ]); - const changeFieldType = async (newField: string) => { - const saveButton = await testSubjects.find('control-editor-save'); - expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield(newField); - expect(await saveButton.isEnabled()).to.be(true); + const changeFieldType = async (controlId: string, newField: string, expectedType?: string) => { + await dashboardControls.editExistingControl(controlId); + await dashboardControls.controlsEditorSetfield(newField, expectedType); await dashboardControls.controlEditorSave(); }; const replaceWithOptionsList = async (controlId: string) => { - await dashboardControls.controlEditorSetType(OPTIONS_LIST_CONTROL); - await changeFieldType('sound.keyword'); + await changeFieldType(controlId, 'sound.keyword', OPTIONS_LIST_CONTROL); await testSubjects.waitForEnabled(`optionsList-control-${controlId}`); await dashboardControls.verifyControlType(controlId, 'optionsList-control'); }; const replaceWithRangeSlider = async (controlId: string) => { - await dashboardControls.controlEditorSetType(RANGE_SLIDER_CONTROL); - await changeFieldType('weightLbs'); + await changeFieldType(controlId, 'weightLbs', RANGE_SLIDER_CONTROL); await retry.try(async () => { await dashboardControls.rangeSliderWaitForLoading(); await dashboardControls.verifyControlType(controlId, 'range-slider-control'); @@ -53,8 +47,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }; const replaceWithTimeSlider = async (controlId: string) => { - await dashboardControls.controlEditorSetType(TIME_SLIDER_CONTROL); - await changeFieldType('@timestamp'); + await changeFieldType(controlId, '@timestamp', TIME_SLIDER_CONTROL); await testSubjects.waitForDeleted('timeSlider-loading-spinner'); await dashboardControls.verifyControlType(controlId, 'timeSlider'); }; @@ -78,7 +71,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { fieldName: 'sound.keyword', }); controlId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(controlId); }); it('with range slider', async () => { @@ -102,7 +94,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await dashboardControls.rangeSliderWaitForLoading(); controlId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(controlId); }); it('with options list', async () => { @@ -124,7 +115,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await testSubjects.waitForDeleted('timeSlider-loading-spinner'); controlId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(controlId); }); it('with options list', async () => { diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index f0438b391ac93..2f8f21c73692e 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -7,12 +7,22 @@ */ import expect from '@kbn/expect'; -import { OPTIONS_LIST_CONTROL, ControlWidth } from '@kbn/controls-plugin/common'; +import { + OPTIONS_LIST_CONTROL, + RANGE_SLIDER_CONTROL, + ControlWidth, +} from '@kbn/controls-plugin/common'; import { ControlGroupChainingSystem } from '@kbn/controls-plugin/common/control_group/types'; import { WebElementWrapper } from '../services/lib/web_element_wrapper'; import { FtrService } from '../ftr_provider_context'; +const CONTROL_DISPLAY_NAMES: { [key: string]: string } = { + default: 'Please select a field', + [OPTIONS_LIST_CONTROL]: 'Options list', + [RANGE_SLIDER_CONTROL]: 'Range slider', +}; + export class DashboardPageControls extends FtrService { private readonly log = this.ctx.getService('log'); private readonly find = this.ctx.getService('find'); @@ -78,14 +88,14 @@ export class DashboardPageControls extends FtrService { } } - public async openCreateControlFlyout(type: string) { - this.log.debug(`Opening flyout for ${type} control`); + public async openCreateControlFlyout() { + this.log.debug(`Opening flyout for creating a control`); await this.testSubjects.click('dashboard-controls-menu-button'); await this.testSubjects.click('controls-create-button'); await this.retry.try(async () => { await this.testSubjects.existOrFail('control-editor-flyout'); }); - await this.controlEditorSetType(type); + await this.controlEditorVerifyType('default'); } /* ----------------------------------------------------------- @@ -238,10 +248,12 @@ export class DashboardPageControls extends FtrService { grow?: boolean; }) { this.log.debug(`Creating ${controlType} control ${title ?? fieldName}`); - await this.openCreateControlFlyout(controlType); + await this.openCreateControlFlyout(); if (dataViewTitle) await this.controlsEditorSetDataView(dataViewTitle); - if (fieldName) await this.controlsEditorSetfield(fieldName); + + if (fieldName) await this.controlsEditorSetfield(fieldName, controlType); + if (title) await this.controlEditorSetTitle(title); if (width) await this.controlEditorSetWidth(width); if (grow !== undefined) await this.controlEditorSetGrow(grow); @@ -377,6 +389,9 @@ export class DashboardPageControls extends FtrService { public async controlEditorSave() { this.log.debug(`Saving changes in control editor`); await this.testSubjects.click(`control-editor-save`); + await this.retry.waitFor('flyout to close', async () => { + return !(await this.testSubjects.exists('control-editor-flyout')); + }); } public async controlEditorCancel(confirm?: boolean) { @@ -396,7 +411,11 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.click(`data-view-picker-${dataViewTitle}`); } - public async controlsEditorSetfield(fieldName: string, shouldSearch: boolean = false) { + public async controlsEditorSetfield( + fieldName: string, + expectedType?: string, + shouldSearch: boolean = false + ) { this.log.debug(`Setting control field to ${fieldName}`); if (shouldSearch) { await this.testSubjects.setValue('field-search-input', fieldName); @@ -405,17 +424,19 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.existOrFail(`field-picker-select-${fieldName}`); }); await this.testSubjects.click(`field-picker-select-${fieldName}`); + if (expectedType) await this.controlEditorVerifyType(expectedType); } - public async controlEditorSetType(type: string) { - this.log.debug(`Setting control type to ${type}`); - await this.testSubjects.click(`create-${type}-control`); + public async controlEditorVerifyType(type: string) { + this.log.debug(`Verifying that the control editor picked the type ${type}`); + const autoSelectedType = await this.testSubjects.getVisibleText('control-editor-type'); + expect(autoSelectedType).to.equal(CONTROL_DISPLAY_NAMES[type]); } // Options List editor functions public async optionsListEditorGetCurrentDataView(openAndCloseFlyout?: boolean) { if (openAndCloseFlyout) { - await this.openCreateControlFlyout(OPTIONS_LIST_CONTROL); + await this.openCreateControlFlyout(); } const dataViewName = (await this.testSubjects.find('open-data-view-picker')).getVisibleText(); if (openAndCloseFlyout) { From a80bfb7283ea8a648514c248a8047b16f46bded6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 19 May 2022 17:18:21 +0100 Subject: [PATCH 067/150] [Content management] Add "Last updated" metadata to TableListView (#132321) --- .../public/services/saved_object_loader.ts | 20 ++- .../table_list_view.test.tsx.snap | 7 +- .../table_list_view/table_list_view.test.tsx | 170 +++++++++++++++++- .../table_list_view/table_list_view.tsx | 158 ++++++++++++---- .../public/utils/saved_visualize_utils.ts | 4 + .../vis_types/vis_type_alias_registry.ts | 4 +- .../public/helpers/saved_workspace_utils.ts | 1 + x-pack/plugins/lens/public/vis_type_alias.ts | 3 +- .../maps/common/map_saved_object_type.ts | 4 - .../maps/public/maps_vis_type_alias.ts | 8 +- .../routes/list_page/maps_list_view.tsx | 1 + .../maps/server/maps_telemetry/find_maps.ts | 6 +- .../index_pattern_stats_collector.ts | 5 +- 13 files changed, 334 insertions(+), 57 deletions(-) diff --git a/src/plugins/dashboard/public/services/saved_object_loader.ts b/src/plugins/dashboard/public/services/saved_object_loader.ts index 3c406357c0294..780daa2939aa4 100644 --- a/src/plugins/dashboard/public/services/saved_object_loader.ts +++ b/src/plugins/dashboard/public/services/saved_object_loader.ts @@ -98,12 +98,16 @@ export class SavedObjectLoader { mapHitSource( source: Record, id: string, - references: SavedObjectReference[] = [] - ) { - source.id = id; - source.url = this.urlFor(id); - source.references = references; - return source; + references: SavedObjectReference[] = [], + updatedAt?: string + ): Record { + return { + ...source, + id, + url: this.urlFor(id), + references, + updatedAt, + }; } /** @@ -116,12 +120,14 @@ export class SavedObjectLoader { attributes, id, references = [], + updatedAt, }: { attributes: Record; id: string; references?: SavedObjectReference[]; + updatedAt?: string; }) { - return this.mapHitSource(attributes, id, references); + return this.mapHitSource(attributes, id, references, updatedAt); } /** diff --git a/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap b/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap index a0c34cfdfee07..2ad9af679e8c6 100644 --- a/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap +++ b/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap @@ -129,6 +129,7 @@ exports[`TableListView render list view 1`] = ` } /> } + onChange={[Function]} pagination={ Object { "initialPageIndex": 0, @@ -155,7 +156,11 @@ exports[`TableListView render list view 1`] = ` "toolsLeft": undefined, } } - sorting={true} + sorting={ + Object { + "sort": undefined, + } + } tableCaption="test caption" tableLayout="fixed" /> diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx index 13423047bc3f0..ba76a6b879e61 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx @@ -7,13 +7,24 @@ */ import { EuiEmptyPrompt } from '@elastic/eui'; -import { shallowWithIntl } from '@kbn/test-jest-helpers'; +import { shallowWithIntl, registerTestBed, TestBed } from '@kbn/test-jest-helpers'; import { ToastsStart } from '@kbn/core/public'; import React from 'react'; +import moment, { Moment } from 'moment'; +import { act } from 'react-dom/test-utils'; import { themeServiceMock, applicationServiceMock } from '@kbn/core/public/mocks'; -import { TableListView } from './table_list_view'; +import { TableListView, TableListViewProps } from './table_list_view'; -const requiredProps = { +jest.mock('lodash', () => { + const original = jest.requireActual('lodash'); + + return { + ...original, + debounce: (handler: () => void) => handler, + }; +}); + +const requiredProps: TableListViewProps> = { entityName: 'test', entityNamePlural: 'tests', listingLimit: 5, @@ -30,6 +41,14 @@ const requiredProps = { }; describe('TableListView', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + test('render default empty prompt', async () => { const component = shallowWithIntl(); @@ -81,4 +100,149 @@ describe('TableListView', () => { expect(component).toMatchSnapshot(); }); + + describe('default columns', () => { + let testBed: TestBed; + + const tableColumns = [ + { + field: 'title', + name: 'Title', + sortable: true, + }, + { + field: 'description', + name: 'Description', + sortable: true, + }, + ]; + + const twoDaysAgo = new Date(new Date().setDate(new Date().getDate() - 2)); + const yesterday = new Date(new Date().setDate(new Date().getDate() - 1)); + + const hits = [ + { + title: 'Item 1', + description: 'Item 1 description', + updatedAt: twoDaysAgo, + }, + { + title: 'Item 2', + description: 'Item 2 description', + // This is the latest updated and should come first in the table + updatedAt: yesterday, + }, + ]; + + const findItems = jest.fn(() => Promise.resolve({ total: hits.length, hits })); + + const defaultProps: TableListViewProps> = { + ...requiredProps, + tableColumns, + findItems, + createItem: () => undefined, + }; + + const setup = registerTestBed(TableListView, { defaultProps }); + + test('should add a "Last updated" column if "updatedAt" is provided', async () => { + await act(async () => { + testBed = await setup(); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + ['Item 2', 'Item 2 description', 'yesterday'], // Comes first as it is the latest updated + ['Item 1', 'Item 1 description', '2 days ago'], + ]); + }); + + test('should not display relative time for items updated more than 7 days ago', async () => { + const updatedAtValues: Moment[] = []; + + const updatedHits = hits.map(({ title, description }, i) => { + const updatedAt = new Date(new Date().setDate(new Date().getDate() - (7 + i))); + updatedAtValues[i] = moment(updatedAt); + + return { + title, + description, + updatedAt, + }; + }); + + await act(async () => { + testBed = await setup({ + findItems: jest.fn(() => + Promise.resolve({ + total: updatedHits.length, + hits: updatedHits, + }) + ), + }); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + // Renders the datetime with this format: "05/10/2022 @ 2:34 PM" + ['Item 1', 'Item 1 description', updatedAtValues[0].format('LL')], + ['Item 2', 'Item 2 description', updatedAtValues[1].format('LL')], + ]); + }); + + test('should not add a "Last updated" column if no "updatedAt" is provided', async () => { + await act(async () => { + testBed = await setup({ + findItems: jest.fn(() => + Promise.resolve({ + total: hits.length, + hits: hits.map(({ title, description }) => ({ title, description })), + }) + ), + }); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + ['Item 1', 'Item 1 description'], // Sorted by title + ['Item 2', 'Item 2 description'], + ]); + }); + + test('should not display anything if there is no updatedAt metadata for an item', async () => { + await act(async () => { + testBed = await setup({ + findItems: jest.fn(() => + Promise.resolve({ + total: hits.length + 1, + hits: [...hits, { title: 'Item 3', description: 'Item 3 description' }], + }) + ), + }); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + ['Item 2', 'Item 2 description', 'yesterday'], + ['Item 1', 'Item 1 description', '2 days ago'], + ['Item 3', 'Item 3 description', '-'], // Empty column as no updatedAt provided + ]); + }); + }); }); diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index ece2fa37cc832..5baaaa78b76ec 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -13,16 +13,21 @@ import { EuiConfirmModal, EuiEmptyPrompt, EuiInMemoryTable, + Criteria, + PropertySort, + Direction, EuiLink, EuiSpacer, EuiTableActionsColumnType, SearchFilterConfig, + EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react'; import { ThemeServiceStart, HttpFetchError, ToastsStart, ApplicationStart } from '@kbn/core/public'; import { debounce, keyBy, sortBy, uniq } from 'lodash'; import React from 'react'; +import moment from 'moment'; import { KibanaPageTemplate } from '../page_template'; import { toMountPoint } from '../util'; @@ -64,6 +69,7 @@ export interface TableListViewProps { export interface TableListViewState { items: V[]; hasInitialFetchReturned: boolean; + hasUpdatedAtMetadata: boolean | null; isFetchingItems: boolean; isDeletingItems: boolean; showDeleteModal: boolean; @@ -72,6 +78,10 @@ export interface TableListViewState { filter: string; selectedIds: string[]; totalItems: number; + tableSort?: { + field: keyof V; + direction: Direction; + }; } // saved object client does not support sorting by title because title is only mapped as analyzed @@ -94,10 +104,12 @@ class TableListView extends React.Component< initialPageSize: props.initialPageSize, pageSizeOptions: uniq([10, 20, 50, props.initialPageSize]).sort(), }; + this.state = { items: [], totalItems: 0, hasInitialFetchReturned: false, + hasUpdatedAtMetadata: null, isFetchingItems: false, isDeletingItems: false, showDeleteModal: false, @@ -120,6 +132,28 @@ class TableListView extends React.Component< this.fetchItems(); } + componentDidUpdate(prevProps: TableListViewProps, prevState: TableListViewState) { + if (this.state.hasUpdatedAtMetadata === null && prevState.items !== this.state.items) { + // We check if the saved object have the "updatedAt" metadata + // to render or not that column in the table + const hasUpdatedAtMetadata = Boolean( + this.state.items.find((item: { updatedAt?: string }) => Boolean(item.updatedAt)) + ); + + this.setState((prev) => { + return { + hasUpdatedAtMetadata, + tableSort: hasUpdatedAtMetadata + ? { + field: 'updatedAt' as keyof V, + direction: 'desc' as const, + } + : prev.tableSort, + }; + }); + } + } + debouncedFetch = debounce(async (filter: string) => { try { const response = await this.props.findItems(filter); @@ -420,6 +454,12 @@ class TableListView extends React.Component< ); } + onTableChange(criteria: Criteria) { + if (criteria.sort) { + this.setState({ tableSort: criteria.sort }); + } + } + renderTable() { const { searchFilters } = this.props; @@ -435,24 +475,6 @@ class TableListView extends React.Component< } : undefined; - const actions: EuiTableActionsColumnType['actions'] = [ - { - name: i18n.translate('kibana-react.tableListView.listing.table.editActionName', { - defaultMessage: 'Edit', - }), - description: i18n.translate( - 'kibana-react.tableListView.listing.table.editActionDescription', - { - defaultMessage: 'Edit', - } - ), - icon: 'pencil', - type: 'icon', - enabled: (v) => !(v as unknown as { error: string })?.error, - onClick: this.props.editItem, - }, - ]; - const search = { onChange: this.setFilter.bind(this), toolsLeft: this.renderToolsLeft(), @@ -464,17 +486,6 @@ class TableListView extends React.Component< filters: searchFilters ?? [], }; - const columns = this.props.tableColumns.slice(); - if (this.props.editItem) { - columns.push({ - name: i18n.translate('kibana-react.tableListView.listing.table.actionTitle', { - defaultMessage: 'Actions', - }), - width: '100px', - actions, - }); - } - const noItemsMessage = ( extends React.Component< values={{ entityNamePlural: this.props.entityNamePlural }} /> ); + return ( extends React.Component< ); } + getTableColumns() { + const columns = this.props.tableColumns.slice(); + + // Add "Last update" column + if (this.state.hasUpdatedAtMetadata) { + const renderUpdatedAt = (dateTime?: string) => { + if (!dateTime) { + return ( + + - + + ); + } + const updatedAt = moment(dateTime); + + if (updatedAt.diff(moment(), 'days') > -7) { + return ( + + {(formattedDate: string) => ( + + {formattedDate} + + )} + + ); + } + return ( + + {updatedAt.format('LL')} + + ); + }; + + columns.push({ + field: 'updatedAt', + name: i18n.translate('kibana-react.tableListView.lastUpdatedColumnTitle', { + defaultMessage: 'Last updated', + }), + render: (field: string, record: { updatedAt?: string }) => + renderUpdatedAt(record.updatedAt), + sortable: true, + width: '150px', + }); + } + + // Add "Actions" column + if (this.props.editItem) { + const actions: EuiTableActionsColumnType['actions'] = [ + { + name: i18n.translate('kibana-react.tableListView.listing.table.editActionName', { + defaultMessage: 'Edit', + }), + description: i18n.translate( + 'kibana-react.tableListView.listing.table.editActionDescription', + { + defaultMessage: 'Edit', + } + ), + icon: 'pencil', + type: 'icon', + enabled: (v) => !(v as unknown as { error: string })?.error, + onClick: this.props.editItem, + }, + ]; + + columns.push({ + name: i18n.translate('kibana-react.tableListView.listing.table.actionTitle', { + defaultMessage: 'Actions', + }), + width: '100px', + actions, + }); + } + + return columns; + } + renderCreateButton() { if (this.props.createItem) { return ( diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts index 5b8ba8ce04cb4..f5444b6269e22 100644 --- a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts @@ -64,10 +64,12 @@ export function mapHitSource( attributes, id, references, + updatedAt, }: { attributes: SavedObjectAttributes; id: string; references: SavedObjectReference[]; + updatedAt?: string; } ) { const newAttributes: { @@ -76,6 +78,7 @@ export function mapHitSource( url: string; savedObjectType?: string; editUrl?: string; + updatedAt?: string; type?: BaseVisType; icon?: BaseVisType['icon']; image?: BaseVisType['image']; @@ -85,6 +88,7 @@ export function mapHitSource( id, references, url: urlFor(id), + updatedAt, ...attributes, }; diff --git a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts index 2945aaa1a0cc8..f113a0a212fe6 100644 --- a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts +++ b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SavedObject } from '@kbn/core/types/saved_objects'; +import type { SimpleSavedObject } from '@kbn/core/public'; import { BaseVisType } from './base_vis_type'; export type VisualizationStage = 'experimental' | 'beta' | 'production'; @@ -30,7 +30,7 @@ export interface VisualizationListItem { export interface VisualizationsAppExtension { docTypes: string[]; searchFields?: string[]; - toListItem: (savedObject: SavedObject) => VisualizationListItem; + toListItem: (savedObject: SimpleSavedObject) => VisualizationListItem; } export interface VisTypeAlias { diff --git a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts index 72cca61832ca0..202d13f9cd539 100644 --- a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts +++ b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts @@ -53,6 +53,7 @@ function mapHits(hit: any, url: string): GraphWorkspaceSavedObject { const source = hit.attributes; source.id = hit.id; source.url = url; + source.updatedAt = hit.updatedAt; source.icon = 'fa-share-alt'; // looks like a graph return source; } diff --git a/x-pack/plugins/lens/public/vis_type_alias.ts b/x-pack/plugins/lens/public/vis_type_alias.ts index be8a5620ce614..11a97ae82470f 100644 --- a/x-pack/plugins/lens/public/vis_type_alias.ts +++ b/x-pack/plugins/lens/public/vis_type_alias.ts @@ -31,12 +31,13 @@ export const getLensAliasConfig = (): VisTypeAlias => ({ docTypes: ['lens'], searchFields: ['title^3'], toListItem(savedObject) { - const { id, type, attributes } = savedObject; + const { id, type, updatedAt, attributes } = savedObject; const { title, description } = attributes as { title: string; description?: string }; return { id, title, description, + updatedAt, editUrl: getEditPath(id), editApp: 'lens', icon: 'lensApp', diff --git a/x-pack/plugins/maps/common/map_saved_object_type.ts b/x-pack/plugins/maps/common/map_saved_object_type.ts index b37c1af5949c1..f16683f56ef6d 100644 --- a/x-pack/plugins/maps/common/map_saved_object_type.ts +++ b/x-pack/plugins/maps/common/map_saved_object_type.ts @@ -7,8 +7,6 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions */ -import { SavedObject } from '@kbn/core/types/saved_objects'; - export type MapSavedObjectAttributes = { title: string; description?: string; @@ -16,5 +14,3 @@ export type MapSavedObjectAttributes = { layerListJSON?: string; uiStateJSON?: string; }; - -export type MapSavedObject = SavedObject; diff --git a/x-pack/plugins/maps/public/maps_vis_type_alias.ts b/x-pack/plugins/maps/public/maps_vis_type_alias.ts index e6dad590b037a..911e886a8199e 100644 --- a/x-pack/plugins/maps/public/maps_vis_type_alias.ts +++ b/x-pack/plugins/maps/public/maps_vis_type_alias.ts @@ -7,8 +7,9 @@ import { i18n } from '@kbn/i18n'; import type { VisualizationsSetup, VisualizationStage } from '@kbn/visualizations-plugin/public'; +import type { SimpleSavedObject } from '@kbn/core/public'; import type { SavedObject } from '@kbn/core/types/saved_objects'; -import type { MapSavedObject } from '../common/map_saved_object_type'; +import type { MapSavedObjectAttributes } from '../common/map_saved_object_type'; import { APP_ID, APP_ICON, @@ -38,12 +39,15 @@ export function getMapsVisTypeAlias(visualizations: VisualizationsSetup) { docTypes: [MAP_SAVED_OBJECT_TYPE], searchFields: ['title^3'], toListItem(savedObject: SavedObject) { - const { id, type, attributes } = savedObject as MapSavedObject; + const { id, type, updatedAt, attributes } = + savedObject as SimpleSavedObject; const { title, description } = attributes; + return { id, title, description, + updatedAt, editUrl: getEditPath(id), editApp: APP_ID, icon: APP_ICON, diff --git a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx index 5aa8e7877628a..9278f08bd4d2d 100644 --- a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx +++ b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx @@ -113,6 +113,7 @@ async function findMaps(searchQuery: string) { title: savedObject.attributes.title, description: savedObject.attributes.description, references: savedObject.references, + updatedAt: savedObject.updatedAt, }; }), }; diff --git a/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts b/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts index cab4b98ffd784..213c1a6cde3ee 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts @@ -6,13 +6,13 @@ */ import { asyncForEach } from '@kbn/std'; -import { ISavedObjectsRepository } from '@kbn/core/server'; +import type { ISavedObjectsRepository, SavedObject } from '@kbn/core/server'; import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; -import { MapSavedObject, MapSavedObjectAttributes } from '../../common/map_saved_object_type'; +import type { MapSavedObjectAttributes } from '../../common/map_saved_object_type'; export async function findMaps( savedObjectsClient: Pick, - callback: (savedObject: MapSavedObject) => Promise + callback: (savedObject: SavedObject) => Promise ) { let nextPage = 1; let hasMorePages = false; diff --git a/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts b/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts index ad1c0239963b4..dcbc9c884275d 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { SavedObject } from '@kbn/core/server'; import { asyncForEach } from '@kbn/std'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; import { DataViewsService } from '@kbn/data-views-plugin/common'; @@ -15,7 +16,7 @@ import { ESSearchSourceDescriptor, LayerDescriptor, } from '../../../common/descriptor_types'; -import { MapSavedObject } from '../../../common/map_saved_object_type'; +import type { MapSavedObjectAttributes } from '../../../common/map_saved_object_type'; import { IndexPatternStats } from './types'; /* @@ -29,7 +30,7 @@ export class IndexPatternStatsCollector { this._indexPatternsService = indexPatternService; } - async push(savedObject: MapSavedObject) { + async push(savedObject: SavedObject) { let layerList: LayerDescriptor[] = []; try { const { attributes } = injectReferences(savedObject); From 0e6e381e38d00f56855b987becadf1e8e5c69912 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 19 May 2022 13:25:30 -0400 Subject: [PATCH 068/150] [Fleet] display current upgrades (#132379) --- .../plugins/fleet/common/services/routes.ts | 3 + .../fleet/common/types/models/agent.ts | 1 + .../current_bulk_upgrade_callout.tsx | 89 +++++++++++++++ .../agent_list_page/components/index.tsx | 9 ++ .../agents/agent_list_page/hooks/index.tsx | 8 ++ .../hooks/use_current_upgrades.tsx | 108 ++++++++++++++++++ .../sections/agents/agent_list_page/index.tsx | 18 ++- .../fleet/public/hooks/use_request/agents.ts | 15 +++ x-pack/plugins/fleet/public/types/index.ts | 2 + .../fleet/server/services/agents/upgrade.ts | 1 + 10 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index d4e8375bbaa5d..a8a6c34f06f3c 100644 --- a/x-pack/plugins/fleet/common/services/routes.ts +++ b/x-pack/plugins/fleet/common/services/routes.ts @@ -175,6 +175,9 @@ export const agentRouteService = { getUpgradePath: (agentId: string) => AGENT_API_ROUTES.UPGRADE_PATTERN.replace('{agentId}', agentId), getBulkUpgradePath: () => AGENT_API_ROUTES.BULK_UPGRADE_PATTERN, + getCurrentUpgradesPath: () => AGENT_API_ROUTES.CURRENT_UPGRADES_PATTERN, + getCancelActionPath: (actionId: string) => + AGENT_API_ROUTES.CANCEL_ACTIONS_PATTERN.replace('{actionId}', actionId), getListPath: () => AGENT_API_ROUTES.LIST_PATTERN, getStatusPath: () => AGENT_API_ROUTES.STATUS_PATTERN, getIncomingDataPath: () => AGENT_API_ROUTES.DATA_PATTERN, diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index b3847ac8c6892..a26f63eba755b 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -98,6 +98,7 @@ export interface CurrentUpgrade { complete: boolean; nbAgents: number; nbAgentsAck: number; + version: string; } // Generated from FleetServer schema.json diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx new file mode 100644 index 0000000000000..a77c26f8fef2f --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiCallOut, + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiLoadingSpinner, +} from '@elastic/eui'; + +import { useStartServices } from '../../../../hooks'; +import type { CurrentUpgrade } from '../../../../types'; + +export interface CurrentBulkUpgradeCalloutProps { + currentUpgrade: CurrentUpgrade; + abortUpgrade: (currentUpgrade: CurrentUpgrade) => Promise; +} + +export const CurrentBulkUpgradeCallout: React.FunctionComponent = ({ + currentUpgrade, + abortUpgrade, +}) => { + const { docLinks } = useStartServices(); + const [isAborting, setIsAborting] = useState(false); + const onClickAbortUpgrade = useCallback(async () => { + try { + setIsAborting(true); + await abortUpgrade(currentUpgrade); + } finally { + setIsAborting(false); + } + }, [currentUpgrade, abortUpgrade]); + + return ( + + + +
+ +    + +
+
+ + + + + +
+ + + + ), + }} + /> +
+ ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx new file mode 100644 index 0000000000000..36028c0d2c9b5 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CurrentBulkUpgradeCallout } from './current_bulk_upgrade_callout'; +export type { CurrentBulkUpgradeCalloutProps } from './current_bulk_upgrade_callout'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx new file mode 100644 index 0000000000000..4ab06bfcc8a91 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx @@ -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 { useCurrentUpgrades } from './use_current_upgrades'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx new file mode 100644 index 0000000000000..02463025c86db --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { sendGetCurrentUpgrades, sendPostCancelAction, useStartServices } from '../../../../hooks'; + +import type { CurrentUpgrade } from '../../../../types'; + +const POLL_INTERVAL = 30 * 1000; + +export function useCurrentUpgrades() { + const [currentUpgrades, setCurrentUpgrades] = useState([]); + const currentTimeoutRef = useRef(); + const isCancelledRef = useRef(false); + const { notifications, overlays } = useStartServices(); + + const refreshUpgrades = useCallback(async () => { + try { + const res = await sendGetCurrentUpgrades(); + if (isCancelledRef.current) { + return; + } + if (res.error) { + throw res.error; + } + + if (!res.data) { + throw new Error('No data'); + } + + setCurrentUpgrades(res.data.items); + } catch (err) { + notifications.toasts.addError(err, { + title: i18n.translate('xpack.fleet.currentUpgrade.fetchRequestError', { + defaultMessage: 'An error happened while fetching current upgrades', + }), + }); + } + }, [notifications.toasts]); + + const abortUpgrade = useCallback( + async (currentUpgrade: CurrentUpgrade) => { + try { + const confirmRes = await overlays.openConfirm( + i18n.translate('xpack.fleet.currentUpgrade.confirmDescription', { + defaultMessage: 'This action will abort upgrade of {nbAgents} agents', + values: { + nbAgents: currentUpgrade.nbAgents - currentUpgrade.nbAgentsAck, + }, + }), + { + title: i18n.translate('xpack.fleet.currentUpgrade.confirmTitle', { + defaultMessage: 'Abort upgrade?', + }), + } + ); + + if (!confirmRes) { + return; + } + await sendPostCancelAction(currentUpgrade.actionId); + await refreshUpgrades(); + } catch (err) { + notifications.toasts.addError(err, { + title: i18n.translate('xpack.fleet.currentUpgrade.abortRequestError', { + defaultMessage: 'An error happened while aborting upgrade', + }), + }); + } + }, + [refreshUpgrades, notifications.toasts, overlays] + ); + + // Poll for upgrades + useEffect(() => { + isCancelledRef.current = false; + + async function pollData() { + await refreshUpgrades(); + if (isCancelledRef.current) { + return; + } + currentTimeoutRef.current = setTimeout(() => pollData(), POLL_INTERVAL); + } + + pollData(); + + return () => { + isCancelledRef.current = true; + + if (currentTimeoutRef.current) { + clearTimeout(currentTimeoutRef.current); + } + }; + }, [refreshUpgrades]); + + return { + currentUpgrades, + refreshUpgrades, + abortUpgrade, + }; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index be38f7688c735..f12a99c6e37f9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -46,12 +46,14 @@ import { } from '../components'; import { useFleetServerUnhealthy } from '../hooks/use_fleet_server_unhealthy'; +import { CurrentBulkUpgradeCallout } from './components'; import { AgentTableHeader } from './components/table_header'; import type { SelectionMode } from './components/types'; import { SearchAndFilterBar } from './components/search_and_filter_bar'; import { Tags } from './components/tags'; import { TableRowActions } from './components/table_row_actions'; import { EmptyPrompt } from './components/empty_prompt'; +import { useCurrentUpgrades } from './hooks'; const REFRESH_INTERVAL_MS = 30000; @@ -335,6 +337,9 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { flyoutContext.openFleetServerFlyout(); }, [flyoutContext]); + // Current upgrades + const { abortUpgrade, currentUpgrades, refreshUpgrades } = useCurrentUpgrades(); + const columns = [ { field: 'local_metadata.host.hostname', @@ -490,7 +495,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { /> )} - {agentToUpgrade && ( = () => { onClose={() => { setAgentToUpgrade(undefined); fetchData(); + refreshUpgrades(); }} version={kibanaVersion} /> )} - {isFleetServerUnhealthy && ( <> {cloud?.deploymentUrl ? ( @@ -515,7 +519,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { )} - + {/* Current upgrades callout */} + {currentUpgrades.map((currentUpgrade) => ( + + + + + ))} {/* Search and filter bar */} = () => { refreshAgents={() => fetchData()} /> - {/* Agent total, bulk actions and status bar */} = () => { }} /> - {/* Agent list table */} ref={tableRef} diff --git a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts index 9bfba13052c35..94390d2f529d2 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts @@ -29,6 +29,7 @@ import type { PostBulkAgentUpgradeResponse, PostNewAgentActionRequest, PostNewAgentActionResponse, + GetCurrentUpgradesResponse, } from '../../types'; import { useRequest, sendRequest } from './use_request'; @@ -177,3 +178,17 @@ export function sendPostBulkAgentUpgrade( ...options, }); } + +export function sendGetCurrentUpgrades() { + return sendRequest({ + path: agentRouteService.getCurrentUpgradesPath(), + method: 'get', + }); +} + +export function sendPostCancelAction(actionId: string) { + return sendRequest({ + path: agentRouteService.getCancelActionPath(actionId), + method: 'post', + }); +} diff --git a/x-pack/plugins/fleet/public/types/index.ts b/x-pack/plugins/fleet/public/types/index.ts index fc29f046aac04..2cd27e81be9d8 100644 --- a/x-pack/plugins/fleet/public/types/index.ts +++ b/x-pack/plugins/fleet/public/types/index.ts @@ -25,6 +25,7 @@ export type { Output, DataStream, Settings, + CurrentUpgrade, GetFleetStatusResponse, GetAgentPoliciesRequest, GetAgentPoliciesResponse, @@ -77,6 +78,7 @@ export type { PostEnrollmentAPIKeyResponse, PostLogstashApiKeyResponse, GetOutputsResponse, + GetCurrentUpgradesResponse, PutOutputRequest, PutOutputResponse, PostOutputRequest, diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 55c105495fd54..6d0174e064184 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -331,6 +331,7 @@ async function _getUpgradeActions(esClient: ElasticsearchClient, now = new Date( nbAgents: 0, complete: false, nbAgentsAck: 0, + version: hit._source.data?.version as string, }; } From 6383b42e5d767325d575fceb40f65f39f242db2a Mon Sep 17 00:00:00 2001 From: Andrea Del Rio Date: Thu, 19 May 2022 10:37:33 -0700 Subject: [PATCH 069/150] [Controls] Improve banner (#132301) --- .../public/control_group/control_group_strings.ts | 7 ++++++- .../public/controls_callout/controls_callout.scss | 9 +++++---- .../public/controls_callout/controls_callout.tsx | 12 +++++++++++- .../controls_callout/controls_illustration.tsx | 14 ++------------ 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/plugins/controls/public/control_group/control_group_strings.ts b/src/plugins/controls/public/control_group/control_group_strings.ts index 23be81f3585d3..cb7b1b2001842 100644 --- a/src/plugins/controls/public/control_group/control_group_strings.ts +++ b/src/plugins/controls/public/control_group/control_group_strings.ts @@ -18,9 +18,14 @@ export const ControlGroupStrings = { defaultMessage: 'Controls', }), emptyState: { + getBadge: () => + i18n.translate('controls.controlGroup.emptyState.badgeText', { + defaultMessage: 'New', + }), getCallToAction: () => i18n.translate('controls.controlGroup.emptyState.callToAction', { - defaultMessage: 'Controls let you filter and interact with your dashboard data', + defaultMessage: + 'Filtering your data just got better with Controls, letting you display only the data you want to explore.', }), getAddControlButtonTitle: () => i18n.translate('controls.controlGroup.emptyState.addControlButtonTitle', { diff --git a/src/plugins/controls/public/controls_callout/controls_callout.scss b/src/plugins/controls/public/controls_callout/controls_callout.scss index e0f7e1481d156..74add651a5237 100644 --- a/src/plugins/controls/public/controls_callout/controls_callout.scss +++ b/src/plugins/controls/public/controls_callout/controls_callout.scss @@ -1,5 +1,5 @@ @include euiBreakpoint('xs', 's') { - .controlsIllustration { + .controlsIllustration, .emptyStateBadge { display: none; } } @@ -15,14 +15,15 @@ } @include euiBreakpoint('m', 'l', 'xl') { - height: $euiSize * 4; + height: $euiSizeS * 6; - .emptyStateText { + .emptyStateBadge { padding-left: $euiSize * 2; + text-transform: uppercase; } } @include euiBreakpoint('xs', 's') { - min-height: $euiSize * 4; + min-height: $euiSizeS * 6; .emptyStateText { padding-left: 0; diff --git a/src/plugins/controls/public/controls_callout/controls_callout.tsx b/src/plugins/controls/public/controls_callout/controls_callout.tsx index 708b224187e1c..b207657cc0288 100644 --- a/src/plugins/controls/public/controls_callout/controls_callout.tsx +++ b/src/plugins/controls/public/controls_callout/controls_callout.tsx @@ -6,7 +6,14 @@ * Side Public License, v 1. */ -import { EuiFlexGroup, EuiFlexItem, EuiText, EuiButtonEmpty, EuiPanel } from '@elastic/eui'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiButtonEmpty, + EuiPanel, +} from '@elastic/eui'; import React from 'react'; import useLocalStorage from 'react-use/lib/useLocalStorage'; @@ -39,6 +46,9 @@ export const ControlsCallout = ({ getCreateControlButton }: CalloutProps) => { + + {ControlGroupStrings.emptyState.getBadge()} +

{ControlGroupStrings.emptyState.getCallToAction()}

diff --git a/src/plugins/controls/public/controls_callout/controls_illustration.tsx b/src/plugins/controls/public/controls_callout/controls_illustration.tsx index 925dd90fc8700..39d96ee8ad857 100644 --- a/src/plugins/controls/public/controls_callout/controls_illustration.tsx +++ b/src/plugins/controls/public/controls_callout/controls_illustration.tsx @@ -11,8 +11,8 @@ import React from 'react'; export const ControlsIllustration = () => ( ( fill="#FCC316" d="M67.873 63.635l-2.678 4.641-2.678-4.64-2.678-4.642H70.55l-2.678 4.641z" /> - - - - Date: Thu, 19 May 2022 13:42:10 -0400 Subject: [PATCH 070/150] Display tooltips for long tags, even if there are less than 3 total tags (#132528) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../agents/agent_list_page/components/tags.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx index 9e084b07e64d1..f93646eb120ab 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx @@ -9,7 +9,7 @@ import { EuiToolTip } from '@elastic/eui'; import { take } from 'lodash'; import React from 'react'; -import { truncateTag } from '../utils'; +import { truncateTag, MAX_TAG_DISPLAY_LENGTH } from '../utils'; interface Props { tags: string[]; @@ -30,7 +30,20 @@ export const Tags: React.FunctionComponent = ({ tags }) => { ) : ( - {tags.map(truncateTag).join(', ')} + + {tags.map((tag, index) => ( + <> + {index > 0 && ', '} + {tag.length > MAX_TAG_DISPLAY_LENGTH ? ( + {tag}} key={tag}> + {truncateTag(tag)} + + ) : ( + {tag} + )} + + ))} + )} ); From 0dfa6374ba4211f23ad2689555bdae495010dad7 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 19 May 2022 13:48:42 -0400 Subject: [PATCH 071/150] Remove broadcast-channel dependency from security plugin (#132427) * Remove broadcast-channel dependency from security plugin * cleanup * Update x-pack/plugins/security/public/session/session_timeout.ts Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> --- package.json | 1 - packages/kbn-test-jest-helpers/src/index.ts | 2 + .../src/stub_broadcast_channel.ts | 83 +++++++++++++++++++ renovate.json | 1 - .../plugins/security/public/plugin.test.tsx | 11 +-- .../public/session/session_timeout.test.ts | 29 ++++--- .../public/session/session_timeout.ts | 20 +++-- yarn.lock | 46 +--------- 8 files changed, 118 insertions(+), 75 deletions(-) create mode 100644 packages/kbn-test-jest-helpers/src/stub_broadcast_channel.ts diff --git a/package.json b/package.json index 84f9be547e7a1..6330d68c742b1 100644 --- a/package.json +++ b/package.json @@ -229,7 +229,6 @@ "base64-js": "^1.3.1", "bitmap-sdf": "^1.0.3", "brace": "0.11.1", - "broadcast-channel": "4.10.0", "canvg": "^3.0.9", "chalk": "^4.1.0", "cheerio": "^1.0.0-rc.10", diff --git a/packages/kbn-test-jest-helpers/src/index.ts b/packages/kbn-test-jest-helpers/src/index.ts index 809d4380df10a..5e794abdbbb78 100644 --- a/packages/kbn-test-jest-helpers/src/index.ts +++ b/packages/kbn-test-jest-helpers/src/index.ts @@ -18,6 +18,8 @@ export * from './redux_helpers'; export * from './router_helpers'; +export * from './stub_broadcast_channel'; + export * from './stub_browser_storage'; export * from './stub_web_worker'; diff --git a/packages/kbn-test-jest-helpers/src/stub_broadcast_channel.ts b/packages/kbn-test-jest-helpers/src/stub_broadcast_channel.ts new file mode 100644 index 0000000000000..ecf34aa7bb68e --- /dev/null +++ b/packages/kbn-test-jest-helpers/src/stub_broadcast_channel.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const channelCache: BroadcastChannel[] = []; + +class StubBroadcastChannel implements BroadcastChannel { + constructor(public readonly name: string) { + channelCache.push(this); + } + + onmessage = jest.fn(); + onmessageerror = jest.fn(); + close = jest.fn(); + postMessage = jest.fn().mockImplementation((data: any) => { + channelCache.forEach((channel) => { + if (channel === this) return; // don't postMessage to ourselves + if (channel.onmessage) { + channel.onmessage(new MessageEvent(this.name, { data })); + } + }); + }); + + addEventListener( + type: K, + listener: (this: BroadcastChannel, ev: BroadcastChannelEventMap[K]) => any, + options?: boolean | AddEventListenerOptions + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + addEventListener(type: any, listener: any, options?: any): void { + throw new Error('Method not implemented.'); + } + removeEventListener( + type: K, + listener: (this: BroadcastChannel, ev: BroadcastChannelEventMap[K]) => any, + options?: boolean | EventListenerOptions + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions + ): void; + removeEventListener(type: any, listener: any, options?: any): void { + throw new Error('Method not implemented.'); + } + dispatchEvent(event: Event): boolean { + throw new Error('Method not implemented.'); + } +} + +/** + * Returns all BroadcastChannel instances. + * @returns BroadcastChannel[] + */ +function getBroadcastChannelInstances() { + return [...channelCache]; +} + +/** + * Removes all BroadcastChannel instances. + */ +function clearBroadcastChannelInstances() { + channelCache.splice(0, channelCache.length); +} + +/** + * Stubs the global window.BroadcastChannel for use in jest tests. + */ +function stubBroadcastChannel() { + if (!window.BroadcastChannel) { + window.BroadcastChannel = StubBroadcastChannel; + } +} + +export { stubBroadcastChannel, getBroadcastChannelInstances, clearBroadcastChannelInstances }; diff --git a/renovate.json b/renovate.json index 3d24e88d638b0..628eeec7c6e35 100644 --- a/renovate.json +++ b/renovate.json @@ -114,7 +114,6 @@ { "groupName": "platform security modules", "matchPackageNames": [ - "broadcast-channel", "node-forge", "@types/node-forge", "require-in-the-middle", diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 5a7cbd659ca7e..8082bb3b34fc9 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { enforceOptions } from 'broadcast-channel'; import { Observable } from 'rxjs'; import type { CoreSetup } from '@kbn/core/public'; @@ -14,19 +13,15 @@ import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { FeaturesPluginStart } from '@kbn/features-plugin/public'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { managementPluginMock } from '@kbn/management-plugin/public/mocks'; +import { stubBroadcastChannel } from '@kbn/test-jest-helpers'; import { ManagementService } from './management'; import type { PluginStartDependencies } from './plugin'; import { SecurityPlugin } from './plugin'; -describe('Security Plugin', () => { - beforeAll(() => { - enforceOptions({ type: 'simulate' }); - }); - afterAll(() => { - enforceOptions(null); - }); +stubBroadcastChannel(); +describe('Security Plugin', () => { describe('#setup', () => { it('should be able to setup if optional plugins are not available', () => { const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext()); diff --git a/x-pack/plugins/security/public/session/session_timeout.test.ts b/x-pack/plugins/security/public/session/session_timeout.test.ts index 09b67082b1a97..e43c1af6ac9c7 100644 --- a/x-pack/plugins/security/public/session/session_timeout.test.ts +++ b/x-pack/plugins/security/public/session/session_timeout.test.ts @@ -5,10 +5,14 @@ * 2.0. */ -import type { BroadcastChannel } from 'broadcast-channel'; - import type { ToastInputFields } from '@kbn/core/public'; import { coreMock } from '@kbn/core/public/mocks'; +import { + clearBroadcastChannelInstances, + getBroadcastChannelInstances, + stubBroadcastChannel, +} from '@kbn/test-jest-helpers'; +stubBroadcastChannel(); import { SESSION_CHECK_MS, @@ -19,11 +23,8 @@ import { } from '../../common/constants'; import type { SessionInfo } from '../../common/types'; import { createSessionExpiredMock } from './session_expired.mock'; -import type { SessionState } from './session_timeout'; import { SessionTimeout, startTimer } from './session_timeout'; -jest.mock('broadcast-channel'); - jest.useFakeTimers(); jest.spyOn(window, 'addEventListener'); @@ -56,6 +57,7 @@ describe('SessionTimeout', () => { afterEach(async () => { jest.clearAllMocks(); jest.clearAllTimers(); + clearBroadcastChannelInstances(); }); test(`does not initialize when starting an anonymous path`, async () => { @@ -242,14 +244,17 @@ describe('SessionTimeout', () => { jest.advanceTimersByTime(30 * 1000); - const [broadcastChannelMock] = jest.requireMock('broadcast-channel').BroadcastChannel.mock - .instances as [BroadcastChannel]; + const [broadcastChannelMock] = getBroadcastChannelInstances(); - broadcastChannelMock.onmessage!({ - lastExtensionTime: Date.now(), - expiresInMs: 60 * 1000, - canBeExtended: true, - }); + broadcastChannelMock.onmessage!( + new MessageEvent('name', { + data: { + lastExtensionTime: Date.now(), + expiresInMs: 60 * 1000, + canBeExtended: true, + }, + }) + ); jest.advanceTimersByTime(30 * 1000); diff --git a/x-pack/plugins/security/public/session/session_timeout.ts b/x-pack/plugins/security/public/session/session_timeout.ts index be7fc4dba883c..02e43c2fd3a83 100644 --- a/x-pack/plugins/security/public/session/session_timeout.ts +++ b/x-pack/plugins/security/public/session/session_timeout.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type { BroadcastChannel as BroadcastChannelType } from 'broadcast-channel'; import type { Subscription } from 'rxjs'; import { BehaviorSubject } from 'rxjs'; import { skip, tap, throttleTime } from 'rxjs/operators'; @@ -34,7 +33,7 @@ export interface SessionState extends Pick; + private channel?: BroadcastChannel; private isVisible = document.visibilityState !== 'hidden'; private isFetchingSessionInfo = false; @@ -77,11 +76,8 @@ export class SessionTimeout { // Subscribe to a broadcast channel for session timeout messages. // This allows us to synchronize the UX across tabs and avoid repetitive API calls. try { - const { BroadcastChannel } = await import('broadcast-channel'); - this.channel = new BroadcastChannel(`${this.tenant}/session_timeout`, { - webWorkerSupport: false, - }); - this.channel.onmessage = this.handleChannelMessage; + this.channel = new BroadcastChannel(`${this.tenant}/session_timeout`); + this.channel.onmessage = (event) => this.handleChannelMessage(event); } catch (error) { // eslint-disable-next-line no-console console.warn( @@ -108,8 +104,14 @@ export class SessionTimeout { /** * Event handler that receives session information from other browser tabs. */ - private handleChannelMessage = (message: SessionState) => { - this.sessionState$.next(message); + private handleChannelMessage = (messageEvent: MessageEvent) => { + if (this.isSessionState(messageEvent.data)) { + this.sessionState$.next(messageEvent.data); + } + }; + + private isSessionState = (data: unknown): data is SessionState => { + return typeof data === 'object' && Object.hasOwn(data ?? {}, 'canBeExtended'); }; /** diff --git a/yarn.lock b/yarn.lock index ef1d5d849ca75..5225ebe505cbe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9428,20 +9428,6 @@ brfs@^2.0.0, brfs@^2.0.2: static-module "^3.0.2" through2 "^2.0.0" -broadcast-channel@4.10.0: - version "4.10.0" - resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-4.10.0.tgz#d19fb902df227df40b1b580351713d30c302d198" - integrity sha512-hOUh312XyHk6JTVyX9cyXaH1UYs+2gHVtnW16oQAu9FL7ALcXGXc/YoJWqlkV8vUn14URQPMmRi4A9q4UrwVEQ== - dependencies: - "@babel/runtime" "^7.16.0" - detect-node "^2.1.0" - microseconds "0.2.0" - nano-time "1.0.0" - oblivious-set "1.0.0" - p-queue "6.6.2" - rimraf "3.0.2" - unload "2.3.1" - broadcast-channel@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-3.4.1.tgz#65b63068d0a5216026a19905c9b2d5e9adf0928a" @@ -12520,7 +12506,7 @@ detect-node-es@^1.1.0: resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== -detect-node@2.1.0, detect-node@^2.0.4, detect-node@^2.1.0: +detect-node@^2.0.4: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== @@ -13921,7 +13907,7 @@ eventemitter2@^6.4.3: resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.3.tgz#35c563619b13f3681e7eb05cbdaf50f56ba58820" integrity sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ== -eventemitter3@^4.0.0, eventemitter3@^4.0.4: +eventemitter3@^4.0.0: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== @@ -21304,11 +21290,6 @@ objectorarray@^1.0.4: resolved "https://registry.yarnpkg.com/objectorarray/-/objectorarray-1.0.4.tgz#d69b2f0ff7dc2701903d308bb85882f4ddb49483" integrity sha512-91k8bjcldstRz1bG6zJo8lWD7c6QXcB4nTDUqiEvIL1xAsLoZlOOZZG+nd6YPz+V7zY1580J4Xxh1vZtyv4i/w== -oblivious-set@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/oblivious-set/-/oblivious-set-1.0.0.tgz#c8316f2c2fb6ff7b11b6158db3234c49f733c566" - integrity sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw== - oboe@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/oboe/-/oboe-2.1.4.tgz#20c88cdb0c15371bb04119257d4fdd34b0aa49f6" @@ -21654,14 +21635,6 @@ p-map@^4.0.0: dependencies: aggregate-error "^3.0.0" -p-queue@6.6.2: - version "6.6.2" - resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" - integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== - dependencies: - eventemitter3 "^4.0.4" - p-timeout "^3.2.0" - p-retry@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-3.0.1.tgz#316b4c8893e2c8dc1cfa891f406c4b422bebf328" @@ -21684,13 +21657,6 @@ p-timeout@^2.0.1: dependencies: p-finally "^1.0.0" -p-timeout@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" - integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== - dependencies: - p-finally "^1.0.0" - p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" @@ -28557,14 +28523,6 @@ unload@2.2.0: "@babel/runtime" "^7.6.2" detect-node "^2.0.4" -unload@2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/unload/-/unload-2.3.1.tgz#9d16862d372a5ce5cb630ad1309c2fd6e35dacfe" - integrity sha512-MUZEiDqvAN9AIDRbbBnVYVvfcR6DrjCqeU2YQMmliFZl9uaBUjTkhuDQkBiyAy8ad5bx1TXVbqZ3gg7namsWjA== - dependencies: - "@babel/runtime" "^7.6.2" - detect-node "2.1.0" - unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" From 483cc454030e0b52f22322fe2a6a655b28d4fa78 Mon Sep 17 00:00:00 2001 From: mgiota Date: Thu, 19 May 2022 20:10:24 +0200 Subject: [PATCH 072/150] [Actionable Observability] update alerts table rule details link to point to o11y rule detail page (#132479) * update alerts table rule details link to point to o11y rule detail page * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * o11y alert flyout should also link to o11y rule details page * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * add data-test-subj to rule details page title and add move path definition * fix failing tests by checking existance of Observability in breadcrumb * use alerts and rules link from the paths file * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * update link in alert flyout to use paths * update rule details link in the rules page Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/observability/public/config/paths.ts | 7 ++++++- .../components/alerts_flyout/alerts_flyout.tsx | 3 +-- .../alerts_table_t_grid/alerts_table_t_grid.tsx | 3 +-- .../public/pages/rule_details/config.ts | 3 --- .../public/pages/rule_details/index.tsx | 16 ++++++---------- .../public/pages/rules/components/name.tsx | 3 ++- .../apps/observability/alerts/index.ts | 4 +++- 7 files changed, 19 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/observability/public/config/paths.ts b/x-pack/plugins/observability/public/config/paths.ts index 57bbc95fef40b..7f6599ef3c483 100644 --- a/x-pack/plugins/observability/public/config/paths.ts +++ b/x-pack/plugins/observability/public/config/paths.ts @@ -5,9 +5,14 @@ * 2.0. */ +export const ALERT_PAGE_LINK = '/app/observability/alerts'; +export const RULES_PAGE_LINK = `${ALERT_PAGE_LINK}/rules`; + export const paths = { observability: { - alerts: '/app/observability/alerts', + alerts: ALERT_PAGE_LINK, + rules: RULES_PAGE_LINK, + ruleDetails: (ruleId: string) => `${RULES_PAGE_LINK}/${encodeURI(ruleId)}`, }, management: { rules: '/app/management/insightsAndAlerting/triggersActions/rules', diff --git a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout.tsx b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout.tsx index d0957f0224b53..5a1b88ff1a420 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout.tsx @@ -77,8 +77,7 @@ export function AlertsFlyout({ } const ruleId = alertData.fields['kibana.alert.rule.uuid'] ?? null; - const linkToRule = ruleId && prepend ? prepend(paths.management.ruleDetails(ruleId)) : null; - + const linkToRule = ruleId && prepend ? prepend(paths.observability.ruleDetails(ruleId)) : null; const overviewListItems = [ { title: translations.alertsFlyout.statusLabel, diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx index 621a43eedfc25..c9d2d67e11bdc 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx @@ -170,8 +170,7 @@ function ObservabilityActions({ const casePermissions = useGetUserCasesPermissions(); const ruleId = alert.fields['kibana.alert.rule.uuid'] ?? null; - const linkToRule = ruleId ? http.basePath.prepend(paths.management.ruleDetails(ruleId)) : null; - + const linkToRule = ruleId ? http.basePath.prepend(paths.observability.ruleDetails(ruleId)) : null; const caseAttachments: CaseAttachments = useMemo(() => { return ecsData?._id ? [ diff --git a/x-pack/plugins/observability/public/pages/rule_details/config.ts b/x-pack/plugins/observability/public/pages/rule_details/config.ts index e73849f47e7b3..8822c68a85a0b 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/config.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/config.ts @@ -18,6 +18,3 @@ export function hasAllPrivilege(rule: InitialRule, ruleType?: RuleType): boolean export const hasExecuteActionsCapability = (capabilities: Capabilities) => capabilities?.actions?.execute; - -export const RULES_PAGE_LINK = '/app/observability/alerts/rules'; -export const ALERT_PAGE_LINK = '/app/observability/alerts'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index 9cce5bfb99c92..e5d6cccab60a8 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -56,12 +56,8 @@ import { PageTitle, ItemTitleRuleSummary, ItemValueRuleSummary, Actions } from ' import { useKibana } from '../../utils/kibana_react'; import { useFetchLast24hAlerts } from '../../hooks/use_fetch_last24h_alerts'; import { formatInterval } from './utils'; -import { - hasExecuteActionsCapability, - hasAllPrivilege, - RULES_PAGE_LINK, - ALERT_PAGE_LINK, -} from './config'; +import { hasExecuteActionsCapability, hasAllPrivilege } from './config'; +import { paths } from '../../config/paths'; export function RuleDetailsPage() { const { @@ -125,10 +121,10 @@ export function RuleDetailsPage() { text: i18n.translate('xpack.observability.breadcrumbs.alertsLinkText', { defaultMessage: 'Alerts', }), - href: http.basePath.prepend(ALERT_PAGE_LINK), + href: http.basePath.prepend(paths.observability.alerts), }, { - href: http.basePath.prepend(RULES_PAGE_LINK), + href: http.basePath.prepend(paths.observability.rules), text: RULES_BREADCRUMB_TEXT, }, { @@ -476,11 +472,11 @@ export function RuleDetailsPage() { { setRuleToDelete([]); - navigateToUrl(http.basePath.prepend(RULES_PAGE_LINK)); + navigateToUrl(http.basePath.prepend(paths.observability.rules)); }} onErrors={async () => { setRuleToDelete([]); - navigateToUrl(http.basePath.prepend(RULES_PAGE_LINK)); + navigateToUrl(http.basePath.prepend(paths.observability.rules)); }} onCancel={() => {}} apiDeleteCall={deleteRules} diff --git a/x-pack/plugins/observability/public/pages/rules/components/name.tsx b/x-pack/plugins/observability/public/pages/rules/components/name.tsx index 15cb44412d880..96418758df0a5 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/name.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/name.tsx @@ -9,10 +9,11 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import { RuleNameProps } from '../types'; import { useKibana } from '../../../utils/kibana_react'; +import { paths } from '../../../config/paths'; export function Name({ name, rule }: RuleNameProps) { const { http } = useKibana().services; - const detailsLink = http.basePath.prepend(`/app/observability/alerts/rules/${rule.id}`); + const detailsLink = http.basePath.prepend(paths.observability.ruleDetails(rule.id)); const link = ( diff --git a/x-pack/test/observability_functional/apps/observability/alerts/index.ts b/x-pack/test/observability_functional/apps/observability/alerts/index.ts index e1fd795d55ffb..5afdb0b00c774 100644 --- a/x-pack/test/observability_functional/apps/observability/alerts/index.ts +++ b/x-pack/test/observability_functional/apps/observability/alerts/index.ts @@ -223,7 +223,9 @@ export default ({ getService }: FtrProviderContext) => { const actionsButton = await observability.alerts.common.getActionsButtonByIndex(0); await actionsButton.click(); await observability.alerts.common.viewRuleDetailsButtonClick(); - expect(await find.existsByCssSelector('[title="Rules and Connectors"]')).to.eql(true); + expect( + await (await find.byCssSelector('[data-test-subj="breadcrumb first"]')).getVisibleText() + ).to.eql('Observability'); }); }); From 956fbc76d96c1a98f13e983c15000c227341a489 Mon Sep 17 00:00:00 2001 From: mgiota Date: Thu, 19 May 2022 20:11:04 +0200 Subject: [PATCH 073/150] [Actionable Observability] render human readable rule type name and notify when fields in o11y rule details page (#132404) * render rule type name * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * human readable text for notify field * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * create getNotifyText function * increase bundle size for triggers_actions_ui plugin (temp) Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-optimizer/limits.yml | 2 +- .../public/pages/rule_details/index.tsx | 16 +++++++++++----- .../sections/rule_form/rule_notify_when.tsx | 2 +- .../plugins/triggers_actions_ui/public/index.ts | 3 +-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 504ba4906ffd5..b9012d30b0f18 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -58,7 +58,7 @@ pageLoadAssetSize: telemetry: 51957 telemetryManagementSection: 38586 transform: 41007 - triggersActionsUi: 107800 #This is temporary. Check https://github.com/elastic/kibana/pull/130710#issuecomment-1119843458 & https://github.com/elastic/kibana/issues/130728 + triggersActionsUi: 119000 #This is temporary. Check https://github.com/elastic/kibana/pull/130710#issuecomment-1119843458 & https://github.com/elastic/kibana/issues/130728 upgradeAssistant: 81241 urlForwarding: 32579 usageCollection: 39762 diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index e5d6cccab60a8..31b9a888ec266 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -34,6 +34,7 @@ import { deleteRules, useLoadRuleTypes, RuleType, + NOTIFY_WHEN_OPTIONS, RuleEventLogListProps, } from '@kbn/triggers-actions-ui-plugin/public'; // TODO: use a Delete modal from triggersActionUI when it's sharable @@ -75,7 +76,7 @@ export function RuleDetailsPage() { const { ruleId } = useParams(); const { ObservabilityPageTemplate } = usePluginContext(); const { isRuleLoading, rule, errorRule, reloadRule } = useFetchRule({ ruleId, http }); - const { ruleTypes } = useLoadRuleTypes({ + const { ruleTypes, ruleTypeIndex } = useLoadRuleTypes({ filteredSolutions: OBSERVABILITY_SOLUTIONS, }); @@ -109,8 +110,9 @@ export function RuleDetailsPage() { useEffect(() => { if (ruleTypes.length && rule) { const matchedRuleType = ruleTypes.find((type) => type.id === rule.ruleTypeId); + setRuleType(matchedRuleType); + if (rule.consumer === ALERTS_FEATURE_ID && matchedRuleType && matchedRuleType.producer) { - setRuleType(matchedRuleType); setFeatures(matchedRuleType.producer); } else setFeatures(rule.consumer); } @@ -217,6 +219,9 @@ export function RuleDetailsPage() { /> ); + const getNotifyText = () => + NOTIFY_WHEN_OPTIONS.find((option) => option.value === rule?.notifyWhen)?.inputDisplay || + rule.notifyWhen; return ( - + @@ -438,8 +445,7 @@ export function RuleDetailsPage() { defaultMessage: 'Notify', })} - - + diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_notify_when.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_notify_when.tsx index 4c23aa0dda40d..992c4df4e5798 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_notify_when.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_notify_when.tsx @@ -28,7 +28,7 @@ import { RuleNotifyWhenType } from '../../../types'; const DEFAULT_NOTIFY_WHEN_VALUE: RuleNotifyWhenType = 'onActionGroupChange'; -const NOTIFY_WHEN_OPTIONS: Array> = [ +export const NOTIFY_WHEN_OPTIONS: Array> = [ { value: 'onActionGroupChange', inputDisplay: i18n.translate( diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 001f63bc6cc6f..9c08dfe597ecf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -89,9 +89,8 @@ export { loadRuleAggregations, loadRuleTags } from './application/lib/rule_api/a export { useLoadRuleTypes } from './application/hooks/use_load_rule_types'; export { loadRule } from './application/lib/rule_api/get_rule'; export { loadAllActions } from './application/lib/action_connector_api'; - export { loadActionTypes } from './application/lib/action_connector_api/connector_types'; - +export { NOTIFY_WHEN_OPTIONS } from './application/sections/rule_form/rule_notify_when'; export type { TIME_UNITS } from './application/constants'; export { getTimeUnitLabel } from './common/lib/get_time_unit_label'; export type { TriggersAndActionsUiServices } from './application/app'; From 4b262a52fd7b48ad7b5729d540a99b8318a2e5f2 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Thu, 19 May 2022 15:04:50 -0400 Subject: [PATCH 074/150] Fix test (#132546) --- .../apps/triggers_actions_ui/alerts_table.ts | 103 ++++++++---------- 1 file changed, 48 insertions(+), 55 deletions(-) diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_table.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_table.ts index 56026093c88dd..27989942d3e95 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_table.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_table.ts @@ -87,48 +87,41 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ); }); - // This keeps failing in CI because the next button is not clickable - // Revisit this once we change the UI around based on feedback - /* - fail: Actions and Triggers app Alerts table should open a flyout and paginate through the flyout - │ Error: retry.try timeout: ElementClickInterceptedError: element click intercepted: Element ... is not clickable at point (1564, 795). Other element would receive the click:
...
- */ - // it('should open a flyout and paginate through the flyout', async () => { - // await PageObjects.common.navigateToUrlWithBrowserHistory('triggersActions', '/alerts'); - // await waitTableIsLoaded(); - // await testSubjects.click('expandColumnCellOpenFlyoutButton-0'); - // await waitFlyoutOpen(); - // await waitFlyoutIsLoaded(); - - // expect(await testSubjects.getVisibleText('alertsFlyoutName')).to.be( - // 'APM Failed Transaction Rate (one)' - // ); - // expect(await testSubjects.getVisibleText('alertsFlyoutReason')).to.be( - // 'Failed transactions rate is greater than 5.0% (current value is 31%) for elastic-co-frontend' - // ); - - // await testSubjects.click('pagination-button-next'); - - // expect(await testSubjects.getVisibleText('alertsFlyoutName')).to.be( - // 'APM Failed Transaction Rate (one)' - // ); - // expect(await testSubjects.getVisibleText('alertsFlyoutReason')).to.be( - // 'Failed transactions rate is greater than 5.0% (current value is 35%) for opbeans-python' - // ); - - // await testSubjects.click('pagination-button-previous'); - // await testSubjects.click('pagination-button-previous'); - - // await waitTableIsLoaded(); - - // const rows = await getRows(); - // expect(rows[0].status).to.be('close'); - // expect(rows[0].lastUpdated).to.be('2021-10-19T14:55:14.503Z'); - // expect(rows[0].duration).to.be('252002000'); - // expect(rows[0].reason).to.be( - // 'CPU usage is greater than a threshold of 40 (current value is 56.7%) for gke-edge-oblt-default-pool-350b44de-c3dd' - // ); - // }); + it('should open a flyout and paginate through the flyout', async () => { + await PageObjects.common.navigateToUrlWithBrowserHistory('triggersActions', '/alerts'); + await waitTableIsLoaded(); + await testSubjects.click('expandColumnCellOpenFlyoutButton-0'); + await waitFlyoutOpen(); + await waitFlyoutIsLoaded(); + + expect(await testSubjects.getVisibleText('alertsFlyoutName')).to.be( + 'APM Failed Transaction Rate (one)' + ); + expect(await testSubjects.getVisibleText('alertsFlyoutReason')).to.be( + 'Failed transactions rate is greater than 5.0% (current value is 31%) for elastic-co-frontend' + ); + + await testSubjects.click('alertsFlyoutPagination > pagination-button-next'); + + expect(await testSubjects.getVisibleText('alertsFlyoutName')).to.be( + 'APM Failed Transaction Rate (one)' + ); + expect(await testSubjects.getVisibleText('alertsFlyoutReason')).to.be( + 'Failed transactions rate is greater than 5.0% (current value is 35%) for opbeans-python' + ); + + await testSubjects.click('alertsFlyoutPagination > pagination-button-previous'); + + await waitTableIsLoaded(); + + const rows = await getRows(); + expect(rows[0].status).to.be('active'); + expect(rows[0].lastUpdated).to.be('2021-10-19T15:20:38.749Z'); + expect(rows[0].duration).to.be('1197194000'); + expect(rows[0].reason).to.be( + 'Failed transactions rate is greater than 5.0% (current value is 31%) for elastic-co-frontend' + ); + }); async function waitTableIsLoaded() { return await retry.try(async () => { @@ -137,19 +130,19 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); } - // async function waitFlyoutOpen() { - // return await retry.try(async () => { - // const exists = await testSubjects.exists('alertsFlyout'); - // if (!exists) throw new Error('Still loading...'); - // }); - // } - - // async function waitFlyoutIsLoaded() { - // return await retry.try(async () => { - // const exists = await testSubjects.exists('alertsFlyoutLoading'); - // if (exists) throw new Error('Still loading...'); - // }); - // } + async function waitFlyoutOpen() { + return await retry.try(async () => { + const exists = await testSubjects.exists('alertsFlyout'); + if (!exists) throw new Error('Still loading...'); + }); + } + + async function waitFlyoutIsLoaded() { + return await retry.try(async () => { + const exists = await testSubjects.exists('alertsFlyoutLoading'); + if (exists) throw new Error('Still loading...'); + }); + } async function getRows() { const euiDataGridRows = await find.allByCssSelector('.euiDataGridRow'); From ee8158002035e3e9e8de5200ca0c6b128a76b423 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 19 May 2022 14:16:49 -0500 Subject: [PATCH 075/150] skip failing test suite (#132288) --- test/functional/apps/discover/_chart_hidden.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/discover/_chart_hidden.ts b/test/functional/apps/discover/_chart_hidden.ts index a9179fd234905..44fa42e568a0b 100644 --- a/test/functional/apps/discover/_chart_hidden.ts +++ b/test/functional/apps/discover/_chart_hidden.ts @@ -19,7 +19,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { defaultIndex: 'logstash-*', }; - describe('discover show/hide chart test', function () { + // Failing: See https://github.com/elastic/kibana/issues/132288 + describe.skip('discover show/hide chart test', function () { before(async function () { log.debug('load kibana index with default index pattern'); From f96ff560ed38ddf9e3027cb1cea5d4da1a0ccdec Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Thu, 19 May 2022 12:28:00 -0700 Subject: [PATCH 076/150] [Fleet] Reduce bundle size limit (#132488) --- packages/kbn-optimizer/limits.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index b9012d30b0f18..8856f7f0aaabb 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -27,7 +27,7 @@ pageLoadAssetSize: indexLifecycleManagement: 107090 indexManagement: 140608 infra: 184320 - fleet: 250000 + fleet: 95000 ingestPipelines: 58003 inputControlVis: 172675 inspector: 148711 From 9814c8515dcb1c767f10b79df0b2bee0dd6e6039 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 19 May 2022 15:41:38 -0500 Subject: [PATCH 077/150] skip failing test suite (#132553) --- test/functional/apps/discover/_context_encoded_url_param.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/discover/_context_encoded_url_param.ts b/test/functional/apps/discover/_context_encoded_url_param.ts index fdbee7a637f46..95540c929130c 100644 --- a/test/functional/apps/discover/_context_encoded_url_param.ts +++ b/test/functional/apps/discover/_context_encoded_url_param.ts @@ -17,7 +17,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const es = getService('es'); - describe('encoded URL params in context page', () => { + // Failing: See https://github.com/elastic/kibana/issues/132553 + describe.skip('encoded URL params in context page', () => { before(async function () { await security.testUser.setRoles(['kibana_admin', 'context_encoded_param']); await PageObjects.common.navigateToApp('settings'); From 42eec11a8d30d63c4d82de2d3a0ecd0272a1a9a4 Mon Sep 17 00:00:00 2001 From: Tre Date: Thu, 19 May 2022 22:12:22 +0100 Subject: [PATCH 078/150] Rebalance dashboard group 1 (#132193) Split a group of the files to group 6. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../functional/apps/dashboard/group1/index.ts | 13 -- .../apps/dashboard/group6/config.ts | 18 ++ .../group6/create_and_add_embeddables.ts | 169 ++++++++++++++++++ .../dashboard_back_button.ts | 0 .../dashboard_error_handling.ts | 0 .../{group1 => group6}/dashboard_options.ts | 0 .../{group1 => group6}/dashboard_query_bar.ts | 0 .../data_shared_attributes.ts | 0 .../{group1 => group6}/embed_mode.ts | 0 .../apps/dashboard/group6/empty_dashboard.ts | 67 +++++++ .../functional/apps/dashboard/group6/index.ts | 46 +++++ .../{group1 => group6}/legacy_urls.ts | 0 .../saved_search_embeddable.ts | 0 .../dashboard/{group1 => group6}/share.ts | 0 14 files changed, 300 insertions(+), 13 deletions(-) create mode 100644 test/functional/apps/dashboard/group6/config.ts create mode 100644 test/functional/apps/dashboard/group6/create_and_add_embeddables.ts rename test/functional/apps/dashboard/{group1 => group6}/dashboard_back_button.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/dashboard_error_handling.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/dashboard_options.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/dashboard_query_bar.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/data_shared_attributes.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/embed_mode.ts (100%) create mode 100644 test/functional/apps/dashboard/group6/empty_dashboard.ts create mode 100644 test/functional/apps/dashboard/group6/index.ts rename test/functional/apps/dashboard/{group1 => group6}/legacy_urls.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/saved_search_embeddable.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/share.ts (100%) diff --git a/test/functional/apps/dashboard/group1/index.ts b/test/functional/apps/dashboard/group1/index.ts index 597102433ef45..736dfd6f577f8 100644 --- a/test/functional/apps/dashboard/group1/index.ts +++ b/test/functional/apps/dashboard/group1/index.ts @@ -37,18 +37,5 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./dashboard_unsaved_state')); loadTestFile(require.resolve('./dashboard_unsaved_listing')); loadTestFile(require.resolve('./edit_visualizations')); - loadTestFile(require.resolve('./dashboard_options')); - loadTestFile(require.resolve('./data_shared_attributes')); - loadTestFile(require.resolve('./share')); - loadTestFile(require.resolve('./embed_mode')); - loadTestFile(require.resolve('./dashboard_back_button')); - loadTestFile(require.resolve('./dashboard_error_handling')); - loadTestFile(require.resolve('./legacy_urls')); - loadTestFile(require.resolve('./saved_search_embeddable')); - - // Note: This one must be last because it unloads some data for one of its tests! - // No, this isn't ideal, but loading/unloading takes so much time and these are all bunched - // to improve efficiency... - loadTestFile(require.resolve('./dashboard_query_bar')); }); } diff --git a/test/functional/apps/dashboard/group6/config.ts b/test/functional/apps/dashboard/group6/config.ts new file mode 100644 index 0000000000000..a70a190ca63f8 --- /dev/null +++ b/test/functional/apps/dashboard/group6/config.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js')); + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('.')], + }; +} diff --git a/test/functional/apps/dashboard/group6/create_and_add_embeddables.ts b/test/functional/apps/dashboard/group6/create_and_add_embeddables.ts new file mode 100644 index 0000000000000..c96e596a88ecf --- /dev/null +++ b/test/functional/apps/dashboard/group6/create_and_add_embeddables.ts @@ -0,0 +1,169 @@ +/* + * 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 expect from '@kbn/expect'; + +import { VisualizeConstants } from '@kbn/visualizations-plugin/common/constants'; +import { VISUALIZE_ENABLE_LABS_SETTING } from '@kbn/visualizations-plugin/common/constants'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); + const browser = getService('browser'); + const kibanaServer = getService('kibanaServer'); + const dashboardAddPanel = getService('dashboardAddPanel'); + + describe('create and add embeddables', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + }); + + it('ensure toolbar popover closes on add', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.dashboard.switchToEditMode(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('LOG_STREAM_EMBEDDABLE'); + await dashboardAddPanel.expectEditorMenuClosed(); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + + describe('add new visualization link', () => { + before(async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + }); + + it('adds new visualization via the top nav link', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.dashboard.switchToEditMode(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from top nav add new panel', + { redirectToOrigin: true } + ); + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds a new visualization', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from add new link', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds a new timelion visualization', async () => { + // adding this case, as the timelion agg-based viz doesn't need the `clickNewSearch()` step + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); + await PageObjects.visualize.clickTimelion(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'timelion visualization from add new link', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds a markdown visualization via the quick button', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await dashboardAddPanel.clickMarkdownQuickButton(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from markdown quick button', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('saves the listing page instead of the visualization to the app link', async () => { + await PageObjects.header.clickVisualize(true); + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).not.to.contain(VisualizeConstants.EDIT_PATH); + }); + + after(async () => { + await PageObjects.header.clickDashboard(); + }); + }); + + describe('visualize:enableLabs advanced setting', () => { + const LAB_VIS_NAME = 'Rendering Test: input control'; + + it('should display lab visualizations in add panel', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + const exists = await dashboardAddPanel.panelAddLinkExists(LAB_VIS_NAME); + await dashboardAddPanel.closeAddPanel(); + expect(exists).to.be(true); + }); + + describe('is false', () => { + before(async () => { + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickKibanaSettings(); + await PageObjects.settings.toggleAdvancedSettingCheckbox(VISUALIZE_ENABLE_LABS_SETTING); + }); + + it('should not display lab visualizations in add panel', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + + const exists = await dashboardAddPanel.panelAddLinkExists(LAB_VIS_NAME); + await dashboardAddPanel.closeAddPanel(); + expect(exists).to.be(false); + }); + + after(async () => { + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickKibanaSettings(); + await PageObjects.settings.clearAdvancedSettings(VISUALIZE_ENABLE_LABS_SETTING); + await PageObjects.header.clickDashboard(); + }); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard/group1/dashboard_back_button.ts b/test/functional/apps/dashboard/group6/dashboard_back_button.ts similarity index 100% rename from test/functional/apps/dashboard/group1/dashboard_back_button.ts rename to test/functional/apps/dashboard/group6/dashboard_back_button.ts diff --git a/test/functional/apps/dashboard/group1/dashboard_error_handling.ts b/test/functional/apps/dashboard/group6/dashboard_error_handling.ts similarity index 100% rename from test/functional/apps/dashboard/group1/dashboard_error_handling.ts rename to test/functional/apps/dashboard/group6/dashboard_error_handling.ts diff --git a/test/functional/apps/dashboard/group1/dashboard_options.ts b/test/functional/apps/dashboard/group6/dashboard_options.ts similarity index 100% rename from test/functional/apps/dashboard/group1/dashboard_options.ts rename to test/functional/apps/dashboard/group6/dashboard_options.ts diff --git a/test/functional/apps/dashboard/group1/dashboard_query_bar.ts b/test/functional/apps/dashboard/group6/dashboard_query_bar.ts similarity index 100% rename from test/functional/apps/dashboard/group1/dashboard_query_bar.ts rename to test/functional/apps/dashboard/group6/dashboard_query_bar.ts diff --git a/test/functional/apps/dashboard/group1/data_shared_attributes.ts b/test/functional/apps/dashboard/group6/data_shared_attributes.ts similarity index 100% rename from test/functional/apps/dashboard/group1/data_shared_attributes.ts rename to test/functional/apps/dashboard/group6/data_shared_attributes.ts diff --git a/test/functional/apps/dashboard/group1/embed_mode.ts b/test/functional/apps/dashboard/group6/embed_mode.ts similarity index 100% rename from test/functional/apps/dashboard/group1/embed_mode.ts rename to test/functional/apps/dashboard/group6/embed_mode.ts diff --git a/test/functional/apps/dashboard/group6/empty_dashboard.ts b/test/functional/apps/dashboard/group6/empty_dashboard.ts new file mode 100644 index 0000000000000..e559c0ef81f60 --- /dev/null +++ b/test/functional/apps/dashboard/group6/empty_dashboard.ts @@ -0,0 +1,67 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const kibanaServer = getService('kibanaServer'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const dashboardVisualizations = getService('dashboardVisualizations'); + const dashboardExpect = getService('dashboardExpect'); + const PageObjects = getPageObjects(['common', 'dashboard']); + + describe('empty dashboard', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + }); + + after(async () => { + await dashboardAddPanel.closeAddPanel(); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + it('should display empty widget', async () => { + const emptyWidgetExists = await testSubjects.exists('emptyDashboardWidget'); + expect(emptyWidgetExists).to.be(true); + }); + + it('should open add panel when add button is clicked', async () => { + await dashboardAddPanel.clickOpenAddPanel(); + const isAddPanelOpen = await dashboardAddPanel.isAddPanelOpen(); + expect(isAddPanelOpen).to.be(true); + await testSubjects.click('euiFlyoutCloseButton'); + }); + + it('should add new visualization from dashboard', async () => { + await dashboardVisualizations.createAndAddMarkdown({ + name: 'Dashboard Test Markdown', + markdown: 'Markdown text', + }); + await PageObjects.dashboard.waitForRenderComplete(); + await dashboardExpect.markdownWithValuesExists(['Markdown text']); + }); + + it('should open editor menu when editor button is clicked', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await testSubjects.existOrFail('dashboardEditorContextMenu'); + }); + }); +} diff --git a/test/functional/apps/dashboard/group6/index.ts b/test/functional/apps/dashboard/group6/index.ts new file mode 100644 index 0000000000000..f78f7e2d549b8 --- /dev/null +++ b/test/functional/apps/dashboard/group6/index.ts @@ -0,0 +1,46 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + + async function loadCurrentData() { + await browser.setWindowSize(1300, 900); + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); + } + + async function unloadCurrentData() { + await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data'); + } + + describe('dashboard app - group 1', function () { + before(loadCurrentData); + after(unloadCurrentData); + + // This has to be first since the other tests create some embeddables as side affects and our counting assumes + // a fresh index. + loadTestFile(require.resolve('./empty_dashboard')); + loadTestFile(require.resolve('./dashboard_options')); + loadTestFile(require.resolve('./data_shared_attributes')); + loadTestFile(require.resolve('./share')); + loadTestFile(require.resolve('./embed_mode')); + loadTestFile(require.resolve('./dashboard_back_button')); + loadTestFile(require.resolve('./dashboard_error_handling')); + loadTestFile(require.resolve('./legacy_urls')); + loadTestFile(require.resolve('./saved_search_embeddable')); + + // Note: This one must be last because it unloads some data for one of its tests! + // No, this isn't ideal, but loading/unloading takes so much time and these are all bunched + // to improve efficiency... + loadTestFile(require.resolve('./dashboard_query_bar')); + }); +} diff --git a/test/functional/apps/dashboard/group1/legacy_urls.ts b/test/functional/apps/dashboard/group6/legacy_urls.ts similarity index 100% rename from test/functional/apps/dashboard/group1/legacy_urls.ts rename to test/functional/apps/dashboard/group6/legacy_urls.ts diff --git a/test/functional/apps/dashboard/group1/saved_search_embeddable.ts b/test/functional/apps/dashboard/group6/saved_search_embeddable.ts similarity index 100% rename from test/functional/apps/dashboard/group1/saved_search_embeddable.ts rename to test/functional/apps/dashboard/group6/saved_search_embeddable.ts diff --git a/test/functional/apps/dashboard/group1/share.ts b/test/functional/apps/dashboard/group6/share.ts similarity index 100% rename from test/functional/apps/dashboard/group1/share.ts rename to test/functional/apps/dashboard/group6/share.ts From b2008488ba0efeff58347e0e998692c3b7701cc0 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Thu, 19 May 2022 16:17:14 -0500 Subject: [PATCH 079/150] [Shared UX] Move No Data Views to package (#131996) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 + packages/BUILD.bazel | 2 + packages/kbn-shared-ux-components/BUILD.bazel | 2 + .../src/empty_state/index.ts | 1 - .../empty_state/kibana_no_data_page.test.tsx | 8 +- .../src/empty_state/kibana_no_data_page.tsx | 33 +- .../no_data_views/no_data_views.stories.tsx | 49 -- .../kbn-shared-ux-components/src/index.ts | 41 -- .../prompt/no_data_views/BUILD.bazel | 142 +++++ .../prompt/no_data_views/README.mdx} | 8 +- .../prompt/no_data_views/jest.config.js} | 7 +- .../prompt/no_data_views/package.json | 8 + .../documentation_link.test.tsx.snap | 4 +- .../src/data_view_illustration.tsx | 552 ++++++++++++++++++ .../src}/documentation_link.test.tsx | 0 .../no_data_views/src}/documentation_link.tsx | 4 +- .../prompt/no_data_views/src/index.tsx | 47 ++ .../src}/no_data_views.component.test.tsx | 12 +- .../src}/no_data_views.component.tsx | 27 +- .../src/no_data_views.stories.tsx | 68 +++ .../no_data_views/src}/no_data_views.test.tsx | 30 +- .../no_data_views/src}/no_data_views.tsx | 20 +- .../prompt/no_data_views/src/services.tsx | 115 ++++ .../prompt/no_data_views/tsconfig.json | 20 + .../empty_prompts/empty_prompts.tsx | 4 +- .../translations/translations/fr-FR.json | 4 +- .../translations/translations/ja-JP.json | 4 +- .../translations/translations/zh-CN.json | 4 +- yarn.lock | 10 + 29 files changed, 1067 insertions(+), 161 deletions(-) delete mode 100644 packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.stories.tsx create mode 100644 packages/shared-ux/prompt/no_data_views/BUILD.bazel rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.mdx => shared-ux/prompt/no_data_views/README.mdx} (74%) rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx => shared-ux/prompt/no_data_views/jest.config.js} (72%) create mode 100644 packages/shared-ux/prompt/no_data_views/package.json rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/__snapshots__/documentation_link.test.tsx.snap (82%) create mode 100644 packages/shared-ux/prompt/no_data_views/src/data_view_illustration.tsx rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/documentation_link.test.tsx (100%) rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/documentation_link.tsx (88%) create mode 100644 packages/shared-ux/prompt/no_data_views/src/index.tsx rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/no_data_views.component.test.tsx (79%) rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/no_data_views.component.tsx (77%) create mode 100644 packages/shared-ux/prompt/no_data_views/src/no_data_views.stories.tsx rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/no_data_views.test.tsx (53%) rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/no_data_views.tsx (72%) create mode 100644 packages/shared-ux/prompt/no_data_views/src/services.tsx create mode 100644 packages/shared-ux/prompt/no_data_views/tsconfig.json diff --git a/package.json b/package.json index 6330d68c742b1..72f4acfc18354 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,7 @@ "@kbn/shared-ux-components": "link:bazel-bin/packages/kbn-shared-ux-components", "@kbn/shared-ux-link-redirect-app": "link:bazel-bin/packages/shared-ux/link/redirect_app", "@kbn/shared-ux-page-analytics-no-data": "link:bazel-bin/packages/shared-ux/page/analytics_no_data", + "@kbn/shared-ux-prompt-no-data-views": "link:bazel-bin/packages/shared-ux/prompt/no_data_views", "@kbn/shared-ux-services": "link:bazel-bin/packages/kbn-shared-ux-services", "@kbn/shared-ux-storybook": "link:bazel-bin/packages/kbn-shared-ux-storybook", "@kbn/shared-ux-utility": "link:bazel-bin/packages/kbn-shared-ux-utility", @@ -682,6 +683,7 @@ "@types/kbn__shared-ux-components": "link:bazel-bin/packages/kbn-shared-ux-components/npm_module_types", "@types/kbn__shared-ux-link-redirect-app": "link:bazel-bin/packages/shared-ux/link/redirect_app/npm_module_types", "@types/kbn__shared-ux-page-analytics-no-data": "link:bazel-bin/packages/shared-ux/page/analytics_no_data/npm_module_types", + "@types/kbn__shared-ux-prompt-no-data-views": "link:bazel-bin/packages/shared-ux/prompt/no_data_views/npm_module_types", "@types/kbn__shared-ux-services": "link:bazel-bin/packages/kbn-shared-ux-services/npm_module_types", "@types/kbn__shared-ux-storybook": "link:bazel-bin/packages/kbn-shared-ux-storybook/npm_module_types", "@types/kbn__shared-ux-utility": "link:bazel-bin/packages/kbn-shared-ux-utility/npm_module_types", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 234a69cb4bdf7..51db32d5d89f7 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -116,6 +116,7 @@ filegroup( "//packages/shared-ux/button/exit_full_screen:build", "//packages/shared-ux/link/redirect_app:build", "//packages/shared-ux/page/analytics_no_data:build", + "//packages/shared-ux/prompt/no_data_views:build", ], ) @@ -215,6 +216,7 @@ filegroup( "//packages/shared-ux/button/exit_full_screen:build_types", "//packages/shared-ux/link/redirect_app:build_types", "//packages/shared-ux/page/analytics_no_data:build_types", + "//packages/shared-ux/prompt/no_data_views:build_types", ], ) diff --git a/packages/kbn-shared-ux-components/BUILD.bazel b/packages/kbn-shared-ux-components/BUILD.bazel index b1420f5376041..1a4a7100ded72 100644 --- a/packages/kbn-shared-ux-components/BUILD.bazel +++ b/packages/kbn-shared-ux-components/BUILD.bazel @@ -44,6 +44,7 @@ RUNTIME_DEPS = [ "//packages/kbn-i18n", "//packages/shared-ux/avatar/solution", "//packages/shared-ux/link/redirect_app", + "//packages/shared-ux/prompt/no_data_views", "//packages/kbn-shared-ux-services", "//packages/kbn-shared-ux-storybook", "//packages/kbn-shared-ux-utility", @@ -72,6 +73,7 @@ TYPES_DEPS = [ "//packages/kbn-i18n:npm_module_types", "//packages/shared-ux/avatar/solution:npm_module_types", "//packages/shared-ux/link/redirect_app:npm_module_types", + "//packages/shared-ux/prompt/no_data_views:npm_module_types", "//packages/kbn-shared-ux-services:npm_module_types", "//packages/kbn-shared-ux-storybook:npm_module_types", "//packages/kbn-shared-ux-utility:npm_module_types", diff --git a/packages/kbn-shared-ux-components/src/empty_state/index.ts b/packages/kbn-shared-ux-components/src/empty_state/index.ts index 68defa5269344..9883d595633a7 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/index.ts +++ b/packages/kbn-shared-ux-components/src/empty_state/index.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -export { NoDataViews, NoDataViewsComponent } from './no_data_views'; export { KibanaNoDataPage } from './kibana_no_data_page'; diff --git a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx index 4f565e55ef52c..3b117f54369a0 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx @@ -12,10 +12,10 @@ import { act } from 'react-dom/test-utils'; import { EuiLoadingElastic } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { SharedUxServicesProvider, mockServicesFactory } from '@kbn/shared-ux-services'; +import { NoDataViewsPrompt } from '@kbn/shared-ux-prompt-no-data-views'; import { KibanaNoDataPage } from './kibana_no_data_page'; import { NoDataConfigPage } from '../page_template'; -import { NoDataViews } from './no_data_views'; describe('Kibana No Data Page', () => { const noDataConfig = { @@ -52,7 +52,7 @@ describe('Kibana No Data Page', () => { component.update(); expect(component.find(NoDataConfigPage).length).toBe(1); - expect(component.find(NoDataViews).length).toBe(0); + expect(component.find(NoDataViewsPrompt).length).toBe(0); }); test('renders NoDataViews', async () => { @@ -66,7 +66,7 @@ describe('Kibana No Data Page', () => { await act(() => new Promise(setImmediate)); component.update(); - expect(component.find(NoDataViews).length).toBe(1); + expect(component.find(NoDataViewsPrompt).length).toBe(1); expect(component.find(NoDataConfigPage).length).toBe(0); }); @@ -90,7 +90,7 @@ describe('Kibana No Data Page', () => { component.update(); expect(component.find(EuiLoadingElastic).length).toBe(1); - expect(component.find(NoDataViews).length).toBe(0); + expect(component.find(NoDataViewsPrompt).length).toBe(0); expect(component.find(NoDataConfigPage).length).toBe(0); }); }); diff --git a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx index 89ba915c07cfd..5d0f84e0bd41b 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx @@ -6,10 +6,14 @@ * Side Public License, v 1. */ import React, { useEffect, useState } from 'react'; +import { useData, useDocLinks, useEditors, usePermissions } from '@kbn/shared-ux-services'; +import { + NoDataViewsPrompt, + NoDataViewsPromptProvider, + NoDataViewsPromptServices, +} from '@kbn/shared-ux-prompt-no-data-views'; import { EuiLoadingElastic } from '@elastic/eui'; -import { useData } from '@kbn/shared-ux-services'; import { NoDataConfigPage, NoDataPageProps } from '../page_template'; -import { NoDataViews } from './no_data_views'; export interface Props { onDataViewCreated: (dataView: unknown) => void; @@ -17,6 +21,11 @@ export interface Props { } export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: Props) => { + // These hooks are temporary, until this component is moved to a package. + const { canCreateNewDataView } = usePermissions(); + const { dataViewsDocLink } = useDocLinks(); + const { openDataViewEditor } = useEditors(); + const { hasESData, hasUserDataView } = useData(); const [isLoading, setIsLoading] = useState(true); const [dataExists, setDataExists] = useState(false); @@ -43,8 +52,26 @@ export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: Props) => return ; } + /* + TODO: clintandrewhall - the use and population of `NoDataViewPromptProvider` here is temporary, + until `KibanaNoDataPage` is moved to a package of its own. + + Once `KibanaNoDataPage` is moved to a package, `NoDataViewsPromptProvider` will be *combined* + with `KibanaNoDataPageProvider`, creating a single Provider that manages contextual dependencies + throughout the React tree from the top-level of composition and consumption. + */ if (!hasUserDataViews) { - return ; + const services: NoDataViewsPromptServices = { + canCreateNewDataView, + dataViewsDocLink, + openDataViewEditor, + }; + + return ( + + + + ); } return null; diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.stories.tsx b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.stories.tsx deleted file mode 100644 index bee7c87d2841b..0000000000000 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.stories.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 { action } from '@storybook/addon-actions'; - -import { servicesFactory } from '@kbn/shared-ux-storybook'; - -import { NoDataViews as NoDataViewsComponent, Props } from './no_data_views.component'; -import { NoDataViews } from './no_data_views'; - -import mdx from './no_data_views.mdx'; - -const services = servicesFactory({}); - -export default { - title: 'No Data/No Data Views', - description: 'A component to display when there are no user-created data views available.', - parameters: { - docs: { - page: mdx, - }, - }, -}; - -export const ConnectedComponent = () => { - return ; -}; - -type Params = Pick; - -export const PureComponent = (params: Params) => { - return ; -}; - -PureComponent.argTypes = { - canCreateNewDataView: { - control: 'boolean', - defaultValue: true, - }, - dataViewsDocLink: { - options: [services.docLinks.dataViewsDocLink, undefined], - control: { type: 'radio' }, - }, -}; diff --git a/packages/kbn-shared-ux-components/src/index.ts b/packages/kbn-shared-ux-components/src/index.ts index 77586e8592b6a..fb4676e9f4e55 100644 --- a/packages/kbn-shared-ux-components/src/index.ts +++ b/packages/kbn-shared-ux-components/src/index.ts @@ -90,44 +90,3 @@ export const KibanaPageTemplateSolutionNavLazy = React.lazy(() => default: KibanaPageTemplateSolutionNav, })) ); - -/** - * A `KibanaPageTemplateSolutionNav` component that is wrapped by the `withSuspense` HOC. This component can - * be used directly by consumers and will load the `KibanaPageTemplateSolutionNavLazy` component lazily with - * a predefined fallback and error boundary. - */ -export const KibanaPageTemplateSolutionNav = withSuspense(KibanaPageTemplateSolutionNavLazy); - -/** - * The Lazily-loaded `NoDataViews` component. Consumers should use `React.Suspennse` or the - * `withSuspense` HOC to load this component. - */ -export const NoDataViewsLazy = React.lazy(() => - import('./empty_state/no_data_views').then(({ NoDataViews }) => ({ - default: NoDataViews, - })) -); - -/** - * A `NoDataViews` component that is wrapped by the `withSuspense` HOC. This component can - * be used directly by consumers and will load the `LazyNoDataViews` component lazily with - * a predefined fallback and error boundary. - */ -export const NoDataViews = withSuspense(NoDataViewsLazy); - -/** - * A pure `NoDataViews` component, with no services hooks. Consumers should use `React.Suspennse` or the - * `withSuspense` HOC to load this component. - */ -export const NoDataViewsComponentLazy = React.lazy(() => - import('./empty_state/no_data_views').then(({ NoDataViewsComponent }) => ({ - default: NoDataViewsComponent, - })) -); - -/** - * A pure `NoDataViews` component, with no services hooks. The component is wrapped by the `withSuspense` HOC. - * This component can be used directly by consumers and will load the `LazyNoDataViewsComponent` lazily with - * a predefined fallback and error boundary. - */ -export const NoDataViewsComponent = withSuspense(NoDataViewsComponentLazy); diff --git a/packages/shared-ux/prompt/no_data_views/BUILD.bazel b/packages/shared-ux/prompt/no_data_views/BUILD.bazel new file mode 100644 index 0000000000000..91fae6aeddea9 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/BUILD.bazel @@ -0,0 +1,142 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "no_data_views" +PKG_REQUIRE_NAME = "@kbn/shared-ux-prompt-no-data-views" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.mdx", + ], + exclude = [ + "**/*.test.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +# In this array place runtime dependencies, including other packages and NPM packages +# which must be available for this code to run. +# +# To reference other packages use: +# "//repo/relative/path/to/package" +# eg. "//packages/kbn-utils" +# +# To reference a NPM package use: +# "@npm//name-of-package" +# eg. "@npm//lodash" +RUNTIME_DEPS = [ + "@npm//@elastic/eui", + "@npm//@emotion/css", + "@npm//@emotion/react", + "@npm//@storybook/addon-actions", + "@npm//enzyme", + "@npm//react", + "//packages/kbn-i18n-react", + "//packages/kbn-i18n", + "//packages/kbn-shared-ux-utility", +] + +# In this array place dependencies necessary to build the types, which will include the +# :npm_module_types target of other packages and packages from NPM, including @types/* +# packages. +# +# To reference the types for another package use: +# "//repo/relative/path/to/package:npm_module_types" +# eg. "//packages/kbn-utils:npm_module_types" +# +# References to NPM packages work the same as RUNTIME_DEPS +TYPES_DEPS = [ + "@npm//@elastic/eui", + "@npm//@emotion/css", + "@npm//@emotion/react", + "@npm//@storybook/addon-actions", + "@npm//@types/enzyme", + "@npm//@types/jest", + "@npm//@types/node", + "@npm//@types/react", + "//packages/kbn-ambient-ui-types", + "//packages/kbn-i18n-react:npm_module_types", + "//packages/kbn-i18n:npm_module_types", + "//packages/kbn-shared-ux-utility:npm_module_types", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +jsts_transpiler( + name = "target_web", + srcs = SRCS, + build_pkg_name = package_name(), + web = True, +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node", ":target_web"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.mdx b/packages/shared-ux/prompt/no_data_views/README.mdx similarity index 74% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.mdx rename to packages/shared-ux/prompt/no_data_views/README.mdx index ef8812c565a9f..730470c72f170 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.mdx +++ b/packages/shared-ux/prompt/no_data_views/README.mdx @@ -1,7 +1,7 @@ -**id:** sharedUX/Components/NoDataViewsPage -**slug:** /shared-ux/components/no-data-views-page -**title:** No Data Views Page -**summary:** A page to be displayed when there is data in Elasticsearch, but no data views +**id:** sharedUX/Components/NoDataViewsPrompt +**slug:** /shared-ux/components/no-data-views +**title:** No Data Views +**summary:** A prompt to be displayed when there is data in Elasticsearch, but no data views **tags:** ['shared-ux', 'component'] **date:** 2022-02-09 diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx b/packages/shared-ux/prompt/no_data_views/jest.config.js similarity index 72% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx rename to packages/shared-ux/prompt/no_data_views/jest.config.js index 6719fffa36740..a89d3ff222089 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx +++ b/packages/shared-ux/prompt/no_data_views/jest.config.js @@ -6,5 +6,8 @@ * Side Public License, v 1. */ -export { NoDataViews } from './no_data_views'; -export { NoDataViews as NoDataViewsComponent } from './no_data_views.component'; +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/packages/shared-ux/prompt/no_data_views'], +}; diff --git a/packages/shared-ux/prompt/no_data_views/package.json b/packages/shared-ux/prompt/no_data_views/package.json new file mode 100644 index 0000000000000..79070e1242994 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/package.json @@ -0,0 +1,8 @@ +{ + "name": "@kbn/shared-ux-prompt-no-data-views", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "browser": "./target_web/index.js", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/__snapshots__/documentation_link.test.tsx.snap b/packages/shared-ux/prompt/no_data_views/src/__snapshots__/documentation_link.test.tsx.snap similarity index 82% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/__snapshots__/documentation_link.test.tsx.snap rename to packages/shared-ux/prompt/no_data_views/src/__snapshots__/documentation_link.test.tsx.snap index e84b997d8df87..0f7160c7b06e8 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/__snapshots__/documentation_link.test.tsx.snap +++ b/packages/shared-ux/prompt/no_data_views/src/__snapshots__/documentation_link.test.tsx.snap @@ -10,7 +10,7 @@ exports[` is rendered correctly 1`] = ` > @@ -26,7 +26,7 @@ exports[` is rendered correctly 1`] = ` > diff --git a/packages/shared-ux/prompt/no_data_views/src/data_view_illustration.tsx b/packages/shared-ux/prompt/no_data_views/src/data_view_illustration.tsx new file mode 100644 index 0000000000000..8a889a9267dee --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/src/data_view_illustration.tsx @@ -0,0 +1,552 @@ +/* + * 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 { useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; + +export const DataViewIllustration = () => { + const { euiTheme } = useEuiTheme(); + const { colors } = euiTheme; + + const dataViewIllustrationVerticalStripes = css` + fill: ${colors.fullShade}; + `; + + const dataViewIllustrationDots = css` + fill: ${colors.lightShade}; + `; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.test.tsx b/packages/shared-ux/prompt/no_data_views/src/documentation_link.test.tsx similarity index 100% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.test.tsx rename to packages/shared-ux/prompt/no_data_views/src/documentation_link.test.tsx diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.tsx b/packages/shared-ux/prompt/no_data_views/src/documentation_link.tsx similarity index 88% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.tsx rename to packages/shared-ux/prompt/no_data_views/src/documentation_link.tsx index 3b3e742ea74ce..2b40f30acc779 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/documentation_link.tsx @@ -20,7 +20,7 @@ export function DocumentationLink({ href }: Props) {
@@ -29,7 +29,7 @@ export function DocumentationLink({ href }: Props) {
diff --git a/packages/shared-ux/prompt/no_data_views/src/index.tsx b/packages/shared-ux/prompt/no_data_views/src/index.tsx new file mode 100644 index 0000000000000..23c2ed068f2af --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/src/index.tsx @@ -0,0 +1,47 @@ +/* + * 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 { withSuspense } from '@kbn/shared-ux-utility'; + +export { NoDataViewsPromptKibanaProvider, NoDataViewsPromptProvider } from './services'; +export type { NoDataViewsPromptKibanaServices, NoDataViewsPromptServices } from './services'; + +/** + * The Lazily-loaded `NoDataViewsPrompt` component. Consumers should use `React.Suspennse` or the + * `withSuspense` HOC to load this component. + */ +export const NoDataViewsPromptLazy = React.lazy(() => + import('./no_data_views').then(({ NoDataViewsPrompt }) => ({ + default: NoDataViewsPrompt, + })) +); + +/** + * A `NoDataViewsPrompt` component that is wrapped by the `withSuspense` HOC. This component can + * be used directly by consumers and will load the `NoDataViewsPromptLazy` component lazily with + * a predefined fallback and error boundary. + */ +export const NoDataViewsPrompt = withSuspense(NoDataViewsPromptLazy); + +/** + * A pure `NoDataViewsPrompt` component, with no services hooks. Consumers should use `React.Suspennse` or the + * `withSuspense` HOC to load this component. + */ +export const NoDataViewsPromptComponentLazy = React.lazy(() => + import('./no_data_views.component').then(({ NoDataViewsPrompt: Component }) => ({ + default: Component, + })) +); + +/** + * A pure `NoDataViewsPrompt` component, with no services hooks. The component is wrapped by the `withSuspense` HOC. + * This component can be used directly by consumers and will load the `NoDataViewsComponentLazy` lazily with + * a predefined fallback and error boundary. + */ +export const NoDataViewsPromptComponent = withSuspense(NoDataViewsPromptComponentLazy); diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.test.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.component.test.tsx similarity index 79% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.test.tsx rename to packages/shared-ux/prompt/no_data_views/src/no_data_views.component.test.tsx index 87dd68e202bc2..d0de72797cc2f 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.test.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.component.test.tsx @@ -9,13 +9,13 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; -import { NoDataViews } from './no_data_views.component'; +import { NoDataViewsPrompt } from './no_data_views.component'; import { DocumentationLink } from './documentation_link'; -describe('', () => { +describe('', () => { test('is rendered correctly', () => { const component = mountWithIntl( - ', () => { }); test('does not render button if canCreateNewDataViews is false', () => { - const component = mountWithIntl(); + const component = mountWithIntl(); expect(component.find(EuiButton).length).toBe(0); }); test('does not documentation link if linkToDocumentation is not provided', () => { const component = mountWithIntl( - + ); expect(component.find(DocumentationLink).length).toBe(0); @@ -43,7 +43,7 @@ describe('', () => { test('onClickCreate', () => { const onClickCreate = jest.fn(); const component = mountWithIntl( - + ); component.find('button').simulate('click'); diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.component.tsx similarity index 77% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx rename to packages/shared-ux/prompt/no_data_views/src/no_data_views.component.tsx index 3131b6ab2a73c..f53a187265703 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.component.tsx @@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, EuiEmptyPrompt, EuiEmptyPromptProps } from '@elastic/eui'; -import { DataViewIllustration } from '../assets'; +import { DataViewIllustration } from './data_view_illustration'; import { DocumentationLink } from './documentation_link'; export interface Props { @@ -23,7 +23,7 @@ export interface Props { emptyPromptColor?: EuiEmptyPromptProps['color']; } -const createDataViewText = i18n.translate('sharedUXComponents.noDataViewsPrompt.addDataViewText', { +const createDataViewText = i18n.translate('sharedUXPackages.noDataViewsPrompt.addDataViewText', { defaultMessage: 'Create data view', }); @@ -33,13 +33,13 @@ const MAX_WIDTH = 830; /** * A presentational component that is shown in cases when there are no data views created yet. */ -export const NoDataViews = ({ +export const NoDataViewsPrompt = ({ onClickCreate, canCreateNewDataView, dataViewsDocLink, emptyPromptColor = 'plain', }: Props) => { - const createNewButton = canCreateNewDataView && ( + const actions = canCreateNewDataView && (
) : (

@@ -74,19 +74,22 @@ export const NoDataViews = ({ const body = canCreateNewDataView ? (

) : (

); + const icon = ; + const footer = dataViewsDocLink ? : undefined; + return ( } - title={title} - body={body} - actions={createNewButton} - footer={dataViewsDocLink && } + {...{ actions, icon, title, body, footer }} /> ); }; diff --git a/packages/shared-ux/prompt/no_data_views/src/no_data_views.stories.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.stories.tsx new file mode 100644 index 0000000000000..c9e983c5f01b2 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.stories.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { action } from '@storybook/addon-actions'; + +import { NoDataViewsPrompt as NoDataViewsPromptComponent, Props } from './no_data_views.component'; +import { NoDataViewsPrompt } from './no_data_views'; +import { NoDataViewsPromptProvider, NoDataViewsPromptServices } from './services'; + +import mdx from '../README.mdx'; + +export default { + title: 'No Data/No Data Views', + description: 'A component to display when there are no user-created data views available.', + parameters: { + docs: { + page: mdx, + }, + }, +}; + +type ConnectedParams = Pick; + +const openDataViewEditor: NoDataViewsPromptServices['openDataViewEditor'] = (options) => { + action('openDataViewEditor')(options); + return () => {}; +}; + +export const ConnectedComponent = (params: ConnectedParams) => { + return ( + + + + ); +}; + +ConnectedComponent.argTypes = { + canCreateNewDataView: { + control: 'boolean', + defaultValue: true, + }, + dataViewsDocLink: { + options: ['some/link', undefined], + control: { type: 'radio' }, + }, +}; + +type PureParams = Pick; + +export const PureComponent = (params: PureParams) => { + return ; +}; + +PureComponent.argTypes = { + canCreateNewDataView: { + control: 'boolean', + defaultValue: true, + }, + dataViewsDocLink: { + options: ['some/link', undefined], + control: { type: 'radio' }, + }, +}; diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.test.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.test.tsx similarity index 53% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.test.tsx rename to packages/shared-ux/prompt/no_data_views/src/no_data_views.test.tsx index bb067544013c8..041e71d87e2ae 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.test.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.test.tsx @@ -12,21 +12,23 @@ import { ReactWrapper } from 'enzyme'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { EuiButton } from '@elastic/eui'; -import { - SharedUxServicesProvider, - SharedUxServices, - mockServicesFactory, -} from '@kbn/shared-ux-services'; -import { NoDataViews } from './no_data_views'; - -describe('', () => { - let services: SharedUxServices; +import { NoDataViewsPrompt } from './no_data_views'; +import { NoDataViewsPromptServices, NoDataViewsPromptProvider } from './services'; + +const getServices = (canCreateNewDataView: boolean = true) => ({ + canCreateNewDataView, + openDataViewEditor: jest.fn(), + dataViewsDocLink: 'some/link', +}); + +describe('', () => { + let services: NoDataViewsPromptServices; let mount: (element: JSX.Element) => ReactWrapper; beforeEach(() => { - services = mockServicesFactory(); + services = getServices(); mount = (element: JSX.Element) => - mountWithIntl({element}); + mountWithIntl({element}); }); afterEach(() => { @@ -34,13 +36,13 @@ describe('', () => { }); test('on dataView created', () => { - const component = mount(); + const component = mount(); - expect(services.editors.openDataViewEditor).not.toHaveBeenCalled(); + expect(services.openDataViewEditor).not.toHaveBeenCalled(); component.find(EuiButton).simulate('click'); component.unmount(); - expect(services.editors.openDataViewEditor).toHaveBeenCalled(); + expect(services.openDataViewEditor).toHaveBeenCalled(); }); }); diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.tsx similarity index 72% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx rename to packages/shared-ux/prompt/no_data_views/src/no_data_views.tsx index 8d0e6d93275e1..da618674810ce 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.tsx @@ -8,20 +8,18 @@ import React, { useCallback, useEffect, useRef } from 'react'; -import { useEditors, usePermissions, useDocLinks } from '@kbn/shared-ux-services'; -import type { SharedUxEditorsService } from '@kbn/shared-ux-services'; - -import { NoDataViews as NoDataViewsComponent } from './no_data_views.component'; +import { NoDataViewsPrompt as NoDataViewsPromptComponent } from './no_data_views.component'; +import { useServices, NoDataViewsPromptServices } from './services'; // TODO: https://github.com/elastic/kibana/issues/127695 export interface Props { onDataViewCreated: (dataView: unknown) => void; } -type CloseDataViewEditorFn = ReturnType; +type CloseDataViewEditorFn = ReturnType; /** - * A service-enabled component that provides Kibana-specific functionality to the `NoDataViews` + * A service-enabled component that provides Kibana-specific functionality to the `NoDataViewsPrompt` * component. * * Use of this component requires both the `EuiTheme` context as well as either a configured Shared UX @@ -29,10 +27,8 @@ type CloseDataViewEditorFn = ReturnType { - const { canCreateNewDataView } = usePermissions(); - const { openDataViewEditor } = useEditors(); - const { dataViewsDocLink } = useDocLinks(); +export const NoDataViewsPrompt = ({ onDataViewCreated }: Props) => { + const { canCreateNewDataView, openDataViewEditor, dataViewsDocLink } = useServices(); const closeDataViewEditor = useRef(); useEffect(() => { @@ -69,5 +65,7 @@ export const NoDataViews = ({ onDataViewCreated }: Props) => { } }, [canCreateNewDataView, openDataViewEditor, setDataViewEditorRef, onDataViewCreated]); - return ; + return ( + + ); }; diff --git a/packages/shared-ux/prompt/no_data_views/src/services.tsx b/packages/shared-ux/prompt/no_data_views/src/services.tsx new file mode 100644 index 0000000000000..58d21d1845b56 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/src/services.tsx @@ -0,0 +1,115 @@ +/* + * 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, { FC, useContext } from 'react'; + +/** + * TODO: `DataView` is a class exported by `src/plugins/data_views/public`. Since this service + * is contained in this package-- and packages can only depend on other packages and never on + * plugins-- we have to set this to `unknown`. If and when `DataView` is exported from a + * stateless package, we can remove this. + * + * @see: https://github.com/elastic/kibana/issues/127695 + */ +type DataView = unknown; + +/** + * A subset of the `DataViewEditorOptions` interface relevant to our service and components. + * + * @see: src/plugins/data_view_editor/public/types.ts + */ +interface DataViewEditorOptions { + /** Handler to be invoked when the Data View Editor completes a save operation. */ + onSave: (dataView: DataView) => void; + /** If set to false, will skip empty prompt in data view editor. */ + showEmptyPrompt?: boolean; +} + +/** + * Abstract external services for this component. + */ +export interface NoDataViewsPromptServices { + /** True if the user has permission to create a new Data View, false otherwise. */ + canCreateNewDataView: boolean; + /** A method to open the Data View Editor flow. */ + openDataViewEditor: (options: DataViewEditorOptions) => () => void; + /** A link to information about Data Views in Kibana */ + dataViewsDocLink: string; +} + +const NoDataViewsPromptContext = React.createContext(null); + +/** + * Abstract external service Provider. + */ +export const NoDataViewsPromptProvider: FC = ({ + children, + ...services +}) => { + return ( + + {children} + + ); +}; + +/** + * Kibana-specific service types. + */ +export interface NoDataViewsPromptKibanaServices { + coreStart: { + docLinks: { + links: { + indexPatterns: { + introduction: string; + }; + }; + }; + }; + dataViewEditor: { + userPermissions: { + editDataView: () => boolean; + }; + openEditor: (options: DataViewEditorOptions) => () => void; + }; +} + +/** + * Kibana-specific Provider that maps to known dependency types. + */ +export const NoDataViewsPromptKibanaProvider: FC = ({ + children, + ...services +}) => { + return ( + + {children} + + ); +}; + +/** + * React hook for accessing pre-wired services. + */ +export function useServices() { + const context = useContext(NoDataViewsPromptContext); + + if (!context) { + throw new Error( + 'NoDataViewsPromptContext is missing. Ensure your component or React root is wrapped with NoDataViewsPromptProvider.' + ); + } + + return context; +} diff --git a/packages/shared-ux/prompt/no_data_views/tsconfig.json b/packages/shared-ux/prompt/no_data_views/tsconfig.json new file mode 100644 index 0000000000000..45842fa3da472 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "rootDir": "src", + "stripInternal": false, + "types": [ + "jest", + "node", + "react", + "@emotion/react/types/css-prop", + "@kbn/ambient-ui-types", + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx index ecfdd9e5c1c92..690bfa1f7acb8 100644 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx +++ b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx @@ -9,9 +9,9 @@ import React, { useState, FC, useEffect } from 'react'; import useAsync from 'react-use/lib/useAsync'; -import { NoDataViewsComponent } from '@kbn/shared-ux-components'; import { EuiFlyoutBody } from '@elastic/eui'; import { DEFAULT_ASSETS_TO_IGNORE } from '@kbn/data-plugin/common'; +import { NoDataViewsPromptComponent } from '@kbn/shared-ux-prompt-no-data-views'; import { useKibana } from '../../shared_imports'; import { MatchedItem, DataViewEditorContext } from '../../types'; @@ -105,7 +105,7 @@ export const EmptyPrompts: FC = ({ return ( <> - setGoToForm(true)} canCreateNewDataView={application.capabilities.indexPatterns.save as boolean} dataViewsDocLink={docLinks.links.indexPatterns.introduction} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 70d3a81a2f808..f211cc9fede8e 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -5367,8 +5367,8 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "Cette intégration n'est pas encore activée. Votre administrateur possède les autorisations requises pour l’activer.", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "Contactez votre administrateur", "sharedUXComponents.noDataPage.elasticAgentCard.title": "Ajouter Elastic Agent", - "sharedUXComponents.noDataViews.learnMore": "Envie d'en savoir plus ?", - "sharedUXComponents.noDataViews.readDocumentation": "Lisez les documents", + "sharedUXPackages.noDataViewsPrompt.learnMore": "Envie d'en savoir plus ?", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "Lisez les documents", "sharedUXComponents.pageTemplate.noDataCard.description": "Continuer sans collecter de données", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "Ajouter depuis la bibliothèque", "telemetry.callout.appliesSettingTitle": "Les modifications apportées à ce paramètre s'appliquent dans {allOfKibanaText} et sont enregistrées automatiquement.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a20feeeccdb1b..eec41bfb71c81 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5469,8 +5469,8 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "この統合はまだ有効ではありません。管理者にはオンにするために必要なアクセス権があります。", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "管理者にお問い合わせください", "sharedUXComponents.noDataPage.elasticAgentCard.title": "Elasticエージェントの追加", - "sharedUXComponents.noDataViews.learnMore": "詳細について", - "sharedUXComponents.noDataViews.readDocumentation": "ドキュメントを読む", + "sharedUXPackages.noDataViewsPrompt.learnMore": "詳細について", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "ドキュメントを読む", "sharedUXComponents.pageTemplate.noDataCard.description": "データを収集せずに続行", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "ライブラリから追加", "telemetry.callout.appliesSettingTitle": "この設定に加えた変更は {allOfKibanaText} に適用され、自動的に保存されます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a2c33d9a1fae7..2d7566bdd8c87 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5480,8 +5480,8 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "尚未启用此集成。您的管理员具有打开它所需的权限。", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "请联系您的管理员", "sharedUXComponents.noDataPage.elasticAgentCard.title": "添加 Elastic 代理", - "sharedUXComponents.noDataViews.learnMore": "希望了解详情?", - "sharedUXComponents.noDataViews.readDocumentation": "阅读文档", + "sharedUXPackages.noDataViewsPrompt.learnMore": "希望了解详情?", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "阅读文档", "sharedUXComponents.pageTemplate.noDataCard.description": "继续,而不收集数据", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "从库中添加", "telemetry.callout.appliesSettingTitle": "对此设置的更改将应用到{allOfKibanaText} 且会自动保存。", diff --git a/yarn.lock b/yarn.lock index 5225ebe505cbe..3668e805f67cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3216,6 +3216,11 @@ version "0.0.0" uid "" + +"@kbn/shared-ux-prompt-no-data-views@link:bazel-bin/packages/shared-ux/prompt/no_data_views": + version "0.0.0" + uid "" + "@kbn/shared-ux-services@link:bazel-bin/packages/kbn-shared-ux-services": version "0.0.0" uid "" @@ -6421,6 +6426,11 @@ version "0.0.0" uid "" + +"@types/kbn__shared-ux-prompt-no-data-views@link:bazel-bin/packages/shared-ux/prompt/no_data_views/npm_module_types": + version "0.0.0" + uid "" + "@types/kbn__shared-ux-services@link:bazel-bin/packages/kbn-shared-ux-services/npm_module_types": version "0.0.0" uid "" From 0c43f86470bb4cc52969e434103e654a854c2c57 Mon Sep 17 00:00:00 2001 From: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> Date: Thu, 19 May 2022 17:29:48 -0400 Subject: [PATCH 080/150] [DOCS] Remove note that pre-configured connectors are not supported on cases (#132186) --- docs/management/connectors/pre-configured-connectors.asciidoc | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/management/connectors/pre-configured-connectors.asciidoc b/docs/management/connectors/pre-configured-connectors.asciidoc index 27d1d80ea7305..7498784ef389e 100644 --- a/docs/management/connectors/pre-configured-connectors.asciidoc +++ b/docs/management/connectors/pre-configured-connectors.asciidoc @@ -12,8 +12,6 @@ action are predefined, including the connector name and ID. - Appear in all spaces because they are not saved objects. - Cannot be edited or deleted. -NOTE: Preconfigured connectors cannot be used with cases. - [float] [[preconfigured-connector-example]] ==== Preconfigured connectors example From efd30bc0077f98db0b162911c23fc703a1ad7880 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 May 2022 15:22:51 -0700 Subject: [PATCH 081/150] Update ftr (#132558) Co-authored-by: Renovate Bot --- package.json | 6 +++--- yarn.lock | 33 +++++++++++++++++++++------------ 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 72f4acfc18354..9b01ec9decdcb 100644 --- a/package.json +++ b/package.json @@ -757,7 +757,7 @@ "@types/redux-logger": "^3.0.8", "@types/resolve": "^1.20.1", "@types/seedrandom": ">=2.0.0 <4.0.0", - "@types/selenium-webdriver": "^4.0.19", + "@types/selenium-webdriver": "^4.1.0", "@types/semver": "^7", "@types/set-value": "^2.0.0", "@types/sinon": "^7.0.13", @@ -812,7 +812,7 @@ "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^100.0.0", + "chromedriver": "^101.0.0", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compression-webpack-plugin": "^4.0.0", @@ -933,7 +933,7 @@ "resolve": "^1.22.0", "rxjs-marbles": "^5.0.6", "sass-loader": "^10.2.0", - "selenium-webdriver": "^4.1.1", + "selenium-webdriver": "^4.1.2", "shelljs": "^0.8.4", "simple-git": "1.116.0", "sinon": "^7.4.2", diff --git a/yarn.lock b/yarn.lock index 3668e805f67cb..88a23a226d0e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7112,10 +7112,12 @@ resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-2.4.28.tgz#9ce8fa048c1e8c85cb71d7fe4d704e000226036f" integrity sha512-SMA+fUwULwK7sd/ZJicUztiPs8F1yCPwF3O23Z9uQ32ME5Ha0NmDK9+QTsYE4O2tHXChzXomSWWeIhCnoN1LqA== -"@types/selenium-webdriver@^4.0.19": - version "4.0.19" - resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-4.0.19.tgz#25699713552a63ee70215effdfd2e5d6dda19f8e" - integrity sha512-Irrh+iKc6Cxj6DwTupi4zgWhSBm1nK+JElOklIUiBVE6rcLYDtT1mwm9oFkHie485BQXNmZRoayjwxhowdInnA== +"@types/selenium-webdriver@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-4.1.0.tgz#b23ba7e7f4f59069529c57f0cbb7f5fba74affe7" + integrity sha512-ehqwZemosqiWVe+W0f5GqcLH7NgtjMBmcknmeaPG6YZHc7EZ69XbD7VVNZcT/L8lyMIL/KG99MsGcvDuFWo3Yw== + dependencies: + "@types/ws" "*" "@types/semver@^7": version "7.3.4" @@ -7387,6 +7389,13 @@ resolved "https://registry.yarnpkg.com/@types/write-pkg/-/write-pkg-3.1.0.tgz#f58767f4fb9a6a3ad8e95d3e9cd1f2d026ceab26" integrity sha512-JRGsPEPCrYqTXU0Cr+Yu7esPBE2yvH7ucOHr+JuBy0F59kglPvO5gkmtyEvf3P6dASSkScvy/XQ6SC1QEBFDuA== +"@types/ws@*": + version "8.5.3" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d" + integrity sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w== + dependencies: + "@types/node" "*" + "@types/xml-crypto@^1.4.2": version "1.4.2" resolved "https://registry.yarnpkg.com/@types/xml-crypto/-/xml-crypto-1.4.2.tgz#5ea7ef970f525ae8fe1e2ce0b3d40da1e3b279ae" @@ -10255,10 +10264,10 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^100.0.0: - version "100.0.0" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-100.0.0.tgz#1b4bf5c89cea12c79f53bc94d8f5bb5aa79ed7be" - integrity sha512-oLfB0IgFEGY9qYpFQO/BNSXbPw7bgfJUN5VX8Okps9W2qNT4IqKh5hDwKWtpUIQNI6K3ToWe2/J5NdpurTY02g== +chromedriver@^101.0.0: + version "101.0.0" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-101.0.0.tgz#ad19003008dd5df1770a1ad96059a9c5fe78e365" + integrity sha512-LkkWxy6KM/0YdJS8qBeg5vfkTZTRamhBfOttb4oic4echDgWvCU1E8QcBbUBOHqZpSrYMyi7WMKmKMhXFUaZ+w== dependencies: "@testim/chrome-version" "^1.1.2" axios "^0.24.0" @@ -25515,10 +25524,10 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= -selenium-webdriver@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.1.1.tgz#da083177d811f36614950e809e2982570f67d02e" - integrity sha512-Fr9e9LC6zvD6/j7NO8M1M/NVxFX67abHcxDJoP5w2KN/Xb1SyYLjMVPGgD14U2TOiKe4XKHf42OmFw9g2JgCBQ== +selenium-webdriver@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.1.2.tgz#d463b4335632d2ea41a9e988e435a55dc41f5314" + integrity sha512-e4Ap8vQvhipgBB8Ry9zBiKGkU6kHKyNnWiavGGLKkrdW81Zv7NVMtFOL/j3yX0G8QScM7XIXijKssNd4EUxSOw== dependencies: jszip "^3.6.0" tmp "^0.2.1" From 1ea3fc6d32486656d8ed5e2f5e637e61baf24245 Mon Sep 17 00:00:00 2001 From: "Joey F. Poon" Date: Thu, 19 May 2022 18:00:14 -0500 Subject: [PATCH 082/150] [Security Solution] improve endpoint metadata tests (#125883) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../data_loaders/index_fleet_agent.ts | 2 +- .../services/endpoint.ts | 68 +++++++++++++++---- .../apis/endpoint_authz.ts | 9 --- .../apis/metadata.ts | 49 ++++++------- 4 files changed, 80 insertions(+), 48 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts index b051eff37edc7..8719db5036b83 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts @@ -23,7 +23,7 @@ import { wrapErrorAndRejectPromise } from './utils'; const defaultFleetAgentGenerator = new FleetAgentGenerator(); export interface IndexedFleetAgentResponse { - agents: Agent[]; + agents: Array; fleetAgentsIndex: string; } diff --git a/x-pack/test/security_solution_endpoint/services/endpoint.ts b/x-pack/test/security_solution_endpoint/services/endpoint.ts index 27dcd67c6d684..d526c59ee6864 100644 --- a/x-pack/test/security_solution_endpoint/services/endpoint.ts +++ b/x-pack/test/security_solution_endpoint/services/endpoint.ts @@ -11,6 +11,7 @@ import { metadataCurrentIndexPattern, metadataTransformPrefix, METADATA_UNITED_INDEX, + METADATA_UNITED_TRANSFORM, } from '@kbn/security-solution-plugin/common/endpoint/constants'; import { deleteIndexedHostsAndAlerts, @@ -77,6 +78,27 @@ export class EndpointTestResources extends FtrService { await this.transform.api.updateTransform(transform.id, { frequency }).catch(catchAndWrapError); } + private async stopTransform(transformId: string) { + const stopRequest = { + transform_id: `${transformId}*`, + force: true, + wait_for_completion: true, + allow_no_match: true, + }; + return this.esClient.transform.stopTransform(stopRequest); + } + + private async startTransform(transformId: string) { + const transformsResponse = await this.esClient.transform.getTransform({ + transform_id: `${transformId}*`, + }); + return Promise.all( + transformsResponse.transforms.map((transform) => { + return this.esClient.transform.startTransform({ transform_id: transform.id }); + }) + ); + } + /** * Loads endpoint host/alert/event data into elasticsearch * @param [options] @@ -86,6 +108,8 @@ export class EndpointTestResources extends FtrService { * @param [options.enableFleetIntegration=true] When set to `true`, Fleet data will also be loaded (ex. Integration Policies, Agent Policies, "fake" Agents) * @param [options.generatorSeed='seed`] The seed to be used by the data generator. Important in order to ensure the same data is generated on very run. * @param [options.waitUntilTransformed=true] If set to `true`, the data loading process will wait until the endpoint hosts metadata is processed by the transform + * @param [options.waitTimeout=60000] If waitUntilTransformed=true, number of ms to wait until timeout + * @param [options.customIndexFn] If provided, will use this function to generate and index data instead */ async loadEndpointData( options: Partial<{ @@ -95,6 +119,8 @@ export class EndpointTestResources extends FtrService { enableFleetIntegration: boolean; generatorSeed: string; waitUntilTransformed: boolean; + waitTimeout: number; + customIndexFn: () => Promise; }> = {} ): Promise { const { @@ -104,25 +130,39 @@ export class EndpointTestResources extends FtrService { enableFleetIntegration = true, generatorSeed = 'seed', waitUntilTransformed = true, + waitTimeout = 60000, + customIndexFn, } = options; + if (waitUntilTransformed) { + // need this before indexing docs so that the united transform doesn't + // create a checkpoint with a timestamp after the doc timestamps + await this.stopTransform(METADATA_UNITED_TRANSFORM); + } + // load data into the system - const indexedData = await indexHostsAndAlerts( - this.esClient as Client, - this.kbnClient, - generatorSeed, - numHosts, - numHostDocs, - 'metrics-endpoint.metadata-default', - 'metrics-endpoint.policy-default', - 'logs-endpoint.events.process-default', - 'logs-endpoint.alerts-default', - alertsPerHost, - enableFleetIntegration - ); + const indexedData = customIndexFn + ? await customIndexFn() + : await indexHostsAndAlerts( + this.esClient as Client, + this.kbnClient, + generatorSeed, + numHosts, + numHostDocs, + 'metrics-endpoint.metadata-default', + 'metrics-endpoint.policy-default', + 'logs-endpoint.events.process-default', + 'logs-endpoint.alerts-default', + alertsPerHost, + enableFleetIntegration + ); if (waitUntilTransformed) { - await this.waitForEndpoints(indexedData.hosts.map((host) => host.agent.id)); + const metadataIds = Array.from(new Set(indexedData.hosts.map((host) => host.agent.id))); + await this.waitForEndpoints(metadataIds, waitTimeout); + await this.startTransform(METADATA_UNITED_TRANSFORM); + const agentIds = Array.from(new Set(indexedData.agents.map((agent) => agent.agent!.id))); + await this.waitForUnitedEndpoints(agentIds, waitTimeout); } return indexedData; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts index f560103c6c862..1a009aaef07ec 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { IndexedHostsAndAlertsResponse } from '@kbn/security-solution-plugin/common/endpoint/index_data'; import { wrapErrorAndRejectPromise } from '@kbn/security-solution-plugin/common/endpoint/data_loaders/utils'; import { FtrProviderContext } from '../ftr_provider_context'; import { @@ -15,23 +14,15 @@ import { } from '../../common/services/security_solution'; export default function ({ getService }: FtrProviderContext) { - const endpointTestResources = getService('endpointTestResources'); const supertestWithoutAuth = getService('supertestWithoutAuth'); describe('When attempting to call an endpoint api with no authz', () => { - let loadedData: IndexedHostsAndAlertsResponse; - before(async () => { // create role/user await createUserAndRole(getService, ROLES.t1_analyst); - loadedData = await endpointTestResources.loadEndpointData(); }); after(async () => { - if (loadedData) { - await endpointTestResources.unloadEndpointData(loadedData); - } - // delete role/user await deleteUserAndRole(getService, ROLES.t1_analyst); }); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 9b023e6992385..047b21827c5c3 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -19,6 +19,8 @@ import { import { AGENTS_INDEX } from '@kbn/fleet-plugin/common'; import { indexFleetEndpointPolicy } from '@kbn/security-solution-plugin/common/endpoint/data_loaders/index_fleet_endpoint_policy'; import { TRANSFORM_STATES } from '@kbn/security-solution-plugin/common/constants'; +import type { IndexedHostsAndAlertsResponse } from '@kbn/security-solution-plugin/common/endpoint/index_data'; + import { generateAgentDocs, generateMetadataDocs } from './metadata.fixtures'; import { deleteAllDocsFromMetadataCurrentIndex, @@ -47,38 +49,37 @@ export default function ({ getService }: FtrProviderContext) { const numberOfHostsInFixture = 2; before(async () => { - await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); await deleteAllDocsFromFleetAgents(getService); await deleteAllDocsFromMetadataDatastream(getService); await deleteAllDocsFromMetadataCurrentIndex(getService); await deleteAllDocsFromIndex(getService, METADATA_UNITED_INDEX); - // generate an endpoint policy and attach id to agents since - // metadata list api filters down to endpoint policies only - const policy = await indexFleetEndpointPolicy( - getService('kibanaServer'), - `Default ${uuid.v4()}`, - '1.1.1' - ); - const policyId = policy.integrationPolicies[0].policy_id; - const currentTime = new Date().getTime(); + const customIndexFn = async (): Promise => { + // generate an endpoint policy and attach id to agents since + // metadata list api filters down to endpoint policies only + const policy = await indexFleetEndpointPolicy( + getService('kibanaServer'), + `Default ${uuid.v4()}`, + '1.1.1' + ); + const policyId = policy.integrationPolicies[0].policy_id; + const currentTime = new Date().getTime(); - const agentDocs = generateAgentDocs(currentTime, policyId); + const agentDocs = generateAgentDocs(currentTime, policyId); + const metadataDocs = generateMetadataDocs(currentTime); - await Promise.all([ - bulkIndex(getService, AGENTS_INDEX, agentDocs), - bulkIndex(getService, METADATA_DATASTREAM, generateMetadataDocs(currentTime)), - ]); + await Promise.all([ + bulkIndex(getService, AGENTS_INDEX, agentDocs), + bulkIndex(getService, METADATA_DATASTREAM, metadataDocs), + ]); - await endpointTestResources.waitForEndpoints( - agentDocs.map((doc) => doc.agent.id), - 60000 - ); - await startTransform(getService, METADATA_UNITED_TRANSFORM); - await endpointTestResources.waitForUnitedEndpoints( - agentDocs.map((doc) => doc.agent.id), - 60000 - ); + return { + agents: agentDocs, + hosts: metadataDocs, + } as unknown as IndexedHostsAndAlertsResponse; + }; + + await endpointTestResources.loadEndpointData({ customIndexFn }); }); after(async () => { From cadd7b33b84d403c4dca2b2fb7c99aa78f505d17 Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Thu, 19 May 2022 18:16:59 -0500 Subject: [PATCH 083/150] Adds example for how to change a field format (#132541) --- docs/api/data-views/update-fields.asciidoc | 50 +++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/api/data-views/update-fields.asciidoc b/docs/api/data-views/update-fields.asciidoc index 3ec4b7c84694a..c43daff187528 100644 --- a/docs/api/data-views/update-fields.asciidoc +++ b/docs/api/data-views/update-fields.asciidoc @@ -60,6 +60,53 @@ $ curl -X POST api/data_views/data-view/my-view/fields -------------------------------------------------- // KIBANA +Change a simple field format: + +[source,sh] +-------------------------------------------------- +$ curl -X POST api/data_views/data-view/my-view/fields +{ + "fields": { + "foo": { + "format": { + "id": "bytes" + } + } + } +} +-------------------------------------------------- +// KIBANA + +Change a complex field format: + +[source,sh] +-------------------------------------------------- +$ curl -X POST api/data_views/data-view/my-view/fields +{ + "fields": { + "foo": { + "format": { + "id": "static_lookup", + "params": { + "lookupEntries": [ + { + "key": "1", + "value": "100" + }, + { + "key": "2", + "value": "200" + } + ], + "unknownKeyValue": "5000" + } + } + } + } +} +-------------------------------------------------- +// KIBANA + Update multiple metadata fields in one request: [source,sh] @@ -80,6 +127,7 @@ $ curl -X POST api/data_views/data-view/my-view/fields // KIBANA Use `null` value to delete metadata: + [source,sh] -------------------------------------------------- $ curl -X POST api/data_views/data-view/my-pattern/fields @@ -93,8 +141,8 @@ $ curl -X POST api/data_views/data-view/my-pattern/fields -------------------------------------------------- // KIBANA - The endpoint returns the updated data view object: + [source,sh] -------------------------------------------------- { From 04f47dda7453fe8c02d8b7137d805f7d406e25a6 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 19 May 2022 20:19:00 -0400 Subject: [PATCH 084/150] Fix upgrade available overflow (#132555) --- .../fleet/sections/agents/agent_list_page/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index f12a99c6e37f9..223ff395eb444 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -400,12 +400,12 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, { field: 'local_metadata.elastic.agent.version', - width: '120px', + width: '135px', name: i18n.translate('xpack.fleet.agentList.versionTitle', { defaultMessage: 'Version', }), render: (version: string, agent: Agent) => ( - + {safeMetadata(version)} From 419d4e2e5942c378045667e8675a20b9db0e19fc Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 20 May 2022 02:30:01 +0100 Subject: [PATCH 085/150] docs(NA): adds @kbn/test-subj-selector into ops devdocs (#132505) --- dev_docs/operations/operations_landing.mdx | 1 + nav-kibana-dev.docnav.json | 3 ++- packages/kbn-test-subj-selector/BUILD.bazel | 1 - packages/kbn-test-subj-selector/README.md | 3 --- packages/kbn-test-subj-selector/README.mdx | 10 ++++++++++ 5 files changed, 13 insertions(+), 5 deletions(-) delete mode 100755 packages/kbn-test-subj-selector/README.md create mode 100755 packages/kbn-test-subj-selector/README.mdx diff --git a/dev_docs/operations/operations_landing.mdx b/dev_docs/operations/operations_landing.mdx index cda44a96fe4dd..8a54ee0a90a43 100644 --- a/dev_docs/operations/operations_landing.mdx +++ b/dev_docs/operations/operations_landing.mdx @@ -45,5 +45,6 @@ layout: landing { pageId: "kibDevDocsOpsExpect" }, { pageId: "kibDevDocsOpsAmbientStorybookTypes" }, { pageId: "kibDevDocsOpsAmbientUiTypes"}, + { pageId: "kibDevDocsOpsTestSubjSelector"}, ]} /> \ No newline at end of file diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index 4704430ba94b6..4bd2349cb18d3 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -200,7 +200,8 @@ { "id": "kibDevDocsOpsJestSerializers" }, { "id": "kibDevDocsOpsExpect" }, { "id": "kibDevDocsOpsAmbientStorybookTypes" }, - { "id": "kibDevDocsOpsAmbientUiTypes" } + { "id": "kibDevDocsOpsAmbientUiTypes" }, + { "id": "kibDevDocsOpsTestSubjSelector"} ] } ] diff --git a/packages/kbn-test-subj-selector/BUILD.bazel b/packages/kbn-test-subj-selector/BUILD.bazel index f494b558ad5a6..cc3334650a5d9 100644 --- a/packages/kbn-test-subj-selector/BUILD.bazel +++ b/packages/kbn-test-subj-selector/BUILD.bazel @@ -18,7 +18,6 @@ filegroup( NPM_MODULE_EXTRA_FILES = [ "package.json", - "README.md", ] RUNTIME_DEPS = [] diff --git a/packages/kbn-test-subj-selector/README.md b/packages/kbn-test-subj-selector/README.md deleted file mode 100755 index 463d6c808e298..0000000000000 --- a/packages/kbn-test-subj-selector/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# test-subj-selector - -Convert a string from test subject syntax to css selectors. diff --git a/packages/kbn-test-subj-selector/README.mdx b/packages/kbn-test-subj-selector/README.mdx new file mode 100755 index 0000000000000..c924d15937129 --- /dev/null +++ b/packages/kbn-test-subj-selector/README.mdx @@ -0,0 +1,10 @@ +--- +id: kibDevDocsOpsTestSubjSelector +slug: /kibana-dev-docs/ops/test-subj-selector +title: "@kbn/test-subj-selector" +description: An utility package to quickly get css selectors from strings +date: 2022-05-19 +tags: ['kibana', 'dev', 'contributor', 'operations', 'test', 'subj', 'selector'] +--- + +Converts a string from a test subject syntax into a css selectors composed by `data-test-subj`. From 963b91d86b49327cf59397da31597f63f4f8fef7 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 20 May 2022 03:28:05 +0100 Subject: [PATCH 086/150] docs(NA): adds @kbn/babel-plugin-synthentic-packages into ops devdocs (#132512) * docs(NA): adds @kbn/babel-plugin-synthentic-packages into ops devdocs * chore(NA): update packages/kbn-babel-plugin-synthetic-packages/README.mdx Co-authored-by: Jonathan Budzenski Co-authored-by: Jonathan Budzenski --- dev_docs/operations/operations_landing.mdx | 1 + nav-kibana-dev.docnav.json | 3 ++- .../kbn-babel-plugin-synthetic-packages/README.mdx | 13 +++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 packages/kbn-babel-plugin-synthetic-packages/README.mdx diff --git a/dev_docs/operations/operations_landing.mdx b/dev_docs/operations/operations_landing.mdx index 8a54ee0a90a43..27bec68ac9014 100644 --- a/dev_docs/operations/operations_landing.mdx +++ b/dev_docs/operations/operations_landing.mdx @@ -25,6 +25,7 @@ layout: landing { pageId: "kibDevDocsOpsOptimizer" }, { pageId: "kibDevDocsOpsBabelPreset" }, { pageId: "kibDevDocsOpsTypeSummarizer" }, + { pageId: "kibDevDocsOpsBabelPluginSyntheticPackages"}, ]} /> diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index 4bd2349cb18d3..d182492c3da14 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -181,7 +181,8 @@ "items": [ { "id": "kibDevDocsOpsOptimizer" }, { "id": "kibDevDocsOpsBabelPreset" }, - { "id": "kibDevDocsOpsTypeSummarizer" } + { "id": "kibDevDocsOpsTypeSummarizer" }, + { "id": "kibDevDocsOpsBabelPluginSyntheticPackages"} ] }, { diff --git a/packages/kbn-babel-plugin-synthetic-packages/README.mdx b/packages/kbn-babel-plugin-synthetic-packages/README.mdx new file mode 100644 index 0000000000000..6f11e9cf2d6b9 --- /dev/null +++ b/packages/kbn-babel-plugin-synthetic-packages/README.mdx @@ -0,0 +1,13 @@ +--- +id: kibDevDocsOpsBabelPluginSyntheticPackages +slug: /kibana-dev-docs/ops/babel-plugin-synthetic-packages +title: "@kbn/babel-plugin-synthetic-packages" +description: A babel plugin that transforms our @kbn/{NAME} imports into paths +date: 2022-05-19 +tags: ['kibana', 'dev', 'contributor', 'operations', 'babel', 'plugin', 'synthetic', 'packages'] +--- + +When developing inside the Kibana repository importing a package from any other package is just easy as importing `@kbn/{package-name}`. +However not every package is a node_module yet and while that is something we are working on to accomplish we need a way to dealing with it for +now. Using this babel plugin is our transitory solution. It allows us to import from module ids and then transform it automatically back into +paths on the transpiled code without friction for our engineering teams. \ No newline at end of file From 753fd99d64d52a5bf836a05a4c3f077406720406 Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Thu, 19 May 2022 23:56:33 -0500 Subject: [PATCH 087/150] add internal/search test for correct handling of 403 error (#132046) --- .../api_integration/apis/search/search.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/x-pack/test/api_integration/apis/search/search.ts b/x-pack/test/api_integration/apis/search/search.ts index e459616304843..e7dfbb52ec701 100644 --- a/x-pack/test/api_integration/apis/search/search.ts +++ b/x-pack/test/api_integration/apis/search/search.ts @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; import type { Context } from 'mocha'; +import { parse as parseCookie } from 'tough-cookie'; import { FtrProviderContext } from '../../ftr_provider_context'; import { verifyErrorResponse } from '../../../../../test/api_integration/apis/search/verify_error'; @@ -16,6 +17,8 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); const log = getService('log'); const retry = getService('retry'); + const security = getService('security'); + const supertestNoAuth = getService('supertestWithoutAuth'); const shardDelayAgg = (delay: string) => ({ aggs: { @@ -266,6 +269,48 @@ export default function ({ getService }: FtrProviderContext) { verifyErrorResponse(resp.body, 400, 'parsing_exception', true); }); + + it('should return 403 for lack of privledges', async () => { + const username = 'no_access'; + const password = 't0pS3cr3t'; + + await security.user.create(username, { + password, + roles: ['test_shakespeare_reader'], + }); + + const loginResponse = await supertestNoAuth + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + + const sessionCookie = parseCookie(loginResponse.headers['set-cookie'][0]); + + await supertestNoAuth + .post(`/internal/search/ese`) + .set('kbn-xsrf', 'foo') + .set('Cookie', sessionCookie!.cookieString()) + .send({ + params: { + index: 'log*', + body: { + query: { + match_all: {}, + }, + }, + wait_for_completion_timeout: '10s', + }, + }) + .expect(403); + + await security.testUser.restoreDefaults(); + }); }); describe('rollup', () => { From 6bdef369052fc5040c5aaa5893a1b96f7e90550d Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Fri, 20 May 2022 10:23:19 +0300 Subject: [PATCH 088/150] Use warn instead of warning (#132516) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../saved_object_migrations.test.ts | 32 +++++++++---------- .../migrations/saved_object_migrations.ts | 4 +-- 2 files changed, 18 insertions(+), 18 deletions(-) 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 d43d4c4cb2a38..53765ed69cdac 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 @@ -181,7 +181,7 @@ describe('Lens migrations', () => { }); describe('7.8.0 auto timestamp', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', @@ -533,7 +533,7 @@ describe('Lens migrations', () => { }); describe('7.11.0 remove suggested priority', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', @@ -618,7 +618,7 @@ describe('Lens migrations', () => { }); describe('7.12.0 restructure datatable state', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mock-saved-object-id', @@ -691,7 +691,7 @@ describe('Lens migrations', () => { }); describe('7.13.0 rename operations for Formula', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -869,7 +869,7 @@ describe('Lens migrations', () => { }); describe('7.14.0 remove time zone from date histogram', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -961,7 +961,7 @@ describe('Lens migrations', () => { }); describe('7.15.0 add layer type information', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1143,7 +1143,7 @@ describe('Lens migrations', () => { }); describe('7.16.0 move reversed default palette to custom palette', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1417,7 +1417,7 @@ describe('Lens migrations', () => { }); describe('8.1.0 update filter reference schema', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1523,7 +1523,7 @@ describe('Lens migrations', () => { }); describe('8.1.0 rename records field', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1709,7 +1709,7 @@ describe('Lens migrations', () => { }); describe('8.1.0 add parentFormat to terms operation', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1785,7 +1785,7 @@ describe('Lens migrations', () => { describe('8.2.0', () => { describe('last_value columns', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1877,7 +1877,7 @@ describe('Lens migrations', () => { }); describe('rename fitRowToContent to new detailed rowHeight and rowHeightLines', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; function getExample(fitToContent: boolean) { return { type: 'lens', @@ -1996,7 +1996,7 @@ describe('Lens migrations', () => { }); describe('8.2.0 include empty rows for date histogram columns', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -2067,7 +2067,7 @@ describe('Lens migrations', () => { }); describe('8.3.0 old metric visualization defaults', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -2117,7 +2117,7 @@ describe('Lens migrations', () => { }); describe('8.3.0 - convert legend sizes to strings', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const migrate = migrations['8.3.0']; const autoLegendSize = 'auto'; @@ -2185,7 +2185,7 @@ describe('Lens migrations', () => { }); describe('8.3.0 valueLabels in XY', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', 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 3870bab9fad65..e6daa2cb99439 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -198,7 +198,7 @@ const removeLensAutoDate: SavedObjectMigrationFn Date: Fri, 20 May 2022 11:18:17 +0300 Subject: [PATCH 089/150] [XY] Usable reference lines for `xyVis`. (#132192) * ReferenceLineLayer -> referenceLine. * Added the referenceLine and splitted the logic at ReferenceLineAnnotations. * Fixed formatters of referenceLines * Added referenceLines keys. * Added test for the referenceLine fn. * Added some tests for reference_lines. * Unified the two different approaches of referenceLines. * Fixed types at tests and limits. --- packages/kbn-optimizer/limits.yml | 2 +- .../expression_xy/common/constants.ts | 3 +- .../common_reference_line_layer_args.ts | 25 - .../extended_reference_line_layer.ts | 50 -- .../common/expression_functions/index.ts | 2 +- .../expression_functions/layered_xy_vis.ts | 9 +- .../reference_line.test.ts | 140 ++++ .../expression_functions/reference_line.ts | 114 +++ .../reference_line_layer.ts | 29 +- .../expression_functions/xy_vis.test.ts | 17 +- .../common/expression_functions/xy_vis.ts | 8 +- .../common/expression_functions/xy_vis_fn.ts | 8 +- .../common/helpers/layers.test.ts | 2 +- .../expression_xy/common/i18n/index.tsx | 14 +- .../expression_xy/common/index.ts | 1 - .../common/types/expression_functions.ts | 65 +- .../common/utils/log_datatables.ts | 13 +- .../public/components/annotations.tsx | 2 +- .../components/reference_lines.test.tsx | 369 ---------- .../public/components/reference_lines.tsx | 268 ------- .../components/reference_lines/index.ts | 10 + .../reference_lines/reference_line.tsx | 56 ++ .../reference_line_annotations.tsx | 137 ++++ .../reference_lines/reference_line_layer.tsx | 92 +++ .../reference_lines.scss | 0 .../reference_lines/reference_lines.test.tsx | 683 ++++++++++++++++++ .../reference_lines/reference_lines.tsx | 79 ++ .../components/reference_lines/utils.tsx | 143 ++++ .../public/components/xy_chart.tsx | 28 +- .../expression_xy/public/helpers/layers.ts | 6 +- .../expression_xy/public/helpers/state.ts | 8 +- .../public/helpers/visualization.ts | 28 +- .../expression_xy/public/plugin.ts | 4 +- .../expression_xy/server/plugin.ts | 6 +- .../public/xy_visualization/to_expression.ts | 2 +- 35 files changed, 1615 insertions(+), 808 deletions(-) delete mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts delete mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.ts delete mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines.test.tsx delete mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines.tsx create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/index.ts create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_annotations.tsx create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_layer.tsx rename src/plugins/chart_expressions/expression_xy/public/components/{ => reference_lines}/reference_lines.scss (100%) create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.test.tsx create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 8856f7f0aaabb..97e9f23784f60 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -128,5 +128,5 @@ pageLoadAssetSize: eventAnnotation: 19334 screenshotting: 22870 synthetics: 40958 - expressionXY: 31000 + expressionXY: 33000 kibanaUsageCollection: 16463 diff --git a/src/plugins/chart_expressions/expression_xy/common/constants.ts b/src/plugins/chart_expressions/expression_xy/common/constants.ts index 68ac2963c9646..fc2e41700b94f 100644 --- a/src/plugins/chart_expressions/expression_xy/common/constants.ts +++ b/src/plugins/chart_expressions/expression_xy/common/constants.ts @@ -9,6 +9,7 @@ export const XY_VIS = 'xyVis'; export const LAYERED_XY_VIS = 'layeredXyVis'; export const Y_CONFIG = 'yConfig'; +export const REFERENCE_LINE_Y_CONFIG = 'referenceLineYConfig'; export const EXTENDED_Y_CONFIG = 'extendedYConfig'; export const DATA_LAYER = 'dataLayer'; export const EXTENDED_DATA_LAYER = 'extendedDataLayer'; @@ -19,8 +20,8 @@ export const ANNOTATION_LAYER = 'annotationLayer'; export const EXTENDED_ANNOTATION_LAYER = 'extendedAnnotationLayer'; export const TICK_LABELS_CONFIG = 'tickLabelsConfig'; export const AXIS_EXTENT_CONFIG = 'axisExtentConfig'; +export const REFERENCE_LINE = 'referenceLine'; export const REFERENCE_LINE_LAYER = 'referenceLineLayer'; -export const EXTENDED_REFERENCE_LINE_LAYER = 'extendedReferenceLineLayer'; export const LABELS_ORIENTATION_CONFIG = 'labelsOrientationConfig'; export const AXIS_TITLES_VISIBILITY_CONFIG = 'axisTitlesVisibilityConfig'; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts deleted file mode 100644 index d85f5ae2b2f77..0000000000000 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 { EXTENDED_Y_CONFIG } from '../constants'; -import { strings } from '../i18n'; -import { ReferenceLineLayerFn, ExtendedReferenceLineLayerFn } from '../types'; - -type CommonReferenceLineLayerFn = ReferenceLineLayerFn | ExtendedReferenceLineLayerFn; - -export const commonReferenceLineLayerArgs: Omit = { - yConfig: { - types: [EXTENDED_Y_CONFIG], - help: strings.getRLYConfigHelp(), - multi: true, - }, - columnToLabel: { - types: ['string'], - help: strings.getColumnToLabelHelp(), - }, -}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts deleted file mode 100644 index 41b264cf53a4d..0000000000000 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; -import { LayerTypes, EXTENDED_REFERENCE_LINE_LAYER } from '../constants'; -import { ExtendedReferenceLineLayerFn } from '../types'; -import { strings } from '../i18n'; -import { commonReferenceLineLayerArgs } from './common_reference_line_layer_args'; - -export const extendedReferenceLineLayerFunction: ExtendedReferenceLineLayerFn = { - name: EXTENDED_REFERENCE_LINE_LAYER, - aliases: [], - type: EXTENDED_REFERENCE_LINE_LAYER, - help: strings.getRLHelp(), - inputTypes: ['datatable'], - args: { - ...commonReferenceLineLayerArgs, - accessors: { - types: ['string'], - help: strings.getRLAccessorsHelp(), - multi: true, - }, - table: { - types: ['datatable'], - help: strings.getTableHelp(), - }, - layerId: { - types: ['string'], - help: strings.getLayerIdHelp(), - }, - }, - fn(input, args) { - const table = args.table ?? input; - const accessors = args.accessors ?? []; - accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); - - return { - type: EXTENDED_REFERENCE_LINE_LAYER, - ...args, - accessors: args.accessors ?? [], - layerType: LayerTypes.REFERENCELINE, - table, - }; - }, -}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts index 30a76217b5c0e..dc82220db6e23 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts @@ -18,6 +18,6 @@ export * from './grid_lines_config'; export * from './axis_extent_config'; export * from './tick_labels_config'; export * from './labels_orientation_config'; +export * from './reference_line'; export * from './reference_line_layer'; -export * from './extended_reference_line_layer'; export * from './axis_titles_visibility_config'; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts index 695bd16613715..f419891e079ea 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts @@ -6,10 +6,11 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import { LayeredXyVisFn } from '../types'; import { EXTENDED_DATA_LAYER, - EXTENDED_REFERENCE_LINE_LAYER, + REFERENCE_LINE_LAYER, LAYERED_XY_VIS, EXTENDED_ANNOTATION_LAYER, } from '../constants'; @@ -24,8 +25,10 @@ export const layeredXyVisFunction: LayeredXyVisFn = { args: { ...commonXYArgs, layers: { - types: [EXTENDED_DATA_LAYER, EXTENDED_REFERENCE_LINE_LAYER, EXTENDED_ANNOTATION_LAYER], - help: strings.getLayersHelp(), + types: [EXTENDED_DATA_LAYER, REFERENCE_LINE_LAYER, EXTENDED_ANNOTATION_LAYER], + help: i18n.translate('expressionXY.layeredXyVis.layers.help', { + defaultMessage: 'Layers of visual series', + }), multi: true, }, }, diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts new file mode 100644 index 0000000000000..b96f39923fab2 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; +import { ReferenceLineArgs, ReferenceLineConfigResult } from '../types'; +import { referenceLineFunction } from './reference_line'; + +describe('referenceLine', () => { + test('produces the correct arguments for minimum arguments', async () => { + const args: ReferenceLineArgs = { + value: 100, + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + textVisibility: false, + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + test('produces the correct arguments for maximum arguments', async () => { + const args: ReferenceLineArgs = { + name: 'some value', + value: 100, + icon: 'alert', + iconPosition: 'below', + axisMode: 'bottom', + lineStyle: 'solid', + lineWidth: 10, + color: '#fff', + fill: 'below', + textVisibility: true, + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + test('adds text visibility if name is provided ', async () => { + const args: ReferenceLineArgs = { + name: 'some name', + value: 100, + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + textVisibility: true, + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + test('hides text if textVisibility is true and no text is provided', async () => { + const args: ReferenceLineArgs = { + value: 100, + textVisibility: true, + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + textVisibility: false, + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + test('applies text visibility if name is provided', async () => { + const checktextVisibility = (textVisibility: boolean = false) => { + const args: ReferenceLineArgs = { + value: 100, + name: 'some text', + textVisibility, + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + textVisibility, + }, + ], + }; + expect(result).toEqual(expectedResult); + }; + + checktextVisibility(); + checktextVisibility(true); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.ts new file mode 100644 index 0000000000000..c294d6ca5aaec --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.ts @@ -0,0 +1,114 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + AvailableReferenceLineIcons, + FillStyles, + IconPositions, + LayerTypes, + LineStyles, + REFERENCE_LINE, + REFERENCE_LINE_Y_CONFIG, + YAxisModes, +} from '../constants'; +import { ReferenceLineFn } from '../types'; +import { strings } from '../i18n'; + +export const referenceLineFunction: ReferenceLineFn = { + name: REFERENCE_LINE, + aliases: [], + type: REFERENCE_LINE, + help: strings.getRLHelp(), + inputTypes: ['datatable', 'null'], + args: { + name: { + types: ['string'], + help: strings.getReferenceLineNameHelp(), + }, + value: { + types: ['number'], + help: strings.getReferenceLineValueHelp(), + required: true, + }, + axisMode: { + types: ['string'], + options: [...Object.values(YAxisModes)], + help: strings.getAxisModeHelp(), + default: YAxisModes.AUTO, + strict: true, + }, + color: { + types: ['string'], + help: strings.getColorHelp(), + }, + lineStyle: { + types: ['string'], + options: [...Object.values(LineStyles)], + help: i18n.translate('expressionXY.yConfig.lineStyle.help', { + defaultMessage: 'The style of the reference line', + }), + default: LineStyles.SOLID, + strict: true, + }, + lineWidth: { + types: ['number'], + help: i18n.translate('expressionXY.yConfig.lineWidth.help', { + defaultMessage: 'The width of the reference line', + }), + default: 1, + }, + icon: { + types: ['string'], + help: i18n.translate('expressionXY.yConfig.icon.help', { + defaultMessage: 'An optional icon used for reference lines', + }), + options: [...Object.values(AvailableReferenceLineIcons)], + strict: true, + }, + iconPosition: { + types: ['string'], + options: [...Object.values(IconPositions)], + help: i18n.translate('expressionXY.yConfig.iconPosition.help', { + defaultMessage: 'The placement of the icon for the reference line', + }), + default: IconPositions.AUTO, + strict: true, + }, + textVisibility: { + types: ['boolean'], + help: i18n.translate('expressionXY.yConfig.textVisibility.help', { + defaultMessage: 'Visibility of the label on the reference line', + }), + }, + fill: { + types: ['string'], + options: [...Object.values(FillStyles)], + help: i18n.translate('expressionXY.yConfig.fill.help', { + defaultMessage: 'Fill', + }), + default: FillStyles.NONE, + strict: true, + }, + }, + fn(table, args) { + const textVisibility = + args.name !== undefined && args.textVisibility === undefined + ? true + : args.name === undefined + ? false + : args.textVisibility; + + return { + type: REFERENCE_LINE, + layerType: LayerTypes.REFERENCELINE, + lineLength: table?.rows.length ?? 0, + yConfig: [{ ...args, textVisibility, type: REFERENCE_LINE_Y_CONFIG }], + }; + }, +}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts index 04c06f92d616f..6b51edd2d209e 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts @@ -7,10 +7,9 @@ */ import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; -import { LayerTypes, REFERENCE_LINE_LAYER } from '../constants'; +import { LayerTypes, REFERENCE_LINE_LAYER, EXTENDED_Y_CONFIG } from '../constants'; import { ReferenceLineLayerFn } from '../types'; import { strings } from '../i18n'; -import { commonReferenceLineLayerArgs } from './common_reference_line_layer_args'; export const referenceLineLayerFunction: ReferenceLineLayerFn = { name: REFERENCE_LINE_LAYER, @@ -19,14 +18,31 @@ export const referenceLineLayerFunction: ReferenceLineLayerFn = { help: strings.getRLHelp(), inputTypes: ['datatable'], args: { - ...commonReferenceLineLayerArgs, accessors: { - types: ['string', 'vis_dimension'], + types: ['string'], help: strings.getRLAccessorsHelp(), multi: true, }, + yConfig: { + types: [EXTENDED_Y_CONFIG], + help: strings.getRLYConfigHelp(), + multi: true, + }, + columnToLabel: { + types: ['string'], + help: strings.getColumnToLabelHelp(), + }, + table: { + types: ['datatable'], + help: strings.getTableHelp(), + }, + layerId: { + types: ['string'], + help: strings.getLayerIdHelp(), + }, }, - fn(table, args) { + fn(input, args) { + const table = args.table ?? input; const accessors = args.accessors ?? []; accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); @@ -34,8 +50,7 @@ export const referenceLineLayerFunction: ReferenceLineLayerFn = { type: REFERENCE_LINE_LAYER, ...args, layerType: LayerTypes.REFERENCELINE, - accessors, - table, + table: args.table ?? input, }; }, }; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts index 8ec1961416638..73d4444217d90 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts @@ -30,11 +30,12 @@ describe('xyVis', () => { } ), } as Datatable; + const { layers, ...rest } = args; const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; const result = await xyVisFunction.fn( newData, - { ...rest, ...restLayerArgs, referenceLineLayers: [], annotationLayers: [] }, + { ...rest, ...restLayerArgs, referenceLines: [], annotationLayers: [] }, createMockExecutionContext() ); @@ -60,7 +61,7 @@ describe('xyVis', () => { ...rest, ...{ ...sampleLayer, markSizeAccessor: 'b' }, markSizeRatio: 0, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], }, createMockExecutionContext() @@ -74,7 +75,7 @@ describe('xyVis', () => { ...rest, ...{ ...sampleLayer, markSizeAccessor: 'b' }, markSizeRatio: 101, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], }, createMockExecutionContext() @@ -92,7 +93,7 @@ describe('xyVis', () => { ...rest, ...restLayerArgs, minTimeBarInterval: '1q', - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], }, createMockExecutionContext() @@ -111,7 +112,7 @@ describe('xyVis', () => { ...rest, ...restLayerArgs, minTimeBarInterval: '1h', - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], }, createMockExecutionContext() @@ -131,7 +132,7 @@ describe('xyVis', () => { { ...rest, ...restLayerArgs, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], splitRowAccessor, }, @@ -152,7 +153,7 @@ describe('xyVis', () => { { ...rest, ...restLayerArgs, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], splitColumnAccessor, }, @@ -172,7 +173,7 @@ describe('xyVis', () => { { ...rest, ...restLayerArgs, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], markSizeRatio: 5, }, diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts index 37baf028178cc..7d2783cf6f1cd 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts @@ -7,7 +7,7 @@ */ import { XyVisFn } from '../types'; -import { XY_VIS, REFERENCE_LINE_LAYER, ANNOTATION_LAYER } from '../constants'; +import { XY_VIS, REFERENCE_LINE, ANNOTATION_LAYER } from '../constants'; import { strings } from '../i18n'; import { commonXYArgs } from './common_xy_args'; import { commonDataLayerArgs } from './common_data_layer_args'; @@ -33,9 +33,9 @@ export const xyVisFunction: XyVisFn = { help: strings.getAccessorsHelp(), multi: true, }, - referenceLineLayers: { - types: [REFERENCE_LINE_LAYER], - help: strings.getReferenceLineLayerHelp(), + referenceLines: { + types: [REFERENCE_LINE], + help: strings.getReferenceLinesHelp(), multi: true, }, annotationLayers: { diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index e879f33b76548..3de2dd35831e4 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -13,7 +13,7 @@ import { } from '@kbn/visualizations-plugin/common/utils'; import type { Datatable } from '@kbn/expressions-plugin/common'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common/expression_functions'; -import { LayerTypes, XY_VIS_RENDERER, DATA_LAYER } from '../constants'; +import { LayerTypes, XY_VIS_RENDERER, DATA_LAYER, REFERENCE_LINE } from '../constants'; import { appendLayerIds, getAccessors, normalizeTable } from '../helpers'; import { DataLayerConfigResult, XYLayerConfig, XyVisFn, XYArgs } from '../types'; import { getLayerDimensions } from '../utils'; @@ -53,7 +53,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateAccessor(args.splitColumnAccessor, data.columns); const { - referenceLineLayers = [], + referenceLines = [], annotationLayers = [], // data_layer args seriesType, @@ -81,7 +81,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { const layers: XYLayerConfig[] = [ ...appendLayerIds(dataLayers, 'dataLayers'), - ...appendLayerIds(referenceLineLayers, 'referenceLineLayers'), + ...appendLayerIds(referenceLines, 'referenceLines'), ...appendLayerIds(annotationLayers, 'annotationLayers'), ]; @@ -90,7 +90,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { handlers.inspectorAdapters.tables.allowCsvExport = true; const layerDimensions = layers.reduce((dimensions, layer) => { - if (layer.layerType === LayerTypes.ANNOTATIONS) { + if (layer.layerType === LayerTypes.ANNOTATIONS || layer.type === REFERENCE_LINE) { return dimensions; } diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts index a3eea973fbf91..895abdb7a60df 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts @@ -63,7 +63,7 @@ describe('#getDataLayers', () => { palette: { type: 'system_palette', name: 'system' }, }, { - type: 'extendedReferenceLineLayer', + type: 'referenceLineLayer', layerType: 'referenceLine', accessors: ['y'], table: { rows: [], columns: [], type: 'datatable' }, diff --git a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx index f3425ec2db625..ba26bb973f64f 100644 --- a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx @@ -93,9 +93,9 @@ export const strings = { i18n.translate('expressionXY.xyVis.dataLayer.help', { defaultMessage: 'Data layer of visual series', }), - getReferenceLineLayerHelp: () => - i18n.translate('expressionXY.xyVis.referenceLineLayer.help', { - defaultMessage: 'Reference line layer', + getReferenceLinesHelp: () => + i18n.translate('expressionXY.xyVis.referenceLines.help', { + defaultMessage: 'Reference line', }), getAnnotationLayerHelp: () => i18n.translate('expressionXY.xyVis.annotationLayer.help', { @@ -237,4 +237,12 @@ export const strings = { i18n.translate('expressionXY.annotationLayer.annotations.help', { defaultMessage: 'Annotations', }), + getReferenceLineNameHelp: () => + i18n.translate('expressionXY.referenceLine.name.help', { + defaultMessage: 'Reference line name', + }), + getReferenceLineValueHelp: () => + i18n.translate('expressionXY.referenceLine.Value.help', { + defaultMessage: 'Reference line value', + }), }; diff --git a/src/plugins/chart_expressions/expression_xy/common/index.ts b/src/plugins/chart_expressions/expression_xy/common/index.ts index 7211a7a7db1b7..005f6c2867c18 100755 --- a/src/plugins/chart_expressions/expression_xy/common/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/index.ts @@ -58,6 +58,5 @@ export type { ReferenceLineLayerConfigResult, CommonXYReferenceLineLayerConfig, AxisTitlesVisibilityConfigResult, - ExtendedReferenceLineLayerConfigResult, CommonXYReferenceLineLayerConfigResult, } from './types'; diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index 0e10f680811ec..0a7b93c495c29 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -26,7 +26,7 @@ import { XYCurveTypes, YAxisModes, YScaleTypes, - REFERENCE_LINE_LAYER, + REFERENCE_LINE, Y_CONFIG, AXIS_TITLES_VISIBILITY_CONFIG, LABELS_ORIENTATION_CONFIG, @@ -36,7 +36,7 @@ import { DATA_LAYER, AXIS_EXTENT_CONFIG, EXTENDED_DATA_LAYER, - EXTENDED_REFERENCE_LINE_LAYER, + REFERENCE_LINE_LAYER, ANNOTATION_LAYER, EndValues, EXTENDED_Y_CONFIG, @@ -44,6 +44,7 @@ import { XY_VIS, LAYERED_XY_VIS, EXTENDED_ANNOTATION_LAYER, + REFERENCE_LINE_Y_CONFIG, } from '../constants'; import { XYRender } from './expression_renderers'; @@ -194,7 +195,7 @@ export interface XYArgs extends DataLayerArgs { endValue?: EndValue; emphasizeFitting?: boolean; valueLabels: ValueLabelMode; - referenceLineLayers: ReferenceLineLayerConfigResult[]; + referenceLines: ReferenceLineConfigResult[]; annotationLayers: AnnotationLayerConfigResult[]; fittingFunction?: FittingFunction; axisTitlesVisibilitySettings?: AxisTitlesVisibilityConfigResult; @@ -287,13 +288,12 @@ export type ExtendedAnnotationLayerConfigResult = ExtendedAnnotationLayerArgs & layerType: typeof LayerTypes.ANNOTATIONS; }; -export interface ReferenceLineLayerArgs { - accessors: Array; - columnToLabel?: string; - yConfig?: ExtendedYConfigResult[]; +export interface ReferenceLineArgs extends Omit { + name?: string; + value: number; } -export interface ExtendedReferenceLineLayerArgs { +export interface ReferenceLineLayerArgs { layerId?: string; accessors: string[]; columnToLabel?: string; @@ -301,26 +301,31 @@ export interface ExtendedReferenceLineLayerArgs { table?: Datatable; } -export type XYLayerArgs = DataLayerArgs | ReferenceLineLayerArgs | AnnotationLayerArgs; -export type XYLayerConfig = DataLayerConfig | ReferenceLineLayerConfig | AnnotationLayerConfig; +export type XYLayerArgs = DataLayerArgs | ReferenceLineArgs | AnnotationLayerArgs; +export type XYLayerConfig = DataLayerConfig | ReferenceLineConfig | AnnotationLayerConfig; export type XYExtendedLayerConfig = | ExtendedDataLayerConfig - | ExtendedReferenceLineLayerConfig + | ReferenceLineLayerConfig | ExtendedAnnotationLayerConfig; export type XYExtendedLayerConfigResult = | ExtendedDataLayerConfigResult - | ExtendedReferenceLineLayerConfigResult + | ReferenceLineLayerConfigResult | ExtendedAnnotationLayerConfigResult; -export type ReferenceLineLayerConfigResult = ReferenceLineLayerArgs & { - type: typeof REFERENCE_LINE_LAYER; +export interface ReferenceLineYConfig extends ReferenceLineArgs { + type: typeof REFERENCE_LINE_Y_CONFIG; +} + +export interface ReferenceLineConfigResult { + type: typeof REFERENCE_LINE; layerType: typeof LayerTypes.REFERENCELINE; - table: Datatable; -}; + lineLength: number; + yConfig: [ReferenceLineYConfig]; +} -export type ExtendedReferenceLineLayerConfigResult = ExtendedReferenceLineLayerArgs & { - type: typeof EXTENDED_REFERENCE_LINE_LAYER; +export type ReferenceLineLayerConfigResult = ReferenceLineLayerArgs & { + type: typeof REFERENCE_LINE_LAYER; layerType: typeof LayerTypes.REFERENCELINE; table: Datatable; }; @@ -337,11 +342,11 @@ export interface WithLayerId { } export type DataLayerConfig = DataLayerConfigResult & WithLayerId; -export type ReferenceLineLayerConfig = ReferenceLineLayerConfigResult & WithLayerId; +export type ReferenceLineConfig = ReferenceLineConfigResult & WithLayerId; export type AnnotationLayerConfig = AnnotationLayerConfigResult & WithLayerId; export type ExtendedDataLayerConfig = ExtendedDataLayerConfigResult & WithLayerId; -export type ExtendedReferenceLineLayerConfig = ExtendedReferenceLineLayerConfigResult & WithLayerId; +export type ReferenceLineLayerConfig = ReferenceLineLayerConfigResult & WithLayerId; export type ExtendedAnnotationLayerConfig = ExtendedAnnotationLayerConfigResult & WithLayerId; export type ExtendedDataLayerConfigResult = Omit & { @@ -370,13 +375,11 @@ export type TickLabelsConfigResult = AxesSettingsConfig & { type: typeof TICK_LA export type CommonXYLayerConfig = XYLayerConfig | XYExtendedLayerConfig; export type CommonXYDataLayerConfigResult = DataLayerConfigResult | ExtendedDataLayerConfigResult; export type CommonXYReferenceLineLayerConfigResult = - | ReferenceLineLayerConfigResult - | ExtendedReferenceLineLayerConfigResult; + | ReferenceLineConfigResult + | ReferenceLineLayerConfigResult; export type CommonXYDataLayerConfig = DataLayerConfig | ExtendedDataLayerConfig; -export type CommonXYReferenceLineLayerConfig = - | ReferenceLineLayerConfig - | ExtendedReferenceLineLayerConfig; +export type CommonXYReferenceLineLayerConfig = ReferenceLineConfig | ReferenceLineLayerConfig; export type CommonXYAnnotationLayerConfig = AnnotationLayerConfig | ExtendedAnnotationLayerConfig; @@ -400,18 +403,18 @@ export type ExtendedDataLayerFn = ExpressionFunctionDefinition< Promise >; +export type ReferenceLineFn = ExpressionFunctionDefinition< + typeof REFERENCE_LINE, + Datatable | null, + ReferenceLineArgs, + ReferenceLineConfigResult +>; export type ReferenceLineLayerFn = ExpressionFunctionDefinition< typeof REFERENCE_LINE_LAYER, Datatable, ReferenceLineLayerArgs, ReferenceLineLayerConfigResult >; -export type ExtendedReferenceLineLayerFn = ExpressionFunctionDefinition< - typeof EXTENDED_REFERENCE_LINE_LAYER, - Datatable, - ExtendedReferenceLineLayerArgs, - ExtendedReferenceLineLayerConfigResult ->; export type YConfigFn = ExpressionFunctionDefinition; export type ExtendedYConfigFn = ExpressionFunctionDefinition< diff --git a/src/plugins/chart_expressions/expression_xy/common/utils/log_datatables.ts b/src/plugins/chart_expressions/expression_xy/common/utils/log_datatables.ts index 79a3cbd2eef19..44026b30ed493 100644 --- a/src/plugins/chart_expressions/expression_xy/common/utils/log_datatables.ts +++ b/src/plugins/chart_expressions/expression_xy/common/utils/log_datatables.ts @@ -8,13 +8,9 @@ import { ExecutionContext } from '@kbn/expressions-plugin'; import { Dimension, prepareLogTable } from '@kbn/visualizations-plugin/common/utils'; -import { LayerTypes } from '../constants'; +import { LayerTypes, REFERENCE_LINE } from '../constants'; import { strings } from '../i18n'; -import { - CommonXYDataLayerConfig, - CommonXYLayerConfig, - CommonXYReferenceLineLayerConfig, -} from '../types'; +import { CommonXYDataLayerConfig, CommonXYLayerConfig, ReferenceLineLayerConfig } from '../types'; export const logDatatables = (layers: CommonXYLayerConfig[], handlers: ExecutionContext) => { if (!handlers?.inspectorAdapters?.tables) { @@ -25,16 +21,17 @@ export const logDatatables = (layers: CommonXYLayerConfig[], handlers: Execution handlers.inspectorAdapters.tables.allowCsvExport = true; layers.forEach((layer) => { - if (layer.layerType === LayerTypes.ANNOTATIONS) { + if (layer.layerType === LayerTypes.ANNOTATIONS || layer.type === REFERENCE_LINE) { return; } + const logTable = prepareLogTable(layer.table, getLayerDimensions(layer), true); handlers.inspectorAdapters.tables.logDatatable(layer.layerId, logTable); }); }; export const getLayerDimensions = ( - layer: CommonXYDataLayerConfig | CommonXYReferenceLineLayerConfig + layer: CommonXYDataLayerConfig | ReferenceLineLayerConfig ): Dimension[] => { let xAccessor; let splitAccessor; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/annotations.tsx b/src/plugins/chart_expressions/expression_xy/public/components/annotations.tsx index 842baeb82d78d..6d76a230737ed 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/annotations.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/annotations.tsx @@ -7,7 +7,7 @@ */ import './annotations.scss'; -import './reference_lines.scss'; +import './reference_lines/reference_lines.scss'; import React from 'react'; import { snakeCase } from 'lodash'; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.test.tsx deleted file mode 100644 index 23e5011fe54a7..0000000000000 --- a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.test.tsx +++ /dev/null @@ -1,369 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 { LineAnnotation, RectAnnotation } from '@elastic/charts'; -import { shallow } from 'enzyme'; -import React from 'react'; -import { Datatable } from '@kbn/expressions-plugin/common'; -import { FieldFormat } from '@kbn/field-formats-plugin/common'; -import { LayerTypes } from '../../common/constants'; -import { - ReferenceLineLayerArgs, - ReferenceLineLayerConfig, - ExtendedYConfig, -} from '../../common/types'; -import { ReferenceLineAnnotations, ReferenceLineAnnotationsProps } from './reference_lines'; - -const row: Record = { - xAccessorFirstId: 1, - xAccessorSecondId: 2, - yAccessorLeftFirstId: 5, - yAccessorLeftSecondId: 10, - yAccessorRightFirstId: 5, - yAccessorRightSecondId: 10, -}; - -const data: Datatable = { - type: 'datatable', - rows: [row], - columns: Object.keys(row).map((id) => ({ - id, - name: `Static value: ${row[id]}`, - meta: { - type: 'number', - params: { id: 'number' }, - }, - })), -}; - -function createLayers(yConfigs: ReferenceLineLayerArgs['yConfig']): ReferenceLineLayerConfig[] { - return [ - { - layerId: 'first', - accessors: (yConfigs || []).map(({ forAccessor }) => forAccessor), - yConfig: yConfigs, - type: 'referenceLineLayer', - layerType: LayerTypes.REFERENCELINE, - table: data, - }, - ]; -} - -interface YCoords { - y0: number | undefined; - y1: number | undefined; -} -interface XCoords { - x0: number | undefined; - x1: number | undefined; -} - -function getAxisFromId(layerPrefix: string): ExtendedYConfig['axisMode'] { - return /left/i.test(layerPrefix) ? 'left' : /right/i.test(layerPrefix) ? 'right' : 'bottom'; -} - -const emptyCoords = { x0: undefined, x1: undefined, y0: undefined, y1: undefined }; - -describe('ReferenceLineAnnotations', () => { - describe('with fill', () => { - let formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; - let defaultProps: Omit; - - beforeEach(() => { - formatters = { - left: { convert: jest.fn((x) => x) } as unknown as FieldFormat, - right: { convert: jest.fn((x) => x) } as unknown as FieldFormat, - bottom: { convert: jest.fn((x) => x) } as unknown as FieldFormat, - }; - - defaultProps = { - formatters, - isHorizontal: false, - axesMap: { left: true, right: false }, - paddingMap: {}, - }; - }); - - it.each([ - ['yAccessorLeft', 'above'], - ['yAccessorLeft', 'below'], - ['yAccessorRight', 'above'], - ['yAccessorRight', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( - 'should render a RectAnnotation for a reference line with fill set: %s %s', - (layerPrefix, fill) => { - const axisMode = getAxisFromId(layerPrefix); - const wrapper = shallow( - - ); - - const y0 = fill === 'above' ? 5 : undefined; - const y1 = fill === 'above' ? undefined : 5; - - expect(wrapper.find(LineAnnotation).exists()).toBe(true); - expect(wrapper.find(RectAnnotation).exists()).toBe(true); - expect(wrapper.find(RectAnnotation).prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { x0: undefined, x1: undefined, y0, y1 }, - details: y0 ?? y1, - header: undefined, - }, - ]) - ); - } - ); - - it.each([ - ['xAccessor', 'above'], - ['xAccessor', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( - 'should render a RectAnnotation for a reference line with fill set: %s %s', - (layerPrefix, fill) => { - const wrapper = shallow( - - ); - - const x0 = fill === 'above' ? 1 : undefined; - const x1 = fill === 'above' ? undefined : 1; - - expect(wrapper.find(LineAnnotation).exists()).toBe(true); - expect(wrapper.find(RectAnnotation).exists()).toBe(true); - expect(wrapper.find(RectAnnotation).prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, x0, x1 }, - details: x0 ?? x1, - header: undefined, - }, - ]) - ); - } - ); - - it.each([ - ['yAccessorLeft', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], - ['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], - ['yAccessorRight', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], - ['yAccessorRight', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], - ] as Array<[string, ExtendedYConfig['fill'], YCoords, YCoords]>)( - 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', - (layerPrefix, fill, coordsA, coordsB) => { - const axisMode = getAxisFromId(layerPrefix); - const wrapper = shallow( - - ); - - expect(wrapper.find(RectAnnotation).first().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsA }, - details: coordsA.y0 ?? coordsA.y1, - header: undefined, - }, - ]) - ); - expect(wrapper.find(RectAnnotation).last().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsB }, - details: coordsB.y1 ?? coordsB.y0, - header: undefined, - }, - ]) - ); - } - ); - - it.each([ - ['xAccessor', 'above', { x0: 1, x1: 2 }, { x0: 2, x1: undefined }], - ['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: 1, x1: 2 }], - ] as Array<[string, ExtendedYConfig['fill'], XCoords, XCoords]>)( - 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', - (layerPrefix, fill, coordsA, coordsB) => { - const wrapper = shallow( - - ); - - expect(wrapper.find(RectAnnotation).first().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsA }, - details: coordsA.x0 ?? coordsA.x1, - header: undefined, - }, - ]) - ); - expect(wrapper.find(RectAnnotation).last().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsB }, - details: coordsB.x1 ?? coordsB.x0, - header: undefined, - }, - ]) - ); - } - ); - - it.each(['yAccessorLeft', 'yAccessorRight', 'xAccessor'])( - 'should let areas in different directions overlap: %s', - (layerPrefix) => { - const axisMode = getAxisFromId(layerPrefix); - - const wrapper = shallow( - - ); - - expect(wrapper.find(RectAnnotation).first().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...(axisMode === 'bottom' ? { x0: 1 } : { y0: 5 }) }, - details: axisMode === 'bottom' ? 1 : 5, - header: undefined, - }, - ]) - ); - expect(wrapper.find(RectAnnotation).last().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...(axisMode === 'bottom' ? { x1: 2 } : { y1: 10 }) }, - details: axisMode === 'bottom' ? 2 : 10, - header: undefined, - }, - ]) - ); - } - ); - - it.each([ - ['above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], - ['below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], - ] as Array<[ExtendedYConfig['fill'], YCoords, YCoords]>)( - 'should be robust and works also for different axes when on same direction: 1x Left + 1x Right both %s', - (fill, coordsA, coordsB) => { - const wrapper = shallow( - - ); - - expect(wrapper.find(RectAnnotation).first().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsA }, - details: coordsA.y0 ?? coordsA.y1, - header: undefined, - }, - ]) - ); - expect(wrapper.find(RectAnnotation).last().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsB }, - details: coordsB.y1 ?? coordsB.y0, - header: undefined, - }, - ]) - ); - } - ); - }); -}); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.tsx deleted file mode 100644 index d17dbf2a70ad1..0000000000000 --- a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.tsx +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 './reference_lines.scss'; - -import React from 'react'; -import { groupBy } from 'lodash'; -import { RectAnnotation, AnnotationDomainType, LineAnnotation, Position } from '@elastic/charts'; -import { euiLightVars } from '@kbn/ui-theme'; -import type { FieldFormat } from '@kbn/field-formats-plugin/common'; -import type { CommonXYReferenceLineLayerConfig, IconPosition, YAxisMode } from '../../common/types'; -import { - LINES_MARKER_SIZE, - mapVerticalToHorizontalPlacement, - Marker, - MarkerBody, -} from '../helpers'; - -export const computeChartMargins = ( - referenceLinePaddings: Partial>, - labelVisibility: Partial>, - titleVisibility: Partial>, - axesMap: Record<'left' | 'right', unknown>, - isHorizontal: boolean -) => { - const result: Partial> = {}; - if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; - result[placement] = referenceLinePaddings.bottom; - } - if ( - referenceLinePaddings.left && - (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; - result[placement] = referenceLinePaddings.left; - } - if ( - referenceLinePaddings.right && - (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; - result[placement] = referenceLinePaddings.right; - } - // there's no top axis, so just check if a margin has been computed - if (referenceLinePaddings.top) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; - result[placement] = referenceLinePaddings.top; - } - return result; -}; - -// if there's just one axis, put it on the other one -// otherwise use the same axis -// this function assume the chart is vertical -export function getBaseIconPlacement( - iconPosition: IconPosition | undefined, - axesMap?: Record, - axisMode?: YAxisMode -) { - if (iconPosition === 'auto') { - if (axisMode === 'bottom') { - return Position.Top; - } - if (axesMap) { - if (axisMode === 'left') { - return axesMap.right ? Position.Left : Position.Right; - } - return axesMap.left ? Position.Right : Position.Left; - } - } - - if (iconPosition === 'left') { - return Position.Left; - } - if (iconPosition === 'right') { - return Position.Right; - } - if (iconPosition === 'below') { - return Position.Bottom; - } - return Position.Top; -} - -export interface ReferenceLineAnnotationsProps { - layers: CommonXYReferenceLineLayerConfig[]; - formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; - axesMap: Record<'left' | 'right', boolean>; - isHorizontal: boolean; - paddingMap: Partial>; -} - -export const ReferenceLineAnnotations = ({ - layers, - formatters, - axesMap, - isHorizontal, - paddingMap, -}: ReferenceLineAnnotationsProps) => { - return ( - <> - {layers.flatMap((layer) => { - if (!layer.yConfig) { - return []; - } - const { columnToLabel, yConfig: yConfigs, table } = layer; - const columnToLabelMap: Record = columnToLabel - ? JSON.parse(columnToLabel) - : {}; - - const row = table.rows[0]; - - const yConfigByValue = yConfigs.sort( - ({ forAccessor: idA }, { forAccessor: idB }) => row[idA] - row[idB] - ); - - const groupedByDirection = groupBy(yConfigByValue, 'fill'); - if (groupedByDirection.below) { - groupedByDirection.below.reverse(); - } - - return yConfigByValue.flatMap((yConfig, i) => { - // Find the formatter for the given axis - const groupId = - yConfig.axisMode === 'bottom' - ? undefined - : yConfig.axisMode === 'right' - ? 'right' - : 'left'; - - const formatter = formatters[groupId || 'bottom']; - - const defaultColor = euiLightVars.euiColorDarkShade; - - // get the position for vertical chart - const markerPositionVertical = getBaseIconPlacement( - yConfig.iconPosition, - axesMap, - yConfig.axisMode - ); - // the padding map is built for vertical chart - const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; - - const props = { - groupId, - marker: ( - - ), - markerBody: ( - - ), - // rotate the position if required - markerPosition: isHorizontal - ? mapVerticalToHorizontalPlacement(markerPositionVertical) - : markerPositionVertical, - }; - const annotations = []; - - const sharedStyle = { - strokeWidth: yConfig.lineWidth || 1, - stroke: yConfig.color || defaultColor, - dash: - yConfig.lineStyle === 'dashed' - ? [(yConfig.lineWidth || 1) * 3, yConfig.lineWidth || 1] - : yConfig.lineStyle === 'dotted' - ? [yConfig.lineWidth || 1, yConfig.lineWidth || 1] - : undefined, - }; - - annotations.push( - ({ - dataValue: row[yConfig.forAccessor], - header: columnToLabelMap[yConfig.forAccessor], - details: formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], - }))} - domainType={ - yConfig.axisMode === 'bottom' - ? AnnotationDomainType.XDomain - : AnnotationDomainType.YDomain - } - style={{ - line: { - ...sharedStyle, - opacity: 1, - }, - }} - /> - ); - - if (yConfig.fill && yConfig.fill !== 'none') { - const isFillAbove = yConfig.fill === 'above'; - const indexFromSameType = groupedByDirection[yConfig.fill].findIndex( - ({ forAccessor }) => forAccessor === yConfig.forAccessor - ); - const shouldCheckNextReferenceLine = - indexFromSameType < groupedByDirection[yConfig.fill].length - 1; - annotations.push( - { - const nextValue = shouldCheckNextReferenceLine - ? row[groupedByDirection[yConfig.fill!][indexFromSameType + 1].forAccessor] - : undefined; - if (yConfig.axisMode === 'bottom') { - return { - coordinates: { - x0: isFillAbove ? row[yConfig.forAccessor] : nextValue, - y0: undefined, - x1: isFillAbove ? nextValue : row[yConfig.forAccessor], - y1: undefined, - }, - header: columnToLabelMap[yConfig.forAccessor], - details: - formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], - }; - } - return { - coordinates: { - x0: undefined, - y0: isFillAbove ? row[yConfig.forAccessor] : nextValue, - x1: undefined, - y1: isFillAbove ? nextValue : row[yConfig.forAccessor], - }, - header: columnToLabelMap[yConfig.forAccessor], - details: - formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], - }; - })} - style={{ - ...sharedStyle, - fill: yConfig.color || defaultColor, - opacity: 0.1, - }} - /> - ); - } - return annotations; - }); - })} - - ); -}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/index.ts b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/index.ts new file mode 100644 index 0000000000000..62b3b31bf8bd5 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './reference_lines'; +export * from './utils'; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx new file mode 100644 index 0000000000000..74bb18597f2f2 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx @@ -0,0 +1,56 @@ +/* + * 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, { FC } from 'react'; +import { Position } from '@elastic/charts'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { ReferenceLineConfig } from '../../../common/types'; +import { getGroupId } from './utils'; +import { ReferenceLineAnnotations } from './reference_line_annotations'; + +interface ReferenceLineProps { + layer: ReferenceLineConfig; + paddingMap: Partial>; + formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + axesMap: Record<'left' | 'right', boolean>; + isHorizontal: boolean; +} + +export const ReferenceLine: FC = ({ + layer, + axesMap, + formatters, + paddingMap, + isHorizontal, +}) => { + const { + yConfig: [yConfig], + } = layer; + + if (!yConfig) { + return null; + } + + const { axisMode, value } = yConfig; + + // Find the formatter for the given axis + const groupId = getGroupId(axisMode); + + const formatter = formatters[groupId || 'bottom']; + const id = `${layer.layerId}-${value}`; + + return ( + + ); +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_annotations.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_annotations.tsx new file mode 100644 index 0000000000000..b5b94b4c2df51 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_annotations.tsx @@ -0,0 +1,137 @@ +/* + * 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 { AnnotationDomainType, LineAnnotation, Position, RectAnnotation } from '@elastic/charts'; +import { euiLightVars } from '@kbn/ui-theme'; +import React, { FC } from 'react'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { LINES_MARKER_SIZE } from '../../helpers'; +import { + AvailableReferenceLineIcon, + FillStyle, + IconPosition, + LineStyle, + YAxisMode, +} from '../../../common/types'; +import { + getBaseIconPlacement, + getBottomRect, + getGroupId, + getHorizontalRect, + getLineAnnotationProps, + getSharedStyle, +} from './utils'; + +export interface ReferenceLineAnnotationConfig { + id: string; + name?: string; + value: number; + nextValue?: number; + icon?: AvailableReferenceLineIcon; + lineWidth?: number; + lineStyle?: LineStyle; + fill?: FillStyle; + iconPosition?: IconPosition; + textVisibility?: boolean; + axisMode?: YAxisMode; + color?: string; +} + +interface Props { + config: ReferenceLineAnnotationConfig; + paddingMap: Partial>; + formatter?: FieldFormat; + axesMap: Record<'left' | 'right', boolean>; + isHorizontal: boolean; +} + +const getRectDataValue = ( + annotationConfig: ReferenceLineAnnotationConfig, + formatter: FieldFormat | undefined +) => { + const { name, value, nextValue, fill, axisMode } = annotationConfig; + const isFillAbove = fill === 'above'; + + if (axisMode === 'bottom') { + return getBottomRect(name, isFillAbove, formatter, value, nextValue); + } + + return getHorizontalRect(name, isFillAbove, formatter, value, nextValue); +}; + +export const ReferenceLineAnnotations: FC = ({ + config, + axesMap, + formatter, + paddingMap, + isHorizontal, +}) => { + const { id, axisMode, iconPosition, name, textVisibility, value, fill, color } = config; + + // Find the formatter for the given axis + const groupId = getGroupId(axisMode); + const defaultColor = euiLightVars.euiColorDarkShade; + // get the position for vertical chart + const markerPositionVertical = getBaseIconPlacement(iconPosition, axesMap, axisMode); + // the padding map is built for vertical chart + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; + + const props = getLineAnnotationProps( + config, + { + markerLabel: name, + markerBodyLabel: textVisibility && !hasReducedPadding ? name : undefined, + }, + axesMap, + paddingMap, + groupId, + isHorizontal + ); + + const sharedStyle = getSharedStyle(config); + + const dataValues = { + dataValue: value, + header: name, + details: formatter?.convert(value) || value.toString(), + }; + + const line = ( + + ); + + let rect; + if (fill && fill !== 'none') { + const rectDataValues = getRectDataValue(config, formatter); + + rect = ( + + ); + } + return ( + <> + {line} + {rect} + + ); +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_layer.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_layer.tsx new file mode 100644 index 0000000000000..210f5bda0126b --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_layer.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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, { FC } from 'react'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { groupBy } from 'lodash'; +import { Position } from '@elastic/charts'; +import { ReferenceLineLayerConfig } from '../../../common/types'; +import { getGroupId } from './utils'; +import { ReferenceLineAnnotations } from './reference_line_annotations'; + +interface ReferenceLineLayerProps { + layer: ReferenceLineLayerConfig; + formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + paddingMap: Partial>; + axesMap: Record<'left' | 'right', boolean>; + isHorizontal: boolean; +} + +export const ReferenceLineLayer: FC = ({ + layer, + formatters, + paddingMap, + axesMap, + isHorizontal, +}) => { + if (!layer.yConfig) { + return null; + } + + const { columnToLabel, yConfig: yConfigs, table } = layer; + const columnToLabelMap: Record = columnToLabel ? JSON.parse(columnToLabel) : {}; + + const row = table.rows[0]; + + const yConfigByValue = yConfigs.sort( + ({ forAccessor: idA }, { forAccessor: idB }) => row[idA] - row[idB] + ); + + const groupedByDirection = groupBy(yConfigByValue, 'fill'); + if (groupedByDirection.below) { + groupedByDirection.below.reverse(); + } + + const referenceLineElements = yConfigByValue.flatMap((yConfig) => { + const { axisMode } = yConfig; + + // Find the formatter for the given axis + const groupId = getGroupId(axisMode); + + const formatter = formatters[groupId || 'bottom']; + const name = columnToLabelMap[yConfig.forAccessor]; + const value = row[yConfig.forAccessor]; + const yConfigsWithSameDirection = groupedByDirection[yConfig.fill!]; + const indexFromSameType = yConfigsWithSameDirection.findIndex( + ({ forAccessor }) => forAccessor === yConfig.forAccessor + ); + + const shouldCheckNextReferenceLine = indexFromSameType < yConfigsWithSameDirection.length - 1; + + const nextValue = shouldCheckNextReferenceLine + ? row[yConfigsWithSameDirection[indexFromSameType + 1].forAccessor] + : undefined; + + const { forAccessor, type, ...restAnnotationConfig } = yConfig; + const id = `${layer.layerId}-${yConfig.forAccessor}`; + + return ( + + ); + }); + + return <>{referenceLineElements}; +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.scss b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.scss similarity index 100% rename from src/plugins/chart_expressions/expression_xy/public/components/reference_lines.scss rename to src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.scss diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.test.tsx new file mode 100644 index 0000000000000..35e434d65bc18 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.test.tsx @@ -0,0 +1,683 @@ +/* + * 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 { LineAnnotation, RectAnnotation } from '@elastic/charts'; +import { shallow } from 'enzyme'; +import React from 'react'; +import { Datatable } from '@kbn/expressions-plugin/common'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { LayerTypes } from '../../../common/constants'; +import { + ReferenceLineLayerArgs, + ReferenceLineLayerConfig, + ExtendedYConfig, + ReferenceLineArgs, + ReferenceLineConfig, +} from '../../../common/types'; +import { ReferenceLines, ReferenceLinesProps } from './reference_lines'; +import { ReferenceLineLayer } from './reference_line_layer'; +import { ReferenceLine } from './reference_line'; +import { ReferenceLineAnnotations } from './reference_line_annotations'; + +const row: Record = { + xAccessorFirstId: 1, + xAccessorSecondId: 2, + yAccessorLeftFirstId: 5, + yAccessorLeftSecondId: 10, + yAccessorRightFirstId: 5, + yAccessorRightSecondId: 10, +}; + +const data: Datatable = { + type: 'datatable', + rows: [row], + columns: Object.keys(row).map((id) => ({ + id, + name: `Static value: ${row[id]}`, + meta: { + type: 'number', + params: { id: 'number' }, + }, + })), +}; + +function createLayers(yConfigs: ReferenceLineLayerArgs['yConfig']): ReferenceLineLayerConfig[] { + return [ + { + layerId: 'first', + accessors: (yConfigs || []).map(({ forAccessor }) => forAccessor), + yConfig: yConfigs, + type: 'referenceLineLayer', + layerType: LayerTypes.REFERENCELINE, + table: data, + }, + ]; +} + +function createReferenceLine( + layerId: string, + lineLength: number, + args: ReferenceLineArgs +): ReferenceLineConfig { + return { + layerId, + type: 'referenceLine', + layerType: 'referenceLine', + lineLength, + yConfig: [{ type: 'referenceLineYConfig', ...args }], + }; +} + +interface YCoords { + y0: number | undefined; + y1: number | undefined; +} +interface XCoords { + x0: number | undefined; + x1: number | undefined; +} + +function getAxisFromId(layerPrefix: string): ExtendedYConfig['axisMode'] { + return /left/i.test(layerPrefix) ? 'left' : /right/i.test(layerPrefix) ? 'right' : 'bottom'; +} + +const emptyCoords = { x0: undefined, x1: undefined, y0: undefined, y1: undefined }; + +describe('ReferenceLines', () => { + describe('referenceLineLayers', () => { + let formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + let defaultProps: Omit; + + beforeEach(() => { + formatters = { + left: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + right: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + bottom: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + }; + + defaultProps = { + formatters, + isHorizontal: false, + axesMap: { left: true, right: false }, + paddingMap: {}, + }; + }); + + it.each([ + ['yAccessorLeft', 'above'], + ['yAccessorLeft', 'below'], + ['yAccessorRight', 'above'], + ['yAccessorRight', 'below'], + ] as Array<[string, ExtendedYConfig['fill']]>)( + 'should render a RectAnnotation for a reference line with fill set: %s %s', + (layerPrefix, fill) => { + const axisMode = getAxisFromId(layerPrefix); + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + + const y0 = fill === 'above' ? 5 : undefined; + const y1 = fill === 'above' ? undefined : 5; + + const referenceLineAnnotation = referenceLineLayer.find(ReferenceLineAnnotations).dive(); + expect(referenceLineAnnotation.find(LineAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { x0: undefined, x1: undefined, y0, y1 }, + details: y0 ?? y1, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['xAccessor', 'above'], + ['xAccessor', 'below'], + ] as Array<[string, ExtendedYConfig['fill']]>)( + 'should render a RectAnnotation for a reference line with fill set: %s %s', + (layerPrefix, fill) => { + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + + const x0 = fill === 'above' ? 1 : undefined; + const x1 = fill === 'above' ? undefined : 1; + + const referenceLineAnnotation = referenceLineLayer.find(ReferenceLineAnnotations).dive(); + expect(referenceLineAnnotation.find(LineAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, x0, x1 }, + details: x0 ?? x1, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['yAccessorLeft', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], + ['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], + ['yAccessorRight', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], + ['yAccessorRight', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], + ] as Array<[string, ExtendedYConfig['fill'], YCoords, YCoords]>)( + 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', + (layerPrefix, fill, coordsA, coordsB) => { + const axisMode = getAxisFromId(layerPrefix); + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + const referenceLineAnnotations = referenceLineLayer.find(ReferenceLineAnnotations); + + expect( + referenceLineAnnotations.first().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: coordsA.y0 ?? coordsA.y1, + header: undefined, + }, + ]) + ); + expect( + referenceLineAnnotations.last().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: coordsB.y1 ?? coordsB.y0, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['xAccessor', 'above', { x0: 1, x1: 2 }, { x0: 2, x1: undefined }], + ['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: 1, x1: 2 }], + ] as Array<[string, ExtendedYConfig['fill'], XCoords, XCoords]>)( + 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', + (layerPrefix, fill, coordsA, coordsB) => { + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + const referenceLineAnnotations = referenceLineLayer.find(ReferenceLineAnnotations); + + expect( + referenceLineAnnotations.first().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: coordsA.x0 ?? coordsA.x1, + header: undefined, + }, + ]) + ); + expect( + referenceLineAnnotations.last().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: coordsB.x1 ?? coordsB.x0, + header: undefined, + }, + ]) + ); + } + ); + + it.each(['yAccessorLeft', 'yAccessorRight', 'xAccessor'])( + 'should let areas in different directions overlap: %s', + (layerPrefix) => { + const axisMode = getAxisFromId(layerPrefix); + + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + const referenceLineAnnotations = referenceLineLayer.find(ReferenceLineAnnotations); + + expect( + referenceLineAnnotations.first().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...(axisMode === 'bottom' ? { x0: 1 } : { y0: 5 }) }, + details: axisMode === 'bottom' ? 1 : 5, + header: undefined, + }, + ]) + ); + expect( + referenceLineAnnotations.last().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...(axisMode === 'bottom' ? { x1: 2 } : { y1: 10 }) }, + details: axisMode === 'bottom' ? 2 : 10, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], + ['below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], + ] as Array<[ExtendedYConfig['fill'], YCoords, YCoords]>)( + 'should be robust and works also for different axes when on same direction: 1x Left + 1x Right both %s', + (fill, coordsA, coordsB) => { + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + const referenceLineAnnotations = referenceLineLayer.find(ReferenceLineAnnotations); + + expect( + referenceLineAnnotations.first().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: coordsA.y0 ?? coordsA.y1, + header: undefined, + }, + ]) + ); + expect( + referenceLineAnnotations.last().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: coordsB.y1 ?? coordsB.y0, + header: undefined, + }, + ]) + ); + } + ); + }); + + describe('referenceLines', () => { + let formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + let defaultProps: Omit; + + beforeEach(() => { + formatters = { + left: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + right: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + bottom: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + }; + + defaultProps = { + formatters, + isHorizontal: false, + axesMap: { left: true, right: false }, + paddingMap: {}, + }; + }); + + it.each([ + ['yAccessorLeft', 'above'], + ['yAccessorLeft', 'below'], + ['yAccessorRight', 'above'], + ['yAccessorRight', 'below'], + ] as Array<[string, ExtendedYConfig['fill']]>)( + 'should render a RectAnnotation for a reference line with fill set: %s %s', + (layerPrefix, fill) => { + const axisMode = getAxisFromId(layerPrefix); + const value = 5; + const wrapper = shallow( + + ); + const referenceLine = wrapper.find(ReferenceLine).dive(); + const referenceLineAnnotation = referenceLine.find(ReferenceLineAnnotations).dive(); + + const y0 = fill === 'above' ? value : undefined; + const y1 = fill === 'above' ? undefined : value; + + expect(referenceLineAnnotation.find(LineAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { x0: undefined, x1: undefined, y0, y1 }, + details: y0 ?? y1, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['xAccessor', 'above'], + ['xAccessor', 'below'], + ] as Array<[string, ExtendedYConfig['fill']]>)( + 'should render a RectAnnotation for a reference line with fill set: %s %s', + (layerPrefix, fill) => { + const value = 1; + const wrapper = shallow( + + ); + const referenceLine = wrapper.find(ReferenceLine).dive(); + const referenceLineAnnotation = referenceLine.find(ReferenceLineAnnotations).dive(); + + const x0 = fill === 'above' ? value : undefined; + const x1 = fill === 'above' ? undefined : value; + + expect(referenceLineAnnotation.find(LineAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, x0, x1 }, + details: x0 ?? x1, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['yAccessorLeft', 'above', { y0: 10, y1: undefined }, { y0: 10, y1: undefined }], + ['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: undefined, y1: 5 }], + ] as Array<[string, ExtendedYConfig['fill'], YCoords, YCoords]>)( + 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', + (layerPrefix, fill, coordsA, coordsB) => { + const axisMode = getAxisFromId(layerPrefix); + const value = coordsA.y0 ?? coordsA.y1!; + const wrapper = shallow( + + ); + const referenceLine = wrapper.find(ReferenceLine).first().dive(); + const referenceLineAnnotation = referenceLine.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation.find(RectAnnotation).first().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: value, + header: undefined, + }, + ]) + ); + expect(referenceLineAnnotation.find(RectAnnotation).last().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: value, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['xAccessor', 'above', { x0: 1, x1: undefined }, { x0: 1, x1: undefined }], + ['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: undefined, x1: 1 }], + ] as Array<[string, ExtendedYConfig['fill'], XCoords, XCoords]>)( + 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', + (layerPrefix, fill, coordsA, coordsB) => { + const value = coordsA.x0 ?? coordsA.x1!; + const wrapper = shallow( + + ); + const referenceLine1 = wrapper.find(ReferenceLine).first().dive(); + const referenceLineAnnotation1 = referenceLine1.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation1.find(RectAnnotation).first().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: value, + header: undefined, + }, + ]) + ); + + const referenceLine2 = wrapper.find(ReferenceLine).last().dive(); + const referenceLineAnnotation2 = referenceLine2.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation2.find(RectAnnotation).last().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: value, + header: undefined, + }, + ]) + ); + } + ); + + it.each(['yAccessorLeft', 'yAccessorRight', 'xAccessor'])( + 'should let areas in different directions overlap: %s', + (layerPrefix) => { + const axisMode = getAxisFromId(layerPrefix); + const value1 = 1; + const value2 = 10; + const wrapper = shallow( + + ); + const referenceLine1 = wrapper.find(ReferenceLine).first().dive(); + const referenceLineAnnotation1 = referenceLine1.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation1.find(RectAnnotation).first().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { + ...emptyCoords, + ...(axisMode === 'bottom' ? { x0: value1 } : { y0: value1 }), + }, + details: value1, + header: undefined, + }, + ]) + ); + + const referenceLine2 = wrapper.find(ReferenceLine).last().dive(); + const referenceLineAnnotation2 = referenceLine2.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation2.find(RectAnnotation).last().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { + ...emptyCoords, + ...(axisMode === 'bottom' ? { x1: value2 } : { y1: value2 }), + }, + details: value2, + header: undefined, + }, + ]) + ); + } + ); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx new file mode 100644 index 0000000000000..9dca7b6107072 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx @@ -0,0 +1,79 @@ +/* + * 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 './reference_lines.scss'; + +import React from 'react'; +import { Position } from '@elastic/charts'; +import type { FieldFormat } from '@kbn/field-formats-plugin/common'; +import type { CommonXYReferenceLineLayerConfig } from '../../../common/types'; +import { isReferenceLine, mapVerticalToHorizontalPlacement } from '../../helpers'; +import { ReferenceLineLayer } from './reference_line_layer'; +import { ReferenceLine } from './reference_line'; + +export const computeChartMargins = ( + referenceLinePaddings: Partial>, + labelVisibility: Partial>, + titleVisibility: Partial>, + axesMap: Record<'left' | 'right', unknown>, + isHorizontal: boolean +) => { + const result: Partial> = {}; + if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; + result[placement] = referenceLinePaddings.bottom; + } + if ( + referenceLinePaddings.left && + (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; + result[placement] = referenceLinePaddings.left; + } + if ( + referenceLinePaddings.right && + (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; + result[placement] = referenceLinePaddings.right; + } + // there's no top axis, so just check if a margin has been computed + if (referenceLinePaddings.top) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; + result[placement] = referenceLinePaddings.top; + } + return result; +}; + +export interface ReferenceLinesProps { + layers: CommonXYReferenceLineLayerConfig[]; + formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + axesMap: Record<'left' | 'right', boolean>; + isHorizontal: boolean; + paddingMap: Partial>; +} + +export const ReferenceLines = ({ layers, ...rest }: ReferenceLinesProps) => { + return ( + <> + {layers.flatMap((layer) => { + if (!layer.yConfig) { + return null; + } + + if (isReferenceLine(layer)) { + return ; + } + + return ( + + ); + })} + + ); +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx new file mode 100644 index 0000000000000..1a6eae6a490e6 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx @@ -0,0 +1,143 @@ +/* + * 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 { Position } from '@elastic/charts'; +import { euiLightVars } from '@kbn/ui-theme'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { IconPosition, YAxisMode } from '../../../common/types'; +import { + LINES_MARKER_SIZE, + mapVerticalToHorizontalPlacement, + Marker, + MarkerBody, +} from '../../helpers'; +import { ReferenceLineAnnotationConfig } from './reference_line_annotations'; + +// if there's just one axis, put it on the other one +// otherwise use the same axis +// this function assume the chart is vertical +export function getBaseIconPlacement( + iconPosition: IconPosition | undefined, + axesMap?: Record, + axisMode?: YAxisMode +) { + if (iconPosition === 'auto') { + if (axisMode === 'bottom') { + return Position.Top; + } + if (axesMap) { + if (axisMode === 'left') { + return axesMap.right ? Position.Left : Position.Right; + } + return axesMap.left ? Position.Right : Position.Left; + } + } + + if (iconPosition === 'left') { + return Position.Left; + } + if (iconPosition === 'right') { + return Position.Right; + } + if (iconPosition === 'below') { + return Position.Bottom; + } + return Position.Top; +} + +export const getSharedStyle = (config: ReferenceLineAnnotationConfig) => ({ + strokeWidth: config.lineWidth || 1, + stroke: config.color || euiLightVars.euiColorDarkShade, + dash: + config.lineStyle === 'dashed' + ? [(config.lineWidth || 1) * 3, config.lineWidth || 1] + : config.lineStyle === 'dotted' + ? [config.lineWidth || 1, config.lineWidth || 1] + : undefined, +}); + +export const getLineAnnotationProps = ( + config: ReferenceLineAnnotationConfig, + labels: { markerLabel?: string; markerBodyLabel?: string }, + axesMap: Record<'left' | 'right', boolean>, + paddingMap: Partial>, + groupId: 'left' | 'right' | undefined, + isHorizontal: boolean +) => { + // get the position for vertical chart + const markerPositionVertical = getBaseIconPlacement( + config.iconPosition, + axesMap, + config.axisMode + ); + // the padding map is built for vertical chart + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; + + return { + groupId, + marker: ( + + ), + markerBody: ( + + ), + // rotate the position if required + markerPosition: isHorizontal + ? mapVerticalToHorizontalPlacement(markerPositionVertical) + : markerPositionVertical, + }; +}; + +export const getGroupId = (axisMode: YAxisMode | undefined) => + axisMode === 'bottom' ? undefined : axisMode === 'right' ? 'right' : 'left'; + +export const getBottomRect = ( + headerLabel: string | undefined, + isFillAbove: boolean, + formatter: FieldFormat | undefined, + currentValue: number, + nextValue?: number +) => ({ + coordinates: { + x0: isFillAbove ? currentValue : nextValue, + y0: undefined, + x1: isFillAbove ? nextValue : currentValue, + y1: undefined, + }, + header: headerLabel, + details: formatter?.convert(currentValue) || currentValue.toString(), +}); + +export const getHorizontalRect = ( + headerLabel: string | undefined, + isFillAbove: boolean, + formatter: FieldFormat | undefined, + currentValue: number, + nextValue?: number +) => ({ + coordinates: { + x0: undefined, + y0: isFillAbove ? currentValue : nextValue, + x1: undefined, + y1: isFillAbove ? nextValue : currentValue, + }, + header: headerLabel, + details: formatter?.convert(currentValue) || currentValue.toString(), +}); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index 9bb3ea4f498e4..80048bcb84038 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -42,14 +42,24 @@ import { LegendSizeToPixels, } from '@kbn/visualizations-plugin/common/constants'; import type { FilterEvent, BrushEvent, FormatFactory } from '../types'; -import type { CommonXYDataLayerConfig, SeriesType, XYChartProps } from '../../common/types'; +import type { + CommonXYDataLayerConfig, + ExtendedYConfig, + ReferenceLineYConfig, + SeriesType, + XYChartProps, +} from '../../common/types'; import { isHorizontalChart, getAnnotationsLayers, getDataLayers, Series, getFormat, + isReferenceLineYConfig, getFormattedTablesByLayers, +} from '../helpers'; + +import { getFilteredLayers, getReferenceLayers, isDataLayer, @@ -60,7 +70,7 @@ import { } from '../helpers'; import { getXDomain, XyEndzones } from './x_domain'; import { getLegendAction } from './legend_action'; -import { ReferenceLineAnnotations, computeChartMargins } from './reference_lines'; +import { ReferenceLines, computeChartMargins } from './reference_lines'; import { visualizationDefinitions } from '../definitions'; import { CommonXYLayerConfig } from '../../common/types'; import { SplitChart } from './split_chart'; @@ -270,6 +280,7 @@ export function XYChart({ }; const referenceLineLayers = getReferenceLayers(layers); + const annotationsLayers = getAnnotationsLayers(layers); const firstTable = dataLayers[0]?.table; @@ -286,7 +297,9 @@ export function XYChart({ const rangeAnnotations = getRangeAnnotations(annotationsLayers); const visualConfigs = [ - ...referenceLineLayers.flatMap(({ yConfig }) => yConfig), + ...referenceLineLayers.flatMap( + ({ yConfig }) => yConfig + ), ...groupedLineAnnotations, ].filter(Boolean); @@ -364,9 +377,10 @@ export function XYChart({ l.yConfig ? l.yConfig.map((yConfig) => ({ layerId: l.layerId, yConfig })) : [] ) .filter(({ yConfig }) => yConfig.axisMode === axis.groupId) - .map( - ({ layerId, yConfig }) => - `${layerId}-${yConfig.forAccessor}-${yConfig.fill !== 'none' ? 'rect' : 'line'}` + .map(({ layerId, yConfig }) => + isReferenceLineYConfig(yConfig) + ? `${layerId}-${yConfig.value}-${yConfig.fill !== 'none' ? 'rect' : 'line'}` + : `${layerId}-${yConfig.forAccessor}-${yConfig.fill !== 'none' ? 'rect' : 'line'}` ), }; }; @@ -668,7 +682,7 @@ export function XYChart({ /> )} {referenceLineLayers.length ? ( - ( - (layer): layer is CommonXYReferenceLineLayerConfig | CommonXYDataLayerConfig => { + return layers.filter( + (layer): layer is ReferenceLineLayerConfig | CommonXYDataLayerConfig => { let table: Datatable | undefined; let accessors: Array = []; let xAccessor: undefined | string | number; diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/state.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/state.ts index e2f95491dbce8..900cba4784853 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/state.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/state.ts @@ -7,7 +7,7 @@ */ import type { CommonXYLayerConfig, SeriesType, ExtendedYConfig, YConfig } from '../../common'; -import { getDataLayers, isAnnotationsLayer, isDataLayer } from './visualization'; +import { getDataLayers, isAnnotationsLayer, isDataLayer, isReferenceLine } from './visualization'; export function isHorizontalSeries(seriesType: SeriesType) { return ( @@ -26,7 +26,11 @@ export function isHorizontalChart(layers: CommonXYLayerConfig[]) { } export const getSeriesColor = (layer: CommonXYLayerConfig, accessor: string) => { - if ((isDataLayer(layer) && layer.splitAccessor) || isAnnotationsLayer(layer)) { + if ( + (isDataLayer(layer) && layer.splitAccessor) || + isAnnotationsLayer(layer) || + isReferenceLine(layer) + ) { return null; } const yConfig: Array | undefined = layer?.yConfig; diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/visualization.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/visualization.ts index db0b431d56fac..480fa5374238e 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/visualization.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/visualization.ts @@ -6,12 +6,21 @@ * Side Public License, v 1. */ -import { LayerTypes } from '../../common/constants'; +import { + LayerTypes, + REFERENCE_LINE, + REFERENCE_LINE_LAYER, + REFERENCE_LINE_Y_CONFIG, +} from '../../common/constants'; import { CommonXYLayerConfig, CommonXYDataLayerConfig, CommonXYReferenceLineLayerConfig, CommonXYAnnotationLayerConfig, + ReferenceLineLayerConfig, + ReferenceLineConfig, + ExtendedYConfigResult, + ReferenceLineYConfig, } from '../../common/types'; export const isDataLayer = (layer: CommonXYLayerConfig): layer is CommonXYDataLayerConfig => @@ -20,13 +29,24 @@ export const isDataLayer = (layer: CommonXYLayerConfig): layer is CommonXYDataLa export const getDataLayers = (layers: CommonXYLayerConfig[]) => (layers || []).filter((layer): layer is CommonXYDataLayerConfig => isDataLayer(layer)); -export const isReferenceLayer = ( +export const isReferenceLayer = (layer: CommonXYLayerConfig): layer is ReferenceLineLayerConfig => + layer.layerType === LayerTypes.REFERENCELINE && layer.type === REFERENCE_LINE_LAYER; + +export const isReferenceLine = (layer: CommonXYLayerConfig): layer is ReferenceLineConfig => + layer.type === REFERENCE_LINE; + +export const isReferenceLineYConfig = ( + yConfig: ReferenceLineYConfig | ExtendedYConfigResult +): yConfig is ReferenceLineYConfig => yConfig.type === REFERENCE_LINE_Y_CONFIG; + +export const isReferenceLineOrLayer = ( layer: CommonXYLayerConfig ): layer is CommonXYReferenceLineLayerConfig => layer.layerType === LayerTypes.REFERENCELINE; export const getReferenceLayers = (layers: CommonXYLayerConfig[]) => - (layers || []).filter((layer): layer is CommonXYReferenceLineLayerConfig => - isReferenceLayer(layer) + (layers || []).filter( + (layer): layer is CommonXYReferenceLineLayerConfig => + isReferenceLayer(layer) || isReferenceLine(layer) ); const isAnnotationLayerCommon = ( diff --git a/src/plugins/chart_expressions/expression_xy/public/plugin.ts b/src/plugins/chart_expressions/expression_xy/public/plugin.ts index 5c27da6b82b28..0dc6f62df3183 100755 --- a/src/plugins/chart_expressions/expression_xy/public/plugin.ts +++ b/src/plugins/chart_expressions/expression_xy/public/plugin.ts @@ -24,8 +24,8 @@ import { gridlinesConfigFunction, axisExtentConfigFunction, tickLabelsConfigFunction, + referenceLineFunction, referenceLineLayerFunction, - extendedReferenceLineLayerFunction, annotationLayerFunction, labelsOrientationConfigFunction, axisTitlesVisibilityConfigFunction, @@ -64,8 +64,8 @@ export class ExpressionXyPlugin { expressions.registerFunction(annotationLayerFunction); expressions.registerFunction(extendedAnnotationLayerFunction); expressions.registerFunction(labelsOrientationConfigFunction); + expressions.registerFunction(referenceLineFunction); expressions.registerFunction(referenceLineLayerFunction); - expressions.registerFunction(extendedReferenceLineLayerFunction); expressions.registerFunction(axisTitlesVisibilityConfigFunction); expressions.registerFunction(xyVisFunction); expressions.registerFunction(layeredXyVisFunction); diff --git a/src/plugins/chart_expressions/expression_xy/server/plugin.ts b/src/plugins/chart_expressions/expression_xy/server/plugin.ts index cefde5d38a5f4..4ddac2b3a3f79 100755 --- a/src/plugins/chart_expressions/expression_xy/server/plugin.ts +++ b/src/plugins/chart_expressions/expression_xy/server/plugin.ts @@ -19,10 +19,10 @@ import { tickLabelsConfigFunction, annotationLayerFunction, labelsOrientationConfigFunction, - referenceLineLayerFunction, + referenceLineFunction, axisTitlesVisibilityConfigFunction, extendedDataLayerFunction, - extendedReferenceLineLayerFunction, + referenceLineLayerFunction, layeredXyVisFunction, extendedAnnotationLayerFunction, } from '../common/expression_functions'; @@ -42,8 +42,8 @@ export class ExpressionXyPlugin expressions.registerFunction(annotationLayerFunction); expressions.registerFunction(extendedAnnotationLayerFunction); expressions.registerFunction(labelsOrientationConfigFunction); + expressions.registerFunction(referenceLineFunction); expressions.registerFunction(referenceLineLayerFunction); - expressions.registerFunction(extendedReferenceLineLayerFunction); expressions.registerFunction(axisTitlesVisibilityConfigFunction); expressions.registerFunction(xyVisFunction); expressions.registerFunction(layeredXyVisFunction); 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 cb6e6cff2d70e..ff5a692a76e96 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -356,7 +356,7 @@ const referenceLineLayerToExpression = ( chain: [ { type: 'function', - function: 'extendedReferenceLineLayer', + function: 'referenceLineLayer', arguments: { layerId: [layer.layerId], yConfig: layer.yConfig From 24bdc97413fbdd749db4d007ccf9f06cc1a243c8 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Fri, 20 May 2022 10:45:50 +0200 Subject: [PATCH 090/150] [ML] Explain log rate spikes: Page setup (#132121) Builds out UI/code boilerplate necessary before we start implementing the feature's own UI on a dedicated page. - Updates navigation to bring up data view/saved search selection before moving on to the explain log spike rates page. The bar chart race demo page was moved to the aiops/single_endpoint_streaming_demo url. It is kept in this PR so we have two different pages + API endpoints that use streaming. With this still in place it's easier to update the streaming code to be more generic and reusable. - The url/page aiops/explain_log_rate_spikes has been added with some dummy request that slowly streams a data view's fields to the client. This page will host the actual UI to be brought over from the PoC in follow ups to this PR. - The structure to embed aiops plugin pages in the ml plugin has been simplified. Instead of a lot of custom code to load the components at runtime in the aiops plugin itself, this now uses React lazy loading with Suspense, similar to how we load Vega charts in other places. We no longer initialize the aiops client side code during startup of the plugin itself and augment it, instead we statically import components and pass on props/contexts from the ml plugin. - The code to handle streaming chunks on the client side in stream_fetch.ts/use_stream_fetch_reducer.ts has been improved to make better use of TS generics so for a given API endpoint it's able to return the appropriate coresponding return data type and only allows to use the supported reducer actions for that endpoint. Buffering client side actions has been tweaked to handle state updates more quickly if updates from the server are stalling. --- .../aiops/common/api/example_stream.ts | 5 +- .../common/api/explain_log_rate_spikes.ts | 34 ++++ x-pack/plugins/aiops/common/api/index.ts | 15 +- x-pack/plugins/aiops/kibana.json | 2 +- x-pack/plugins/aiops/public/api/index.ts | 15 -- .../plugins/aiops/public/components/app.tsx | 167 ------------------ .../components/explain_log_rate_spikes.tsx | 34 ---- .../explain_log_rate_spikes.tsx | 49 +++++ .../explain_log_rate_spikes/index.ts | 13 ++ .../explain_log_rate_spikes/stream_reducer.ts | 37 ++++ .../get_status_message.tsx | 0 .../single_endpoint_streaming_demo}/index.ts | 7 +- .../single_endpoint_streaming_demo.tsx | 135 ++++++++++++++ .../stream_reducer.ts | 4 +- .../{components => hooks}/stream_fetch.ts | 35 +++- .../use_stream_fetch_reducer.ts | 23 ++- x-pack/plugins/aiops/public/index.ts | 4 +- .../plugins/aiops/public/kibana_services.ts | 19 -- .../aiops/public/lazy_load_bundle/index.ts | 30 ---- x-pack/plugins/aiops/public/plugin.ts | 11 +- .../aiops/public/shared_lazy_components.tsx | 42 +++++ .../server/lib/accept_compression.test.ts | 42 +++++ .../aiops/server/lib/accept_compression.ts | 44 +++++ .../aiops/server/lib/stream_factory.test.ts | 106 +++++++++++ .../aiops/server/lib/stream_factory.ts | 70 ++++++++ x-pack/plugins/aiops/server/plugin.ts | 27 ++- .../aiops/server/routes/example_stream.ts | 109 ++++++++++++ .../server/routes/explain_log_rate_spikes.ts | 90 ++++++++++ x-pack/plugins/aiops/server/routes/index.ts | 124 +------------ x-pack/plugins/aiops/server/types.ts | 10 ++ x-pack/plugins/ml/common/constants/locator.ts | 2 + x-pack/plugins/ml/common/types/locator.ts | 4 +- .../aiops/explain_log_rate_spikes.tsx | 40 ++--- .../aiops/single_endpoint_streaming_demo.tsx | 34 ++++ .../components/ml_page/side_nav.tsx | 11 +- .../application/contexts/ml/ml_context.ts | 6 +- .../public/application/routing/breadcrumbs.ts | 2 +- .../routes/aiops/explain_log_rate_spikes.tsx | 2 +- .../application/routing/routes/aiops/index.ts | 1 + .../aiops/single_endpoint_streaming_demo.tsx | 63 +++++++ .../routes/new_job/index_or_search.tsx | 30 ++++ .../plugins/ml/public/locator/ml_locator.ts | 2 + .../apis/aiops/example_stream.ts | 29 +-- .../apis/aiops/explain_log_rate_spikes.ts | 126 +++++++++++++ .../test/api_integration/apis/aiops/index.ts | 1 + .../apis/aiops/parse_stream.ts | 28 +++ 46 files changed, 1203 insertions(+), 481 deletions(-) create mode 100644 x-pack/plugins/aiops/common/api/explain_log_rate_spikes.ts delete mode 100644 x-pack/plugins/aiops/public/api/index.ts delete mode 100755 x-pack/plugins/aiops/public/components/app.tsx delete mode 100644 x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx create mode 100644 x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes.tsx create mode 100644 x-pack/plugins/aiops/public/components/explain_log_rate_spikes/index.ts create mode 100644 x-pack/plugins/aiops/public/components/explain_log_rate_spikes/stream_reducer.ts rename x-pack/plugins/aiops/public/components/{ => single_endpoint_streaming_demo}/get_status_message.tsx (100%) rename x-pack/plugins/aiops/public/{lazy_load_bundle/lazy => components/single_endpoint_streaming_demo}/index.ts (52%) create mode 100644 x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/single_endpoint_streaming_demo.tsx rename x-pack/plugins/aiops/public/components/{ => single_endpoint_streaming_demo}/stream_reducer.ts (92%) rename x-pack/plugins/aiops/public/{components => hooks}/stream_fetch.ts (62%) rename x-pack/plugins/aiops/public/{components => hooks}/use_stream_fetch_reducer.ts (77%) delete mode 100644 x-pack/plugins/aiops/public/kibana_services.ts delete mode 100644 x-pack/plugins/aiops/public/lazy_load_bundle/index.ts create mode 100644 x-pack/plugins/aiops/public/shared_lazy_components.tsx create mode 100644 x-pack/plugins/aiops/server/lib/accept_compression.test.ts create mode 100644 x-pack/plugins/aiops/server/lib/accept_compression.ts create mode 100644 x-pack/plugins/aiops/server/lib/stream_factory.test.ts create mode 100644 x-pack/plugins/aiops/server/lib/stream_factory.ts create mode 100644 x-pack/plugins/aiops/server/routes/example_stream.ts create mode 100644 x-pack/plugins/aiops/server/routes/explain_log_rate_spikes.ts create mode 100644 x-pack/plugins/ml/public/application/aiops/single_endpoint_streaming_demo.tsx create mode 100644 x-pack/plugins/ml/public/application/routing/routes/aiops/single_endpoint_streaming_demo.tsx create mode 100644 x-pack/test/api_integration/apis/aiops/explain_log_rate_spikes.ts create mode 100644 x-pack/test/api_integration/apis/aiops/parse_stream.ts diff --git a/x-pack/plugins/aiops/common/api/example_stream.ts b/x-pack/plugins/aiops/common/api/example_stream.ts index 1210cccf55487..ccef04fc8473a 100644 --- a/x-pack/plugins/aiops/common/api/example_stream.ts +++ b/x-pack/plugins/aiops/common/api/example_stream.ts @@ -65,4 +65,7 @@ export function deleteEntityAction(payload: string): ApiActionDeleteEntity { }; } -export type ApiAction = ApiActionUpdateProgress | ApiActionAddToEntity | ApiActionDeleteEntity; +export type AiopsExampleStreamApiAction = + | ApiActionUpdateProgress + | ApiActionAddToEntity + | ApiActionDeleteEntity; diff --git a/x-pack/plugins/aiops/common/api/explain_log_rate_spikes.ts b/x-pack/plugins/aiops/common/api/explain_log_rate_spikes.ts new file mode 100644 index 0000000000000..b5c5524cdef01 --- /dev/null +++ b/x-pack/plugins/aiops/common/api/explain_log_rate_spikes.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const aiopsExplainLogRateSpikesSchema = schema.object({ + /** The index to query for log rate spikes */ + index: schema.string(), +}); + +export type AiopsExplainLogRateSpikesSchema = TypeOf; + +export const API_ACTION_NAME = { + ADD_FIELDS: 'add_fields', +} as const; +export type ApiActionName = typeof API_ACTION_NAME[keyof typeof API_ACTION_NAME]; + +interface ApiActionAddFields { + type: typeof API_ACTION_NAME.ADD_FIELDS; + payload: string[]; +} + +export function addFieldsAction(payload: string[]): ApiActionAddFields { + return { + type: API_ACTION_NAME.ADD_FIELDS, + payload, + }; +} + +export type AiopsExplainLogRateSpikesApiAction = ApiActionAddFields; diff --git a/x-pack/plugins/aiops/common/api/index.ts b/x-pack/plugins/aiops/common/api/index.ts index da1e091d3fb54..6b987fef13d1a 100644 --- a/x-pack/plugins/aiops/common/api/index.ts +++ b/x-pack/plugins/aiops/common/api/index.ts @@ -5,15 +5,24 @@ * 2.0. */ -import type { AiopsExampleStreamSchema } from './example_stream'; +import type { + AiopsExplainLogRateSpikesSchema, + AiopsExplainLogRateSpikesApiAction, +} from './explain_log_rate_spikes'; +import type { AiopsExampleStreamSchema, AiopsExampleStreamApiAction } from './example_stream'; export const API_ENDPOINT = { EXAMPLE_STREAM: '/internal/aiops/example_stream', - ANOTHER: '/internal/aiops/another', + EXPLAIN_LOG_RATE_SPIKES: '/internal/aiops/explain_log_rate_spikes', } as const; export type ApiEndpoint = typeof API_ENDPOINT[keyof typeof API_ENDPOINT]; export interface ApiEndpointOptions { [API_ENDPOINT.EXAMPLE_STREAM]: AiopsExampleStreamSchema; - [API_ENDPOINT.ANOTHER]: { anotherOption: string }; + [API_ENDPOINT.EXPLAIN_LOG_RATE_SPIKES]: AiopsExplainLogRateSpikesSchema; +} + +export interface ApiEndpointActions { + [API_ENDPOINT.EXAMPLE_STREAM]: AiopsExampleStreamApiAction; + [API_ENDPOINT.EXPLAIN_LOG_RATE_SPIKES]: AiopsExplainLogRateSpikesApiAction; } diff --git a/x-pack/plugins/aiops/kibana.json b/x-pack/plugins/aiops/kibana.json index b74a23bf2bc9e..2d1e60bca74e3 100755 --- a/x-pack/plugins/aiops/kibana.json +++ b/x-pack/plugins/aiops/kibana.json @@ -9,7 +9,7 @@ "description": "AIOps plugin maintained by ML team.", "server": true, "ui": true, - "requiredPlugins": [], + "requiredPlugins": ["data"], "optionalPlugins": [], "requiredBundles": ["kibanaReact"], "extraPublicDirs": ["common"] diff --git a/x-pack/plugins/aiops/public/api/index.ts b/x-pack/plugins/aiops/public/api/index.ts deleted file mode 100644 index 6aa171df5286c..0000000000000 --- a/x-pack/plugins/aiops/public/api/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { lazyLoadModules } from '../lazy_load_bundle'; - -import type { ExplainLogRateSpikesSpec } from '../components/explain_log_rate_spikes'; - -export async function getExplainLogRateSpikesComponent(): Promise<() => ExplainLogRateSpikesSpec> { - const modules = await lazyLoadModules(); - return () => modules.ExplainLogRateSpikes; -} diff --git a/x-pack/plugins/aiops/public/components/app.tsx b/x-pack/plugins/aiops/public/components/app.tsx deleted file mode 100755 index 963253b154e27..0000000000000 --- a/x-pack/plugins/aiops/public/components/app.tsx +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useEffect, useState } from 'react'; - -import { Chart, Settings, Axis, BarSeries, Position, ScaleType } from '@elastic/charts'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; - -import { - EuiBadge, - EuiButton, - EuiCheckbox, - EuiFlexGroup, - EuiFlexItem, - EuiPage, - EuiPageBody, - EuiPageContent, - EuiPageContentBody, - EuiPageContentHeader, - EuiProgress, - EuiSpacer, - EuiTitle, - EuiText, -} from '@elastic/eui'; - -import { getStatusMessage } from './get_status_message'; -import { initialState, resetStream, streamReducer } from './stream_reducer'; -import { useStreamFetchReducer } from './use_stream_fetch_reducer'; - -export const AiopsApp = () => { - const { notifications } = useKibana(); - - const [simulateErrors, setSimulateErrors] = useState(false); - - const { dispatch, start, cancel, data, isCancelled, isRunning } = useStreamFetchReducer( - '/internal/aiops/example_stream', - streamReducer, - initialState, - { simulateErrors } - ); - - const { errors, progress, entities } = data; - - const onClickHandler = async () => { - if (isRunning) { - cancel(); - } else { - dispatch(resetStream()); - start(); - } - }; - - useEffect(() => { - if (errors.length > 0) { - notifications.toasts.danger({ body: errors[errors.length - 1] }); - } - }, [errors, notifications.toasts]); - - const buttonLabel = isRunning - ? i18n.translate('xpack.aiops.stopbuttonText', { - defaultMessage: 'Stop development', - }) - : i18n.translate('xpack.aiops.startbuttonText', { - defaultMessage: 'Start development', - }); - - return ( - - - - - -

- -

-
-
- - - - - - {buttonLabel} - - - - - {progress}% - - - - - - - -
- - - - - - { - return { - x, - y, - }; - }) - .sort((a, b) => b.y - a.y)} - /> - -
-

{getStatusMessage(isRunning, isCancelled, data.progress)}

- setSimulateErrors(!simulateErrors)} - compressed - /> -
-
-
-
-
- ); -}; diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx deleted file mode 100644 index 21d7b39a2a148..0000000000000 --- a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FC } from 'react'; - -import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; -import { I18nProvider } from '@kbn/i18n-react'; - -import { getCoreStart } from '../kibana_services'; - -import { AiopsApp } from './app'; - -/** - * Spec used for lazy loading in the ML plugin - */ -export type ExplainLogRateSpikesSpec = typeof ExplainLogRateSpikes; - -export const ExplainLogRateSpikes: FC = () => { - const coreStart = getCoreStart(); - - return ( - - - - - - - - ); -}; diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes.tsx new file mode 100644 index 0000000000000..12c4837194f80 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, FC } from 'react'; + +import { EuiBadge, EuiSpacer, EuiText } from '@elastic/eui'; + +import type { DataView } from '@kbn/data-views-plugin/public'; + +import { useStreamFetchReducer } from '../../hooks/use_stream_fetch_reducer'; + +import { initialState, streamReducer } from './stream_reducer'; + +/** + * ExplainLogRateSpikes props require a data view. + */ +export interface ExplainLogRateSpikesProps { + /** The data view to analyze. */ + dataView: DataView; +} + +export const ExplainLogRateSpikes: FC = ({ dataView }) => { + const { start, data, isRunning } = useStreamFetchReducer( + '/internal/aiops/explain_log_rate_spikes', + streamReducer, + initialState, + { index: dataView.title } + ); + + useEffect(() => { + start(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + +

{dataView.title}

+

{isRunning ? 'Loading fields ...' : 'Loaded all fields.'}

+ + {data.fields.map((field) => ( + {field} + ))} +
+ ); +}; diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/index.ts b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/index.ts new file mode 100644 index 0000000000000..3e48c6816dda9 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { ExplainLogRateSpikesProps } from './explain_log_rate_spikes'; +import { ExplainLogRateSpikes } from './explain_log_rate_spikes'; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default ExplainLogRateSpikes; diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/stream_reducer.ts b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/stream_reducer.ts new file mode 100644 index 0000000000000..7ec710f4ae65d --- /dev/null +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/stream_reducer.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + API_ACTION_NAME, + AiopsExplainLogRateSpikesApiAction, +} from '../../../common/api/explain_log_rate_spikes'; + +interface StreamState { + fields: string[]; +} + +export const initialState: StreamState = { + fields: [], +}; + +export function streamReducer( + state: StreamState, + action: AiopsExplainLogRateSpikesApiAction | AiopsExplainLogRateSpikesApiAction[] +): StreamState { + if (Array.isArray(action)) { + return action.reduce(streamReducer, state); + } + + switch (action.type) { + case API_ACTION_NAME.ADD_FIELDS: + return { + fields: [...state.fields, ...action.payload], + }; + default: + return state; + } +} diff --git a/x-pack/plugins/aiops/public/components/get_status_message.tsx b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/get_status_message.tsx similarity index 100% rename from x-pack/plugins/aiops/public/components/get_status_message.tsx rename to x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/get_status_message.tsx diff --git a/x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/index.ts similarity index 52% rename from x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.ts rename to x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/index.ts index 967525de9bd6e..38eb279568051 100644 --- a/x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.ts +++ b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/index.ts @@ -5,5 +5,8 @@ * 2.0. */ -export type { ExplainLogRateSpikesSpec } from '../../components/explain_log_rate_spikes'; -export { ExplainLogRateSpikes } from '../../components/explain_log_rate_spikes'; +import { SingleEndpointStreamingDemo } from './single_endpoint_streaming_demo'; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default SingleEndpointStreamingDemo; diff --git a/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/single_endpoint_streaming_demo.tsx b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/single_endpoint_streaming_demo.tsx new file mode 100644 index 0000000000000..12f33aada133c --- /dev/null +++ b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/single_endpoint_streaming_demo.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState, FC } from 'react'; + +import { Chart, Settings, Axis, BarSeries, Position, ScaleType } from '@elastic/charts'; + +import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +import { + EuiBadge, + EuiButton, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiProgress, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import { useStreamFetchReducer } from '../../hooks/use_stream_fetch_reducer'; + +import { getStatusMessage } from './get_status_message'; +import { initialState, resetStream, streamReducer } from './stream_reducer'; + +export const SingleEndpointStreamingDemo: FC = () => { + const { notifications } = useKibana(); + + const [simulateErrors, setSimulateErrors] = useState(false); + + const { dispatch, start, cancel, data, isCancelled, isRunning } = useStreamFetchReducer( + '/internal/aiops/example_stream', + streamReducer, + initialState, + { simulateErrors } + ); + + const { errors, progress, entities } = data; + + const onClickHandler = async () => { + if (isRunning) { + cancel(); + } else { + dispatch(resetStream()); + start(); + } + }; + + useEffect(() => { + if (errors.length > 0) { + notifications.toasts.danger({ body: errors[errors.length - 1] }); + } + }, [errors, notifications.toasts]); + + const buttonLabel = isRunning + ? i18n.translate('xpack.aiops.stopbuttonText', { + defaultMessage: 'Stop development', + }) + : i18n.translate('xpack.aiops.startbuttonText', { + defaultMessage: 'Start development', + }); + + return ( + + + + + {buttonLabel} + + + + + {progress}% + + + + + + + +
+ + + + + + { + return { + x, + y, + }; + }) + .sort((a, b) => b.y - a.y)} + /> + +
+

{getStatusMessage(isRunning, isCancelled, data.progress)}

+ setSimulateErrors(!simulateErrors)} + compressed + /> +
+ ); +}; diff --git a/x-pack/plugins/aiops/public/components/stream_reducer.ts b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/stream_reducer.ts similarity index 92% rename from x-pack/plugins/aiops/public/components/stream_reducer.ts rename to x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/stream_reducer.ts index 3e68e139ceeca..a3e9724f24a1f 100644 --- a/x-pack/plugins/aiops/public/components/stream_reducer.ts +++ b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/stream_reducer.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ApiAction, API_ACTION_NAME } from '../../common/api/example_stream'; +import { AiopsExampleStreamApiAction, API_ACTION_NAME } from '../../../common/api/example_stream'; export const UI_ACTION_NAME = { ERROR: 'error', @@ -37,7 +37,7 @@ export function resetStream(): UiActionResetStream { } type UiAction = UiActionResetStream | UiActionError; -export type ReducerAction = ApiAction | UiAction; +export type ReducerAction = AiopsExampleStreamApiAction | UiAction; export function streamReducer( state: StreamState, action: ReducerAction | ReducerAction[] diff --git a/x-pack/plugins/aiops/public/components/stream_fetch.ts b/x-pack/plugins/aiops/public/hooks/stream_fetch.ts similarity index 62% rename from x-pack/plugins/aiops/public/components/stream_fetch.ts rename to x-pack/plugins/aiops/public/hooks/stream_fetch.ts index 37d7c13dd3b55..abfec63702012 100644 --- a/x-pack/plugins/aiops/public/components/stream_fetch.ts +++ b/x-pack/plugins/aiops/public/hooks/stream_fetch.ts @@ -7,14 +7,19 @@ import type React from 'react'; -import type { ApiEndpoint, ApiEndpointOptions } from '../../common/api'; +import type { ApiEndpoint, ApiEndpointActions, ApiEndpointOptions } from '../../common/api'; -export async function* streamFetch( +interface ErrorAction { + type: 'error'; + payload: string; +} + +export async function* streamFetch( endpoint: E, abortCtrl: React.MutableRefObject, - options: ApiEndpointOptions[ApiEndpoint], + options: ApiEndpointOptions[E], basePath = '' -) { +): AsyncGenerator> { const stream = await fetch(`${basePath}${endpoint}`, { signal: abortCtrl.current.signal, method: 'POST', @@ -36,7 +41,7 @@ export async function* streamFetch( const bufferBounce = 100; let partial = ''; - let actionBuffer: A[] = []; + let actionBuffer: Array = []; let lastCall = 0; while (true) { @@ -52,7 +57,7 @@ export async function* streamFetch( partial = last ?? ''; - const actions = parts.map((p) => JSON.parse(p)); + const actions = parts.map((p) => JSON.parse(p)) as Array; actionBuffer.push(...actions); const now = Date.now(); @@ -61,10 +66,26 @@ export async function* streamFetch( yield actionBuffer; actionBuffer = []; lastCall = now; + + // In cases where the next chunk takes longer to be received than the `bufferBounce` timeout, + // we trigger this client side timeout to clear a potential intermediate buffer state. + // Since `yield` cannot be passed on to other scopes like callbacks, + // this pattern using a Promise is used to wait for the timeout. + yield new Promise>((resolve) => { + setTimeout(() => { + if (actionBuffer.length > 0) { + resolve(actionBuffer); + actionBuffer = []; + lastCall = now; + } else { + resolve([]); + } + }, bufferBounce + 10); + }); } } catch (error) { if (error.name !== 'AbortError') { - yield { type: 'error', payload: error.toString() }; + yield [{ type: 'error', payload: error.toString() }]; } break; } diff --git a/x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts b/x-pack/plugins/aiops/public/hooks/use_stream_fetch_reducer.ts similarity index 77% rename from x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts rename to x-pack/plugins/aiops/public/hooks/use_stream_fetch_reducer.ts index 77ac09e0ff429..ba64831bec60e 100644 --- a/x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts +++ b/x-pack/plugins/aiops/public/hooks/use_stream_fetch_reducer.ts @@ -5,7 +5,15 @@ * 2.0. */ -import { useReducer, useRef, useState, Reducer, ReducerAction, ReducerState } from 'react'; +import { + useEffect, + useReducer, + useRef, + useState, + Reducer, + ReducerAction, + ReducerState, +} from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; @@ -13,11 +21,11 @@ import type { ApiEndpoint, ApiEndpointOptions } from '../../common/api'; import { streamFetch } from './stream_fetch'; -export const useStreamFetchReducer = , E = ApiEndpoint>( +export const useStreamFetchReducer = , E extends ApiEndpoint>( endpoint: E, reducer: R, initialState: ReducerState, - options: ApiEndpointOptions[ApiEndpoint] + options: ApiEndpointOptions[E] ) => { const kibana = useKibana(); @@ -44,7 +52,9 @@ export const useStreamFetchReducer = , E = ApiEndpoi options, kibana.services.http?.basePath.get() )) { - dispatch(actions as ReducerAction); + if (actions.length > 0) { + dispatch(actions as ReducerAction); + } } setIsRunning(false); @@ -56,6 +66,11 @@ export const useStreamFetchReducer = , E = ApiEndpoi setIsRunning(false); }; + // If components using this custom hook get unmounted, cancel any ongoing request. + useEffect(() => { + return () => abortCtrl.current.abort(); + }, []); + return { cancel, data, diff --git a/x-pack/plugins/aiops/public/index.ts b/x-pack/plugins/aiops/public/index.ts index 30bcaf5afabdc..53fc1d7a6eeca 100755 --- a/x-pack/plugins/aiops/public/index.ts +++ b/x-pack/plugins/aiops/public/index.ts @@ -13,6 +13,6 @@ export function plugin() { return new AiopsPlugin(); } +export type { ExplainLogRateSpikesProps } from './components/explain_log_rate_spikes'; +export { ExplainLogRateSpikes, SingleEndpointStreamingDemo } from './shared_lazy_components'; export type { AiopsPluginSetup, AiopsPluginStart } from './types'; - -export type { ExplainLogRateSpikesSpec } from './components/explain_log_rate_spikes'; diff --git a/x-pack/plugins/aiops/public/kibana_services.ts b/x-pack/plugins/aiops/public/kibana_services.ts deleted file mode 100644 index 9a43d2de5e5a1..0000000000000 --- a/x-pack/plugins/aiops/public/kibana_services.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CoreStart } from '@kbn/core/public'; -import { AppPluginStartDependencies } from './types'; - -let coreStart: CoreStart; -let pluginsStart: AppPluginStartDependencies; -export function setStartServices(core: CoreStart, plugins: AppPluginStartDependencies) { - coreStart = core; - pluginsStart = plugins; -} - -export const getCoreStart = () => coreStart; -export const getPluginsStart = () => pluginsStart; diff --git a/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts b/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts deleted file mode 100644 index 0072336080175..0000000000000 --- a/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ExplainLogRateSpikesSpec } from '../components/explain_log_rate_spikes'; - -let loadModulesPromise: Promise; - -interface LazyLoadedModules { - ExplainLogRateSpikes: ExplainLogRateSpikesSpec; -} - -export async function lazyLoadModules(): Promise { - if (typeof loadModulesPromise !== 'undefined') { - return loadModulesPromise; - } - - loadModulesPromise = new Promise(async (resolve, reject) => { - try { - const lazyImports = await import('./lazy'); - resolve({ ...lazyImports }); - } catch (error) { - reject(error); - } - }); - return loadModulesPromise; -} diff --git a/x-pack/plugins/aiops/public/plugin.ts b/x-pack/plugins/aiops/public/plugin.ts index 3c3cff39abb80..ef65ab247c40f 100755 --- a/x-pack/plugins/aiops/public/plugin.ts +++ b/x-pack/plugins/aiops/public/plugin.ts @@ -7,19 +7,10 @@ import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; -import { getExplainLogRateSpikesComponent } from './api'; -import { setStartServices } from './kibana_services'; import { AiopsPluginSetup, AiopsPluginStart } from './types'; export class AiopsPlugin implements Plugin { public setup(core: CoreSetup) {} - - public start(core: CoreStart) { - setStartServices(core, {}); - return { - getExplainLogRateSpikesComponent, - }; - } - + public start(core: CoreStart) {} public stop() {} } diff --git a/x-pack/plugins/aiops/public/shared_lazy_components.tsx b/x-pack/plugins/aiops/public/shared_lazy_components.tsx new file mode 100644 index 0000000000000..f707a77cf7f90 --- /dev/null +++ b/x-pack/plugins/aiops/public/shared_lazy_components.tsx @@ -0,0 +1,42 @@ +/* + * 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, Suspense } from 'react'; + +import { EuiErrorBoundary, EuiLoadingContent } from '@elastic/eui'; + +import type { ExplainLogRateSpikesProps } from './components/explain_log_rate_spikes'; + +const ExplainLogRateSpikesLazy = React.lazy(() => import('./components/explain_log_rate_spikes')); +const SingleEndpointStreamingDemoLazy = React.lazy( + () => import('./components/single_endpoint_streaming_demo') +); + +const LazyWrapper: FC = ({ children }) => ( + + }>{children} + +); + +/** + * Lazy-wrapped ExplainLogRateSpikes React component + * @param {ExplainLogRateSpikesProps} props - properties specifying the data on which to run the analysis. + */ +export const ExplainLogRateSpikes: FC = (props) => ( + + + +); + +/** + * Lazy-wrapped SingleEndpointStreamingDemo React component + */ +export const SingleEndpointStreamingDemo: FC = () => ( + + + +); diff --git a/x-pack/plugins/aiops/server/lib/accept_compression.test.ts b/x-pack/plugins/aiops/server/lib/accept_compression.test.ts new file mode 100644 index 0000000000000..f1c51f75cbe0c --- /dev/null +++ b/x-pack/plugins/aiops/server/lib/accept_compression.test.ts @@ -0,0 +1,42 @@ +/* + * 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 { acceptCompression } from './accept_compression'; + +describe('acceptCompression', () => { + it('should return false for empty headers', () => { + expect(acceptCompression({})).toBe(false); + }); + it('should return false for other header containing gzip as string', () => { + expect(acceptCompression({ 'other-header': 'gzip, other' })).toBe(false); + }); + it('should return false for other header containing gzip as array', () => { + expect(acceptCompression({ 'other-header': ['gzip', 'other'] })).toBe(false); + }); + it('should return true for upper-case header containing gzip as string', () => { + expect(acceptCompression({ 'Accept-Encoding': 'gzip, other' })).toBe(true); + }); + it('should return true for lower-case header containing gzip as string', () => { + expect(acceptCompression({ 'accept-encoding': 'gzip, other' })).toBe(true); + }); + it('should return true for upper-case header containing gzip as array', () => { + expect(acceptCompression({ 'Accept-Encoding': ['gzip', 'other'] })).toBe(true); + }); + it('should return true for lower-case header containing gzip as array', () => { + expect(acceptCompression({ 'accept-encoding': ['gzip', 'other'] })).toBe(true); + }); + it('should return true for mixed headers containing gzip as string', () => { + expect( + acceptCompression({ 'accept-encoding': 'gzip, other', 'other-header': 'other-value' }) + ).toBe(true); + }); + it('should return true for mixed headers containing gzip as array', () => { + expect( + acceptCompression({ 'accept-encoding': ['gzip', 'other'], 'other-header': 'other-value' }) + ).toBe(true); + }); +}); diff --git a/x-pack/plugins/aiops/server/lib/accept_compression.ts b/x-pack/plugins/aiops/server/lib/accept_compression.ts new file mode 100644 index 0000000000000..0fd092d647314 --- /dev/null +++ b/x-pack/plugins/aiops/server/lib/accept_compression.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Headers } from '@kbn/core/server'; + +/** + * Returns whether request headers accept a response using gzip compression. + * + * @param headers - Request headers. + * @returns boolean + */ +export function acceptCompression(headers: Headers) { + let compressed = false; + + Object.keys(headers).forEach((key) => { + if (key.toLocaleLowerCase() === 'accept-encoding') { + const acceptEncoding = headers[key]; + + function containsGzip(s: string) { + return s + .split(',') + .map((d) => d.trim()) + .includes('gzip'); + } + + if (typeof acceptEncoding === 'string') { + compressed = containsGzip(acceptEncoding); + } else if (Array.isArray(acceptEncoding)) { + for (const ae of acceptEncoding) { + if (containsGzip(ae)) { + compressed = true; + break; + } + } + } + } + }); + + return compressed; +} diff --git a/x-pack/plugins/aiops/server/lib/stream_factory.test.ts b/x-pack/plugins/aiops/server/lib/stream_factory.test.ts new file mode 100644 index 0000000000000..7082a4e7e763c --- /dev/null +++ b/x-pack/plugins/aiops/server/lib/stream_factory.test.ts @@ -0,0 +1,106 @@ +/* + * 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 zlib from 'zlib'; + +import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; + +import { API_ENDPOINT } from '../../common/api'; +import type { ApiEndpointActions } from '../../common/api'; + +import { streamFactory } from './stream_factory'; + +type Action = ApiEndpointActions['/internal/aiops/explain_log_rate_spikes']; + +const mockItem1: Action = { + type: 'add_fields', + payload: ['clientip'], +}; +const mockItem2: Action = { + type: 'add_fields', + payload: ['referer'], +}; + +describe('streamFactory', () => { + let mockLogger: MockedLogger; + + beforeEach(() => { + mockLogger = loggerMock.create(); + }); + + it('should encode and receive an uncompressed stream', async () => { + const { DELIMITER, end, push, responseWithHeaders, stream } = streamFactory< + typeof API_ENDPOINT.EXPLAIN_LOG_RATE_SPIKES + >(mockLogger, {}); + + push(mockItem1); + push(mockItem2); + end(); + + let streamResult = ''; + for await (const chunk of stream) { + streamResult += chunk.toString('utf8'); + } + + const streamItems = streamResult.split(DELIMITER); + const lastItem = streamItems.pop(); + + const parsedItems = streamItems.map((d) => JSON.parse(d)); + + expect(responseWithHeaders.headers).toBe(undefined); + expect(parsedItems).toHaveLength(2); + expect(parsedItems[0]).toStrictEqual(mockItem1); + expect(parsedItems[1]).toStrictEqual(mockItem2); + expect(lastItem).toBe(''); + }); + + // Because zlib.gunzip's API expects a callback, we need to use `done` here + // to indicate once all assertions are run. However, it's not allowed to use both + // `async` and `done` for the test callback. That's why we're using an "async IIFE" + // pattern inside the tests callback to still be able to do async/await for the + // `for await()` part. Note that the unzipping here is done just to be able to + // decode the stream for the test and assert it. When used in actual code, + // the browser on the client side will automatically take care of unzipping + // without the need for additional custom code. + it('should encode and receive a compressed stream', (done) => { + (async () => { + const { DELIMITER, end, push, responseWithHeaders, stream } = streamFactory< + typeof API_ENDPOINT.EXPLAIN_LOG_RATE_SPIKES + >(mockLogger, { 'accept-encoding': 'gzip' }); + + push(mockItem1); + push(mockItem2); + end(); + + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const buffer = Buffer.concat(chunks); + + zlib.gunzip(buffer, function (err, decoded) { + expect(err).toBe(null); + + const streamResult = decoded.toString('utf8'); + + const streamItems = streamResult.split(DELIMITER); + const lastItem = streamItems.pop(); + + const parsedItems = streamItems.map((d) => JSON.parse(d)); + + expect(responseWithHeaders.headers).toStrictEqual({ 'content-encoding': 'gzip' }); + expect(parsedItems).toHaveLength(2); + expect(parsedItems[0]).toStrictEqual(mockItem1); + expect(parsedItems[1]).toStrictEqual(mockItem2); + expect(lastItem).toBe(''); + + done(); + }); + })(); + }); +}); diff --git a/x-pack/plugins/aiops/server/lib/stream_factory.ts b/x-pack/plugins/aiops/server/lib/stream_factory.ts new file mode 100644 index 0000000000000..dc67a54902527 --- /dev/null +++ b/x-pack/plugins/aiops/server/lib/stream_factory.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Stream } from 'stream'; +import zlib from 'zlib'; + +import type { Headers, Logger } from '@kbn/core/server'; + +import { ApiEndpoint, ApiEndpointActions } from '../../common/api'; + +import { acceptCompression } from './accept_compression'; + +// We need this otherwise Kibana server will crash with a 'ERR_METHOD_NOT_IMPLEMENTED' error. +class ResponseStream extends Stream.PassThrough { + flush() {} + _read() {} +} + +const DELIMITER = '\n'; + +/** + * Sets up a response stream with support for gzip compression depending on provided + * request headers. + * + * @param logger - Kibana provided logger. + * @param headers - Request headers. + * @returns An object with stream attributes and methods. + */ +export function streamFactory(logger: Logger, headers: Headers) { + const isCompressed = acceptCompression(headers); + + const stream = isCompressed ? zlib.createGzip() : new ResponseStream(); + + function push(d: ApiEndpointActions[T]) { + try { + const line = JSON.stringify(d); + stream.write(`${line}${DELIMITER}`); + + // Calling .flush() on a compression stream will + // make zlib return as much output as currently possible. + if (isCompressed) { + stream.flush(); + } + } catch (error) { + logger.error('Could not serialize or stream a message.'); + logger.error(error); + } + } + + function end() { + stream.end(); + } + + const responseWithHeaders = { + body: stream, + ...(isCompressed + ? { + headers: { + 'content-encoding': 'gzip', + }, + } + : {}), + }; + + return { DELIMITER, end, push, responseWithHeaders, stream }; +} diff --git a/x-pack/plugins/aiops/server/plugin.ts b/x-pack/plugins/aiops/server/plugin.ts index c6b1b8b22a187..3743d32e3a081 100755 --- a/x-pack/plugins/aiops/server/plugin.ts +++ b/x-pack/plugins/aiops/server/plugin.ts @@ -6,23 +6,38 @@ */ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from '@kbn/core/server'; +import type { DataRequestHandlerContext } from '@kbn/data-plugin/server'; -import { AiopsPluginSetup, AiopsPluginStart } from './types'; -import { defineRoutes } from './routes'; +import { AIOPS_ENABLED } from '../common'; -export class AiopsPlugin implements Plugin { +import { + AiopsPluginSetup, + AiopsPluginStart, + AiopsPluginSetupDeps, + AiopsPluginStartDeps, +} from './types'; +import { defineExampleStreamRoute, defineExplainLogRateSpikesRoute } from './routes'; + +export class AiopsPlugin + implements Plugin +{ private readonly logger: Logger; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); } - public setup(core: CoreSetup) { + public setup(core: CoreSetup, deps: AiopsPluginSetupDeps) { this.logger.debug('aiops: Setup'); - const router = core.http.createRouter(); + const router = core.http.createRouter(); // Register server side APIs - defineRoutes(router, this.logger); + if (AIOPS_ENABLED) { + core.getStartServices().then(([_, depsStart]) => { + defineExampleStreamRoute(router, this.logger); + defineExplainLogRateSpikesRoute(router, this.logger); + }); + } return {}; } diff --git a/x-pack/plugins/aiops/server/routes/example_stream.ts b/x-pack/plugins/aiops/server/routes/example_stream.ts new file mode 100644 index 0000000000000..38ca28ce6f176 --- /dev/null +++ b/x-pack/plugins/aiops/server/routes/example_stream.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IRouter, Logger } from '@kbn/core/server'; + +import { + aiopsExampleStreamSchema, + updateProgressAction, + addToEntityAction, + deleteEntityAction, +} from '../../common/api/example_stream'; +import { API_ENDPOINT } from '../../common/api'; + +import { streamFactory } from '../lib/stream_factory'; + +export const defineExampleStreamRoute = (router: IRouter, logger: Logger) => { + router.post( + { + path: API_ENDPOINT.EXAMPLE_STREAM, + validate: { + body: aiopsExampleStreamSchema, + }, + }, + async (context, request, response) => { + const maxTimeoutMs = request.body.timeout ?? 250; + const simulateError = request.body.simulateErrors ?? false; + + let shouldStop = false; + request.events.aborted$.subscribe(() => { + shouldStop = true; + }); + request.events.completed$.subscribe(() => { + shouldStop = true; + }); + + const { DELIMITER, end, push, responseWithHeaders, stream } = streamFactory< + typeof API_ENDPOINT.EXAMPLE_STREAM + >(logger, request.headers); + + const entities = [ + 'kimchy', + 's1monw', + 'martijnvg', + 'jasontedor', + 'nik9000', + 'javanna', + 'rjernst', + 'jrodewig', + ]; + + const actions = [...Array(19).fill('add'), 'delete']; + + if (simulateError) { + actions.push('server-only-error'); + actions.push('server-to-client-error'); + actions.push('client-error'); + } + + let progress = 0; + + async function pushStreamUpdate() { + setTimeout(() => { + try { + progress++; + + if (progress > 100 || shouldStop) { + end(); + return; + } + + push(updateProgressAction(progress)); + + const randomEntity = entities[Math.floor(Math.random() * entities.length)]; + const randomAction = actions[Math.floor(Math.random() * actions.length)]; + + if (randomAction === 'add') { + const randomCommits = Math.floor(Math.random() * 100); + push(addToEntityAction(randomEntity, randomCommits)); + } else if (randomAction === 'delete') { + push(deleteEntityAction(randomEntity)); + } else if (randomAction === 'server-to-client-error') { + // Throw an error. It should not crash Kibana! + throw new Error('There was a (simulated) server side error!'); + } else if (randomAction === 'client-error') { + // Return not properly encoded JSON to the client. + stream.push(`{body:'Not valid JSON${DELIMITER}`); + } + + pushStreamUpdate(); + } catch (error) { + stream.push( + `${JSON.stringify({ type: 'error', payload: error.toString() })}${DELIMITER}` + ); + end(); + } + }, Math.floor(Math.random() * maxTimeoutMs)); + } + + // do not call this using `await` so it will run asynchronously while we return the stream already. + pushStreamUpdate(); + + return response.ok(responseWithHeaders); + } + ); +}; diff --git a/x-pack/plugins/aiops/server/routes/explain_log_rate_spikes.ts b/x-pack/plugins/aiops/server/routes/explain_log_rate_spikes.ts new file mode 100644 index 0000000000000..f8aeb06435b76 --- /dev/null +++ b/x-pack/plugins/aiops/server/routes/explain_log_rate_spikes.ts @@ -0,0 +1,90 @@ +/* + * 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 { firstValueFrom } from 'rxjs'; + +import type { IRouter, Logger } from '@kbn/core/server'; +import type { DataRequestHandlerContext, IEsSearchRequest } from '@kbn/data-plugin/server'; + +import { + aiopsExplainLogRateSpikesSchema, + addFieldsAction, +} from '../../common/api/explain_log_rate_spikes'; +import { API_ENDPOINT } from '../../common/api'; + +import { streamFactory } from '../lib/stream_factory'; + +export const defineExplainLogRateSpikesRoute = ( + router: IRouter, + logger: Logger +) => { + router.post( + { + path: API_ENDPOINT.EXPLAIN_LOG_RATE_SPIKES, + validate: { + body: aiopsExplainLogRateSpikesSchema, + }, + }, + async (context, request, response) => { + const index = request.body.index; + + const controller = new AbortController(); + + let shouldStop = false; + request.events.aborted$.subscribe(() => { + shouldStop = true; + controller.abort(); + }); + request.events.completed$.subscribe(() => { + shouldStop = true; + controller.abort(); + }); + + const search = await context.search; + const res = await firstValueFrom( + search.search( + { + params: { + index, + body: { size: 1 }, + }, + } as IEsSearchRequest, + { abortSignal: controller.signal } + ) + ); + + const doc = res.rawResponse.hits.hits.pop(); + const fields = Object.keys(doc?._source ?? {}); + + const { end, push, responseWithHeaders } = streamFactory< + typeof API_ENDPOINT.EXPLAIN_LOG_RATE_SPIKES + >(logger, request.headers); + + async function pushField() { + setTimeout(() => { + if (shouldStop) { + end(); + return; + } + + const field = fields.pop(); + + if (field !== undefined) { + push(addFieldsAction([field])); + pushField(); + } else { + end(); + } + }, Math.random() * 1000); + } + + pushField(); + + return response.ok(responseWithHeaders); + } + ); +}; diff --git a/x-pack/plugins/aiops/server/routes/index.ts b/x-pack/plugins/aiops/server/routes/index.ts index e87c27e2af81e..d69ef6cc7df09 100755 --- a/x-pack/plugins/aiops/server/routes/index.ts +++ b/x-pack/plugins/aiops/server/routes/index.ts @@ -5,125 +5,5 @@ * 2.0. */ -import { Readable } from 'stream'; - -import type { IRouter, Logger } from '@kbn/core/server'; - -import { AIOPS_ENABLED } from '../../common'; -import type { ApiAction } from '../../common/api/example_stream'; -import { - aiopsExampleStreamSchema, - updateProgressAction, - addToEntityAction, - deleteEntityAction, -} from '../../common/api/example_stream'; - -// We need this otherwise Kibana server will crash with a 'ERR_METHOD_NOT_IMPLEMENTED' error. -class ResponseStream extends Readable { - _read(): void {} -} - -const delimiter = '\n'; - -export function defineRoutes(router: IRouter, logger: Logger) { - if (AIOPS_ENABLED) { - router.post( - { - path: '/internal/aiops/example_stream', - validate: { - body: aiopsExampleStreamSchema, - }, - }, - async (context, request, response) => { - const maxTimeoutMs = request.body.timeout ?? 250; - const simulateError = request.body.simulateErrors ?? false; - - let shouldStop = false; - request.events.aborted$.subscribe(() => { - shouldStop = true; - }); - request.events.completed$.subscribe(() => { - shouldStop = true; - }); - - const stream = new ResponseStream(); - - function streamPush(d: ApiAction) { - try { - const line = JSON.stringify(d); - stream.push(`${line}${delimiter}`); - } catch (error) { - logger.error('Could not serialize or stream a message.'); - logger.error(error); - } - } - - const entities = [ - 'kimchy', - 's1monw', - 'martijnvg', - 'jasontedor', - 'nik9000', - 'javanna', - 'rjernst', - 'jrodewig', - ]; - - const actions = [...Array(19).fill('add'), 'delete']; - - if (simulateError) { - actions.push('server-only-error'); - actions.push('server-to-client-error'); - actions.push('client-error'); - } - - let progress = 0; - - async function pushStreamUpdate() { - setTimeout(() => { - try { - progress++; - - if (progress > 100 || shouldStop) { - stream.push(null); - return; - } - - streamPush(updateProgressAction(progress)); - - const randomEntity = entities[Math.floor(Math.random() * entities.length)]; - const randomAction = actions[Math.floor(Math.random() * actions.length)]; - - if (randomAction === 'add') { - const randomCommits = Math.floor(Math.random() * 100); - streamPush(addToEntityAction(randomEntity, randomCommits)); - } else if (randomAction === 'delete') { - streamPush(deleteEntityAction(randomEntity)); - } else if (randomAction === 'server-to-client-error') { - // Throw an error. It should not crash Kibana! - throw new Error('There was a (simulated) server side error!'); - } else if (randomAction === 'client-error') { - // Return not properly encoded JSON to the client. - stream.push(`{body:'Not valid JSON${delimiter}`); - } - - pushStreamUpdate(); - } catch (error) { - stream.push( - `${JSON.stringify({ type: 'error', payload: error.toString() })}${delimiter}` - ); - stream.push(null); - } - }, Math.floor(Math.random() * maxTimeoutMs)); - } - - // do not call this using `await` so it will run asynchronously while we return the stream already. - pushStreamUpdate(); - - return response.ok({ - body: stream, - }); - } - ); - } -} +export { defineExampleStreamRoute } from './example_stream'; +export { defineExplainLogRateSpikesRoute } from './explain_log_rate_spikes'; diff --git a/x-pack/plugins/aiops/server/types.ts b/x-pack/plugins/aiops/server/types.ts index 526e7280e9495..3d27a9625db4c 100755 --- a/x-pack/plugins/aiops/server/types.ts +++ b/x-pack/plugins/aiops/server/types.ts @@ -5,6 +5,16 @@ * 2.0. */ +import { PluginSetup, PluginStart } from '@kbn/data-plugin/server'; + +export interface AiopsPluginSetupDeps { + data: PluginSetup; +} + +export interface AiopsPluginStartDeps { + data: PluginStart; +} + /** * aiops plugin server setup contract */ diff --git a/x-pack/plugins/ml/common/constants/locator.ts b/x-pack/plugins/ml/common/constants/locator.ts index 7b98eefe0ab24..a5b94836e5a1d 100644 --- a/x-pack/plugins/ml/common/constants/locator.ts +++ b/x-pack/plugins/ml/common/constants/locator.ts @@ -54,6 +54,8 @@ export const ML_PAGES = { OVERVIEW: 'overview', AIOPS: 'aiops', AIOPS_EXPLAIN_LOG_RATE_SPIKES: 'aiops/explain_log_rate_spikes', + AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT: 'aiops/explain_log_rate_spikes_index_select', + AIOPS_SINGLE_ENDPOINT_STREAMING_DEMO: 'aiops/single_endpoint_streaming_demo', } as const; export type MlPages = typeof ML_PAGES[keyof typeof ML_PAGES]; diff --git a/x-pack/plugins/ml/common/types/locator.ts b/x-pack/plugins/ml/common/types/locator.ts index 0d5cb7aeddd81..742486c78b5bf 100644 --- a/x-pack/plugins/ml/common/types/locator.ts +++ b/x-pack/plugins/ml/common/types/locator.ts @@ -63,7 +63,9 @@ export type MlGenericUrlState = MLPageState< | typeof ML_PAGES.DATA_VISUALIZER_FILE | typeof ML_PAGES.DATA_VISUALIZER_INDEX_SELECT | typeof ML_PAGES.AIOPS - | typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES, + | typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES + | typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT + | typeof ML_PAGES.AIOPS_SINGLE_ENDPOINT_STREAMING_DEMO, MlGenericUrlPageState | undefined >; diff --git a/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx index 473525d40ca9a..39fa5272799fd 100644 --- a/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx +++ b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx @@ -5,44 +5,32 @@ * 2.0. */ -import React, { FC, useEffect, useState } from 'react'; +import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { ExplainLogRateSpikesSpec } from '@kbn/aiops-plugin/public'; -import { useMlKibana, useTimefilter } from '../contexts/kibana'; +import { ExplainLogRateSpikes } from '@kbn/aiops-plugin/public'; + +import { useMlContext } from '../contexts/ml'; +import { useMlKibana } from '../contexts/kibana'; import { HelpMenu } from '../components/help_menu'; import { MlPageHeader } from '../components/page_header'; export const ExplainLogRateSpikesPage: FC = () => { - useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); const { - services: { docLinks, aiops }, + services: { docLinks }, } = useMlKibana(); - const [ExplainLogRateSpikes, setExplainLogRateSpikes] = useState( - null - ); - - useEffect(() => { - if (aiops !== undefined) { - const { getExplainLogRateSpikesComponent } = aiops; - getExplainLogRateSpikesComponent().then(setExplainLogRateSpikes); - } - }, []); + const context = useMlContext(); return ( <> - {ExplainLogRateSpikes !== null ? ( - <> - - - - - - ) : null} + + + + ); diff --git a/x-pack/plugins/ml/public/application/aiops/single_endpoint_streaming_demo.tsx b/x-pack/plugins/ml/public/application/aiops/single_endpoint_streaming_demo.tsx new file mode 100644 index 0000000000000..fa2bc7f7051e4 --- /dev/null +++ b/x-pack/plugins/ml/public/application/aiops/single_endpoint_streaming_demo.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { SingleEndpointStreamingDemo } from '@kbn/aiops-plugin/public'; +import { useMlKibana, useTimefilter } from '../contexts/kibana'; +import { HelpMenu } from '../components/help_menu'; + +import { MlPageHeader } from '../components/page_header'; + +export const SingleEndpointStreamingDemoPage: FC = () => { + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); + const { + services: { docLinks }, + } = useMlKibana(); + + return ( + <> + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx index 84474e85330d6..250dbc52cfd9c 100644 --- a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx @@ -229,13 +229,22 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { items: [ { id: 'explainlogratespikes', - pathId: ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES, + pathId: ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT, name: i18n.translate('xpack.ml.navMenu.explainLogRateSpikesLinkText', { defaultMessage: 'Explain log rate spikes', }), disabled: disableLinks, testSubj: 'mlMainTab explainLogRateSpikes', }, + { + id: 'singleEndpointStreamingDemo', + pathId: ML_PAGES.AIOPS_SINGLE_ENDPOINT_STREAMING_DEMO, + name: i18n.translate('xpack.ml.navMenu.singleEndpointStreamingDemoLinkText', { + defaultMessage: 'Single endpoint streaming demo', + }), + disabled: disableLinks, + testSubj: 'mlMainTab singleEndpointStreamingDemo', + }, ], }); } diff --git a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts index 2a8806bf3ff38..8b755b02f99b9 100644 --- a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts @@ -6,9 +6,9 @@ */ import React from 'react'; -import { DataView, DataViewsContract } from '@kbn/data-views-plugin/public'; -import { SavedSearchSavedObject } from '../../../../common/types/kibana'; -import { MlServicesContext } from '../../app'; +import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public'; +import type { SavedSearchSavedObject } from '../../../../common/types/kibana'; +import type { MlServicesContext } from '../../app'; export interface MlContextValue { combinedQuery: any; diff --git a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts index 54aedb4a71857..38ace0233cbb8 100644 --- a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts +++ b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts @@ -59,7 +59,7 @@ export const AIOPS_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ text: i18n.translate('xpack.ml.aiopsBreadcrumbLabel', { defaultMessage: 'AIOps', }), - href: '/aiops', + href: '/aiops/explain_log_rate_spikes_index_select', }); export const CREATE_JOB_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx index ca670df258a6a..5fac891a79675 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx @@ -37,7 +37,7 @@ export const explainLogRateSpikesRouteFactory = ( getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB', navigateToPath, basePath), { - text: i18n.translate('xpack.ml.AiopsBreadcrumbs.explainLogRateSpikesLabel', { + text: i18n.translate('xpack.ml.aiopsBreadcrumbs.explainLogRateSpikesLabel', { defaultMessage: 'Explain log rate spikes', }), }, diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts b/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts index f2b192a4cd097..10f0eba1adeda 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts @@ -6,3 +6,4 @@ */ export * from './explain_log_rate_spikes'; +export * from './single_endpoint_streaming_demo'; diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/single_endpoint_streaming_demo.tsx b/x-pack/plugins/ml/public/application/routing/routes/aiops/single_endpoint_streaming_demo.tsx new file mode 100644 index 0000000000000..636357518d0d0 --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/single_endpoint_streaming_demo.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { parse } from 'query-string'; + +import { i18n } from '@kbn/i18n'; + +import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; + +import { NavigateToPath } from '../../../contexts/kibana'; + +import { MlRoute, PageLoader, PageProps } from '../../router'; +import { useResolver } from '../../use_resolver'; +import { SingleEndpointStreamingDemoPage as Page } from '../../../aiops/single_endpoint_streaming_demo'; + +import { checkBasicLicense } from '../../../license'; +import { checkGetJobsCapabilitiesResolver } from '../../../capabilities/check_capabilities'; +import { cacheDataViewsContract } from '../../../util/index_utils'; +import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; + +export const singleEndpointStreamingDemoRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ + id: 'single_endpoint_streaming_demo', + path: '/aiops/single_endpoint_streaming_demo', + title: i18n.translate('xpack.ml.aiops.singleEndpointStreamingDemo.docTitle', { + defaultMessage: 'Single endpoint streaming demo', + }), + render: (props, deps) => , + breadcrumbs: [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB', navigateToPath, basePath), + { + text: i18n.translate('xpack.ml.aiopsBreadcrumbs.singleEndpointStreamingDemoLabel', { + defaultMessage: 'Single endpoint streaming demo', + }), + }, + ], + disabled: !AIOPS_ENABLED, +}); + +const PageWrapper: FC = ({ location, deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + + const { index, savedSearchId }: Record = parse(location.search, { sort: false }); + const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, { + checkBasicLicense, + cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + }); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx index d1d547ca8bc90..5ea3bfa9d35eb 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx @@ -50,6 +50,16 @@ const getDataVisBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) }, ]; +const getExplainLogRateSpikesBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB', navigateToPath, basePath), + { + text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDateViewLabel', { + defaultMessage: 'Data View', + }), + }, +]; + export const indexOrSearchRouteFactory = ( navigateToPath: NavigateToPath, basePath: string @@ -86,6 +96,26 @@ export const dataVizIndexOrSearchRouteFactory = ( breadcrumbs: getDataVisBreadcrumbs(navigateToPath, basePath), }); +export const explainLogRateSpikesIndexOrSearchRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ + id: 'data_view_explain_log_rate_spikes', + path: '/aiops/explain_log_rate_spikes_index_select', + title: i18n.translate('xpack.ml.selectDataViewLabel', { + defaultMessage: 'Select Data View', + }), + render: (props, deps) => ( + + ), + breadcrumbs: getExplainLogRateSpikesBreadcrumbs(navigateToPath, basePath), +}); + const PageWrapper: FC = ({ nextStepPath, deps, mode }) => { const { services: { diff --git a/x-pack/plugins/ml/public/locator/ml_locator.ts b/x-pack/plugins/ml/public/locator/ml_locator.ts index 295dbaebbbae6..b36029329c087 100644 --- a/x-pack/plugins/ml/public/locator/ml_locator.ts +++ b/x-pack/plugins/ml/public/locator/ml_locator.ts @@ -86,6 +86,8 @@ export class MlLocatorDefinition implements LocatorDefinition { case ML_PAGES.DATA_VISUALIZER_INDEX_SELECT: case ML_PAGES.AIOPS: case ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES: + case ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT: + case ML_PAGES.AIOPS_SINGLE_ENDPOINT_STREAMING_DEMO: case ML_PAGES.OVERVIEW: case ML_PAGES.SETTINGS: case ML_PAGES.FILTER_LISTS_MANAGE: diff --git a/x-pack/test/api_integration/apis/aiops/example_stream.ts b/x-pack/test/api_integration/apis/aiops/example_stream.ts index 693a6de2c6716..c1e410655dbfc 100644 --- a/x-pack/test/api_integration/apis/aiops/example_stream.ts +++ b/x-pack/test/api_integration/apis/aiops/example_stream.ts @@ -12,6 +12,8 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { parseStream } from './parse_stream'; + export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const config = getService('config'); @@ -67,34 +69,15 @@ export default ({ getService }: FtrProviderContext) => { expect(stream).not.to.be(null); if (stream !== null) { - let partial = ''; - let threw = false; const progressData: any[] = []; - try { - for await (const value of stream) { - const full = `${partial}${value}`; - const parts = full.split('\n'); - const last = parts.pop(); - - partial = last ?? ''; - - const actions = parts.map((p) => JSON.parse(p)); - - actions.forEach((action) => { - expect(typeof action.type).to.be('string'); - - if (action.type === 'update_progress') { - progressData.push(action); - } - }); + for await (const action of parseStream(stream)) { + expect(action.type).not.to.be('error'); + if (action.type === 'update_progress') { + progressData.push(action); } - } catch (e) { - threw = true; } - expect(threw).to.be(false); - expect(progressData.length).to.be(100); expect(progressData[0].payload).to.be(1); expect(progressData[progressData.length - 1].payload).to.be(100); diff --git a/x-pack/test/api_integration/apis/aiops/explain_log_rate_spikes.ts b/x-pack/test/api_integration/apis/aiops/explain_log_rate_spikes.ts new file mode 100644 index 0000000000000..11ef63809a52f --- /dev/null +++ b/x-pack/test/api_integration/apis/aiops/explain_log_rate_spikes.ts @@ -0,0 +1,126 @@ +/* + * 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 fetch from 'node-fetch'; +import { format as formatUrl } from 'url'; + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +import { parseStream } from './parse_stream'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const config = getService('config'); + const kibanaServerUrl = formatUrl(config.get('servers.kibana')); + + const expectedFields = [ + 'category', + 'currency', + 'customer_first_name', + 'customer_full_name', + 'customer_gender', + 'customer_id', + 'customer_last_name', + 'customer_phone', + 'day_of_week', + 'day_of_week_i', + 'email', + 'geoip', + 'manufacturer', + 'order_date', + 'order_id', + 'products', + 'sku', + 'taxful_total_price', + 'taxless_total_price', + 'total_quantity', + 'total_unique_products', + 'type', + 'user', + ]; + + describe('POST /internal/aiops/explain_log_rate_spikes', () => { + const esArchiver = getService('esArchiver'); + + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ecommerce'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/ml/ecommerce'); + }); + + it('should return full data without streaming', async () => { + const resp = await supertest + .post(`/internal/aiops/explain_log_rate_spikes`) + .set('kbn-xsrf', 'kibana') + .send({ + index: 'ft_ecommerce', + }) + .expect(200); + + expect(Buffer.isBuffer(resp.body)).to.be(true); + + const chunks: string[] = resp.body.toString().split('\n'); + + expect(chunks.length).to.be(24); + + const lastChunk = chunks.pop(); + expect(lastChunk).to.be(''); + + let data: any[] = []; + + expect(() => { + data = chunks.map((c) => JSON.parse(c)); + }).not.to.throwError(); + + data.forEach((d) => { + expect(typeof d.type).to.be('string'); + }); + + const fields = data.map((d) => d.payload[0]).sort(); + + expect(fields.length).to.equal(expectedFields.length); + fields.forEach((f) => { + expect(expectedFields.includes(f)); + }); + }); + + it('should return data in chunks with streaming', async () => { + const response = await fetch(`${kibanaServerUrl}/internal/aiops/explain_log_rate_spikes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'kbn-xsrf': 'stream', + }, + body: JSON.stringify({ index: 'ft_ecommerce' }), + }); + + const stream = response.body; + + expect(stream).not.to.be(null); + + if (stream !== null) { + const data: any[] = []; + + for await (const action of parseStream(stream)) { + expect(action.type).not.to.be('error'); + data.push(action); + } + + const fields = data.map((d) => d.payload[0]).sort(); + + expect(fields.length).to.equal(expectedFields.length); + fields.forEach((f) => { + expect(expectedFields.includes(f)); + }); + } + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/aiops/index.ts b/x-pack/test/api_integration/apis/aiops/index.ts index 04b4181906dbf..d2aacc454b567 100644 --- a/x-pack/test/api_integration/apis/aiops/index.ts +++ b/x-pack/test/api_integration/apis/aiops/index.ts @@ -12,5 +12,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { this.tags(['ml']); loadTestFile(require.resolve('./example_stream')); + loadTestFile(require.resolve('./explain_log_rate_spikes')); }); } diff --git a/x-pack/test/api_integration/apis/aiops/parse_stream.ts b/x-pack/test/api_integration/apis/aiops/parse_stream.ts new file mode 100644 index 0000000000000..f3da52e6024bb --- /dev/null +++ b/x-pack/test/api_integration/apis/aiops/parse_stream.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export async function* parseStream(stream: NodeJS.ReadableStream) { + let partial = ''; + + try { + for await (const value of stream) { + const full = `${partial}${value}`; + const parts = full.split('\n'); + const last = parts.pop(); + + partial = last ?? ''; + + const actions = parts.map((p) => JSON.parse(p)); + + for (const action of actions) { + yield action; + } + } + } catch (error) { + yield { type: 'error', payload: error.toString() }; + } +} From 8c19c36b36e23aab26cefea9ed85df18c71d882d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Fri, 20 May 2022 09:46:09 +0100 Subject: [PATCH 091/150] [Content management] Surface "Last updated" column in Saved object management (#132525) --- .../saved_objects_table.test.tsx.snap | 6 ++ .../__snapshots__/table.test.tsx.snap | 34 ++++++++++- .../objects_table/components/table.test.tsx | 4 ++ .../objects_table/components/table.tsx | 58 ++++++++++++++++++- .../objects_table/saved_objects_table.tsx | 19 ++++-- .../server/routes/find.ts | 1 + .../apis/saved_objects_management/find.ts | 33 +++++++++++ 7 files changed, 145 insertions(+), 10 deletions(-) diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index f7026af66c500..61501ed45b47d 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -296,6 +296,12 @@ exports[`SavedObjectsTable should render normally 1`] = ` "onSelectionChange": [Function], } } + sort={ + Object { + "direction": "desc", + "field": "updated_at", + } + } totalItemCount={4} /> diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index 2515a8ce6d788..4b3bc4f5bd0cf 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -131,7 +131,7 @@ exports[`Table prevents saved objects from being deleted 1`] = ` "field": "type", "name": "Type", "render": [Function], - "sortable": false, + "sortable": true, "width": "50px", }, Object { @@ -143,6 +143,13 @@ exports[`Table prevents saved objects from being deleted 1`] = ` "render": [Function], "sortable": false, }, + Object { + "field": "updated_at", + "name": "Last updated", + "render": [Function], + "sortable": true, + "width": "150px", + }, Object { "actions": Array [ Object { @@ -215,6 +222,14 @@ exports[`Table prevents saved objects from being deleted 1`] = ` "onSelectionChange": [Function], } } + sorting={ + Object { + "sort": Object { + "direction": "desc", + "field": "updated_at", + }, + } + } tableLayout="fixed" />
@@ -351,7 +366,7 @@ exports[`Table should render normally 1`] = ` "field": "type", "name": "Type", "render": [Function], - "sortable": false, + "sortable": true, "width": "50px", }, Object { @@ -363,6 +378,13 @@ exports[`Table should render normally 1`] = ` "render": [Function], "sortable": false, }, + Object { + "field": "updated_at", + "name": "Last updated", + "render": [Function], + "sortable": true, + "width": "150px", + }, Object { "actions": Array [ Object { @@ -435,6 +457,14 @@ exports[`Table should render normally 1`] = ` "onSelectionChange": [Function], } } + sorting={ + Object { + "sort": Object { + "direction": "desc", + "field": "updated_at", + }, + } + } tableLayout="fixed" />
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx index 4ee1510a7627c..86f2b766002ac 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx @@ -50,6 +50,10 @@ const defaultProps: TableProps = { canGoInApp: () => true, pageIndex: 1, pageSize: 2, + sort: { + field: 'updated_at', + direction: 'desc', + }, items: [ { id: '1', diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index ff5d49da99c61..0ffd353c8ddd2 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -8,6 +8,7 @@ import { ApplicationStart, IBasePath } from '@kbn/core/public'; import React, { PureComponent, Fragment } from 'react'; +import moment from 'moment'; import { EuiSearchBar, EuiBasicTable, @@ -24,9 +25,10 @@ import { EuiTableFieldDataColumnType, EuiTableActionsColumnType, QueryType, + CriteriaWithPagination, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react'; import { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { SavedObjectManagementTypeInfo } from '../../../../common/types'; import { getDefaultTitle, getSavedObjectLabel } from '../../../lib'; @@ -55,6 +57,7 @@ export interface TableProps { goInspectObject: (obj: SavedObjectWithMetadata) => void; pageIndex: number; pageSize: number; + sort: CriteriaWithPagination['sort']; items: SavedObjectWithMetadata[]; itemId: string | (() => string); totalItemCount: number; @@ -128,10 +131,59 @@ export class Table extends PureComponent { this.setState({ isExportPopoverOpen: false }); }; + getUpdatedAtColumn = () => { + const renderUpdatedAt = (dateTime?: string) => { + if (!dateTime) { + return ( + + - + + ); + } + const updatedAt = moment(dateTime); + + if (updatedAt.diff(moment(), 'days') > -7) { + return ( + + {(formattedDate: string) => ( + + {formattedDate} + + )} + + ); + } + return ( + + {updatedAt.format('LL')} + + ); + }; + + return { + field: 'updated_at', + name: i18n.translate('savedObjectsManagement.objectsTable.table.lastUpdatedColumnTitle', { + defaultMessage: 'Last updated', + }), + render: (field: string, record: { updated_at?: string }) => + renderUpdatedAt(record.updated_at), + sortable: true, + width: '150px', + }; + }; + render() { const { pageIndex, pageSize, + sort, itemId, items, totalItemCount, @@ -186,7 +238,7 @@ export class Table extends PureComponent { 'savedObjectsManagement.objectsTable.table.columnTypeDescription', { defaultMessage: 'Type of the saved object' } ), - sortable: false, + sortable: true, 'data-test-subj': 'savedObjectsTableRowType', render: (type: string, object: SavedObjectWithMetadata) => { const typeLabel = getSavedObjectLabel(type, allowedTypes); @@ -239,6 +291,7 @@ export class Table extends PureComponent { 'data-test-subj': `savedObjectsTableColumn-${column.id}`, }; }), + this.getUpdatedAtColumn(), { name: i18n.translate('savedObjectsManagement.objectsTable.table.columnActionsName', { defaultMessage: 'Actions', @@ -422,6 +475,7 @@ export class Table extends PureComponent { items={items} columns={columns as any} pagination={pagination} + sorting={{ sort }} selection={selection} onChange={onTableChange} rowProps={(item) => ({ diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index c8330e0eb9cf3..b0afbcc163ef8 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -10,7 +10,7 @@ import React, { Component } from 'react'; import { debounce } from 'lodash'; // @ts-expect-error import { saveAs } from '@elastic/filesaver'; -import { EuiSpacer, Query } from '@elastic/eui'; +import { EuiSpacer, Query, CriteriaWithPagination } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SavedObjectsClientContract, @@ -78,6 +78,7 @@ export interface SavedObjectsTableState { totalCount: number; page: number; perPage: number; + sort: CriteriaWithPagination['sort']; savedObjects: SavedObjectWithMetadata[]; savedObjectCounts: Record; activeQuery: Query; @@ -114,6 +115,10 @@ export class SavedObjectsTable extends Component { typeToCountMap[type.name] = 0; @@ -211,7 +216,7 @@ export class SavedObjectsTable extends Component { - const { activeQuery: query, page, perPage } = this.state; + const { activeQuery: query, page, perPage, sort } = this.state; const { notifications, http, allowedTypes, taggingApi } = this.props; const { queryText, visibleTypes, selectedTags } = parseQuery(query, allowedTypes); @@ -228,9 +233,8 @@ export class SavedObjectsTable extends Component 1) { - findOptions.sortField = 'type'; - } + findOptions.sortField = sort?.field; + findOptions.sortOrder = sort?.direction; findOptions.hasReference = getTagFindReferences({ selectedTags, taggingApi }); @@ -352,7 +356,7 @@ export class SavedObjectsTable extends Component { + onTableChange = async (table: CriteriaWithPagination) => { const { index: page, size: perPage } = table.page || {}; this.setState( @@ -360,6 +364,7 @@ export class SavedObjectsTable extends Component { + it('sort objects by "type" in "asc" order', async () => { + await supertest + .get('/api/kibana/management/saved_objects/_find') + .query({ + type: ['visualization', 'dashboard'], + sortField: 'type', + sortOrder: 'asc', + }) + .expect(200) + .then((resp) => { + const objects = resp.body.saved_objects; + expect(objects.length).be.greaterThan(1); // Need more than 1 result for our test + expect(objects[0].type).to.be('dashboard'); + }); + }); + + it('sort objects by "type" in "desc" order', async () => { + await supertest + .get('/api/kibana/management/saved_objects/_find') + .query({ + type: ['visualization', 'dashboard'], + sortField: 'type', + sortOrder: 'desc', + }) + .expect(200) + .then((resp) => { + const objects = resp.body.saved_objects; + expect(objects[0].type).to.be('visualization'); + }); + }); + }); }); describe('meta attributes injected properly', () => { From 63e67ab630083ebb3eebba8bec9aab7572992478 Mon Sep 17 00:00:00 2001 From: mgiota Date: Fri, 20 May 2022 10:46:33 +0200 Subject: [PATCH 092/150] move error into a useEffect (#132491) --- .../public/pages/rule_details/components/actions.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx index 5a692e570281a..e450404120e89 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { EuiText, EuiSpacer, @@ -38,6 +38,11 @@ export function Actions({ ruleActions }: ActionsProps) { notifications: { toasts }, } = useKibana().services; const { isLoadingActions, allActions, errorActions } = useFetchRuleActions({ http }); + useEffect(() => { + if (errorActions) { + toasts.addDanger({ title: errorActions }); + } + }, [errorActions, toasts]); if (ruleActions && ruleActions.length <= 0) return ( @@ -65,7 +70,6 @@ export function Actions({ ruleActions }: ActionsProps) { ))} - {errorActions && toasts.addDanger({ title: errorActions })} ); } From c1365153630afb5f5768b52592864d93ce1bd194 Mon Sep 17 00:00:00 2001 From: Faisal Kanout Date: Fri, 20 May 2022 12:35:30 +0300 Subject: [PATCH 093/150] [Actionable Observability] Add execution log count in the last 24h in the Rule details page (#132411) * Add execution log count in the last 24h * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' --- .../use_fetch_last24h_rule_execution_log.ts | 67 +++++++++++++++++++ .../public/pages/rule_details/index.tsx | 25 +++++++ .../public/pages/rule_details/translations.ts | 6 ++ .../public/pages/rule_details/types.ts | 5 ++ .../triggers_actions_ui/public/index.ts | 1 + 5 files changed, 104 insertions(+) create mode 100644 x-pack/plugins/observability/public/hooks/use_fetch_last24h_rule_execution_log.ts diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_last24h_rule_execution_log.ts b/x-pack/plugins/observability/public/hooks/use_fetch_last24h_rule_execution_log.ts new file mode 100644 index 0000000000000..edb08f69b44f3 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_last24h_rule_execution_log.ts @@ -0,0 +1,67 @@ +/* + * 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 { useEffect, useState, useCallback } from 'react'; +import { loadExecutionLogAggregations } from '@kbn/triggers-actions-ui-plugin/public'; +import { IExecutionLogWithErrorsResult } from '@kbn/alerting-plugin/common'; +import moment from 'moment'; +import { FetchRuleExecutionLogProps } from '../pages/rule_details/types'; +import { EXECUTION_LOG_ERROR } from '../pages/rule_details/translations'; +import { useKibana } from '../utils/kibana_react'; + +interface FetchExecutionLog { + isLoadingExecutionLog: boolean; + executionLog: IExecutionLogWithErrorsResult; + errorExecutionLog?: string; +} + +export function useFetchLast24hRuleExecutionLog({ http, ruleId }: FetchRuleExecutionLogProps) { + const { + notifications: { toasts }, + } = useKibana().services; + const [executionLog, setExecutionLog] = useState({ + isLoadingExecutionLog: true, + executionLog: { + total: 0, + data: [], + totalErrors: 0, + errors: [], + }, + errorExecutionLog: undefined, + }); + + const fetchRuleActions = useCallback(async () => { + try { + const date = new Date().toISOString(); + const response = await loadExecutionLogAggregations({ + id: ruleId, + dateStart: moment(date).subtract(24, 'h').toISOString(), + dateEnd: date, + http, + }); + setExecutionLog((oldState: FetchExecutionLog) => ({ + ...oldState, + isLoadingExecutionLog: false, + executionLog: response, + })); + } catch (error) { + toasts.addDanger({ title: error }); + setExecutionLog((oldState: FetchExecutionLog) => ({ + ...oldState, + isLoadingExecutionLog: false, + errorExecutionLog: EXECUTION_LOG_ERROR( + error instanceof Error ? error.message : typeof error === 'string' ? error : '' + ), + })); + } + }, [http, ruleId, toasts]); + useEffect(() => { + fetchRuleActions(); + }, [fetchRuleActions]); + + return { ...executionLog, reloadExecutionLogs: useFetchLast24hRuleExecutionLog }; +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index 31b9a888ec266..96af4de1eb053 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -56,6 +56,7 @@ import { RULES_BREADCRUMB_TEXT } from '../rules/translations'; import { PageTitle, ItemTitleRuleSummary, ItemValueRuleSummary, Actions } from './components'; import { useKibana } from '../../utils/kibana_react'; import { useFetchLast24hAlerts } from '../../hooks/use_fetch_last24h_alerts'; +import { useFetchLast24hRuleExecutionLog } from '../../hooks/use_fetch_last24h_rule_execution_log'; import { formatInterval } from './utils'; import { hasExecuteActionsCapability, hasAllPrivilege } from './config'; import { paths } from '../../config/paths'; @@ -76,6 +77,7 @@ export function RuleDetailsPage() { const { ruleId } = useParams(); const { ObservabilityPageTemplate } = usePluginContext(); const { isRuleLoading, rule, errorRule, reloadRule } = useFetchRule({ ruleId, http }); + const { isLoadingExecutionLog, executionLog } = useFetchLast24hRuleExecutionLog({ http, ruleId }); const { ruleTypes, ruleTypeIndex } = useLoadRuleTypes({ filteredSolutions: OBSERVABILITY_SOLUTIONS, }); @@ -350,6 +352,29 @@ export function RuleDetailsPage() { )}`} /> + + {isLoadingExecutionLog ? ( + + ) : ( + + + {i18n.translate('xpack.observability.ruleDetails.execution', { + defaultMessage: 'Executions', + })} + + + + + )} diff --git a/x-pack/plugins/observability/public/pages/rule_details/translations.ts b/x-pack/plugins/observability/public/pages/rule_details/translations.ts index f162f30906c21..bda8284c31a9e 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/translations.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/translations.ts @@ -18,6 +18,12 @@ export const ACTIONS_LOAD_ERROR = (errorMessage: string) => values: { message: errorMessage }, }); +export const EXECUTION_LOG_ERROR = (errorMessage: string) => + i18n.translate('xpack.observability.ruleDetails.executionLogError', { + defaultMessage: 'Unable to load rule execution log. Reason: {message}', + values: { message: errorMessage }, + }); + export const TAGS_TITLE = i18n.translate('xpack.observability.ruleDetails.tagsTitle', { defaultMessage: 'Tags', }); diff --git a/x-pack/plugins/observability/public/pages/rule_details/types.ts b/x-pack/plugins/observability/public/pages/rule_details/types.ts index 9855bf2c7f184..0ce91d0481dd9 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/types.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/types.ts @@ -35,6 +35,11 @@ export interface FetchRuleActionsProps { http: HttpSetup; } +export interface FetchRuleExecutionLogProps { + http: HttpSetup; + ruleId: string; +} + export interface FetchRuleSummary { isLoadingRuleSummary: boolean; ruleSummary?: RuleSummary; diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 9c08dfe597ecf..4580600b4bff8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -76,6 +76,7 @@ export { Plugin }; export * from './plugin'; // TODO remove this import when we expose the Rules tables as a component export { loadRules } from './application/lib/rule_api/rules'; +export { loadExecutionLogAggregations } from './application/lib/rule_api/load_execution_log_aggregations'; export { loadRuleTypes } from './application/lib/rule_api'; export { loadRuleSummary } from './application/lib/rule_api/rule_summary'; export { deleteRules } from './application/lib/rule_api/delete'; From de90ea592becedda956fe29e6ee1c4490b29fab0 Mon Sep 17 00:00:00 2001 From: mgiota Date: Fri, 20 May 2022 11:48:26 +0200 Subject: [PATCH 094/150] [Actionable Observability] Display action connector icon in o11y rule details page (#132026) * get iconClass from actionRegistry * use suspendedComponentWithProps when iconClass is a react component and write some tests * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * fix xmatters svg icon * apply design feedback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...observability_public_plugins_start.mock.ts | 6 ++ .../rule_details/components/actions.test.tsx | 84 +++++++++++++++++++ .../pages/rule_details/components/actions.tsx | 38 ++++----- .../public/pages/rule_details/index.tsx | 3 +- .../public/pages/rule_details/types.ts | 8 +- .../builtin_action_types/xmatters/logo.tsx | 5 +- .../triggers_actions_ui/public/index.ts | 1 + 7 files changed, 121 insertions(+), 24 deletions(-) create mode 100644 x-pack/plugins/observability/public/pages/rule_details/components/actions.test.tsx diff --git a/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts b/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts index ab1f769c1c4b9..a20e42cd37841 100644 --- a/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts +++ b/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts @@ -46,6 +46,12 @@ const triggersActionsUiStartMock = { get: jest.fn(), list: jest.fn(), }, + actionTypeRegistry: { + has: jest.fn((x) => true), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), + }, }; }, }; diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/actions.test.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/actions.test.tsx new file mode 100644 index 0000000000000..9000d9dbf5f99 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/actions.test.tsx @@ -0,0 +1,84 @@ +/* + * 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 { ReactWrapper, mount } from 'enzyme'; +import { Actions } from './actions'; +import { observabilityPublicPluginsStartMock } from '../../../observability_public_plugins_start.mock'; +import { kibanaStartMock } from '../../../utils/kibana_react.mock'; + +const mockUseKibanaReturnValue = kibanaStartMock.startContract(); + +jest.mock('../../../utils/kibana_react', () => ({ + __esModule: true, + useKibana: jest.fn(() => mockUseKibanaReturnValue), +})); + +jest.mock('../../../hooks/use_fetch_rule_actions', () => ({ + useFetchRuleActions: jest.fn(), +})); + +const { useFetchRuleActions } = jest.requireMock('../../../hooks/use_fetch_rule_actions'); + +describe('Actions', () => { + let wrapper: ReactWrapper; + async function setup() { + const ruleActions = [ + { + id: 1, + group: 'metrics.inventory_threshold.fired', + actionTypeId: '.server-log', + }, + { + id: 2, + group: 'metrics.inventory_threshold.fired', + actionTypeId: '.slack', + }, + ]; + const allActions = [ + { + id: 1, + name: 'Server log', + actionTypeId: '.server-log', + }, + { + id: 2, + name: 'Slack', + actionTypeId: '.slack', + }, + { + id: 3, + name: 'Email', + actionTypeId: '.email', + }, + ]; + useFetchRuleActions.mockReturnValue({ + allActions, + }); + + const actionTypeRegistryMock = + observabilityPublicPluginsStartMock.createStart().triggersActionsUi.actionTypeRegistry; + actionTypeRegistryMock.list.mockReturnValue([ + { id: '.server-log', iconClass: 'logsApp' }, + { id: '.slack', iconClass: 'logoSlack' }, + { id: '.email', iconClass: 'email' }, + { id: '.index', iconClass: 'indexOpen' }, + ]); + wrapper = mount( + + ); + } + + it("renders action connector icons for user's selected rule actions", async () => { + await setup(); + wrapper.debug(); + expect(wrapper.find('[data-euiicon-type]').length).toBe(2); + expect(wrapper.find('[data-euiicon-type="logsApp"]').length).toBe(1); + expect(wrapper.find('[data-euiicon-type="logoSlack"]').length).toBe(1); + expect(wrapper.find('[data-euiicon-type="index"]').length).toBe(0); + expect(wrapper.find('[data-euiicon-type="email"]').length).toBe(0); + }); +}); diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx index e450404120e89..d3dbe3cf4bdef 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx @@ -15,24 +15,13 @@ import { EuiLoadingSpinner, } from '@elastic/eui'; import { intersectionBy } from 'lodash'; +import { suspendedComponentWithProps } from '@kbn/triggers-actions-ui-plugin/public'; import { i18n } from '@kbn/i18n'; import { ActionsProps } from '../types'; import { useFetchRuleActions } from '../../../hooks/use_fetch_rule_actions'; import { useKibana } from '../../../utils/kibana_react'; -interface MapActionTypeIcon { - [key: string]: string | IconType; -} -const mapActionTypeIcon: MapActionTypeIcon = { - /* TODO: Add the rest of the application logs (SVGs ones) */ - '.server-log': 'logsApp', - '.email': 'email', - '.pagerduty': 'apps', - '.index': 'indexOpen', - '.slack': 'logoSlack', - '.webhook': 'logoWebhook', -}; -export function Actions({ ruleActions }: ActionsProps) { +export function Actions({ ruleActions, actionTypeRegistry }: ActionsProps) { const { http, notifications: { toasts }, @@ -53,22 +42,31 @@ export function Actions({ ruleActions }: ActionsProps) { ); + + function getActionIconClass(actionGroupId?: string): IconType | undefined { + const actionGroup = actionTypeRegistry.list().find((group) => group.id === actionGroupId); + return typeof actionGroup?.iconClass === 'string' + ? actionGroup?.iconClass + : suspendedComponentWithProps(actionGroup?.iconClass as React.ComponentType); + } const actions = intersectionBy(allActions, ruleActions, 'actionTypeId'); if (isLoadingActions) return ; return ( - {actions.map((action) => ( - <> - + {actions.map(({ actionTypeId, name }) => ( + + - + - - {action.name} + + + {name} + - + ))} ); diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index 96af4de1eb053..99000a91671b8 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -68,6 +68,7 @@ export function RuleDetailsPage() { ruleTypeRegistry, getRuleStatusDropdown, getEditAlertFlyout, + actionTypeRegistry, getRuleEventLogList, }, application: { capabilities, navigateToUrl }, @@ -481,7 +482,7 @@ export function RuleDetailsPage() { })} - + diff --git a/x-pack/plugins/observability/public/pages/rule_details/types.ts b/x-pack/plugins/observability/public/pages/rule_details/types.ts index 0ce91d0481dd9..4b1c62f7dbb9a 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/types.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/types.ts @@ -6,7 +6,12 @@ */ import { HttpSetup } from '@kbn/core/public'; -import { Rule, RuleSummary, RuleType } from '@kbn/triggers-actions-ui-plugin/public'; +import { + Rule, + RuleSummary, + RuleType, + ActionTypeRegistryContract, +} from '@kbn/triggers-actions-ui-plugin/public'; export interface RuleDetailsPathParams { ruleId: string; @@ -68,6 +73,7 @@ export interface ItemValueRuleSummaryProps { } export interface ActionsProps { ruleActions: any[]; + actionTypeRegistry: ActionTypeRegistryContract; } export const EVENT_LOG_LIST_TAB = 'rule_event_log_list'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/logo.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/logo.tsx index f65f66587ba74..dad43f666ad0a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/logo.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/logo.tsx @@ -6,16 +6,17 @@ */ import React from 'react'; +import { LogoProps } from '../types'; -const Logo = () => ( +const Logo = (props: LogoProps) => ( x-logo diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 4580600b4bff8..8295fada788e3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -90,6 +90,7 @@ export { loadRuleAggregations, loadRuleTags } from './application/lib/rule_api/a export { useLoadRuleTypes } from './application/hooks/use_load_rule_types'; export { loadRule } from './application/lib/rule_api/get_rule'; export { loadAllActions } from './application/lib/action_connector_api'; +export { suspendedComponentWithProps } from './application/lib/suspended_component_with_props'; export { loadActionTypes } from './application/lib/action_connector_api/connector_types'; export { NOTIFY_WHEN_OPTIONS } from './application/sections/rule_form/rule_notify_when'; export type { TIME_UNITS } from './application/constants'; From f75b6fa1561fb8592a493c41c08302fddd136760 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Fri, 20 May 2022 12:50:46 +0300 Subject: [PATCH 095/150] [XY] Add `addTimeMarker` arg (#131495) * Add `addTimeMarker` arg * Some fixes * Update validation * Fix snapshots * Some fixes after merge * Add unit tests * Fix CI * Update src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx Co-authored-by: Yaroslav Kuznietsov * Fixed tests * Fix checks Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Yaroslav Kuznietsov --- .../expression_xy/common/__mocks__/index.ts | 6 +- .../__snapshots__/xy_vis.test.ts.snap | 2 + .../common_data_layer_args.ts | 1 - .../expression_functions/common_xy_args.ts | 5 + .../expression_functions/layered_xy_vis_fn.ts | 4 +- .../common/expression_functions/validate.ts | 14 + .../expression_functions/xy_vis.test.ts | 40 +- .../common/expression_functions/xy_vis_fn.ts | 2 + .../common/helpers/visualization.ts | 7 +- .../expression_xy/common/i18n/index.tsx | 4 + .../common/types/expression_functions.ts | 3 + .../__snapshots__/xy_chart.test.tsx.snap | 880 +++++++++--------- .../public/components/data_layers.tsx | 4 + .../public/components/xy_chart.test.tsx | 13 +- .../public/components/xy_chart.tsx | 16 +- .../public/components/xy_current_time.tsx | 26 + .../public/helpers/data_layers.tsx | 4 +- .../expression_xy/public/helpers/interval.ts | 5 +- 18 files changed, 580 insertions(+), 456 deletions(-) create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/xy_current_time.tsx diff --git a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts index 76e524960b159..1f19428e420bf 100644 --- a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts @@ -35,7 +35,7 @@ export const createSampleDatatableWithRows = (rows: DatatableRow[]): Datatable = id: 'c', name: 'c', meta: { - type: 'string', + type: 'date', field: 'order_date', sourceParams: { type: 'date-histogram', params: { interval: 'auto' } }, params: { id: 'string' }, @@ -128,8 +128,8 @@ export const createArgsWithLayers = ( export function sampleArgs() { const data = createSampleDatatableWithRows([ - { a: 1, b: 2, c: 'I', d: 'Foo' }, - { a: 1, b: 5, c: 'J', d: 'Bar' }, + { a: 1, b: 2, c: 1652034840000, d: 'Foo' }, + { a: 1, b: 5, c: 1652122440000, d: 'Bar' }, ]); return { diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap index 05109cc65446b..e396aace05191 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap @@ -1,5 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`xyVis it should throw error if addTimeMarker applied for not time chart 1`] = `"Only time charts can have current time marker"`; + exports[`xyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 1`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; exports[`xyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 2`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts index 0c9085cce7664..f4543c5236ce2 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts @@ -36,7 +36,6 @@ export const commonDataLayerArgs: Omit< xScaleType: { options: [...Object.values(XScaleTypes)], help: strings.getXScaleTypeHelp(), - default: XScaleTypes.ORDINAL, strict: true, }, isHistogram: { diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts index 0921760f9f676..2e2e6765734cf 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts @@ -128,6 +128,11 @@ export const commonXYArgs: CommonXYFn['args'] = { types: ['string'], help: strings.getAriaLabelHelp(), }, + addTimeMarker: { + types: ['boolean'], + default: false, + help: strings.getAddTimeMakerHelp(), + }, markSizeRatio: { types: ['number'], help: strings.getMarkSizeRatioHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts index 29624d8037393..fb7c91c682847 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts @@ -7,15 +7,16 @@ */ import { XY_VIS_RENDERER } from '../constants'; -import { appendLayerIds, getDataLayers } from '../helpers'; import { LayeredXyVisFn } from '../types'; import { logDatatables } from '../utils'; import { validateMarkSizeRatioLimits, + validateAddTimeMarker, validateMinTimeBarInterval, hasBarLayer, errors, } from './validate'; +import { appendLayerIds, getDataLayers } from '../helpers'; export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) => { const layers = appendLayerIds(args.layers ?? [], 'layers'); @@ -24,6 +25,7 @@ export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) const dataLayers = getDataLayers(layers); const hasBar = hasBarLayer(dataLayers); + validateAddTimeMarker(dataLayers, args.addTimeMarker); validateMarkSizeRatioLimits(args.markSizeRatio); validateMinTimeBarInterval(dataLayers, hasBar, args.minTimeBarInterval); const hasMarkSizeAccessors = diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts index 60e590b0f8cca..df7f9ee08632e 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts @@ -17,6 +17,7 @@ import { CommonXYDataLayerConfigResult, ValueLabelMode, CommonXYDataLayerConfig, + ExtendedDataLayerConfigResult, } from '../types'; import { isTimeChart } from '../helpers'; @@ -58,6 +59,10 @@ export const errors = { i18n.translate('expressionXY.reusable.function.xyVis.errors.dataBoundsForNotLineChartError', { defaultMessage: 'Only line charts can be fit to the data bounds', }), + timeMarkerForNotTimeChartsError: () => + i18n.translate('expressionXY.reusable.function.xyVis.errors.timeMarkerForNotTimeChartsError', { + defaultMessage: 'Only time charts can have current time marker', + }), isInvalidIntervalError: () => i18n.translate('expressionXY.reusable.function.xyVis.errors.isInvalidIntervalError', { defaultMessage: @@ -135,6 +140,15 @@ export const validateValueLabels = ( } }; +export const validateAddTimeMarker = ( + dataLayers: Array, + addTimeMarker?: boolean +) => { + if (addTimeMarker && !isTimeChart(dataLayers)) { + throw new Error(errors.timeMarkerForNotTimeChartsError()); + } +}; + export const validateMarkSizeForChartType = ( markSizeAccessor: ExpressionValueVisDimension | string | undefined, seriesType: SeriesType diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts index 73d4444217d90..8a327ccca9e20 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts @@ -7,7 +7,6 @@ */ import { xyVisFunction } from '.'; -import { Datatable } from '@kbn/expressions-plugin/common'; import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; import { sampleArgs, sampleLayer } from '../__mocks__'; import { XY_VIS } from '../constants'; @@ -15,26 +14,10 @@ import { XY_VIS } from '../constants'; describe('xyVis', () => { test('it renders with the specified data and args', async () => { const { data, args } = sampleArgs(); - const newData = { - ...data, - type: 'datatable', - - columns: data.columns.map((c) => - c.id !== 'c' - ? c - : { - ...c, - meta: { - type: 'string', - }, - } - ), - } as Datatable; - const { layers, ...rest } = args; const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; const result = await xyVisFunction.fn( - newData, + data, { ...rest, ...restLayerArgs, referenceLines: [], annotationLayers: [] }, createMockExecutionContext() ); @@ -45,7 +28,7 @@ describe('xyVis', () => { value: { args: { ...rest, - layers: [{ layerType, table: newData, layerId: 'dataLayers-0', type, ...restLayerArgs }], + layers: [{ layerType, table: data, layerId: 'dataLayers-0', type, ...restLayerArgs }], }, }, }); @@ -120,6 +103,25 @@ describe('xyVis', () => { ).rejects.toThrowErrorMatchingSnapshot(); }); + test('it should throw error if addTimeMarker applied for not time chart', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...restLayerArgs, + addTimeMarker: true, + referenceLines: [], + annotationLayers: [], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + test('it should throw error if splitRowAccessor is pointing to the absent column', async () => { const { data, args } = sampleArgs(); const { layers, ...rest } = args; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index 3de2dd35831e4..4c25e3378d523 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -25,6 +25,7 @@ import { validateFillOpacity, validateMarkSizeRatioLimits, validateValueLabels, + validateAddTimeMarker, validateMinTimeBarInterval, validateMarkSizeForChartType, validateMarkSizeRatioWithAccessor, @@ -107,6 +108,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateExtent(args.yLeftExtent, hasBar || hasArea, dataLayers); validateExtent(args.yRightExtent, hasBar || hasArea, dataLayers); validateFillOpacity(args.fillOpacity, hasArea); + validateAddTimeMarker(dataLayers, args.addTimeMarker); validateMinTimeBarInterval(dataLayers, hasBar, args.minTimeBarInterval); const hasNotHistogramBars = !hasHistogramBarLayer(dataLayers); diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts index 8ddbc4bc97f10..66d4c11a9f7ae 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts @@ -6,13 +6,16 @@ * Side Public License, v 1. */ +import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { XScaleTypes } from '../constants'; import { CommonXYDataLayerConfigResult } from '../types'; export function isTimeChart(layers: CommonXYDataLayerConfigResult[]) { return layers.every( (l): l is CommonXYDataLayerConfigResult => - l.table.columns.find((col) => col.id === l.xAccessor)?.meta.type === 'date' && - l.xScaleType === XScaleTypes.TIME + (l.xAccessor + ? getColumnByAccessor(l.xAccessor, l.table.columns)?.meta.type === 'date' + : false) && + (!l.xScaleType || l.xScaleType === XScaleTypes.TIME) ); } diff --git a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx index ba26bb973f64f..ed2ef4a7a57ce 100644 --- a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx @@ -121,6 +121,10 @@ export const strings = { i18n.translate('expressionXY.xyVis.ariaLabel.help', { defaultMessage: 'Specifies the aria label of the xy chart', }), + getAddTimeMakerHelp: () => + i18n.translate('expressionXY.xyVis.addTimeMaker.help', { + defaultMessage: 'Show time marker', + }), getMarkSizeRatioHelp: () => i18n.translate('expressionXY.xyVis.markSizeRatio.help', { defaultMessage: 'Specifies the ratio of the dots at the line and area charts', diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index 0a7b93c495c29..c0336fc67536f 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -207,6 +207,7 @@ export interface XYArgs extends DataLayerArgs { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + addTimeMarker?: boolean; markSizeRatio?: number; minTimeBarInterval?: string; splitRowAccessor?: ExpressionValueVisDimension | string; @@ -236,6 +237,7 @@ export interface LayeredXYArgs { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + addTimeMarker?: boolean; markSizeRatio?: number; minTimeBarInterval?: string; } @@ -263,6 +265,7 @@ export interface XYProps { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + addTimeMarker?: boolean; markSizeRatio?: number; minTimeBarInterval?: string; splitRowAccessor?: ExpressionValueVisDimension | string; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap index e7a26ec20bbfc..c3d1fc980ad01 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap @@ -334,6 +334,10 @@ exports[`XYChart component it renders area 1`] = ` } } /> + + + + + + + + + + = ({ @@ -67,6 +69,7 @@ export const DataLayers: FC = ({ shouldShowValueLabels, formattedDatatables, chartHasMoreThanOneBarSeries, + defaultXScaleType, }) => { const colorAssignments = getColorAssignments(layers, formatFactory); return ( @@ -104,6 +107,7 @@ export const DataLayers: FC = ({ timeZone, emphasizeFitting, fillOpacity, + defaultXScaleType, }); const index = `${layer.layerId}-${accessorIndex}`; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx index d03a5e648f366..91e5ae8ad1484 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx @@ -1967,17 +1967,10 @@ describe('XYChart component', () => { test('it should pass the formatter function to the axis', () => { const { args } = sampleArgs(); - const instance = shallow(); - - const tickFormatter = instance.find(Axis).first().prop('tickFormat'); - - if (!tickFormatter) { - throw new Error('tickFormatter prop not found'); - } - - tickFormatter('I'); + shallow(); - expect(convertSpy).toHaveBeenCalledWith('I'); + expect(convertSpy).toHaveBeenCalledWith(1652034840000); + expect(convertSpy).toHaveBeenCalledWith(1652122440000); }); test('it should set the tickLabel visibility on the x axis if the tick labels is hidden', () => { diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index 80048bcb84038..7eceb72ecf75d 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -42,6 +42,7 @@ import { LegendSizeToPixels, } from '@kbn/visualizations-plugin/common/constants'; import type { FilterEvent, BrushEvent, FormatFactory } from '../types'; +import { isTimeChart } from '../../common/helpers'; import type { CommonXYDataLayerConfig, ExtendedYConfig, @@ -81,8 +82,10 @@ import { OUTSIDE_RECT_ANNOTATION_WIDTH, OUTSIDE_RECT_ANNOTATION_WIDTH_SUGGESTION, } from './annotations'; -import { AxisExtentModes, SeriesTypes, ValueLabelModes } from '../../common/constants'; +import { AxisExtentModes, SeriesTypes, ValueLabelModes, XScaleTypes } from '../../common/constants'; import { DataLayers } from './data_layers'; +import { XYCurrentTime } from './xy_current_time'; + import './xy_chart.scss'; declare global { @@ -249,7 +252,10 @@ export function XYChart({ filteredBarLayers.some((layer) => layer.accessors.length > 1) || filteredBarLayers.some((layer) => isDataLayer(layer) && layer.splitAccessor); - const isTimeViz = Boolean(dataLayers.every((l) => l.xScaleType === 'time')); + const isTimeViz = isTimeChart(dataLayers); + + const defaultXScaleType = isTimeViz ? XScaleTypes.TIME : XScaleTypes.ORDINAL; + const isHistogramViz = dataLayers.every((l) => l.isHistogram); const { baseDomain: rawXDomain, extendedDomain: xDomain } = getXDomain( @@ -604,6 +610,11 @@ export function XYChart({ ariaLabel={args.ariaLabel} ariaUseDefaultSummary={!args.ariaLabel} /> + )} {referenceLineLayers.length ? ( diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_current_time.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_current_time.tsx new file mode 100644 index 0000000000000..68f1dd0d60b13 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_current_time.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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, { FC } from 'react'; +import { DomainRange } from '@elastic/charts'; +import { CurrentTime } from '@kbn/charts-plugin/public'; + +interface XYCurrentTime { + enabled: boolean; + isDarkMode: boolean; + domain?: DomainRange; +} + +export const XYCurrentTime: FC = ({ enabled, isDarkMode, domain }) => { + if (!enabled) { + return null; + } + + const domainEnd = domain && 'max' in domain ? domain.max : undefined; + return ; +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx index 7ac661ed9709d..08761f633f851 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx @@ -53,6 +53,7 @@ type GetSeriesPropsFn = (config: { emphasizeFitting?: boolean; fillOpacity?: number; formattedDatatableInfo: DatatableWithFormatInfo; + defaultXScaleType: XScaleType; }) => SeriesSpec; type GetSeriesNameFn = ( @@ -280,6 +281,7 @@ export const getSeriesProps: GetSeriesPropsFn = ({ emphasizeFitting, fillOpacity, formattedDatatableInfo, + defaultXScaleType, }): SeriesSpec => { const { table, markSizeAccessor } = layer; const isStacked = layer.seriesType.includes('stacked'); @@ -342,7 +344,7 @@ export const getSeriesProps: GetSeriesPropsFn = ({ markSizeAccessor: markSizeColumnId, markFormat: (value) => markFormatter.convert(value), data: rows, - xScaleType: xColumnId ? layer.xScaleType : 'ordinal', + xScaleType: xColumnId ? layer.xScaleType ?? defaultXScaleType : 'ordinal', yScaleType: formatter?.id === 'bytes' && yAxis?.scale === ScaleType.Linear ? ScaleType.LinearBinary diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts index a9f68ffc0a29b..5c202bb6200a9 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts @@ -9,13 +9,14 @@ import { search } from '@kbn/data-plugin/public'; import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { XYChartProps } from '../../common'; +import { isTimeChart } from '../../common/helpers'; import { getFilteredLayers } from './layers'; -import { isDataLayer } from './visualization'; +import { isDataLayer, getDataLayers } from './visualization'; export function calculateMinInterval({ args: { layers, minTimeBarInterval } }: XYChartProps) { const filteredLayers = getFilteredLayers(layers); if (filteredLayers.length === 0) return; - const isTimeViz = filteredLayers.every((l) => isDataLayer(l) && l.xScaleType === 'time'); + const isTimeViz = isTimeChart(getDataLayers(filteredLayers)); const xColumn = isDataLayer(filteredLayers[0]) && filteredLayers[0].xAccessor && From 569e10a6b81ae287b5395d1b3af83a441dd2d9ee Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Fri, 20 May 2022 11:51:04 +0200 Subject: [PATCH 096/150] expose docLinks from ConfigDeprecationContext (#132424) * expose docLinks from ConfigDeprecationContext * fix mock * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-config/BUILD.bazel | 2 ++ .../src/config_service.test.mocks.ts | 11 ++++++++++ .../kbn-config/src/config_service.test.ts | 11 +++++++++- packages/kbn-config/src/config_service.ts | 20 +++++++++++-------- .../deprecation/apply_deprecations.test.ts | 2 ++ .../src/deprecation/deprecations.mock.ts | 2 ++ packages/kbn-config/src/deprecation/types.ts | 3 +++ 7 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/kbn-config/BUILD.bazel b/packages/kbn-config/BUILD.bazel index 3567c549a77c4..e735e2cb346eb 100644 --- a/packages/kbn-config/BUILD.bazel +++ b/packages/kbn-config/BUILD.bazel @@ -38,6 +38,7 @@ RUNTIME_DEPS = [ "//packages/kbn-utility-types", "//packages/kbn-i18n", "//packages/kbn-plugin-discovery", + "//packages/kbn-doc-links", "@npm//js-yaml", "@npm//load-json-file", "@npm//lodash", @@ -54,6 +55,7 @@ TYPES_DEPS = [ "//packages/kbn-utility-types:npm_module_types", "//packages/kbn-i18n:npm_module_types", "//packages/kbn-plugin-discovery:npm_module_types", + "//packages/kbn-doc-links:npm_module_types", "@npm//load-json-file", "@npm//rxjs", "@npm//@types/jest", diff --git a/packages/kbn-config/src/config_service.test.mocks.ts b/packages/kbn-config/src/config_service.test.mocks.ts index 39aa551ae85f9..40379e69a3cb2 100644 --- a/packages/kbn-config/src/config_service.test.mocks.ts +++ b/packages/kbn-config/src/config_service.test.mocks.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import type { DocLinks } from '@kbn/doc-links'; + export const mockPackage = new Proxy({ raw: {} as any }, { get: (obj, prop) => obj.raw[prop] }); import type { applyDeprecations } from './deprecation/apply_deprecations'; @@ -26,3 +28,12 @@ export const mockApplyDeprecations = jest.fn< jest.mock('./deprecation/apply_deprecations', () => ({ applyDeprecations: mockApplyDeprecations, })); + +export const docLinksMock = { + settings: 'settings', +} as DocLinks; +export const getDocLinksMock = jest.fn().mockReturnValue(docLinksMock); + +jest.doMock('@kbn/doc-links', () => ({ + getDocLinks: getDocLinksMock, +})); diff --git a/packages/kbn-config/src/config_service.test.ts b/packages/kbn-config/src/config_service.test.ts index 51e67956637ee..b427af4e50229 100644 --- a/packages/kbn-config/src/config_service.test.ts +++ b/packages/kbn-config/src/config_service.test.ts @@ -9,7 +9,12 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { first, take } from 'rxjs/operators'; -import { mockApplyDeprecations, mockedChangedPaths } from './config_service.test.mocks'; +import { + mockApplyDeprecations, + mockedChangedPaths, + docLinksMock, + getDocLinksMock, +} from './config_service.test.mocks'; import { rawConfigServiceMock } from './raw/raw_config_service.mock'; import { schema } from '@kbn/config-schema'; @@ -39,6 +44,7 @@ const getRawConfigProvider = (rawConfig: Record) => beforeEach(() => { logger = loggerMock.create(); mockApplyDeprecations.mockClear(); + getDocLinksMock.mockClear(); }); test('returns config at path as observable', async () => { @@ -469,6 +475,7 @@ test('calls `applyDeprecations` with the correct parameters', async () => { const context: ConfigDeprecationContext = { branch: defaultEnv.packageInfo.branch, version: defaultEnv.packageInfo.version, + docLinks: docLinksMock, }; const deprecationA = jest.fn(); @@ -479,6 +486,8 @@ test('calls `applyDeprecations` with the correct parameters', async () => { await configService.validate(); + expect(getDocLinksMock).toHaveBeenCalledTimes(1); + expect(mockApplyDeprecations).toHaveBeenCalledTimes(1); expect(mockApplyDeprecations).toHaveBeenCalledWith( cfg, diff --git a/packages/kbn-config/src/config_service.ts b/packages/kbn-config/src/config_service.ts index bb7bb54e75ce5..0da30aad0e232 100644 --- a/packages/kbn-config/src/config_service.ts +++ b/packages/kbn-config/src/config_service.ts @@ -12,6 +12,7 @@ import { isEqual } from 'lodash'; import { BehaviorSubject, combineLatest, firstValueFrom, Observable } from 'rxjs'; import { distinctUntilChanged, first, map, shareReplay, tap } from 'rxjs/operators'; import { Logger, LoggerFactory } from '@kbn/logging'; +import { getDocLinks, DocLinks } from '@kbn/doc-links'; import { Config, ConfigPath, Env } from '.'; import { hasConfigPathIntersection } from './config'; @@ -42,6 +43,7 @@ export interface ConfigValidateParameters { export class ConfigService { private readonly log: Logger; private readonly deprecationLog: Logger; + private readonly docLinks: DocLinks; private validated = false; private readonly config$: Observable; @@ -67,6 +69,7 @@ export class ConfigService { ) { this.log = logger.get('config'); this.deprecationLog = logger.get('config', 'deprecation'); + this.docLinks = getDocLinks({ kibanaBranch: env.packageInfo.branch }); this.config$ = combineLatest([this.rawConfigProvider.getConfig$(), this.deprecations]).pipe( map(([rawConfig, deprecations]) => { @@ -104,7 +107,7 @@ export class ConfigService { ...provider(configDeprecationFactory).map((deprecation) => ({ deprecation, path: flatPath, - context: createDeprecationContext(this.env), + context: this.createDeprecationContext(), })), ]); } @@ -262,6 +265,14 @@ export class ConfigService { handledDeprecatedConfig.push(config); this.handledDeprecatedConfigs.set(domainId, handledDeprecatedConfig); } + + private createDeprecationContext(): ConfigDeprecationContext { + return { + branch: this.env.packageInfo.branch, + version: this.env.packageInfo.version, + docLinks: this.docLinks, + }; + } } const pathToString = (path: ConfigPath) => (Array.isArray(path) ? path.join('.') : path); @@ -272,10 +283,3 @@ const pathToString = (path: ConfigPath) => (Array.isArray(path) ? path.join('.') */ const isPathHandled = (path: string, handledPaths: string[]) => handledPaths.some((handledPath) => hasConfigPathIntersection(path, handledPath)); - -const createDeprecationContext = (env: Env): ConfigDeprecationContext => { - return { - branch: env.packageInfo.branch, - version: env.packageInfo.version, - }; -}; diff --git a/packages/kbn-config/src/deprecation/apply_deprecations.test.ts b/packages/kbn-config/src/deprecation/apply_deprecations.test.ts index 5acf725ba93a6..73e7b2b422017 100644 --- a/packages/kbn-config/src/deprecation/apply_deprecations.test.ts +++ b/packages/kbn-config/src/deprecation/apply_deprecations.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { DocLinks } from '@kbn/doc-links'; import { applyDeprecations } from './apply_deprecations'; import { ConfigDeprecation, ConfigDeprecationContext, ConfigDeprecationWithContext } from './types'; import { configDeprecationFactory as deprecations } from './deprecation_factory'; @@ -14,6 +15,7 @@ describe('applyDeprecations', () => { const context: ConfigDeprecationContext = { version: '7.16.2', branch: '7.16', + docLinks: {} as DocLinks, }; const wrapHandler = ( diff --git a/packages/kbn-config/src/deprecation/deprecations.mock.ts b/packages/kbn-config/src/deprecation/deprecations.mock.ts index 80b65c84b4879..06b467290b47e 100644 --- a/packages/kbn-config/src/deprecation/deprecations.mock.ts +++ b/packages/kbn-config/src/deprecation/deprecations.mock.ts @@ -6,12 +6,14 @@ * Side Public License, v 1. */ +import type { DocLinks } from '@kbn/doc-links'; import type { ConfigDeprecationContext } from './types'; const createMockedContext = (): ConfigDeprecationContext => { return { branch: 'master', version: '8.0.0', + docLinks: {} as DocLinks, }; }; diff --git a/packages/kbn-config/src/deprecation/types.ts b/packages/kbn-config/src/deprecation/types.ts index 052741c0b4be3..6d656ab97921f 100644 --- a/packages/kbn-config/src/deprecation/types.ts +++ b/packages/kbn-config/src/deprecation/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ import type { RecursiveReadonly } from '@kbn/utility-types'; +import type { DocLinks } from '@kbn/doc-links'; /** * Config deprecation hook used when invoking a {@link ConfigDeprecation} @@ -77,6 +78,8 @@ export interface ConfigDeprecationContext { version: string; /** The current Kibana branch, e.g `7.x`, `7.16`, `master` */ branch: string; + /** Allow direct access to the doc links from the deprecation handler */ + docLinks: DocLinks; } /** From 968f7a9ed3cdf15f0e337fef1954816571ca3041 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Fri, 20 May 2022 12:52:26 +0300 Subject: [PATCH 097/150] Remove `injectedMetadata` in `vega` (#132521) * Remove injectedMetadata in vega * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/vis_types/vega/public/data_model/search_api.ts | 3 +-- src/plugins/vis_types/vega/public/plugin.ts | 3 --- src/plugins/vis_types/vega/public/services.ts | 6 +----- src/plugins/vis_types/vega/public/vega_request_handler.ts | 3 +-- .../vega/public/vega_view/vega_map_view/view.test.ts | 2 -- .../vis_types/vega/public/vega_visualization.test.js | 3 --- 6 files changed, 3 insertions(+), 17 deletions(-) diff --git a/src/plugins/vis_types/vega/public/data_model/search_api.ts b/src/plugins/vis_types/vega/public/data_model/search_api.ts index 530449da9aa26..40238b445c8c2 100644 --- a/src/plugins/vis_types/vega/public/data_model/search_api.ts +++ b/src/plugins/vis_types/vega/public/data_model/search_api.ts @@ -8,7 +8,7 @@ import { combineLatest, from } from 'rxjs'; import { map, tap, switchMap } from 'rxjs/operators'; -import type { CoreStart, IUiSettingsClient, KibanaExecutionContext } from '@kbn/core/public'; +import type { IUiSettingsClient, KibanaExecutionContext } from '@kbn/core/public'; import { getSearchParamsFromRequest, SearchRequest, @@ -47,7 +47,6 @@ export const extendSearchParamsWithRuntimeFields = async ( export interface SearchAPIDependencies { uiSettings: IUiSettingsClient; - injectedMetadata: CoreStart['injectedMetadata']; search: DataPublicPluginStart['search']; indexPatterns: DataViewsPublicPluginStart; } diff --git a/src/plugins/vis_types/vega/public/plugin.ts b/src/plugins/vis_types/vega/public/plugin.ts index a95d646427306..c9af49f009dee 100644 --- a/src/plugins/vis_types/vega/public/plugin.ts +++ b/src/plugins/vis_types/vega/public/plugin.ts @@ -20,7 +20,6 @@ import { setDataViews, setInjectedVars, setUISettings, - setInjectedMetadata, setDocLinks, setMapsEms, } from './services'; @@ -73,7 +72,6 @@ export class VegaPlugin implements Plugin { ) { setInjectedVars({ enableExternalUrls: this.initializerContext.config.get().enableExternalUrls, - emsTileLayerId: core.injectedMetadata.getInjectedVar('emsTileLayerId', true), }); setUISettings(core.uiSettings); @@ -98,7 +96,6 @@ export class VegaPlugin implements Plugin { setNotifications(core.notifications); setData(data); setDataViews(dataViews); - setInjectedMetadata(core.injectedMetadata); setDocLinks(core.docLinks); setMapsEms(mapsEms); } diff --git a/src/plugins/vis_types/vega/public/services.ts b/src/plugins/vis_types/vega/public/services.ts index f7f0444803a00..304d9965f056d 100644 --- a/src/plugins/vis_types/vega/public/services.ts +++ b/src/plugins/vis_types/vega/public/services.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { CoreStart, NotificationsStart, IUiSettingsClient, DocLinksStart } from '@kbn/core/public'; +import { NotificationsStart, IUiSettingsClient, DocLinksStart } from '@kbn/core/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; @@ -24,12 +24,8 @@ export const [getNotifications, setNotifications] = export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); export const [getMapsEms, setMapsEms] = createGetterSetter('mapsEms'); -export const [getInjectedMetadata, setInjectedMetadata] = - createGetterSetter('InjectedMetadata'); - export const [getInjectedVars, setInjectedVars] = createGetterSetter<{ enableExternalUrls: boolean; - emsTileLayerId: unknown; }>('InjectedVars'); export const getEnableExternalUrls = () => getInjectedVars().enableExternalUrls; diff --git a/src/plugins/vis_types/vega/public/vega_request_handler.ts b/src/plugins/vis_types/vega/public/vega_request_handler.ts index 8670fd9499529..84b5663df0be6 100644 --- a/src/plugins/vis_types/vega/public/vega_request_handler.ts +++ b/src/plugins/vis_types/vega/public/vega_request_handler.ts @@ -15,7 +15,7 @@ import { TimeCache } from './data_model/time_cache'; import { VegaVisualizationDependencies } from './plugin'; import { VisParams } from './vega_fn'; -import { getData, getInjectedMetadata, getDataViews } from './services'; +import { getData, getDataViews } from './services'; import { VegaInspectorAdapters } from './vega_inspector'; interface VegaRequestHandlerParams { @@ -57,7 +57,6 @@ export function createVegaRequestHandler( uiSettings, search, indexPatterns: dataViews, - injectedMetadata: getInjectedMetadata(), }, context.abortSignal, context.inspectorAdapters, diff --git a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts index 6c0d693349ef6..eafe75534154a 100644 --- a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts +++ b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts @@ -116,7 +116,6 @@ describe('vega_map_view/view', () => { let vegaParser: VegaParser; setInjectedVars({ - emsTileLayerId: {}, enableExternalUrls: true, }); setData(dataPluginStart); @@ -150,7 +149,6 @@ describe('vega_map_view/view', () => { search: dataPluginStart.search, indexPatterns: dataViewsStart, uiSettings: coreStart.uiSettings, - injectedMetadata: coreStart.injectedMetadata, }), new TimeCache(dataPluginStart.query.timefilter.timefilter, 0), {}, diff --git a/src/plugins/vis_types/vega/public/vega_visualization.test.js b/src/plugins/vis_types/vega/public/vega_visualization.test.js index d1c821e962021..024d935a2f356 100644 --- a/src/plugins/vis_types/vega/public/vega_visualization.test.js +++ b/src/plugins/vis_types/vega/public/vega_visualization.test.js @@ -56,7 +56,6 @@ describe('VegaVisualizations', () => { beforeEach(() => { setInjectedVars({ - emsTileLayerId: {}, enableExternalUrls: true, }); setData(dataPluginStart); @@ -97,7 +96,6 @@ describe('VegaVisualizations', () => { search: dataPluginStart.search, indexPatterns: dataViewsPluginStart, uiSettings: coreStart.uiSettings, - injectedMetadata: coreStart.injectedMetadata, }), 0, 0, @@ -130,7 +128,6 @@ describe('VegaVisualizations', () => { search: dataPluginStart.search, indexPatterns: dataViewsPluginStart, uiSettings: coreStart.uiSettings, - injectedMetadata: coreStart.injectedMetadata, }), 0, 0, From f88b140f9f23869590df985097e9739859bfbec1 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Fri, 20 May 2022 12:16:22 +0200 Subject: [PATCH 098/150] [data.query] Add `getState()` api to retrieve whole `QueryState` (#132035) --- .../lib/sync_dashboard_filter_state.ts | 4 +- src/plugins/dashboard/public/locator.ts | 9 +- src/plugins/data/README.mdx | 2 + src/plugins/data/public/index.ts | 2 + src/plugins/data/public/query/index.tsx | 1 + src/plugins/data/public/query/mocks.ts | 2 + .../data/public/query/query_service.test.ts | 91 +++++++++++++++++++ .../data/public/query/query_service.ts | 13 ++- src/plugins/data/public/query/query_state.ts | 40 ++++++++ .../state_sync/connect_to_query_state.test.ts | 5 +- .../state_sync/connect_to_query_state.ts | 6 +- ...le.ts => create_query_state_observable.ts} | 27 +++--- .../data/public/query/state_sync/index.ts | 4 +- .../state_sync/sync_state_with_url.test.ts | 8 +- .../query/state_sync/sync_state_with_url.ts | 20 ++-- .../data/public/query/state_sync/types.ts | 23 ++--- src/plugins/discover/public/locator.ts | 11 ++- .../utils/get_visualize_list_item_link.ts | 12 ++- .../index_data_visualizer/locator/locator.ts | 4 +- x-pack/plugins/maps/public/locators.ts | 11 ++- .../saved_map/get_initial_refresh_config.ts | 4 +- .../saved_map/get_initial_time_filters.ts | 4 +- 22 files changed, 240 insertions(+), 63 deletions(-) create mode 100644 src/plugins/data/public/query/query_service.test.ts create mode 100644 src/plugins/data/public/query/query_state.ts rename src/plugins/data/public/query/state_sync/{create_global_query_observable.ts => create_query_state_observable.ts} (79%) diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts index ff64f4672922c..94c9d996499c3 100644 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts +++ b/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts @@ -20,7 +20,7 @@ import { Filter, Query, waitUntilNextSessionCompletes$, - QueryState, + GlobalQueryStateFromUrl, } from '../../services/data'; import { cleanFiltersForSerialize } from '.'; @@ -166,7 +166,7 @@ export const applyDashboardFilterState = ({ * time range and refresh interval to the query service. */ if (currentDashboardState.timeRestore) { - const globalQueryState = kbnUrlStateStorage.get('_g'); + const globalQueryState = kbnUrlStateStorage.get('_g'); if (!globalQueryState?.time) { if (savedDashboard.timeFrom && savedDashboard.timeTo) { timefilterService.setTime({ diff --git a/src/plugins/dashboard/public/locator.ts b/src/plugins/dashboard/public/locator.ts index 9c187ca0803cf..7649343e5bf6e 100644 --- a/src/plugins/dashboard/public/locator.ts +++ b/src/plugins/dashboard/public/locator.ts @@ -9,7 +9,12 @@ import type { SerializableRecord } from '@kbn/utility-types'; import { flow } from 'lodash'; import { type Filter } from '@kbn/es-query'; -import type { TimeRange, Query, QueryState, RefreshInterval } from '@kbn/data-plugin/public'; +import type { + TimeRange, + Query, + GlobalQueryStateFromUrl, + RefreshInterval, +} from '@kbn/data-plugin/public'; import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import { ViewMode } from '@kbn/embeddable-plugin/public'; @@ -155,7 +160,7 @@ export class DashboardAppLocatorDefinition implements LocatorDefinition( + path = setStateToKbnUrl( '_g', cleanEmptyKeys({ time: params.timeRange, diff --git a/src/plugins/data/README.mdx b/src/plugins/data/README.mdx index e24a949a0c2ec..a8cb06ff9e60b 100644 --- a/src/plugins/data/README.mdx +++ b/src/plugins/data/README.mdx @@ -91,6 +91,8 @@ function searchOnChange(indexPattern: IndexPattern, aggConfigs: AggConfigs) { ``` +You can also retrieve a snapshot of the whole `QueryState` by using `data.query.getState()` + ### Timefilter `data.query.timefilter` is responsible for the time range filter and the auto refresh behavior settings. diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 90169ca552ac2..0f50384893b18 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -255,6 +255,7 @@ export { createSavedQueryService, connectToQueryState, syncQueryStateWithUrl, + syncGlobalQueryStateWithUrl, getDefaultQuery, FilterManager, TimeHistory, @@ -280,6 +281,7 @@ export type { QueryStringContract, QuerySetup, TimefilterSetup, + GlobalQueryStateFromUrl, } from './query'; export type { AggsStart } from './search/aggs'; diff --git a/src/plugins/data/public/query/index.tsx b/src/plugins/data/public/query/index.tsx index f426573e1bd6c..392b8fda14417 100644 --- a/src/plugins/data/public/query/index.tsx +++ b/src/plugins/data/public/query/index.tsx @@ -15,3 +15,4 @@ export * from './saved_query'; export * from './persisted_log'; export * from './state_sync'; export type { QueryStringContract } from './query_string'; +export type { QueryState } from './query_state'; diff --git a/src/plugins/data/public/query/mocks.ts b/src/plugins/data/public/query/mocks.ts index 2ab15aab26db6..a2d73e5b5ce34 100644 --- a/src/plugins/data/public/query/mocks.ts +++ b/src/plugins/data/public/query/mocks.ts @@ -21,6 +21,7 @@ const createSetupContractMock = () => { timefilter: timefilterServiceMock.createSetupContract(), queryString: queryStringManagerMock.createSetupContract(), state$: new Observable(), + getState: jest.fn(), }; return setupContract; @@ -33,6 +34,7 @@ const createStartContractMock = () => { queryString: queryStringManagerMock.createStartContract(), savedQueries: jest.fn() as any, state$: new Observable(), + getState: jest.fn(), timefilter: timefilterServiceMock.createStartContract(), getEsQuery: jest.fn(), }; diff --git a/src/plugins/data/public/query/query_service.test.ts b/src/plugins/data/public/query/query_service.test.ts new file mode 100644 index 0000000000000..5eb6815c3ba20 --- /dev/null +++ b/src/plugins/data/public/query/query_service.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { FilterStateStore } from '@kbn/es-query'; +import { FilterManager } from './filter_manager'; +import { QueryStringContract } from './query_string'; +import { getFilter } from './filter_manager/test_helpers/get_stub_filter'; +import { UI_SETTINGS } from '../../common'; +import { coreMock } from '@kbn/core/public/mocks'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { QueryService, QueryStart } from './query_service'; +import { StubBrowserStorage } from '@kbn/test-jest-helpers'; +import { TimefilterContract } from './timefilter'; +import { createNowProviderMock } from '../now_provider/mocks'; + +const setupMock = coreMock.createSetup(); +const startMock = coreMock.createStart(); + +setupMock.uiSettings.get.mockImplementation((key: string) => { + switch (key) { + case UI_SETTINGS.FILTERS_PINNED_BY_DEFAULT: + return true; + case UI_SETTINGS.SEARCH_QUERY_LANGUAGE: + return 'kuery'; + case UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS: + return { from: 'now-15m', to: 'now' }; + case UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: + return { pause: false, value: 0 }; + default: + throw new Error(`query_service test: not mocked uiSetting: ${key}`); + } +}); + +describe('query_service', () => { + let queryServiceStart: QueryStart; + let filterManager: FilterManager; + let timeFilter: TimefilterContract; + let queryStringManager: QueryStringContract; + + beforeEach(() => { + const queryService = new QueryService(); + queryService.setup({ + uiSettings: setupMock.uiSettings, + storage: new Storage(new StubBrowserStorage()), + nowProvider: createNowProviderMock(), + }); + queryServiceStart = queryService.start({ + uiSettings: setupMock.uiSettings, + storage: new Storage(new StubBrowserStorage()), + http: startMock.http, + }); + filterManager = queryServiceStart.filterManager; + timeFilter = queryServiceStart.timefilter.timefilter; + queryStringManager = queryServiceStart.queryString; + }); + + test('state is initialized with state from query service', () => { + const state = queryServiceStart.getState(); + + expect(state).toEqual({ + filters: filterManager.getFilters(), + refreshInterval: timeFilter.getRefreshInterval(), + time: timeFilter.getTime(), + query: queryStringManager.getQuery(), + }); + }); + + test('state is updated when underlying state in service updates', () => { + const filters = [getFilter(FilterStateStore.GLOBAL_STATE, true, true, 'key1', 'value1')]; + const query = { language: 'kql', query: 'query' }; + const time = { from: new Date().toISOString(), to: new Date().toISOString() }; + const refreshInterval = { pause: false, value: 10 }; + + filterManager.setFilters(filters); + queryStringManager.setQuery(query); + timeFilter.setTime(time); + timeFilter.setRefreshInterval(refreshInterval); + + expect(queryServiceStart.getState()).toEqual({ + filters, + refreshInterval, + time, + query, + }); + }); +}); diff --git a/src/plugins/data/public/query/query_service.ts b/src/plugins/data/public/query/query_service.ts index 1b634fda28996..8b309c9821d3e 100644 --- a/src/plugins/data/public/query/query_service.ts +++ b/src/plugins/data/public/query/query_service.ts @@ -15,7 +15,8 @@ import { createAddToQueryLog } from './lib'; import { TimefilterService } from './timefilter'; import type { TimefilterSetup } from './timefilter'; import { createSavedQueryService } from './saved_query/saved_query_service'; -import { createQueryStateObservable } from './state_sync/create_global_query_observable'; +import { createQueryStateObservable } from './state_sync/create_query_state_observable'; +import { getQueryState } from './query_state'; import type { QueryStringContract } from './query_string'; import { QueryStringManager } from './query_string'; import { getEsQueryConfig, TimeRange } from '../../common'; @@ -69,6 +70,7 @@ export class QueryService { timefilter: this.timefilter, queryString: this.queryStringManager, state$: this.state$, + getState: () => this.getQueryState(), }; } @@ -82,6 +84,7 @@ export class QueryService { queryString: this.queryStringManager, savedQueries: createSavedQueryService(http), state$: this.state$, + getState: () => this.getQueryState(), timefilter: this.timefilter, getEsQuery: (indexPattern: IndexPattern, timeRange?: TimeRange) => { const timeFilter = this.timefilter.timefilter.createFilter(indexPattern, timeRange); @@ -99,6 +102,14 @@ export class QueryService { public stop() { // nothing to do here yet } + + private getQueryState() { + return getQueryState({ + timefilter: this.timefilter, + queryString: this.queryStringManager, + filterManager: this.filterManager, + }); + } } /** @public */ diff --git a/src/plugins/data/public/query/query_state.ts b/src/plugins/data/public/query/query_state.ts new file mode 100644 index 0000000000000..77242c981bda2 --- /dev/null +++ b/src/plugins/data/public/query/query_state.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Filter } from '@kbn/es-query'; +import type { TimefilterSetup } from './timefilter'; +import type { FilterManager } from './filter_manager'; +import type { QueryStringContract } from './query_string'; +import type { RefreshInterval, TimeRange, Query } from '../../common'; + +/** + * All query state service state + */ +export interface QueryState { + time?: TimeRange; + refreshInterval?: RefreshInterval; + filters?: Filter[]; + query?: Query; +} + +export function getQueryState({ + timefilter: { timefilter }, + filterManager, + queryString, +}: { + timefilter: TimefilterSetup; + filterManager: FilterManager; + queryString: QueryStringContract; +}): QueryState { + return { + time: timefilter.getTime(), + refreshInterval: timefilter.getRefreshInterval(), + filters: filterManager.getFilters(), + query: queryString.getQuery(), + }; +} diff --git a/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts b/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts index d1d3ea5865c7e..515cc38783cbd 100644 --- a/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts +++ b/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts @@ -7,16 +7,17 @@ */ import { Subscription } from 'rxjs'; +import { Filter, FilterStateStore } from '@kbn/es-query'; import { FilterManager } from '../filter_manager'; import { getFilter } from '../filter_manager/test_helpers/get_stub_filter'; -import { Filter, FilterStateStore, UI_SETTINGS } from '../../../common'; +import { UI_SETTINGS } from '../../../common'; import { coreMock } from '@kbn/core/public/mocks'; import { BaseStateContainer, createStateContainer, Storage } from '@kbn/kibana-utils-plugin/public'; import { QueryService, QueryStart } from '../query_service'; import { StubBrowserStorage } from '@kbn/test-jest-helpers'; import { connectToQueryState } from './connect_to_query_state'; import { TimefilterContract } from '../timefilter'; -import { QueryState } from './types'; +import { QueryState } from '../query_state'; import { createNowProviderMock } from '../../now_provider/mocks'; const connectToQueryGlobalState = (query: QueryStart, state: BaseStateContainer) => diff --git a/src/plugins/data/public/query/state_sync/connect_to_query_state.ts b/src/plugins/data/public/query/state_sync/connect_to_query_state.ts index b9bb05841f161..a625dff04b0a3 100644 --- a/src/plugins/data/public/query/state_sync/connect_to_query_state.ts +++ b/src/plugins/data/public/query/state_sync/connect_to_query_state.ts @@ -9,10 +9,12 @@ import { Subscription } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import _ from 'lodash'; +import { COMPARE_ALL_OPTIONS, compareFilters } from '@kbn/es-query'; import { BaseStateContainer } from '@kbn/kibana-utils-plugin/public'; import { QuerySetup, QueryStart } from '../query_service'; -import { QueryState, QueryStateChange } from './types'; -import { FilterStateStore, COMPARE_ALL_OPTIONS, compareFilters } from '../../../common'; +import { QueryState } from '../query_state'; +import { QueryStateChange } from './types'; +import { FilterStateStore } from '../../../common'; import { validateTimeRange } from '../timefilter'; /** diff --git a/src/plugins/data/public/query/state_sync/create_global_query_observable.ts b/src/plugins/data/public/query/state_sync/create_query_state_observable.ts similarity index 79% rename from src/plugins/data/public/query/state_sync/create_global_query_observable.ts rename to src/plugins/data/public/query/state_sync/create_query_state_observable.ts index 2e054229a55da..39e7802753ee2 100644 --- a/src/plugins/data/public/query/state_sync/create_global_query_observable.ts +++ b/src/plugins/data/public/query/state_sync/create_query_state_observable.ts @@ -8,16 +8,16 @@ import { Observable, Subscription } from 'rxjs'; import { map, tap } from 'rxjs/operators'; -import { isFilterPinned } from '@kbn/es-query'; +import { COMPARE_ALL_OPTIONS, compareFilters, isFilterPinned } from '@kbn/es-query'; import { createStateContainer } from '@kbn/kibana-utils-plugin/public'; import type { TimefilterSetup } from '../timefilter'; import { FilterManager } from '../filter_manager'; -import { QueryState, QueryStateChange } from '.'; -import { compareFilters, COMPARE_ALL_OPTIONS } from '../../../common'; +import { getQueryState, QueryState } from '../query_state'; +import { QueryStateChange } from './types'; import type { QueryStringContract } from '../query_string'; export function createQueryStateObservable({ - timefilter: { timefilter }, + timefilter, filterManager, queryString, }: { @@ -25,27 +25,24 @@ export function createQueryStateObservable({ filterManager: FilterManager; queryString: QueryStringContract; }): Observable<{ changes: QueryStateChange; state: QueryState }> { - return new Observable((subscriber) => { - const state = createStateContainer({ - time: timefilter.getTime(), - refreshInterval: timefilter.getRefreshInterval(), - filters: filterManager.getFilters(), - query: queryString.getQuery(), - }); + const state = createStateContainer( + getQueryState({ timefilter, filterManager, queryString }) + ); + return new Observable((subscriber) => { let currentChange: QueryStateChange = {}; const subs: Subscription[] = [ queryString.getUpdates$().subscribe(() => { currentChange.query = true; state.set({ ...state.get(), query: queryString.getQuery() }); }), - timefilter.getTimeUpdate$().subscribe(() => { + timefilter.timefilter.getTimeUpdate$().subscribe(() => { currentChange.time = true; - state.set({ ...state.get(), time: timefilter.getTime() }); + state.set({ ...state.get(), time: timefilter.timefilter.getTime() }); }), - timefilter.getRefreshIntervalUpdate$().subscribe(() => { + timefilter.timefilter.getRefreshIntervalUpdate$().subscribe(() => { currentChange.refreshInterval = true; - state.set({ ...state.get(), refreshInterval: timefilter.getRefreshInterval() }); + state.set({ ...state.get(), refreshInterval: timefilter.timefilter.getRefreshInterval() }); }), filterManager.getUpdates$().subscribe(() => { currentChange.filters = true; diff --git a/src/plugins/data/public/query/state_sync/index.ts b/src/plugins/data/public/query/state_sync/index.ts index 58740cfab06d0..ffeda864f5172 100644 --- a/src/plugins/data/public/query/state_sync/index.ts +++ b/src/plugins/data/public/query/state_sync/index.ts @@ -7,5 +7,5 @@ */ export { connectToQueryState } from './connect_to_query_state'; -export { syncQueryStateWithUrl } from './sync_state_with_url'; -export type { QueryState, QueryStateChange } from './types'; +export { syncQueryStateWithUrl, syncGlobalQueryStateWithUrl } from './sync_state_with_url'; +export type { QueryStateChange, GlobalQueryStateFromUrl } from './types'; diff --git a/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts index edeaa7c772575..feb9fc5238ab6 100644 --- a/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts +++ b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts @@ -21,7 +21,7 @@ import { QueryService, QueryStart } from '../query_service'; import { StubBrowserStorage } from '@kbn/test-jest-helpers'; import { TimefilterContract } from '../timefilter'; import { syncQueryStateWithUrl } from './sync_state_with_url'; -import { QueryState } from './types'; +import { GlobalQueryStateFromUrl } from './types'; import { createNowProviderMock } from '../../now_provider/mocks'; const setupMock = coreMock.createSetup(); @@ -100,14 +100,14 @@ describe('sync_query_state_with_url', () => { test('when filters change, global filters synced to urlStorage', () => { const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage); filterManager.setFilters([gF, aF]); - expect(kbnUrlStateStorage.get('_g')?.filters).toHaveLength(1); + expect(kbnUrlStateStorage.get('_g')?.filters).toHaveLength(1); stop(); }); test('when time range changes, time synced to urlStorage', () => { const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage); timefilter.setTime({ from: 'now-30m', to: 'now' }); - expect(kbnUrlStateStorage.get('_g')?.time).toEqual({ + expect(kbnUrlStateStorage.get('_g')?.time).toEqual({ from: 'now-30m', to: 'now', }); @@ -117,7 +117,7 @@ describe('sync_query_state_with_url', () => { test('when refresh interval changes, refresh interval is synced to urlStorage', () => { const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage); timefilter.setRefreshInterval({ pause: true, value: 100 }); - expect(kbnUrlStateStorage.get('_g')?.refreshInterval).toEqual({ + expect(kbnUrlStateStorage.get('_g')?.refreshInterval).toEqual({ pause: true, value: 100, }); diff --git a/src/plugins/data/public/query/state_sync/sync_state_with_url.ts b/src/plugins/data/public/query/state_sync/sync_state_with_url.ts index fd52ca5ffc979..030cc1f91d4fe 100644 --- a/src/plugins/data/public/query/state_sync/sync_state_with_url.ts +++ b/src/plugins/data/public/query/state_sync/sync_state_with_url.ts @@ -13,17 +13,17 @@ import { } from '@kbn/kibana-utils-plugin/public'; import { QuerySetup, QueryStart } from '../query_service'; import { connectToQueryState } from './connect_to_query_state'; -import { QueryState } from './types'; import { FilterStateStore } from '../../../common'; +import { GlobalQueryStateFromUrl } from './types'; const GLOBAL_STATE_STORAGE_KEY = '_g'; /** - * Helper to setup syncing of global data with the URL + * Helper to sync global query state {@link GlobalQueryStateFromUrl} with the URL (`_g` query param that is preserved between apps) * @param QueryService: either setup or start * @param kbnUrlStateStorage to use for syncing */ -export const syncQueryStateWithUrl = ( +export const syncGlobalQueryStateWithUrl = ( query: Pick, kbnUrlStateStorage: IKbnUrlStateStorage ) => { @@ -31,14 +31,15 @@ export const syncQueryStateWithUrl = ( timefilter: { timefilter }, filterManager, } = query; - const defaultState: QueryState = { + const defaultState: GlobalQueryStateFromUrl = { time: timefilter.getTime(), refreshInterval: timefilter.getRefreshInterval(), filters: filterManager.getGlobalFilters(), }; // retrieve current state from `_g` url - const initialStateFromUrl = kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY); + const initialStateFromUrl = + kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY); // remember whether there was info in the URL const hasInheritedQueryFromUrl = Boolean( @@ -46,7 +47,7 @@ export const syncQueryStateWithUrl = ( ); // prepare initial state, whatever was in URL takes precedences over current state in services - const initialState: QueryState = { + const initialState: GlobalQueryStateFromUrl = { ...defaultState, ...initialStateFromUrl, }; @@ -61,7 +62,7 @@ export const syncQueryStateWithUrl = ( // if there weren't any initial state in url, // then put _g key into url if (!initialStateFromUrl) { - kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, initialState, { + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, initialState, { replace: true, }); } @@ -92,3 +93,8 @@ export const syncQueryStateWithUrl = ( hasInheritedQueryFromUrl, }; }; + +/** + * @deprecated use {@link syncGlobalQueryStateWithUrl} instead + */ +export const syncQueryStateWithUrl = syncGlobalQueryStateWithUrl; diff --git a/src/plugins/data/public/query/state_sync/types.ts b/src/plugins/data/public/query/state_sync/types.ts index 8bfd47987ab90..653dd36577b8d 100644 --- a/src/plugins/data/public/query/state_sync/types.ts +++ b/src/plugins/data/public/query/state_sync/types.ts @@ -6,17 +6,9 @@ * Side Public License, v 1. */ -import { Filter, RefreshInterval, TimeRange, Query } from '../../../common'; - -/** - * All query state service state - */ -export interface QueryState { - time?: TimeRange; - refreshInterval?: RefreshInterval; - filters?: Filter[]; - query?: Query; -} +import type { Filter } from '@kbn/es-query'; +import type { QueryState } from '../query_state'; +import { RefreshInterval, TimeRange } from '../../../common/types'; type QueryStateChangePartial = { [P in keyof QueryState]?: boolean; @@ -26,3 +18,12 @@ export interface QueryStateChange extends QueryStateChangePartial { appFilters?: boolean; // specifies if app filters change globalFilters?: boolean; // specifies if global filters change } + +/** + * Part of {@link QueryState} serialized in the `_g` portion of Url + */ +export interface GlobalQueryStateFromUrl { + time?: TimeRange; + refreshInterval?: RefreshInterval; + filters?: Filter[]; +} diff --git a/src/plugins/discover/public/locator.ts b/src/plugins/discover/public/locator.ts index d1b4d73571550..eb4731bd44e64 100644 --- a/src/plugins/discover/public/locator.ts +++ b/src/plugins/discover/public/locator.ts @@ -8,7 +8,12 @@ import type { SerializableRecord } from '@kbn/utility-types'; import type { Filter } from '@kbn/es-query'; -import type { TimeRange, Query, QueryState, RefreshInterval } from '@kbn/data-plugin/public'; +import type { + TimeRange, + Query, + GlobalQueryStateFromUrl, + RefreshInterval, +} from '@kbn/data-plugin/public'; import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import type { VIEW_MODE } from './components/view_mode_toggle'; @@ -126,7 +131,7 @@ export class DiscoverAppLocatorDefinition implements LocatorDefinition('_g', queryState, { useHash }, path); + path = setStateToKbnUrl('_g', queryState, { useHash }, path); path = setStateToKbnUrl('_a', appState, { useHash }, path); if (searchSessionId) { diff --git a/src/plugins/visualizations/public/visualize_app/utils/get_visualize_list_item_link.ts b/src/plugins/visualizations/public/visualize_app/utils/get_visualize_list_item_link.ts index fc41486fae84a..1285da1f3bf15 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/get_visualize_list_item_link.ts +++ b/src/plugins/visualizations/public/visualize_app/utils/get_visualize_list_item_link.ts @@ -8,7 +8,7 @@ import { ApplicationStart } from '@kbn/core/public'; import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; -import { QueryState } from '@kbn/data-plugin/public'; +import { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import { getUISettings } from '../../services'; import { GLOBAL_STATE_STORAGE_KEY, VISUALIZE_APP_NAME } from '../../../common/constants'; @@ -24,8 +24,14 @@ export const getVisualizeListItemLink = ( path: editApp ? editUrl : `#${editUrl}`, }); const useHash = getUISettings().get('state:storeInSessionStorage'); - const globalStateInUrl = kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY) || {}; + const globalStateInUrl = + kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY) || {}; - url = setStateToKbnUrl(GLOBAL_STATE_STORAGE_KEY, globalStateInUrl, { useHash }, url); + url = setStateToKbnUrl( + GLOBAL_STATE_STORAGE_KEY, + globalStateInUrl, + { useHash }, + url + ); return url; }; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts index 0f197f4a13ddd..0b3176154c5ff 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts @@ -10,7 +10,7 @@ import { SerializableRecord } from '@kbn/utility-types'; import { Filter } from '@kbn/es-query'; import { RefreshInterval, TimeRange } from '@kbn/data-plugin/common'; import { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/common'; -import { QueryState } from '@kbn/data-plugin/public'; +import { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; import { Dictionary, isRisonSerializationRequired } from '../../common/util/url_state'; import { SearchQueryLanguage } from '../types/combined_query'; @@ -124,7 +124,7 @@ export class IndexDataVisualizerLocatorDefinition sortField?: string; showDistributions?: number; } = {}; - const queryState: QueryState = {}; + const queryState: GlobalQueryStateFromUrl = {}; if (query) { appState.searchQuery = query.searchQuery; diff --git a/x-pack/plugins/maps/public/locators.ts b/x-pack/plugins/maps/public/locators.ts index 6c5d5a730edf7..7cfdb7a0d3fb1 100644 --- a/x-pack/plugins/maps/public/locators.ts +++ b/x-pack/plugins/maps/public/locators.ts @@ -10,7 +10,12 @@ import rison from 'rison-node'; import type { SerializableRecord } from '@kbn/utility-types'; import { type Filter, isFilterPinned } from '@kbn/es-query'; -import type { TimeRange, Query, QueryState, RefreshInterval } from '@kbn/data-plugin/public'; +import type { + TimeRange, + Query, + GlobalQueryStateFromUrl, + RefreshInterval, +} from '@kbn/data-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; import type { LayerDescriptor } from '../common/descriptor_types'; @@ -78,7 +83,7 @@ export class MapsAppLocatorDefinition implements LocatorDefinition !isFilterPinned(f)); @@ -87,7 +92,7 @@ export class MapsAppLocatorDefinition implements LocatorDefinition('_g', queryState, { useHash }, path); + path = setStateToKbnUrl('_g', queryState, { useHash }, path); path = setStateToKbnUrl('_a', appState, { useHash }, path); if (initialLayers && initialLayers.length) { diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_refresh_config.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_refresh_config.ts index 8e816c6930fdb..79d3603055874 100644 --- a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_refresh_config.ts +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_refresh_config.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { QueryState } from '@kbn/data-plugin/public'; +import { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; import { UI_SETTINGS } from '@kbn/data-plugin/public'; import { getUiSettings } from '../../../kibana_services'; import { SerializedMapState } from './types'; @@ -15,7 +15,7 @@ export function getInitialRefreshConfig({ globalState = {}, }: { serializedMapState?: SerializedMapState; - globalState: QueryState; + globalState: GlobalQueryStateFromUrl; }) { const uiSettings = getUiSettings(); diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_time_filters.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_time_filters.ts index da293d5c52d29..fc3754256d659 100644 --- a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_time_filters.ts +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_time_filters.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { QueryState } from '@kbn/data-plugin/public'; +import { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; import { getUiSettings } from '../../../kibana_services'; import { SerializedMapState } from './types'; @@ -14,7 +14,7 @@ export function getInitialTimeFilters({ globalState, }: { serializedMapState?: SerializedMapState; - globalState: QueryState; + globalState: GlobalQueryStateFromUrl; }) { if (serializedMapState?.timeFilters) { return serializedMapState.timeFilters; From 473141f58b23c799c83be976afb331e09ec2d022 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Fri, 20 May 2022 12:17:58 +0200 Subject: [PATCH 099/150] [Cases] Show deprecated icon in connectors with isDeprecated true (#132237) Co-authored-by: Christos Nasikas --- .../server/lib/is_conector_deprecated.test.ts | 25 +++++++ .../server/lib/is_conector_deprecated.ts | 40 +++++++++-- .../cases/common/api/connectors/index.ts | 10 ++- .../cases/public/common/mock/connectors.ts | 5 ++ .../components/all_cases/columns.test.tsx | 1 + .../connectors_dropdown.test.tsx | 23 ++++++ .../public/components/connectors/mock.ts | 2 + .../servicenow_itsm_case_fields.test.tsx | 19 ++--- .../servicenow_itsm_case_fields.tsx | 3 +- .../servicenow_sir_case_fields.test.tsx | 19 ++--- .../servicenow/servicenow_sir_case_fields.tsx | 3 +- .../servicenow/use_get_choices.test.tsx | 1 + .../connectors/servicenow/validator.test.ts | 48 ------------- .../connectors/servicenow/validator.ts | 34 --------- .../connectors/swimlane/validator.test.ts | 21 ++++++ .../connectors/swimlane/validator.ts | 19 +++-- .../cases/public/components/utils.test.ts | 70 ++++++++----------- .../plugins/cases/public/components/utils.ts | 45 +++++------- .../cases/server/client/cases/utils.test.ts | 1 + .../alerting_api_integration/common/config.ts | 11 +++ .../group2/tests/actions/get_all.ts | 24 +++++++ .../tests/telemetry/actions_telemetry.ts | 2 +- .../spaces_only/tests/actions/get.ts | 12 ++++ .../spaces_only/tests/actions/get_all.ts | 24 +++++++ .../cases_api_integration/common/config.ts | 1 + 25 files changed, 285 insertions(+), 178 deletions(-) delete mode 100644 x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts delete mode 100644 x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts diff --git a/x-pack/plugins/actions/server/lib/is_conector_deprecated.test.ts b/x-pack/plugins/actions/server/lib/is_conector_deprecated.test.ts index f5ace7e055254..c3697cea6a34e 100644 --- a/x-pack/plugins/actions/server/lib/is_conector_deprecated.test.ts +++ b/x-pack/plugins/actions/server/lib/is_conector_deprecated.test.ts @@ -17,6 +17,11 @@ describe('isConnectorDeprecated', () => { isPreconfigured: false as const, }; + it('returns false if the config is not defined', () => { + // @ts-expect-error + expect(isConnectorDeprecated({})).toBe(false); + }); + it('returns false if the connector is not ITSM or SecOps', () => { expect(isConnectorDeprecated(connector)).toBe(false); }); @@ -48,4 +53,24 @@ describe('isConnectorDeprecated', () => { }) ).toBe(true); }); + + it('returns true if the connector is .servicenow and the usesTableApi is omitted', () => { + expect( + isConnectorDeprecated({ + ...connector, + actionTypeId: '.servicenow', + config: { apiUrl: 'http://example.com' }, + }) + ).toBe(true); + }); + + it('returns true if the connector is .servicenow-sir and the usesTableApi is omitted', () => { + expect( + isConnectorDeprecated({ + ...connector, + actionTypeId: '.servicenow-sir', + config: { apiUrl: 'http://example.com' }, + }) + ).toBe(true); + }); }); diff --git a/x-pack/plugins/actions/server/lib/is_conector_deprecated.ts b/x-pack/plugins/actions/server/lib/is_conector_deprecated.ts index 210631cb532f6..ed46f5e685459 100644 --- a/x-pack/plugins/actions/server/lib/is_conector_deprecated.ts +++ b/x-pack/plugins/actions/server/lib/is_conector_deprecated.ts @@ -5,11 +5,14 @@ * 2.0. */ +import { isPlainObject } from 'lodash'; import { PreConfiguredAction, RawAction } from '../types'; export type ConnectorWithOptionalDeprecation = Omit & Pick, 'isDeprecated'>; +const isObject = (obj: unknown): obj is Record => isPlainObject(obj); + export const isConnectorDeprecated = ( connector: RawAction | ConnectorWithOptionalDeprecation ): boolean => { @@ -18,11 +21,40 @@ export const isConnectorDeprecated = ( * Connectors after the Elastic ServiceNow application use the * Import Set API (https://developer.servicenow.com/dev.do#!/reference/api/rome/rest/c_ImportSetAPI) * A ServiceNow connector is considered deprecated if it uses the Table API. - * - * All other connectors do not have the usesTableApi config property - * so the function will always return false for them. */ - return !!connector.config?.usesTableApi; + + /** + * We cannot deduct if the connector is + * deprecated without config. In this case + * we always return false. + */ + if (!isObject(connector.config)) { + return false; + } + + /** + * If the usesTableApi is not defined it means that the connector is created + * before the introduction of the usesTableApi property. In that case, the connector is assumed + * to be deprecated because all connectors prior 7.16 where using the Table API. + * Migrations x-pack/plugins/actions/server/saved_objects/actions_migrations.ts set + * the usesTableApi property to true to all connectors prior 7.16. Pre configured connectors + * cannot be migrated. This check ensures that pre configured connectors without the + * usesTableApi property explicitly in the kibana.yml file are considered deprecated. + * According to the schema defined here x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts + * if the property is not defined it will be set to true at the execution of the connector. + */ + if (!Object.hasOwn(connector.config, 'usesTableApi')) { + return true; + } + + /** + * Connector created prior to 7.16 will be migrated to have the usesTableApi property set to true. + * Connectors created after 7.16 should have the usesTableApi property set to true or false. + * If the usesTableApi is omitted on an API call it will be defaulted to true. Check the schema + * here x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts. + * The !! is to make TS happy. + */ + return !!connector.config.usesTableApi; } return false; diff --git a/x-pack/plugins/cases/common/api/connectors/index.ts b/x-pack/plugins/cases/common/api/connectors/index.ts index bb1892525f8e0..df9a7b0e24fd7 100644 --- a/x-pack/plugins/cases/common/api/connectors/index.ts +++ b/x-pack/plugins/cases/common/api/connectors/index.ts @@ -7,7 +7,15 @@ import * as rt from 'io-ts'; -import { ActionResult, ActionType } from '@kbn/actions-plugin/common'; +import type { ActionType } from '@kbn/actions-plugin/common'; +/** + * ActionResult type from the common folder is outdated. + * The type from server is not exported properly so we + * disable the linting for the moment + */ + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import type { ActionResult } from '@kbn/actions-plugin/server/types'; import { JiraFieldsRT } from './jira'; import { ResilientFieldsRT } from './resilient'; import { ServiceNowITSMFieldsRT } from './servicenow_itsm'; diff --git a/x-pack/plugins/cases/public/common/mock/connectors.ts b/x-pack/plugins/cases/public/common/mock/connectors.ts index 01afbbee118a8..d186b68053e7f 100644 --- a/x-pack/plugins/cases/public/common/mock/connectors.ts +++ b/x-pack/plugins/cases/public/common/mock/connectors.ts @@ -16,6 +16,7 @@ export const connectorsMock: ActionConnector[] = [ apiUrl: 'https://instance1.service-now.com', }, isPreconfigured: false, + isDeprecated: false, }, { id: 'resilient-2', @@ -26,6 +27,7 @@ export const connectorsMock: ActionConnector[] = [ orgId: '201', }, isPreconfigured: false, + isDeprecated: false, }, { id: 'jira-1', @@ -35,6 +37,7 @@ export const connectorsMock: ActionConnector[] = [ apiUrl: 'https://instance.atlassian.ne', }, isPreconfigured: false, + isDeprecated: false, }, { id: 'servicenow-sir', @@ -44,6 +47,7 @@ export const connectorsMock: ActionConnector[] = [ apiUrl: 'https://instance1.service-now.com', }, isPreconfigured: false, + isDeprecated: false, }, { id: 'servicenow-uses-table-api', @@ -54,6 +58,7 @@ export const connectorsMock: ActionConnector[] = [ usesTableApi: true, }, isPreconfigured: false, + isDeprecated: true, }, ]; diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx index 764a51443b0e3..b09eecbb31f4f 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx @@ -77,6 +77,7 @@ describe('ExternalServiceColumn ', () => { name: 'None', config: {}, isPreconfigured: false, + isDeprecated: false, }, ]} /> diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx index 63fc2e2695a3a..e8093325c1e09 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -249,6 +249,7 @@ describe('ConnectorsDropdown', () => { name: 'None', config: {}, isPreconfigured: false, + isDeprecated: false, }, ]} />, @@ -269,4 +270,26 @@ describe('ConnectorsDropdown', () => { ); expect(tooltips[0]).toBeInTheDocument(); }); + + test('it shows the deprecated tooltip when the connector is deprecated by configuration', () => { + const connector = connectors[0]; + render( + , + { wrapper: ({ children }) => {children} } + ); + + const tooltips = screen.getAllByText( + 'This connector is deprecated. Update it, or create a new one.' + ); + expect(tooltips[0]).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/cases/public/components/connectors/mock.ts b/x-pack/plugins/cases/public/components/connectors/mock.ts index 2eb512af0f2ef..ba29319a8926c 100644 --- a/x-pack/plugins/cases/public/components/connectors/mock.ts +++ b/x-pack/plugins/cases/public/components/connectors/mock.ts @@ -13,6 +13,7 @@ export const connector = { actionTypeId: '.jira', config: {}, isPreconfigured: false, + isDeprecated: false, }; export const swimlaneConnector = { @@ -29,6 +30,7 @@ export const swimlaneConnector = { }, }, isPreconfigured: false, + isDeprecated: false, }; export const issues = [ diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx index cfc16f1fb6e8b..e2f4a683772c7 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx @@ -135,18 +135,18 @@ describe('ServiceNowITSM Fields', () => { ); }); - it('shows the deprecated callout when the connector uses the table API', async () => { - const tableApiConnector = { ...connector, config: { usesTableApi: true } }; + it('shows the deprecated callout if the connector is deprecated', async () => { + const tableApiConnector = { ...connector, isDeprecated: true }; render(); expect(screen.getByTestId('deprecated-connector-warning-callout')).toBeInTheDocument(); }); - it('does not show the deprecated callout when the connector does not uses the table API', async () => { + it('does not show the deprecated callout when the connector is not deprecated', async () => { render(); expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); }); - it('does not show the deprecated callout when the connector is preconfigured', async () => { + it('does not show the deprecated callout when the connector is preconfigured and not deprecated', async () => { render( { expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); }); - it('does not show the deprecated callout when the config of the connector is undefined', async () => { + it('shows the deprecated callout when the connector is preconfigured and deprecated', async () => { render( - // @ts-expect-error - + ); - expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('deprecated-connector-warning-callout')).toBeInTheDocument(); }); it('should hide subcategory if selecting a category without subcategories', async () => { diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index f366cc95ff77a..2dae544ec274c 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -16,7 +16,6 @@ import { ConnectorCard } from '../card'; import { useGetChoices } from './use_get_choices'; import { Fields, Choice } from './types'; import { choicesToEuiOptions } from './helpers'; -import { connectorValidator } from './validator'; import { DeprecatedCallout } from '../deprecated_callout'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; @@ -44,7 +43,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< } = fields ?? {}; const { http, notifications } = useKibana().services; const [choices, setChoices] = useState(defaultFields); - const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]); + const showConnectorWarning = connector.isDeprecated; const categoryOptions = useMemo( () => choicesToEuiOptions(choices.category), diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx index a2c61ac78be0b..1b06e0cfdce81 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx @@ -169,18 +169,18 @@ describe('ServiceNowSIR Fields', () => { ]); }); - test('it shows the deprecated callout when the connector uses the table API', async () => { - const tableApiConnector = { ...connector, config: { usesTableApi: true } }; + test('shows the deprecated callout if the connector is deprecated', async () => { + const tableApiConnector = { ...connector, isDeprecated: true }; render(); expect(screen.getByTestId('deprecated-connector-warning-callout')).toBeInTheDocument(); }); - test('it does not show the deprecated callout when the connector does not uses the table API', async () => { + test('does not show the deprecated callout when the connector is not deprecated', async () => { render(); expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); }); - it('does not show the deprecated callout when the connector is preconfigured', async () => { + it('does not show the deprecated callout when the connector is preconfigured and not deprecated', async () => { render( { expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); }); - it('does not show the deprecated callout when the config of the connector is undefined', async () => { + it('shows the deprecated callout when the connector is preconfigured and deprecated', async () => { render( - // @ts-expect-error - + ); - expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('deprecated-connector-warning-callout')).toBeInTheDocument(); }); test('it should hide subcategory if selecting a category without subcategories', async () => { diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index 99bbe8aabaeda..78f17a1d4215a 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -17,7 +17,6 @@ import { Choice, Fields } from './types'; import { choicesToEuiOptions } from './helpers'; import * as i18n from './translations'; -import { connectorValidator } from './validator'; import { DeprecatedCallout } from '../deprecated_callout'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; @@ -43,7 +42,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< const { http, notifications } = useKibana().services; const [choices, setChoices] = useState(defaultFields); - const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]); + const showConnectorWarning = connector.isDeprecated; const onChangeCb = useCallback( ( diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx index 950b17d6f784f..9a4e19d126bba 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx @@ -29,6 +29,7 @@ const connector = { actionTypeId: '.servicenow', name: 'ServiceNow', isPreconfigured: false, + isDeprecated: false, config: { apiUrl: 'https://dev94428.service-now.com/', }, diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts deleted file mode 100644 index ab21a6b5c779c..0000000000000 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { connector } from '../mock'; -import { connectorValidator } from './validator'; - -describe('ServiceNow validator', () => { - describe('connectorValidator', () => { - test('it returns an error message if the connector uses the table API', () => { - const invalidConnector = { - ...connector, - config: { - ...connector.config, - usesTableApi: true, - }, - }; - - expect(connectorValidator(invalidConnector)).toEqual({ message: 'Deprecated connector' }); - }); - - test('it does not return an error message if the connector does not uses the table API', () => { - const invalidConnector = { - ...connector, - config: { - ...connector.config, - usesTableApi: false, - }, - }; - - expect(connectorValidator(invalidConnector)).toBeFalsy(); - }); - - test('it does not return an error message if the config of the connector is undefined', () => { - const { config, ...invalidConnector } = connector; - - // @ts-expect-error - expect(connectorValidator(invalidConnector)).toBeFalsy(); - }); - - test('it does not return an error message if the config of the connector is preconfigured', () => { - expect(connectorValidator({ ...connector, isPreconfigured: true })).toBeFalsy(); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts deleted file mode 100644 index fed2900715527..0000000000000 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ValidationConfig } from '../../../common/shared_imports'; -import { CaseActionConnector } from '../../types'; - -/** - * The user can not create cases with connectors that use the table API - */ - -export const connectorValidator = ( - connector: CaseActionConnector -): ReturnType => { - /** - * It is not possible to know if a preconfigured connector - * is deprecated or not as the config property of a - * preconfigured connector is not returned by the - * actions framework - */ - - if (connector.isPreconfigured || connector.config == null) { - return; - } - - if (connector.config?.usesTableApi) { - return { - message: 'Deprecated connector', - }; - } -}; diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts index c8cb142232972..a179091282991 100644 --- a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts @@ -56,5 +56,26 @@ describe('Swimlane validator', () => { expect(connectorValidator(invalidConnector)).toBe(undefined); } ); + + test('it does not return an error message if the config is undefined', () => { + const invalidConnector = { + ...connector, + config: undefined, + }; + + expect(connectorValidator(invalidConnector)).toBe(undefined); + }); + + test('it returns an error message if the mappings are undefined', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + mappings: undefined, + }, + }; + + expect(connectorValidator(invalidConnector)).toEqual({ message: 'Invalid connector' }); + }); }); }); diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts index 90d9946d4adb8..d3c94d0150bbe 100644 --- a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts @@ -28,10 +28,21 @@ export const isAnyRequiredFieldNotSet = (mapping: Record | unde export const connectorValidator = ( connector: CaseActionConnector ): ReturnType => { - const { - config: { mappings, connectorType }, - } = connector; - if (connectorType === SwimlaneConnectorType.Alerts || isAnyRequiredFieldNotSet(mappings)) { + const config = connector.config as + | { + mappings: Record | undefined; + connectorType: string; + } + | undefined; + + if (config == null) { + return; + } + + if ( + config.connectorType === SwimlaneConnectorType.Alerts || + isAnyRequiredFieldNotSet(config.mappings) + ) { return { message: 'Invalid connector', }; diff --git a/x-pack/plugins/cases/public/components/utils.test.ts b/x-pack/plugins/cases/public/components/utils.test.ts index 278bb28b86627..99ec0213ff4ad 100644 --- a/x-pack/plugins/cases/public/components/utils.test.ts +++ b/x-pack/plugins/cases/public/components/utils.test.ts @@ -7,9 +7,19 @@ import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; -import { getConnectorIcon, isDeprecatedConnector } from './utils'; +import { connectorDeprecationValidator, getConnectorIcon, isDeprecatedConnector } from './utils'; describe('Utils', () => { + const connector = { + id: 'test', + actionTypeId: '.webhook', + name: 'Test', + config: { usesTableApi: false }, + secrets: {}, + isPreconfigured: false, + isDeprecated: false, + }; + describe('getConnectorIcon', () => { const { createMockActionTypeModel } = actionTypeRegistryMock; const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); @@ -38,60 +48,40 @@ describe('Utils', () => { }); }); - describe('isDeprecatedConnector', () => { - const connector = { - id: 'test', - actionTypeId: '.webhook', - name: 'Test', - config: { usesTableApi: false }, - secrets: {}, - isPreconfigured: false, - }; - - it('returns false if the connector is not defined', () => { - expect(isDeprecatedConnector()).toBe(false); + describe('connectorDeprecationValidator', () => { + it('returns undefined if the connector is not deprecated', () => { + expect(connectorDeprecationValidator(connector)).toBe(undefined); }); - it('returns false if the connector is not ITSM or SecOps', () => { - expect(isDeprecatedConnector(connector)).toBe(false); + it('returns a deprecation message if the connector is deprecated', () => { + expect(connectorDeprecationValidator({ ...connector, isDeprecated: true })).toEqual({ + message: 'Deprecated connector', + }); }); + }); - it('returns false if the connector is .servicenow and the usesTableApi=false', () => { - expect(isDeprecatedConnector({ ...connector, actionTypeId: '.servicenow' })).toBe(false); + describe('isDeprecatedConnector', () => { + it('returns false if the connector is not defined', () => { + expect(isDeprecatedConnector()).toBe(false); }); - it('returns false if the connector is .servicenow-sir and the usesTableApi=false', () => { - expect(isDeprecatedConnector({ ...connector, actionTypeId: '.servicenow-sir' })).toBe(false); + it('returns false if the connector is marked as deprecated', () => { + expect(isDeprecatedConnector({ ...connector, isDeprecated: false })).toBe(false); }); - it('returns true if the connector is .servicenow and the usesTableApi=true', () => { - expect( - isDeprecatedConnector({ - ...connector, - actionTypeId: '.servicenow', - config: { usesTableApi: true }, - }) - ).toBe(true); + it('returns true if the connector is marked as deprecated', () => { + expect(isDeprecatedConnector({ ...connector, isDeprecated: true })).toBe(true); }); - it('returns true if the connector is .servicenow-sir and the usesTableApi=true', () => { + it('returns true if the connector is marked as deprecated (preconfigured connector)', () => { expect( - isDeprecatedConnector({ - ...connector, - actionTypeId: '.servicenow-sir', - config: { usesTableApi: true }, - }) + isDeprecatedConnector({ ...connector, isDeprecated: true, isPreconfigured: true }) ).toBe(true); }); - it('returns false if the connector preconfigured', () => { - expect(isDeprecatedConnector({ ...connector, isPreconfigured: true })).toBe(false); - }); - - it('returns false if the config is undefined', () => { + it('returns false if the connector is not marked as deprecated (preconfigured connector)', () => { expect( - // @ts-expect-error - isDeprecatedConnector({ ...connector, config: undefined }) + isDeprecatedConnector({ ...connector, isDeprecated: false, isPreconfigured: true }) ).toBe(false); }); }); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 34ebffb4eacb4..403f55574f9a6 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -10,7 +10,6 @@ import { ConnectorTypes } from '../../common/api'; import { FieldConfig, ValidationConfig } from '../common/shared_imports'; import { CasesPluginStart } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; -import { connectorValidator as servicenowConnectorValidator } from './connectors/servicenow/validator'; import { CaseActionConnector } from './types'; export const getConnectorById = ( @@ -23,8 +22,16 @@ const validators: Record< (connector: CaseActionConnector) => ReturnType > = { [ConnectorTypes.swimlane]: swimlaneConnectorValidator, - [ConnectorTypes.serviceNowITSM]: servicenowConnectorValidator, - [ConnectorTypes.serviceNowSIR]: servicenowConnectorValidator, +}; + +export const connectorDeprecationValidator = ( + connector: CaseActionConnector +): ReturnType => { + if (connector.isDeprecated) { + return { + message: 'Deprecated connector', + }; + } }; export const getConnectorsFormValidators = ({ @@ -36,6 +43,14 @@ export const getConnectorsFormValidators = ({ }): FieldConfig => ({ ...config, validations: [ + { + validator: ({ value: connectorId }) => { + const connector = getConnectorById(connectorId as string, connectors); + if (connector != null) { + return connectorDeprecationValidator(connector); + } + }, + }, { validator: ({ value: connectorId }) => { const connector = getConnectorById(connectorId as string, connectors); @@ -72,28 +87,6 @@ export const getConnectorIcon = ( return emptyResponse; }; -// TODO: Remove when the applications are certified export const isDeprecatedConnector = (connector?: CaseActionConnector): boolean => { - /** - * It is not possible to know if a preconfigured connector - * is deprecated or not as the config property of a - * preconfigured connector is not returned by the - * actions framework - */ - if (connector == null || connector.config == null || connector.isPreconfigured) { - return false; - } - - if (connector.actionTypeId === '.servicenow' || connector.actionTypeId === '.servicenow-sir') { - /** - * Connector's prior to the Elastic ServiceNow application - * use the Table API (https://developer.servicenow.com/dev.do#!/reference/api/rome/rest/c_TableAPI) - * Connectors after the Elastic ServiceNow application use the - * Import Set API (https://developer.servicenow.com/dev.do#!/reference/api/rome/rest/c_ImportSetAPI) - * A ServiceNow connector is considered deprecated if it uses the Table API. - */ - return !!connector.config.usesTableApi; - } - - return false; + return connector?.isDeprecated ?? false; }; diff --git a/x-pack/plugins/cases/server/client/cases/utils.test.ts b/x-pack/plugins/cases/server/client/cases/utils.test.ts index 4832ffe5b2eaf..baf32fd30d74b 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.test.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.test.ts @@ -521,6 +521,7 @@ describe('utils', () => { apiUrl: 'https://elastic.jira.com', }, isPreconfigured: false, + isDeprecated: false, }; it('creates an external incident', async () => { diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index d1bf39b575ab5..0c5f95189ae90 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -210,6 +210,17 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) password: 'somepassword', }, }, + 'my-deprecated-servicenow-default': { + actionTypeId: '.servicenow', + name: 'ServiceNow#xyz', + config: { + apiUrl: 'https://ven04334.service-now.com', + }, + secrets: { + username: 'elastic_integration', + password: 'somepassword', + }, + }, 'custom-system-abc-connector': { actionTypeId: 'system-abc-action-type', name: 'SystemABC', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts index 103ae5abd3071..69f618c804eb1 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts @@ -95,6 +95,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, @@ -222,6 +230,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, @@ -313,6 +329,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/actions_telemetry.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/actions_telemetry.ts index b187b9e9f9759..b1e77b98b792d 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/actions_telemetry.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/actions_telemetry.ts @@ -188,7 +188,7 @@ export default function createActionsTelemetryTests({ getService }: FtrProviderC const telemetry = JSON.parse(taskState!); // total number of connectors - expect(telemetry.count_total).to.equal(18); + expect(telemetry.count_total).to.equal(19); // total number of active connectors (used by a rule) expect(telemetry.count_active_total).to.equal(7); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get.ts index d5d5109b6e738..6d923452faac5 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get.ts @@ -98,6 +98,18 @@ export default function getActionTests({ getService }: FtrProviderContext) { connector_type_id: '.servicenow', name: 'ServiceNow#xyz', }); + + await supertest + .get( + `${getUrlPrefix(Spaces.space1.id)}/api/actions/connector/my-deprecated-servicenow-default` + ) + .expect(200, { + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + connector_type_id: '.servicenow', + name: 'ServiceNow#xyz', + }); }); describe('legacy', () => { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts index 54a0e6e10a198..0632f48ed6e8d 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts @@ -83,6 +83,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, @@ -162,6 +170,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, @@ -254,6 +270,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referencedByCount: 0, }, + { + actionTypeId: '.servicenow', + id: 'my-deprecated-servicenow-default', + isPreconfigured: true, + isDeprecated: true, + name: 'ServiceNow#xyz', + referencedByCount: 0, + }, { id: 'my-slack1', isPreconfigured: true, diff --git a/x-pack/test/cases_api_integration/common/config.ts b/x-pack/test/cases_api_integration/common/config.ts index 89dd19ae74897..a20dd300a4e6e 100644 --- a/x-pack/test/cases_api_integration/common/config.ts +++ b/x-pack/test/cases_api_integration/common/config.ts @@ -144,6 +144,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) actionTypeId: '.servicenow', config: { apiUrl: 'https://example.com', + usesTableApi: false, }, secrets: { username: 'elastic', From aa4c389ed2839d18ab008dd00a7a89c8f4080d74 Mon Sep 17 00:00:00 2001 From: Cristina Amico Date: Fri, 20 May 2022 12:24:38 +0200 Subject: [PATCH 100/150] [Fleet] Changes to agent upgrade modal to allow for rolling upgrades (#132421) * [Fleet] Changes to agent upgrade modal to allow for rolling upgrades * Update the onSubmit logic and handle case with single agent * Fix check * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * Add option to upgrade immediately; minor fixes * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * Add callout in modal for 400 errors * Linter fixes * Fix i18n error * Address code review comments Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../fleet/common/types/rest_spec/agent.ts | 1 + .../components/actions_menu.tsx | 1 - .../components/bulk_actions.tsx | 7 +- .../sections/agents/agent_list_page/index.tsx | 1 - .../agent_upgrade_modal/constants.tsx | 32 +++ .../components/agent_upgrade_modal/index.tsx | 187 ++++++++++++++---- .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 9 files changed, 184 insertions(+), 51 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/constants.tsx diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index 7a8b7b918c1e3..886730d38f831 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -89,6 +89,7 @@ export interface PostBulkAgentUpgradeRequest { agents: string[] | string; source_uri?: string; version: string; + rollout_duration_seconds?: number; }; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx index 44e87d7fb4e63..239afe6c7e330 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx @@ -70,7 +70,6 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ { setIsUpgradeModalOpen(false); refreshAgent(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx index a2515b51814ee..e27c647e25f70 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx @@ -24,7 +24,6 @@ import { AgentUnenrollAgentModal, AgentUpgradeAgentModal, } from '../../components'; -import { useKibanaVersion } from '../../../../hooks'; import type { SelectionMode } from './types'; @@ -48,11 +47,10 @@ export const AgentBulkActions: React.FunctionComponent = ({ selectedAgents, refreshAgents, }) => { - const kibanaVersion = useKibanaVersion(); // Bulk actions menu states const [isMenuOpen, setIsMenuOpen] = useState(false); const closeMenu = () => setIsMenuOpen(false); - const openMenu = () => setIsMenuOpen(true); + const onClickMenu = () => setIsMenuOpen(!isMenuOpen); // Actions states const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(false); @@ -150,7 +148,6 @@ export const AgentBulkActions: React.FunctionComponent = ({ {isUpgradeModalOpen && ( { @@ -172,7 +169,7 @@ export const AgentBulkActions: React.FunctionComponent = ({ fill iconType="arrowDown" iconSide="right" - onClick={openMenu} + onClick={onClickMenu} data-test-subj="agentBulkActionsButton" > = () => { fetchData(); refreshUpgrades(); }} - version={kibanaVersion} /> )} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/constants.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/constants.tsx new file mode 100644 index 0000000000000..b5d8cd8f4d72d --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/constants.tsx @@ -0,0 +1,32 @@ +/* + * 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. + */ + +// Available versions for the upgrade of the Elastic Agent +// These versions are only intended to be used as a fallback +// in the event that the updated versions cannot be retrieved from the endpoint + +export const FALLBACK_VERSIONS = [ + '8.2.0', + '8.1.3', + '8.1.2', + '8.1.1', + '8.1.0', + '8.0.1', + '8.0.0', + '7.9.3', + '7.9.2', + '7.9.1', + '7.9.0', + '7.8.1', + '7.8.0', + '7.17.3', + '7.17.2', + '7.17.1', + '7.17.0', +]; + +export const MAINTAINANCE_VALUES = [1, 2, 4, 8, 12, 24, 48]; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx index 72ca7a5b80fd7..2122abb5e2785 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx @@ -7,34 +7,89 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal, EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiConfirmModal, + EuiComboBox, + EuiFormRow, + EuiSpacer, + EuiToolTip, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiCallOut, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; + import type { Agent } from '../../../../types'; import { sendPostAgentUpgrade, sendPostBulkAgentUpgrade, useStartServices, + useKibanaVersion, } from '../../../../hooks'; +import { FALLBACK_VERSIONS, MAINTAINANCE_VALUES } from './constants'; + interface Props { onClose: () => void; agents: Agent[] | string; agentCount: number; - version: string; } +const getVersion = (version: Array>) => version[0].value as string; + export const AgentUpgradeAgentModal: React.FunctionComponent = ({ onClose, agents, agentCount, - version, }) => { const { notifications } = useStartServices(); + const kibanaVersion = useKibanaVersion(); const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState(); + const isSingleAgent = Array.isArray(agents) && agents.length === 1; + const isSmallBatch = Array.isArray(agents) && agents.length > 1 && agents.length <= 10; const isAllAgents = agents === ''; + + const fallbackVersions = [kibanaVersion].concat(FALLBACK_VERSIONS); + const fallbackOptions: Array> = fallbackVersions.map( + (option) => ({ + label: option, + value: option, + }) + ); + const maintainanceWindows = isSmallBatch ? [0].concat(MAINTAINANCE_VALUES) : MAINTAINANCE_VALUES; + const maintainanceOptions: Array> = maintainanceWindows.map( + (option) => ({ + label: + option === 0 + ? i18n.translate('xpack.fleet.upgradeAgents.noMaintainanceWindowOption', { + defaultMessage: 'Immediately', + }) + : i18n.translate('xpack.fleet.upgradeAgents.hourLabel', { + defaultMessage: '{option} {count, plural, one {hour} other {hours}}', + values: { option, count: option === 1 }, + }), + value: option === 0 ? 0 : option * 3600, + }) + ); + const [selectedVersion, setSelectedVersion] = useState([fallbackOptions[0]]); + const [selectedMantainanceWindow, setSelectedMantainanceWindow] = useState([ + maintainanceOptions[0], + ]); + async function onSubmit() { + const version = getVersion(selectedVersion); + const rolloutOptions = + selectedMantainanceWindow.length > 0 && (selectedMantainanceWindow[0]?.value as number) > 0 + ? { + rollout_duration_seconds: selectedMantainanceWindow[0].value, + } + : {}; + try { setIsSubmitting(true); const { data, error } = isSingleAgent @@ -42,10 +97,14 @@ export const AgentUpgradeAgentModal: React.FunctionComponent = ({ version, }) : await sendPostBulkAgentUpgrade({ - agents: Array.isArray(agents) ? agents.map((agent) => agent.id) : agents, version, + agents: Array.isArray(agents) ? agents.map((agent) => agent.id) : agents, + ...rolloutOptions, }); if (error) { + if (error?.statusCode === 400) { + setErrors(error?.message); + } throw error; } @@ -114,39 +173,20 @@ export const AgentUpgradeAgentModal: React.FunctionComponent = ({ - - {isSingleAgent ? ( - - ) : ( - - )} - - - - } - tooltipContent={ - - } + <> + {isSingleAgent ? ( + + ) : ( + - - + )} + } onCancel={onClose} onConfirm={onSubmit} @@ -179,17 +219,88 @@ export const AgentUpgradeAgentModal: React.FunctionComponent = ({ defaultMessage="This action will upgrade the agent running on '{hostName}' to version {version}. This action can not be undone. Are you sure you wish to continue?" values={{ hostName: ((agents[0] as Agent).local_metadata.host as any).hostname, - version, + version: getVersion(selectedVersion), }} /> ) : ( )}

+ + + >) => { + setSelectedVersion(selected); + }} + /> + + + {!isSingleAgent ? ( + + + {i18n.translate('xpack.fleet.upgradeAgents.maintainanceAvailableLabel', { + defaultMessage: 'Maintainance window available', + })} + + + + + + + + + } + fullWidth + > + >) => { + setSelectedMantainanceWindow(selected); + }} + /> + + ) : null} + {errors ? ( + <> + + + ) : null}
); }; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index f211cc9fede8e..8bd7308a27a70 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -13071,8 +13071,6 @@ "xpack.fleet.upgradeAgents.cancelButtonLabel": "Annuler", "xpack.fleet.upgradeAgents.confirmMultipleButtonLabel": "Mettre à niveau {count, plural, one {l'agent} other {{count} agents} =true {tous les agents sélectionnés}}", "xpack.fleet.upgradeAgents.confirmSingleButtonLabel": "Mettre à niveau l'agent", - "xpack.fleet.upgradeAgents.experimentalLabel": "Expérimental", - "xpack.fleet.upgradeAgents.experimentalLabelTooltip": "Une modification ou une suppression de la mise à niveau de l'agent peut intervenir dans une version ultérieure. La mise à niveau n'est pas soumise à l'accord de niveau de service du support technique.", "xpack.fleet.upgradeAgents.fatalErrorNotificationTitle": "Erreur lors de la mise à niveau de {count, plural, one {l'agent} other {{count} agents} =true {tous les agents sélectionnés}}", "xpack.fleet.upgradeAgents.successMultiNotificationTitle": "{isMixed, select, true {{success} agents sur {total}} other {{isAllAgents, select, true {Tous les agents sélectionnés} other {{success}} }}} mis à niveau", "xpack.fleet.upgradeAgents.successSingleNotificationTitle": "{count} agent mis à niveau", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index eec41bfb71c81..12300057ca7ff 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13178,8 +13178,6 @@ "xpack.fleet.upgradeAgents.cancelButtonLabel": "キャンセル", "xpack.fleet.upgradeAgents.confirmMultipleButtonLabel": "{count, plural, other {{count}個のエージェント} =true {すべての選択されたエージェント}}をアップグレード", "xpack.fleet.upgradeAgents.confirmSingleButtonLabel": "エージェントをアップグレード", - "xpack.fleet.upgradeAgents.experimentalLabel": "実験的", - "xpack.fleet.upgradeAgents.experimentalLabelTooltip": "アップグレードエージェントは今後のリリースで変更または削除される可能性があり、SLA のサポート対象になりません。", "xpack.fleet.upgradeAgents.fatalErrorNotificationTitle": "{count, plural, other {{count}個のエージェント} =true {すべての選択されたエージェント}}のアップグレードエラー", "xpack.fleet.upgradeAgents.successMultiNotificationTitle": "{isMixed, select, true {{success}/{total}個の} other {{isAllAgents, select, true {すべての選択された} other {{success}} }}}エージェントをアップグレードしました", "xpack.fleet.upgradeAgents.successSingleNotificationTitle": "{count}個のエージェントをアップグレードしました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 2d7566bdd8c87..5953802b0a0a5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13202,8 +13202,6 @@ "xpack.fleet.upgradeAgents.cancelButtonLabel": "取消", "xpack.fleet.upgradeAgents.confirmMultipleButtonLabel": "升级{count, plural, one {代理} other { {count} 个代理} =true {所有选定代理}}", "xpack.fleet.upgradeAgents.confirmSingleButtonLabel": "升级代理", - "xpack.fleet.upgradeAgents.experimentalLabel": "实验性", - "xpack.fleet.upgradeAgents.experimentalLabelTooltip": "在未来的版本中可能会更改或移除升级代理,其不受支持 SLA 的约束。", "xpack.fleet.upgradeAgents.fatalErrorNotificationTitle": "升级{count, plural, one {代理} other { {count} 个代理} =true {所有选定代理}}时出错", "xpack.fleet.upgradeAgents.successMultiNotificationTitle": "已升级{isMixed, select, true { {success} 个(共 {total} 个)} other {{isAllAgents, select, true {所有选定} other { {success} 个} }}}代理", "xpack.fleet.upgradeAgents.successSingleNotificationTitle": "已升级 {count} 个代理", From 57d783a8c7806a525b926bcab6916a6afda889d2 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 20 May 2022 12:42:07 +0200 Subject: [PATCH 101/150] add tooltip and change icon (#132581) --- .../datatable_visualization/visualization.tsx | 9 +++++---- .../config_panel/color_indicator.tsx | 11 +++++++++++ .../shared_components/collapse_setting.tsx | 19 +++++++++++++++++-- x-pack/plugins/lens/public/types.ts | 2 +- .../public/xy_visualization/visualization.tsx | 2 +- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index d42af9aa3932c..12c5dafb5d942 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -203,10 +203,11 @@ export const getDatatableVisualization = ({ ) .map((accessor) => ({ columnId: accessor, - triggerIcon: - columnMap[accessor].hidden || columnMap[accessor].collapseFn - ? 'invisible' - : undefined, + triggerIcon: columnMap[accessor].hidden + ? 'invisible' + : columnMap[accessor].collapseFn + ? 'aggregate' + : undefined, })), supportsMoreColumns: true, filterOperations: (op) => op.isBucketed, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx index b8a5819d45532..b12f50a7b35a0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx @@ -59,6 +59,17 @@ export function ColorIndicator({ })} /> )} + {accessorConfig.triggerIcon === 'aggregate' && ( + + )} {accessorConfig.triggerIcon === 'colorBy' && ( +
+ {i18n.translate('xpack.lens.collapse.label', { defaultMessage: 'Collapse by' })} + {''} + + + + } display="columnCompressed" fullWidth > diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 1f2ee1266ddb7..1ffc300542b09 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -557,7 +557,7 @@ export type VisualizationDimensionEditorProps = VisualizationConfig export interface AccessorConfig { columnId: string; - triggerIcon?: 'color' | 'disabled' | 'colorBy' | 'none' | 'invisible'; + triggerIcon?: 'color' | 'disabled' | 'colorBy' | 'none' | 'invisible' | 'aggregate'; color?: string; palette?: string[] | Array<{ color: string; stop: number }>; } diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 096c395b31eaf..b35247f4d9d97 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -276,7 +276,7 @@ export const getXyVisualization = ({ ? [ { columnId: dataLayer.splitAccessor, - triggerIcon: dataLayer.collapseFn ? ('invisible' as const) : ('colorBy' as const), + triggerIcon: dataLayer.collapseFn ? ('aggregate' as const) : ('colorBy' as const), palette: dataLayer.collapseFn ? undefined : paletteService From b3aee1740ba63fdc83af0f9ce98246858c8fb929 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Fri, 20 May 2022 12:56:03 +0200 Subject: [PATCH 102/150] [ML] Disable AIOps UI/APIs. (#132589) This disables the UI and APIs for Explain log rate spikes in the ML plugin since it will not be part of 8.3. Once 8.3 has been branched off, we can reenable it in main. This also adds a check to the API integration tests to run the tests only when the hard coded feature flag is set to true. --- x-pack/plugins/aiops/common/index.ts | 2 +- x-pack/test/api_integration/apis/aiops/index.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/aiops/common/index.ts b/x-pack/plugins/aiops/common/index.ts index 0f4835d67ecc7..162fa9f1af624 100755 --- a/x-pack/plugins/aiops/common/index.ts +++ b/x-pack/plugins/aiops/common/index.ts @@ -19,4 +19,4 @@ export const PLUGIN_NAME = 'AIOps'; * This is an internal hard coded feature flag so we can easily turn on/off the * "Explain log rate spikes UI" during development until the first release. */ -export const AIOPS_ENABLED = true; +export const AIOPS_ENABLED = false; diff --git a/x-pack/test/api_integration/apis/aiops/index.ts b/x-pack/test/api_integration/apis/aiops/index.ts index d2aacc454b567..8d6b6ea13399f 100644 --- a/x-pack/test/api_integration/apis/aiops/index.ts +++ b/x-pack/test/api_integration/apis/aiops/index.ts @@ -5,13 +5,17 @@ * 2.0. */ +import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; + import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('AIOps', function () { this.tags(['ml']); - loadTestFile(require.resolve('./example_stream')); - loadTestFile(require.resolve('./explain_log_rate_spikes')); + if (AIOPS_ENABLED) { + loadTestFile(require.resolve('./example_stream')); + loadTestFile(require.resolve('./explain_log_rate_spikes')); + } }); } From 3b7c7e81ff633eab2cd8c1da412ed88af7344e71 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Fri, 20 May 2022 13:08:20 +0200 Subject: [PATCH 103/150] Update user risk dashboard name (#132441) --- .../public/users/pages/navigation/user_risk_tab_body.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx index bb1f73765bf59..1684297fd236d 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx @@ -27,7 +27,7 @@ const StyledEuiFlexGroup = styled(EuiFlexGroup)` margin-top: ${({ theme }) => theme.eui.paddingSizes.l}; `; -const RISKY_USERS_DASHBOARD_TITLE = 'User Risk Score (Start Here)'; +const RISKY_USERS_DASHBOARD_TITLE = 'Current Risk Score For Users'; const UserRiskTabBodyComponent: React.FC< Pick & { From f0cb40af75a0848068d1775427762cdcab093ef6 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Fri, 20 May 2022 05:32:02 -0600 Subject: [PATCH 104/150] [ML] Data Frame Analytics: replace custom types with estypes (#132443) * replace common data_frame_analytics types from server with esclient types * remove unused ts error ignore commments * remove generic analyis type and move types to common dir * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * move types to commont folder Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../ml/common/types/data_frame_analytics.ts | 155 +++++++++--------- .../common/analytics.test.ts | 3 + .../data_frame_analytics/common/analytics.ts | 97 +---------- .../data_frame_analytics/common/fields.ts | 2 +- .../data_frame_analytics/common/index.ts | 8 +- .../analysis_fields_table.tsx | 2 +- .../configuration_step_form.tsx | 2 +- .../components/shared/fetch_explain_data.ts | 12 +- .../column_data.tsx | 2 +- .../evaluate_panel.tsx | 2 +- .../get_roc_curve_chart_vega_lite_spec.tsx | 2 +- .../use_confusion_matrix.ts | 8 +- .../use_roc_curve.ts | 4 +- .../exploration_page_wrapper.tsx | 2 +- .../use_exploration_results.ts | 2 +- .../outlier_exploration/use_outlier_data.ts | 6 +- .../regression_exploration/evaluate_panel.tsx | 2 +- .../action_edit/edit_action_flyout.tsx | 10 +- .../analytics_list/expanded_row.tsx | 6 +- .../components/analytics_list/use_columns.tsx | 1 + .../use_create_analytics_form/reducer.ts | 1 + .../use_create_analytics_form/state.test.ts | 1 + .../hooks/use_create_analytics_form/state.ts | 4 +- .../use_create_analytics_form.ts | 4 +- .../analytics_service/get_analytics.test.ts | 2 + .../ml_api_service/data_frame_analytics.ts | 6 +- .../data_frame_analytics/analytics_manager.ts | 1 - .../models/data_frame_analytics/validation.ts | 30 +++- .../plugins/ml/server/saved_objects/checks.ts | 2 +- .../ml/stack_management_jobs/export_jobs.ts | 3 - 30 files changed, 155 insertions(+), 227 deletions(-) diff --git a/x-pack/plugins/ml/common/types/data_frame_analytics.ts b/x-pack/plugins/ml/common/types/data_frame_analytics.ts index 92c0c1d06ef93..3d7dda658a0ba 100644 --- a/x-pack/plugins/ml/common/types/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/types/data_frame_analytics.ts @@ -7,11 +7,9 @@ import Boom from '@hapi/boom'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { RuntimeMappings } from './fields'; import { EsErrorBody } from '../util/errors'; import { ANALYSIS_CONFIG_TYPE } from '../constants/data_frame_analytics'; -import { DATA_FRAME_TASK_STATE } from '../constants/data_frame_analytics'; export interface DeleteDataFrameAnalyticsWithIndexStatus { success: boolean; @@ -57,69 +55,90 @@ export interface ClassificationAnalysis { classification: Classification; } -interface GenericAnalysis { - [key: string]: Record; +export type AnalysisConfig = estypes.MlDataframeAnalysisContainer; +export interface DataFrameAnalyticsConfig + extends Omit { + analyzed_fields?: estypes.MlDataframeAnalysisAnalyzedFields; } -export type AnalysisConfig = - | OutlierAnalysis - | RegressionAnalysis - | ClassificationAnalysis - | GenericAnalysis; - -export interface DataFrameAnalyticsConfig { - id: DataFrameAnalyticsId; +export interface UpdateDataFrameAnalyticsConfig { + allow_lazy_start?: string; description?: string; - dest: { - index: IndexName; - results_field: string; - }; - source: { - index: IndexName | IndexName[]; - query?: estypes.QueryDslQueryContainer; - runtime_mappings?: RuntimeMappings; - }; - analysis: AnalysisConfig; - analyzed_fields?: { - includes?: string[]; - excludes?: string[]; - }; - model_memory_limit: string; + model_memory_limit?: string; max_num_threads?: number; - create_time: number; - version: string; - allow_lazy_start?: boolean; } export type DataFrameAnalysisConfigType = typeof ANALYSIS_CONFIG_TYPE[keyof typeof ANALYSIS_CONFIG_TYPE]; -export type DataFrameTaskStateType = - typeof DATA_FRAME_TASK_STATE[keyof typeof DATA_FRAME_TASK_STATE]; +export type DataFrameTaskStateType = estypes.MlDataframeState | 'analyzing' | 'reindexing'; + +export interface DataFrameAnalyticsStats extends Omit { + failure_reason?: string; + state: DataFrameTaskStateType; +} + +export type DfAnalyticsExplainResponse = estypes.MlExplainDataFrameAnalyticsResponse; + +export interface PredictedClass { + predicted_class: string; + count: number; +} +export interface ConfusionMatrix { + actual_class: string; + actual_class_doc_count: number; + predicted_classes: PredictedClass[]; + other_predicted_class_doc_count: number; +} + +export interface RocCurveItem { + fpr: number; + threshold: number; + tpr: number; +} -interface ProgressSection { - phase: string; - progress_percent: number; +interface EvalClass { + class_name: string; + value: number; +} +export interface ClassificationEvaluateResponse { + classification: { + multiclass_confusion_matrix?: { + confusion_matrix: ConfusionMatrix[]; + }; + recall?: { + classes: EvalClass[]; + avg_recall: number; + }; + accuracy?: { + classes: EvalClass[]; + overall_accuracy: number; + }; + auc_roc?: { + curve?: RocCurveItem[]; + value: number; + }; + }; } -export interface DataFrameAnalyticsStats { - assignment_explanation?: string; - id: DataFrameAnalyticsId; - memory_usage?: { - timestamp?: string; - peak_usage_bytes: number; - status: string; +export interface EvaluateMetrics { + classification: { + accuracy?: object; + recall?: object; + multiclass_confusion_matrix?: object; + auc_roc?: { include_curve: boolean; class_name: string }; }; - node?: { - attributes: Record; - ephemeral_id: string; - id: string; - name: string; - transport_address: string; + regression: { + r_squared: object; + mse: object; + msle: object; + huber: object; }; - progress: ProgressSection[]; - failure_reason?: string; - state: DataFrameTaskStateType; +} + +export interface FieldSelectionItem + extends Omit { + mapping_types?: string[]; } export interface AnalyticsMapNodeElement { @@ -146,30 +165,14 @@ export interface AnalyticsMapReturnType { error: null | any; } -export interface FeatureProcessor { - frequency_encoding: { - feature_name: string; - field: string; - frequency_map: Record; - }; - multi_encoding: { - processors: any[]; - }; - n_gram_encoding: { - feature_prefix?: string; - field: string; - length?: number; - n_grams: number[]; - start?: number; - }; - one_hot_encoding: { - field: string; - hot_map: string; - }; - target_mean_encoding: { - default_value: number; - feature_name: string; - field: string; - target_map: Record; +export type FeatureProcessor = estypes.MlDataframeAnalysisFeatureProcessor; + +export interface TrackTotalHitsSearchResponse { + hits: { + total: { + value: number; + relation: string; + }; + hits: any[]; }; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.test.ts index 0cd4d190ebbbd..aa83ce0a1f4ad 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.test.ts @@ -13,15 +13,18 @@ describe('Data Frame Analytics: Analytics utils', () => { expect(getAnalysisType(outlierAnalysis)).toBe('outlier_detection'); const regressionAnalysis = { regression: {} }; + // @ts-expect-error incomplete regression analysis expect(getAnalysisType(regressionAnalysis)).toBe('regression'); // test against a job type that does not exist yet. const otherAnalysis = { other: {} }; + // @ts-expect-error unkown analysis type expect(getAnalysisType(otherAnalysis)).toBe('other'); // if the analysis object has a shape that is not just a single property, // the job type will be returned as 'unknown'. const unknownAnalysis = { outlier_detection: {}, regression: {} }; + // @ts-expect-error unkown analysis type expect(getAnalysisType(unknownAnalysis)).toBe('unknown'); }); 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 c2c2563c5ba7c..064416cd722d1 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 @@ -12,6 +12,11 @@ import { cloneDeep } from 'lodash'; import { ml } from '../../services/ml_api_service'; import { Dictionary } from '../../../../common/types/common'; import { extractErrorMessage } from '../../../../common/util/errors'; +import { + ClassificationEvaluateResponse, + EvaluateMetrics, + TrackTotalHitsSearchResponse, +} from '../../../../common/types/data_frame_analytics'; import { SavedSearchQuery } from '../../contexts/ml'; import { AnalysisConfig, @@ -106,23 +111,6 @@ export enum INDEX_STATUS { ERROR, } -export interface FieldSelectionItem { - name: string; - mappings_types?: string[]; - is_included: boolean; - is_required: boolean; - feature_type?: string; - reason?: string; -} - -export interface DfAnalyticsExplainResponse { - field_selection?: FieldSelectionItem[]; - memory_estimation: { - expected_memory_without_disk: string; - expected_memory_with_disk: string; - }; -} - export interface Eval { mse: number | string; msle: number | string; @@ -148,49 +136,6 @@ export interface RegressionEvaluateResponse { }; } -export interface PredictedClass { - predicted_class: string; - count: number; -} - -export interface ConfusionMatrix { - actual_class: string; - actual_class_doc_count: number; - predicted_classes: PredictedClass[]; - other_predicted_class_doc_count: number; -} - -export interface RocCurveItem { - fpr: number; - threshold: number; - tpr: number; -} - -interface EvalClass { - class_name: string; - value: number; -} - -export interface ClassificationEvaluateResponse { - classification: { - multiclass_confusion_matrix?: { - confusion_matrix: ConfusionMatrix[]; - }; - recall?: { - classes: EvalClass[]; - avg_recall: number; - }; - accuracy?: { - classes: EvalClass[]; - overall_accuracy: number; - }; - auc_roc?: { - curve?: RocCurveItem[]; - value: number; - }; - }; -} - interface LoadEvaluateResult { success: boolean; eval: RegressionEvaluateResponse | ClassificationEvaluateResponse | null; @@ -279,13 +224,6 @@ export const isClassificationEvaluateResponse = ( ); }; -export interface UpdateDataFrameAnalyticsConfig { - allow_lazy_start?: string; - description?: string; - model_memory_limit?: string; - max_num_threads?: number; -} - export enum REFRESH_ANALYTICS_LIST_STATE { ERROR = 'error', IDLE = 'idle', @@ -451,21 +389,6 @@ export enum REGRESSION_STATS { HUBER = 'huber', } -interface EvaluateMetrics { - classification: { - accuracy?: object; - recall?: object; - multiclass_confusion_matrix?: object; - auc_roc?: { include_curve: boolean; class_name: string }; - }; - regression: { - r_squared: object; - mse: object; - msle: object; - huber: object; - }; -} - interface LoadEvalDataConfig { isTraining?: boolean; index: string; @@ -548,16 +471,6 @@ export const loadEvalData = async ({ } }; -interface TrackTotalHitsSearchResponse { - hits: { - total: { - value: number; - relation: string; - }; - hits: any[]; - }; -} - interface LoadDocsCountConfig { ignoreDefaultQuery?: boolean; isTraining?: boolean; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts index 89c05643f0dc8..3ab82daa6b1f3 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts @@ -96,7 +96,7 @@ export const sortExplorationResultsFields = ( if (isClassificationAnalysis(jobConfig.analysis) || isRegressionAnalysis(jobConfig.analysis)) { const dependentVariable = getDependentVar(jobConfig.analysis); - const predictedField = getPredictedFieldName(resultsField, jobConfig.analysis, true); + const predictedField = getPredictedFieldName(resultsField!, jobConfig.analysis, true); if (a === `${resultsField}.is_training`) { return -1; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts index 2fb0daa1ed45e..f47b5b66f4944 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts @@ -5,13 +5,7 @@ * 2.0. */ -export type { - UpdateDataFrameAnalyticsConfig, - IndexPattern, - RegressionEvaluateResponse, - Eval, - SearchQuery, -} from './analytics'; +export type { IndexPattern, RegressionEvaluateResponse, Eval, SearchQuery } from './analytics'; export { getAnalysisType, getDependentVar, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx index d98940588f48f..f4a9eb0d5c0a8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx @@ -12,7 +12,7 @@ import { isEqual } from 'lodash'; import { LEFT_ALIGNMENT, SortableProperties } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { FieldSelectionItem } from '../../../../common/analytics'; +import { FieldSelectionItem } from '../../../../../../../common/types/data_frame_analytics'; // @ts-ignore could not find declaration file import { CustomSelectionTable } from '../../../../../components/custom_selection_table'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index b4f55bcae0947..758fd01a133c6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -28,10 +28,10 @@ import { ANALYSIS_CONFIG_TYPE, TRAINING_PERCENT_MIN, TRAINING_PERCENT_MAX, - FieldSelectionItem, } from '../../../../common/analytics'; import { getScatterplotMatrixLegendType } from '../../../../common/get_scatterplot_matrix_legend_type'; import { RuntimeMappings as RuntimeMappingsType } from '../../../../../../../common/types/fields'; +import { FieldSelectionItem } from '../../../../../../../common/types/data_frame_analytics'; import { isRuntimeMappings, isRuntimeField, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts index 7c83b0af15107..ca334a58b36c2 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts @@ -7,19 +7,15 @@ import { ml } from '../../../../../services/ml_api_service'; import { extractErrorProperties } from '../../../../../../../common/util/errors'; -import { DfAnalyticsExplainResponse, FieldSelectionItem } from '../../../../common/analytics'; +import { + DfAnalyticsExplainResponse, + FieldSelectionItem, +} from '../../../../../../../common/types/data_frame_analytics'; import { getJobConfigFromFormState, State, } from '../../../analytics_management/hooks/use_create_analytics_form/state'; -export interface FetchExplainDataReturnType { - success: boolean; - expectedMemory: string; - fieldSelection: FieldSelectionItem[]; - errorMessage: string; -} - export const fetchExplainData = async (formState: State['form']) => { const jobConfig = getJobConfigFromFormState(formState); let errorMessage = ''; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx index 31b7db66f81ae..c983511f80393 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx @@ -14,7 +14,7 @@ import { EuiPopover, EuiText, } from '@elastic/eui'; -import { ConfusionMatrix } from '../../../../common/analytics'; +import { ConfusionMatrix } from '../../../../../../../common/types/data_frame_analytics'; const COL_INITIAL_WIDTH = 165; // in pixels 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 97ab582832b64..8ba780a3e512a 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 @@ -119,7 +119,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se columns.map(({ id }: { id: string }) => id) ); - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field!; const isTraining = isTrainingFilter(searchQuery, resultsField); const { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx index 3ca1f65cf2ecc..e3f92c36507c6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx @@ -16,7 +16,7 @@ import { i18n } from '@kbn/i18n'; import { LEGEND_TYPES } from '../../../../../components/vega_chart/common'; -import { RocCurveItem } from '../../../../common/analytics'; +import { RocCurveItem } from '../../../../../../../common/types/data_frame_analytics'; const GRAY = euiPaletteGray(1)[0]; const BASELINE = 'baseline'; 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 2a75acf823e88..c51f5bf3e9665 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,13 +9,15 @@ import { useState, useEffect } from 'react'; import { isClassificationEvaluateResponse, - ClassificationEvaluateResponse, - ConfusionMatrix, ResultsSearchQuery, ANALYSIS_CONFIG_TYPE, ClassificationMetricItem, } from '../../../../common/analytics'; import { isKeywordAndTextType } from '../../../../common/fields'; +import { + ClassificationEvaluateResponse, + ConfusionMatrix, +} from '../../../../../../../common/types/data_frame_analytics'; import { getDependentVar, @@ -78,7 +80,7 @@ export const useConfusionMatrix = ( let requiresKeyword = false; const dependentVariable = getDependentVar(jobConfig.analysis); - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field!; const isTraining = isTrainingFilter(searchQuery, resultsField); try { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts index 20521258cd374..f83f9f9f31e0f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts @@ -10,10 +10,10 @@ import { useState, useEffect } from 'react'; import { isClassificationEvaluateResponse, ResultsSearchQuery, - RocCurveItem, ANALYSIS_CONFIG_TYPE, } from '../../../../common/analytics'; import { isKeywordAndTextType } from '../../../../common/fields'; +import { RocCurveItem } from '../../../../../../../common/types/data_frame_analytics'; import { getDependentVar, @@ -58,7 +58,7 @@ export const useRocCurve = ( setIsLoading(true); const dependentVariable = getDependentVar(jobConfig.analysis); - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field!; const newRocCurveData: RocCurveDataRow[] = []; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx index 48477acfe7be8..17453dd87b0d0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx @@ -170,7 +170,7 @@ export const ExplorationPageWrapper: FC = ({ indexPattern={indexPattern} setSearchQuery={searchQueryUpdateHandler} query={query} - filters={getFilters(jobConfig.dest.results_field)} + filters={getFilters(jobConfig.dest.results_field!)} /> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts index c0590fd80a5d5..593ef5465d196 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts @@ -57,7 +57,7 @@ export const useExplorationResults = ( const columns: EuiDataGridColumn[] = []; if (jobConfig !== undefined) { - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field!; const { fieldTypes } = getIndexFields(jobConfig, needsDestIndexFields); columns.push( ...getDataGridSchemasFromFieldTypes(fieldTypes, resultsField).sort((a: any, b: any) => diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts index 45653209cdb8a..920023c23a2bd 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts @@ -55,7 +55,7 @@ export const useOutlierData = ( const resultsField = jobConfig.dest.results_field; const { fieldTypes } = getIndexFields(jobConfig, needsDestIndexFields); newColumns.push( - ...getDataGridSchemasFromFieldTypes(fieldTypes, resultsField).sort((a: any, b: any) => + ...getDataGridSchemasFromFieldTypes(fieldTypes, resultsField!).sort((a: any, b: any) => sortExplorationResultsFields(a.id, b.id, jobConfig) ) ); @@ -135,7 +135,9 @@ export const useOutlierData = ( const colorRange = useColorRange( COLOR_RANGE.BLUE, COLOR_RANGE_SCALE.INFLUENCER, - jobConfig !== undefined ? getFeatureCount(jobConfig.dest.results_field, dataGrid.tableItems) : 1 + jobConfig !== undefined + ? getFeatureCount(jobConfig.dest.results_field!, dataGrid.tableItems) + : 1 ); const renderCellValue = useRenderCellValue( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx index 6d5417db24607..1249b736960d8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx @@ -75,7 +75,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) const dependentVariable = getDependentVar(jobConfig.analysis); const predictionFieldName = getPredictionFieldName(jobConfig.analysis); // default is 'ml' - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field ?? 'ml'; const loadGeneralizationData = async (ignoreDefaultQuery: boolean = true) => { setIsLoadingGeneralization(true); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_action_flyout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_action_flyout.tsx index 766f1bda64d5e..3b8d3ed5460ff 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_action_flyout.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_action_flyout.tsx @@ -34,10 +34,8 @@ import { MemoryInputValidatorResult, } from '../../../../../../../common/util/validators'; import { DATA_FRAME_TASK_STATE } from '../analytics_list/common'; -import { - useRefreshAnalyticsList, - UpdateDataFrameAnalyticsConfig, -} from '../../../../common/analytics'; +import { useRefreshAnalyticsList } from '../../../../common/analytics'; +import { UpdateDataFrameAnalyticsConfig } from '../../../../../../../common/types/data_frame_analytics'; import { EditAction } from './use_edit_action'; @@ -51,7 +49,9 @@ export const EditActionFlyout: FC> = ({ closeFlyout, item } const [allowLazyStart, setAllowLazyStart] = useState(initialAllowLazyStart); const [description, setDescription] = useState(config.description || ''); - const [modelMemoryLimit, setModelMemoryLimit] = useState(config.model_memory_limit); + const [modelMemoryLimit, setModelMemoryLimit] = useState( + config.model_memory_limit + ); const [mmlValidationError, setMmlValidationError] = useState(); const [maxNumThreads, setMaxNumThreads] = useState(config.max_num_threads); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx index 3f7072fba4040..2d072d1aecc1f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx @@ -75,7 +75,7 @@ export const ExpandedRow: FC = ({ item }) => { const dependentVariable = getDependentVar(item.config.analysis); const predictionFieldName = getPredictionFieldName(item.config.analysis); // default is 'ml' - const resultsField = item.config.dest.results_field; + const resultsField = item.config.dest.results_field ?? 'ml'; const jobIsCompleted = isCompletedAnalyticsJob(item.stats); const isRegressionJob = isRegressionAnalysis(item.config.analysis); const analysisType = getAnalysisType(item.config.analysis); @@ -232,8 +232,8 @@ export const ExpandedRow: FC = ({ item }) => { moment(item.config.create_time).unix() * 1000 ), }, - { title: 'model_memory_limit', description: item.config.model_memory_limit }, - { title: 'version', description: item.config.version }, + { title: 'model_memory_limit', description: item.config.model_memory_limit ?? '' }, + { title: 'version', description: item.config.version ?? '' }, ], position: 'left', dataTestSubj: 'mlAnalyticsTableRowDetailsSection stats', diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index 3077f0fb38726..efa1f58ecddc0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -43,6 +43,7 @@ enum TASK_STATE_COLOR { started = 'primary', starting = 'primary', stopped = 'hollow', + stopping = 'hollow', } export const getTaskStateBadge = ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index 5559e7db2d631..58a471b4e7246 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -193,6 +193,7 @@ export const validateAdvancedEditor = (state: State): State => { dependentVariableEmpty = dependentVariableName === ''; if ( !dependentVariableEmpty && + Array.isArray(analyzedFields) && analyzedFields.length > 0 && !analyzedFields.includes(dependentVariableName) ) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts index c51ccf1e20d8d..c27137fca9519 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts @@ -82,6 +82,7 @@ describe('useCreateAnalyticsForm', () => { expect(jobConfig?.dest?.index).toBe('the-destination-index'); expect(jobConfig?.source?.index).toBe('the-source-index'); expect(jobConfig?.analyzed_fields?.includes).toStrictEqual([]); + // @ts-ignore property 'excludes' does not exist expect(typeof jobConfig?.analyzed_fields?.excludes).toBe('undefined'); // test the conversion of comma-separated Kibana index patterns to ES array based index patterns diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 0b2cb8fcfc716..ca54c552f8ebf 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -370,7 +370,9 @@ export function getFormStateFromJobConfig( runtimeMappings: analyticsJobConfig.source.runtime_mappings, modelMemoryLimit: analyticsJobConfig.model_memory_limit, maxNumThreads: analyticsJobConfig.max_num_threads, - includes: analyticsJobConfig.analyzed_fields?.includes ?? [], + includes: Array.isArray(analyticsJobConfig.analyzed_fields?.includes) + ? analyticsJobConfig.analyzed_fields?.includes + : [], jobConfigQuery: analyticsJobConfig.source.query || defaultSearchQuery, }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index 41a8ae4eeba92..cddc4fcd092dc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -333,8 +333,8 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { dispatch({ type: ACTION.SWITCH_TO_FORM }); }; - const setEstimatedModelMemoryLimit = (value: State['estimatedModelMemoryLimit']) => { - dispatch({ type: ACTION.SET_ESTIMATED_MODEL_MEMORY_LIMIT, value }); + const setEstimatedModelMemoryLimit = (value: State['estimatedModelMemoryLimit'] | undefined) => { + dispatch({ type: ACTION.SET_ESTIMATED_MODEL_MEMORY_LIMIT, value: value ?? '' }); }; const setJobClone = async (cloneJob: DeepReadonly) => { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts index 1c2598477064f..e0324a261e57d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts @@ -15,6 +15,7 @@ describe('get_analytics', () => { const mockResponse: GetDataFrameAnalyticsStatsResponseOk = { count: 2, data_frame_analytics: [ + // @ts-expect-error test response missing expected properties { id: 'outlier-cloudwatch', state: DATA_FRAME_TASK_STATE.STOPPED, @@ -37,6 +38,7 @@ describe('get_analytics', () => { }, ], }, + // @ts-expect-error test response missing expected properties { id: 'reg-gallery', state: DATA_FRAME_TASK_STATE.FAILED, diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts index e4deb90d81073..479f8c50ae035 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts @@ -10,12 +10,10 @@ import { http } from '../http_service'; import { basePath } from '.'; import type { DataFrameAnalyticsStats } from '../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; import type { ValidateAnalyticsJobResponse } from '../../../../common/constants/validation'; -import type { - DataFrameAnalyticsConfig, - UpdateDataFrameAnalyticsConfig, -} from '../../data_frame_analytics/common'; +import type { DataFrameAnalyticsConfig } from '../../data_frame_analytics/common'; import type { DeepPartial } from '../../../../common/types/common'; import type { NewJobCapsResponse } from '../../../../common/types/fields'; +import type { UpdateDataFrameAnalyticsConfig } from '../../../../common/types/data_frame_analytics'; import type { JobMessage } from '../../../../common/types/audit_message'; import type { DeleteDataFrameAnalyticsWithIndexStatus, diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts index 894354a0113fc..d4076a7cf496a 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts @@ -78,7 +78,6 @@ export class AnalyticsManager { async setJobStats() { try { const jobStats = await this.getAnalyticsStats(); - // @ts-expect-error @elastic-elasticsearch Data frame types incomplete this.jobStats = jobStats; } catch (error) { // eslint-disable-next-line diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts index 9cd8b67be2a6d..517f3cadf3b18 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts @@ -239,14 +239,16 @@ async function getValidationCheckMessages( let analysisFieldsEmpty = false; const fieldLimit = - analyzedFields.length <= MINIMUM_NUM_FIELD_FOR_CHECK + Array.isArray(analyzedFields) && analyzedFields.length <= MINIMUM_NUM_FIELD_FOR_CHECK ? analyzedFields.length : MINIMUM_NUM_FIELD_FOR_CHECK; - let aggs = analyzedFields.slice(0, fieldLimit).reduce((acc, curr) => { - acc[curr] = { missing: { field: curr } }; - return acc; - }, {} as any); + let aggs = Array.isArray(analyzedFields) + ? analyzedFields.slice(0, fieldLimit).reduce((acc, curr) => { + acc[curr] = { missing: { field: curr } }; + return acc; + }, {} as any) + : {}; if (depVar !== '') { const depVarAgg = { @@ -344,10 +346,18 @@ async function getValidationCheckMessages( ); messages.push(...regressionAndClassificationMessages); - if (analyzedFields.length && analyzedFields.length > INCLUDED_FIELDS_THRESHOLD) { + if ( + Array.isArray(analyzedFields) && + analyzedFields.length && + analyzedFields.length > INCLUDED_FIELDS_THRESHOLD + ) { analysisFieldsNumHigh = true; } else { - if (analysisType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && analyzedFields.length < 1) { + if ( + analysisType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && + Array.isArray(analyzedFields) && + analyzedFields.length < 1 + ) { lowFieldCountWarningMessage.text = i18n.translate( 'xpack.ml.models.dfaValidation.messages.lowFieldCountOutlierWarningText', { @@ -358,6 +368,7 @@ async function getValidationCheckMessages( messages.push(lowFieldCountWarningMessage); } else if ( analysisType !== ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && + Array.isArray(analyzedFields) && analyzedFields.length < 2 ) { lowFieldCountWarningMessage.text = i18n.translate( @@ -446,9 +457,12 @@ export async function validateAnalyticsJob( client: IScopedClusterClient, job: DataFrameAnalyticsConfig ) { + const includedFields = ( + Array.isArray(job?.analyzed_fields?.includes) ? job?.analyzed_fields?.includes : [] + ) as string[]; const messages = await getValidationCheckMessages( client.asCurrentUser, - job?.analyzed_fields?.includes || [], + includedFields, job.analysis, job.source ); diff --git a/x-pack/plugins/ml/server/saved_objects/checks.ts b/x-pack/plugins/ml/server/saved_objects/checks.ts index 93b68ea3fd990..a5cb560d324d2 100644 --- a/x-pack/plugins/ml/server/saved_objects/checks.ts +++ b/x-pack/plugins/ml/server/saved_objects/checks.ts @@ -131,7 +131,7 @@ export function checksFactory( ); const dfaJobsCreateTimeMap = dfaJobs.data_frame_analytics.reduce((acc, cur) => { - acc.set(cur.id, cur.create_time); + acc.set(cur.id, cur.create_time!); return acc; }, new Map()); diff --git a/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts b/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts index c43cf74e3048c..69ecc7f446b58 100644 --- a/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts +++ b/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts @@ -169,7 +169,6 @@ const testADJobs: Array<{ job: Job; datafeed: Datafeed }> = [ ]; const testDFAJobs: DataFrameAnalyticsConfig[] = [ - // @ts-expect-error not full interface { id: `bm_1_1`, description: @@ -198,7 +197,6 @@ const testDFAJobs: DataFrameAnalyticsConfig[] = [ model_memory_limit: '60mb', allow_lazy_start: false, }, - // @ts-expect-error not full interface { id: `ihp_1_2`, description: 'This is the job description', @@ -221,7 +219,6 @@ const testDFAJobs: DataFrameAnalyticsConfig[] = [ }, model_memory_limit: '5mb', }, - // @ts-expect-error not full interface { id: `egs_1_3`, description: 'This is the job description', From 92ac7f925545e088de8466e39d77a284ab9ffd67 Mon Sep 17 00:00:00 2001 From: Katrin Freihofner Date: Fri, 20 May 2022 13:51:51 +0200 Subject: [PATCH 105/150] adds small styling updates to header panels (#132596) --- .../public/pages/rule_details/index.tsx | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index 99000a91671b8..5cc12452e57e1 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -301,15 +301,15 @@ export function RuleDetailsPage() { : [], }} > - + {/* Left side of Rule Summary */} - + @@ -318,7 +318,7 @@ export function RuleDetailsPage() { - + {i18n.translate('xpack.observability.ruleDetails.lastRun', { @@ -330,11 +330,7 @@ export function RuleDetailsPage() { itemValue={moment(rule.executionStatus.lastExecutionDate).fromNow()} /> - - - - - + {i18n.translate('xpack.observability.ruleDetails.alerts', { @@ -376,8 +372,6 @@ export function RuleDetailsPage() { /> )} - - @@ -385,7 +379,7 @@ export function RuleDetailsPage() { {/* Right side of Rule Summary */} - + @@ -401,7 +395,7 @@ export function RuleDetailsPage() { )} - + @@ -416,9 +410,9 @@ export function RuleDetailsPage() { /> - + - + {i18n.translate('xpack.observability.ruleDetails.description', { defaultMessage: 'Description', @@ -429,7 +423,7 @@ export function RuleDetailsPage() { /> - + @@ -449,8 +443,6 @@ export function RuleDetailsPage() { - - @@ -463,7 +455,7 @@ export function RuleDetailsPage() { - + @@ -474,7 +466,7 @@ export function RuleDetailsPage() { - + {i18n.translate('xpack.observability.ruleDetails.actions', { From 1c2eb9f03da58875360ed1f65a6a0bad33e52c6e Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Fri, 20 May 2022 13:59:56 +0100 Subject: [PATCH 106/150] [Security Solution] New Side nav integrating links config (#132210) * Update navigation landing pages to use appLinks config * align app links changes * link configs refactor to use updater$ * navigation panel categories * test and type fixes * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * types changes * shared style change moved to a separate PR * use old deep links * minor changes after ux meeting * add links filtering * remove duplicated categories * temporary increase of plugin size limit * swap management links order * improve performance closing nav panel * test updated * host isolation page filterd and some improvements * remove async from plugin start * move links register from start to mount * restore size limits * Fix use_show_timeline unit tests Co-authored-by: Pablo Neves Machado Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../public/app/deep_links/index.ts | 39 +- .../app/home/template_wrapper/index.tsx | 25 +- .../public/app/translations.ts | 2 +- .../security_solution/public/cases/links.ts | 12 +- .../components/navigation/nav_links.test.ts | 51 +- .../common/components/navigation/nav_links.ts | 34 +- .../security_side_nav/icons/launch.tsx | 25 + .../navigation/security_side_nav/index.ts | 8 + .../security_side_nav.test.tsx | 256 +++++++++ .../security_side_nav/security_side_nav.tsx | 156 ++++++ .../solution_grouped_nav.test.tsx | 4 +- .../solution_grouped_nav.tsx | 253 +++++---- .../solution_grouped_nav_item.tsx | 188 ------- .../solution_grouped_nav_panel.test.tsx | 17 +- .../solution_grouped_nav_panel.tsx | 123 ++++- .../navigation/solution_grouped_nav/types.ts | 32 ++ .../common/components/navigation/types.ts | 18 +- .../__snapshots__/index.test.tsx.snap | 2 +- .../use_primary_navigation.tsx | 6 +- .../public/common/links/app_links.ts | 60 +- .../public/common/links/index.tsx | 1 + .../public/common/links/links.test.ts | 520 ++++++------------ .../public/common/links/links.ts | 313 ++++++----- .../public/common/links/types.ts | 108 ++-- .../utils/timeline/use_show_timeline.test.tsx | 22 + .../public/detections/links.ts | 7 +- .../security_solution/public/hosts/links.ts | 1 - .../components/landing_links_icons.test.tsx | 3 +- .../components/landing_links_icons.tsx | 2 +- .../components/landing_links_images.test.tsx | 3 +- .../components/landing_links_images.tsx | 2 +- .../public/landing_pages/constants.ts | 36 -- .../public/landing_pages/links.ts | 52 ++ .../landing_pages/pages/manage.test.tsx | 172 +++++- .../public/landing_pages/pages/manage.tsx | 81 +-- .../public/management/links.ts | 60 +- .../public/overview/links.ts | 28 +- .../security_solution/public/plugin.tsx | 100 +++- .../public/timelines/links.ts | 7 +- .../security_solution/server/ui_settings.ts | 2 +- 40 files changed, 1710 insertions(+), 1121 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts delete mode 100644 x-pack/plugins/security_solution/public/landing_pages/constants.ts create mode 100644 x-pack/plugins/security_solution/public/landing_pages/links.ts diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index 550ec608a76cb..6598e0dc29426 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -10,7 +10,8 @@ import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; import { LicenseType } from '@kbn/licensing-plugin/common/types'; import { getCasesDeepLinks } from '@kbn/cases-plugin/public'; -import { AppDeepLink, AppNavLinkStatus, Capabilities } from '@kbn/core/public'; +import { AppDeepLink, AppNavLinkStatus, AppUpdater, Capabilities } from '@kbn/core/public'; +import { Subject } from 'rxjs'; import { SecurityPageName } from '../types'; import { OVERVIEW, @@ -63,6 +64,8 @@ import { RULES_CREATE_PATH, } from '../../../common/constants'; import { ExperimentalFeatures } from '../../../common/experimental_features'; +import { subscribeAppLinks } from '../../common/links'; +import { AppLinkItems } from '../../common/links/types'; const FEATURE = { general: `${SERVER_APP_ID}.show`, @@ -553,3 +556,37 @@ export function isPremiumLicense(licenseType?: LicenseType): boolean { licenseType === 'trial' ); } + +/** + * New deep links code starts here. + * All the code above will be removed once the appLinks migration is over. + * The code below manages the new implementation using the unified appLinks. + */ + +const formatDeepLinks = (appLinks: AppLinkItems): AppDeepLink[] => + appLinks.map((appLink) => ({ + id: appLink.id, + path: appLink.path, + title: appLink.title, + navLinkStatus: appLink.globalNavEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden, + searchable: !appLink.globalSearchDisabled, + ...(appLink.globalSearchKeywords != null ? { keywords: appLink.globalSearchKeywords } : {}), + ...(appLink.globalNavOrder != null ? { order: appLink.globalNavOrder } : {}), + ...(appLink.links && appLink.links?.length + ? { + deepLinks: formatDeepLinks(appLink.links), + } + : {}), + })); + +/** + * Registers any change in appLinks to be updated in app deepLinks + */ +export const registerDeepLinksUpdater = (appUpdater$: Subject) => { + subscribeAppLinks((appLinks) => { + appUpdater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // needed to prevent main security link to switch to visible after update + deepLinks: formatDeepLinks(appLinks), + })); + }); +}; diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx index 3b436d2bdefc1..8d7d9daad550d 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx @@ -26,6 +26,7 @@ import { gutterTimeline } from '../../../common/lib/helpers'; import { useKibana } from '../../../common/lib/kibana'; import { useShowPagesWithEmptyView } from '../../../common/utils/empty_view/use_show_pages_with_empty_view'; import { useIsPolicySettingsBarVisible } from '../../../management/pages/policy/view/policy_hooks'; +import { useIsGroupedNavigationEnabled } from '../../../common/components/navigation/helpers'; const NO_DATA_PAGE_MAX_WIDTH = 950; @@ -44,8 +45,7 @@ const NO_DATA_PAGE_TEMPLATE_PROPS = { */ const StyledKibanaPageTemplate = styled(KibanaPageTemplate)<{ $isShowingTimelineOverlay?: boolean; - $isTimelineBottomBarVisible?: boolean; - $isPolicySettingsVisible?: boolean; + $addBottomPadding?: boolean; }>` .${BOTTOM_BAR_CLASSNAME} { animation: 'none !important'; // disable the default bottom bar slide animation @@ -63,19 +63,8 @@ const StyledKibanaPageTemplate = styled(KibanaPageTemplate)<{ } // If the bottom bar is visible add padding to the navigation - ${({ $isTimelineBottomBarVisible }) => - $isTimelineBottomBarVisible && - ` - @media (min-width: 768px) { - .kbnPageTemplateSolutionNav { - padding-bottom: ${gutterTimeline}; - } - } - `} - - // If the policy settings bottom bar is visible add padding to the navigation - ${({ $isPolicySettingsVisible }) => - $isPolicySettingsVisible && + ${({ $addBottomPadding }) => + $addBottomPadding && ` @media (min-width: 768px) { .kbnPageTemplateSolutionNav { @@ -98,6 +87,9 @@ export const SecuritySolutionTemplateWrapper: React.FC getTimelineShowStatus(state, TimelineId.active) ); + const isGroupedNavEnabled = useIsGroupedNavigationEnabled(); + const addBottomPadding = + isTimelineBottomBarVisible || isPolicySettingsVisible || isGroupedNavEnabled; const userHasSecuritySolutionVisible = useKibana().services.application.capabilities.siem.show; const showEmptyState = useShowPagesWithEmptyView(); @@ -117,9 +109,8 @@ export const SecuritySolutionTemplateWrapper: React.FC diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 9857e7160a209..354ba438ff52a 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -23,7 +23,7 @@ export const HOSTS = i18n.translate('xpack.securitySolution.navigation.hosts', { }); export const GETTING_STARTED = i18n.translate('xpack.securitySolution.navigation.gettingStarted', { - defaultMessage: 'Getting started', + defaultMessage: 'Get started', }); export const THREAT_HUNTING = i18n.translate('xpack.securitySolution.navigation.threatHunting', { diff --git a/x-pack/plugins/security_solution/public/cases/links.ts b/x-pack/plugins/security_solution/public/cases/links.ts index 9ed7a1f3980a6..bafaee6baa583 100644 --- a/x-pack/plugins/security_solution/public/cases/links.ts +++ b/x-pack/plugins/security_solution/public/cases/links.ts @@ -6,8 +6,8 @@ */ import { getCasesDeepLinks } from '@kbn/cases-plugin/public'; -import { CASES_PATH, SecurityPageName } from '../../common/constants'; -import { FEATURE, LinkItem } from '../common/links/types'; +import { CASES_FEATURE_ID, CASES_PATH, SecurityPageName } from '../../common/constants'; +import { LinkItem } from '../common/links/types'; export const getCasesLinkItems = (): LinkItem => { const casesLinks = getCasesDeepLinks({ @@ -16,15 +16,17 @@ export const getCasesLinkItems = (): LinkItem => { [SecurityPageName.case]: { globalNavEnabled: true, globalNavOrder: 9006, - features: [FEATURE.casesRead], + capabilities: [`${CASES_FEATURE_ID}.read_cases`], }, [SecurityPageName.caseConfigure]: { - features: [FEATURE.casesCrud], + capabilities: [`${CASES_FEATURE_ID}.crud_cases`], licenseType: 'gold', + sideNavDisabled: true, hideTimeline: true, }, [SecurityPageName.caseCreate]: { - features: [FEATURE.casesCrud], + capabilities: [`${CASES_FEATURE_ID}.crud_cases`], + sideNavDisabled: true, hideTimeline: true, }, }, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts index ff7aa7581fc4b..41b62e8589854 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts @@ -7,11 +7,12 @@ import { renderHook } from '@testing-library/react-hooks'; import { SecurityPageName } from '../../../app/types'; -import { NavLinkItem } from '../../links/types'; +import { AppLinkItems } from '../../links'; import { TestProviders } from '../../mock'; import { useAppNavLinks, useAppRootNavLink } from './nav_links'; +import { NavLinkItem } from './types'; -const mockNavLinks = [ +const mockNavLinks: AppLinkItems = [ { description: 'description', id: SecurityPageName.administration, @@ -22,6 +23,10 @@ const mockNavLinks = [ links: [], path: '/path_2', title: 'title 2', + sideNavDisabled: true, + landingIcon: 'someicon', + landingImage: 'someimage', + skipUrlState: true, }, ], path: '/path', @@ -30,7 +35,7 @@ const mockNavLinks = [ ]; jest.mock('../../links', () => ({ - getNavLinkItems: () => mockNavLinks, + useAppLinks: () => mockNavLinks, })); const renderUseAppNavLinks = () => @@ -44,11 +49,47 @@ const renderUseAppRootNavLink = (id: SecurityPageName) => describe('useAppNavLinks', () => { it('should return all nav links', () => { const { result } = renderUseAppNavLinks(); - expect(result.current).toEqual(mockNavLinks); + expect(result.current).toMatchInlineSnapshot(` + Array [ + Object { + "description": "description", + "id": "administration", + "links": Array [ + Object { + "description": "description 2", + "disabled": true, + "icon": "someicon", + "id": "endpoints", + "image": "someimage", + "skipUrlState": true, + "title": "title 2", + }, + ], + "title": "title", + }, + ] + `); }); it('should return a root nav links', () => { const { result } = renderUseAppRootNavLink(SecurityPageName.administration); - expect(result.current).toEqual(mockNavLinks[0]); + expect(result.current).toMatchInlineSnapshot(` + Object { + "description": "description", + "id": "administration", + "links": Array [ + Object { + "description": "description 2", + "disabled": true, + "icon": "someicon", + "id": "endpoints", + "image": "someimage", + "skipUrlState": true, + "title": "title 2", + }, + ], + "title": "title", + } + `); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts index efdf72a1f7926..db8b5788b04d6 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts @@ -5,21 +5,35 @@ * 2.0. */ -import { useKibana } from '../../lib/kibana'; -import { useEnableExperimental } from '../../hooks/use_experimental_features'; -import { useLicense } from '../../hooks/use_license'; -import { getNavLinkItems } from '../../links'; +import { useMemo } from 'react'; +import { useAppLinks } from '../../links'; import type { SecurityPageName } from '../../../app/types'; -import type { NavLinkItem } from '../../links/types'; +import { NavLinkItem } from './types'; +import { AppLinkItems } from '../../links/types'; export const useAppNavLinks = (): NavLinkItem[] => { - const license = useLicense(); - const enableExperimental = useEnableExperimental(); - const capabilities = useKibana().services.application.capabilities; - - return getNavLinkItems({ enableExperimental, license, capabilities }); + const appLinks = useAppLinks(); + const navLinks = useMemo(() => formatNavLinkItems(appLinks), [appLinks]); + return navLinks; }; export const useAppRootNavLink = (linkId: SecurityPageName): NavLinkItem | undefined => { return useAppNavLinks().find(({ id }) => id === linkId); }; + +const formatNavLinkItems = (appLinks: AppLinkItems): NavLinkItem[] => + appLinks.map((link) => ({ + id: link.id, + title: link.title, + ...(link.categories != null ? { categories: link.categories } : {}), + ...(link.description != null ? { description: link.description } : {}), + ...(link.sideNavDisabled === true ? { disabled: true } : {}), + ...(link.landingIcon != null ? { icon: link.landingIcon } : {}), + ...(link.landingImage != null ? { image: link.landingImage } : {}), + ...(link.skipUrlState != null ? { skipUrlState: link.skipUrlState } : {}), + ...(link.links && link.links.length + ? { + links: formatNavLinkItems(link.links), + } + : {}), + })); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.tsx new file mode 100644 index 0000000000000..de96338ef98e6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { SVGProps } from 'react'; + +export const EuiIconLaunch: React.FC> = ({ ...props }) => ( + + + + + + + +); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts new file mode 100644 index 0000000000000..a2c866e604e16 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SecuritySideNav } from './security_side_nav'; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx new file mode 100644 index 0000000000000..c0ebd0722f725 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { SecurityPageName } from '../../../../app/types'; +import { TestProviders } from '../../../mock'; +import { SecuritySideNav } from './security_side_nav'; +import { SolutionGroupedNavProps } from '../solution_grouped_nav/solution_grouped_nav'; +import { NavLinkItem } from '../types'; + +const manageNavLink: NavLinkItem = { + id: SecurityPageName.administration, + title: 'manage', + description: 'manage description', + categories: [{ label: 'test category', linkIds: [SecurityPageName.endpoints] }], + links: [ + { + id: SecurityPageName.endpoints, + title: 'title 2', + description: 'description 2', + }, + ], +}; +const alertsNavLink: NavLinkItem = { + id: SecurityPageName.alerts, + title: 'alerts', + description: 'alerts description', +}; + +const mockSolutionGroupedNav = jest.fn((_: SolutionGroupedNavProps) => <>); +jest.mock('../solution_grouped_nav', () => ({ + SolutionGroupedNav: (props: SolutionGroupedNavProps) => mockSolutionGroupedNav(props), +})); +const mockUseRouteSpy = jest.fn(() => [{ pageName: SecurityPageName.alerts }]); +jest.mock('../../../utils/route/use_route_spy', () => ({ + useRouteSpy: () => mockUseRouteSpy(), +})); + +const mockedUseCanSeeHostIsolationExceptionsMenu = jest.fn(); +jest.mock('../../../../management/pages/host_isolation_exceptions/view/hooks', () => ({ + useCanSeeHostIsolationExceptionsMenu: () => mockedUseCanSeeHostIsolationExceptionsMenu(), +})); +jest.mock('../../../links', () => ({ + getAncestorLinksInfo: (id: string) => [{ id }], +})); + +const mockUseAppNavLinks = jest.fn((): NavLinkItem[] => [alertsNavLink, manageNavLink]); +jest.mock('../nav_links', () => ({ + useAppNavLinks: () => mockUseAppNavLinks(), +})); +jest.mock('../../links', () => ({ + useGetSecuritySolutionLinkProps: + () => + ({ deepLinkId }: { deepLinkId: SecurityPageName }) => ({ + href: `/${deepLinkId}`, + }), +})); + +const renderNav = () => + render(, { + wrapper: TestProviders, + }); + +describe('SecuritySideNav', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render main items', () => { + mockUseAppNavLinks.mockReturnValueOnce([alertsNavLink]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith({ + selectedId: SecurityPageName.alerts, + items: [ + { + id: SecurityPageName.alerts, + label: 'alerts', + href: '/alerts', + }, + ], + footerItems: [], + }); + }); + + it('should render the loader if items are still empty', () => { + mockUseAppNavLinks.mockReturnValueOnce([]); + const result = renderNav(); + expect(result.getByTestId('sideNavLoader')).toBeInTheDocument(); + expect(mockSolutionGroupedNav).not.toHaveBeenCalled(); + }); + + it('should render with selected id', () => { + mockUseRouteSpy.mockReturnValueOnce([{ pageName: SecurityPageName.administration }]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + selectedId: SecurityPageName.administration, + }) + ); + }); + + it('should render footer items', () => { + mockUseAppNavLinks.mockReturnValueOnce([manageNavLink]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [ + { + id: SecurityPageName.endpoints, + label: 'title 2', + description: 'description 2', + href: '/endpoints', + }, + ], + }, + ], + }) + ); + }); + + it('should not render disabled items', () => { + mockUseAppNavLinks.mockReturnValueOnce([ + { ...alertsNavLink, disabled: true }, + { + ...manageNavLink, + links: [ + { + id: SecurityPageName.endpoints, + title: 'title 2', + description: 'description 2', + disabled: true, + }, + ], + }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [], + }, + ], + }) + ); + }); + + it('should render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is true', () => { + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValue(true); + const hostIsolationExceptionsLink = { + id: SecurityPageName.hostIsolationExceptions, + title: 'test hostIsolationExceptions', + description: 'test description hostIsolationExceptions', + }; + + mockUseAppNavLinks.mockReturnValueOnce([ + { + ...manageNavLink, + links: [hostIsolationExceptionsLink], + }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [ + { + id: hostIsolationExceptionsLink.id, + label: hostIsolationExceptionsLink.title, + description: hostIsolationExceptionsLink.description, + href: '/host_isolation_exceptions', + }, + ], + }, + ], + }) + ); + }); + + it('should not render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is false', () => { + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValue(false); + const hostIsolationExceptionsLink = { + id: SecurityPageName.hostIsolationExceptions, + title: 'test hostIsolationExceptions', + description: 'test description hostIsolationExceptions', + }; + + mockUseAppNavLinks.mockReturnValueOnce([ + { + ...manageNavLink, + links: [hostIsolationExceptionsLink], + }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [], + }, + ], + }) + ); + }); + + it('should render custom item', () => { + mockUseAppNavLinks.mockReturnValueOnce([ + { id: SecurityPageName.landing, title: 'get started' }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.landing, + render: expect.any(Function), + }, + ], + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx new file mode 100644 index 0000000000000..b9173270e381e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useCallback } from 'react'; +import { EuiHorizontalRule, EuiListGroupItem, EuiLoadingSpinner } from '@elastic/eui'; +import { SecurityPageName } from '../../../../app/types'; +import { getAncestorLinksInfo } from '../../../links'; +import { useRouteSpy } from '../../../utils/route/use_route_spy'; +import { SecuritySolutionLinkAnchor, useGetSecuritySolutionLinkProps } from '../../links'; +import { useAppNavLinks } from '../nav_links'; +import { SolutionGroupedNav } from '../solution_grouped_nav'; +import { CustomSideNavItem, DefaultSideNavItem, SideNavItem } from '../solution_grouped_nav/types'; +import { NavLinkItem } from '../types'; +import { EuiIconLaunch } from './icons/launch'; +import { useCanSeeHostIsolationExceptionsMenu } from '../../../../management/pages/host_isolation_exceptions/view/hooks'; + +const isFooterNavItem = (id: SecurityPageName) => + id === SecurityPageName.landing || id === SecurityPageName.administration; + +type FormatSideNavItems = (navItems: NavLinkItem) => SideNavItem; + +/** + * Renders the navigation item for "Get Started" custom link + */ +const GetStartedCustomLinkComponent: React.FC<{ + isSelected: boolean; + title: string; +}> = ({ isSelected, title }) => ( + + + + +); +const GetStartedCustomLink = React.memo(GetStartedCustomLinkComponent); + +/** + * Returns a function to format generic `NavLinkItem` array to the `SideNavItem` type + */ +const useFormatSideNavItem = (): FormatSideNavItems => { + const hideHostIsolationExceptions = !useCanSeeHostIsolationExceptionsMenu(); + const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); // adds href and onClick props + + const formatSideNavItem: FormatSideNavItems = useCallback( + (navLinkItem) => { + const formatDefaultItem = (navItem: NavLinkItem): DefaultSideNavItem => ({ + id: navItem.id, + label: navItem.title, + ...getSecuritySolutionLinkProps({ deepLinkId: navItem.id }), + ...(navItem.categories && navItem.categories.length > 0 + ? { categories: navItem.categories } + : {}), + ...(navItem.links && navItem.links.length > 0 + ? { + items: navItem.links + .filter( + (link) => + !link.disabled && + !( + link.id === SecurityPageName.hostIsolationExceptions && + hideHostIsolationExceptions + ) + ) + .map((panelNavItem) => ({ + id: panelNavItem.id, + label: panelNavItem.title, + description: panelNavItem.description, + ...getSecuritySolutionLinkProps({ deepLinkId: panelNavItem.id }), + })), + } + : {}), + }); + + const formatGetStartedItem = (navItem: NavLinkItem): CustomSideNavItem => ({ + id: navItem.id, + render: (isSelected) => ( + + ), + }); + + if (navLinkItem.id === SecurityPageName.landing) { + return formatGetStartedItem(navLinkItem); + } + return formatDefaultItem(navLinkItem); + }, + [getSecuritySolutionLinkProps, hideHostIsolationExceptions] + ); + + return formatSideNavItem; +}; + +/** + * Returns the formatted `items` and `footerItems` to be rendered in the navigation + */ +const useSideNavItems = () => { + const appNavLinks = useAppNavLinks(); + const formatSideNavItem = useFormatSideNavItem(); + + const sideNavItems = useMemo(() => { + const mainNavItems: SideNavItem[] = []; + const footerNavItems: SideNavItem[] = []; + appNavLinks.forEach((appNavLink) => { + if (appNavLink.disabled) { + return; + } + + if (isFooterNavItem(appNavLink.id)) { + footerNavItems.push(formatSideNavItem(appNavLink)); + } else { + mainNavItems.push(formatSideNavItem(appNavLink)); + } + }); + return [mainNavItems, footerNavItems]; + }, [appNavLinks, formatSideNavItem]); + + return sideNavItems; +}; + +const useSelectedId = (): SecurityPageName => { + const [{ pageName }] = useRouteSpy(); + const selectedId = useMemo(() => { + const [rootLinkInfo] = getAncestorLinksInfo(pageName as SecurityPageName); + return rootLinkInfo?.id ?? ''; + }, [pageName]); + + return selectedId; +}; + +/** + * Main security navigation component. + * It takes the links to render from the generic application `links` configs. + */ +export const SecuritySideNav: React.FC = () => { + const [items, footerItems] = useSideNavItems(); + const selectedId = useSelectedId(); + + if (items.length === 0 && footerItems.length === 0) { + return ; + } + + return ; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx index f141264bd97e4..e41b566bbc7c8 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx @@ -9,15 +9,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { SecurityPageName } from '../../../../app/types'; import { TestProviders } from '../../../mock'; -import { NavItem } from './solution_grouped_nav_item'; import { SolutionGroupedNav, SolutionGroupedNavProps } from './solution_grouped_nav'; +import { SideNavItem } from './types'; const mockUseShowTimeline = jest.fn((): [boolean] => [false]); jest.mock('../../../utils/timeline/use_show_timeline', () => ({ useShowTimeline: () => mockUseShowTimeline(), })); -const mockItems: NavItem[] = [ +const mockItems: SideNavItem[] = [ { id: SecurityPageName.dashboardsLanding, label: 'Dashboards', diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx index fcfcc9d6b1b4b..073723b80f518 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx @@ -15,22 +15,38 @@ import { } from '@elastic/eui'; import classNames from 'classnames'; -import { SolutionGroupedNavPanel } from './solution_grouped_nav_panel'; +import { SolutionNavPanel } from './solution_grouped_nav_panel'; import { EuiListGroupItemStyled } from './solution_grouped_nav.styles'; -import { - isCustomNavItem, - isDefaultNavItem, - NavItem, - PortalNavItem, -} from './solution_grouped_nav_item'; +import { DefaultSideNavItem, SideNavItem, isCustomItem, isDefaultItem } from './types'; import { EuiIconSpaces } from './icons/spaces'; +import type { LinkCategories } from '../../../links'; export interface SolutionGroupedNavProps { - items: NavItem[]; + items: SideNavItem[]; + selectedId: string; + footerItems?: SideNavItem[]; +} +export interface SolutionNavItemsProps { + items: SideNavItem[]; selectedId: string; - footerItems?: NavItem[]; + activePanelNavId: ActivePanelNav; + isMobileSize: boolean; + navItemsById: NavItemsById; + onOpenPanelNav: (id: string) => void; } -type ActivePortalNav = string | null; +export interface SolutionNavItemProps { + item: SideNavItem; + isSelected: boolean; + isActive: boolean; + hasPanelNav: boolean; + onOpenPanelNav: (id: string) => void; +} + +type ActivePanelNav = string | null; +type NavItemsById = Record< + string, + { title: string; panelItems: DefaultSideNavItem[]; categories?: LinkCategories } +>; export const SolutionGroupedNavComponent: React.FC = ({ items, @@ -39,41 +55,40 @@ export const SolutionGroupedNavComponent: React.FC = ({ }) => { const isMobileSize = useIsWithinBreakpoints(['xs', 's']); - const [activePortalNavId, setActivePortalNavId] = useState(null); - const activePortalNavIdRef = useRef(null); + const [activePanelNavId, setActivePanelNavId] = useState(null); + const activePanelNavIdRef = useRef(null); - const openPortalNav = (navId: string) => { - activePortalNavIdRef.current = navId; - setActivePortalNavId(navId); + const openPanelNav = (id: string) => { + activePanelNavIdRef.current = id; + setActivePanelNavId(id); }; - const closePortalNav = () => { - activePortalNavIdRef.current = null; - setActivePortalNavId(null); - }; + const onClosePanelNav = useCallback(() => { + activePanelNavIdRef.current = null; + setActivePanelNavId(null); + }, []); - const onClosePortalNav = useCallback(() => { - const currentPortalNavId = activePortalNavIdRef.current; + const onOutsidePanelClick = useCallback(() => { + const currentPanelNavId = activePanelNavIdRef.current; setTimeout(() => { // This event is triggered on outside click. // Closing the side nav at the end of event loop to make sure it - // closes also if the active "nav group" button has been clicked (toggle), - // but it does not close if any some other "nav group" open button has been clicked. - if (activePortalNavIdRef.current === currentPortalNavId) { - closePortalNav(); + // closes also if the active panel button has been clicked (toggle), + // but it does not close if any any other panel open button has been clicked. + if (activePanelNavIdRef.current === currentPanelNavId) { + onClosePanelNav(); } }); - }, []); + }, [onClosePanelNav]); - const navItemsById = useMemo( + const navItemsById = useMemo( () => - [...items, ...footerItems].reduce< - Record - >((acc, navItem) => { - if (isDefaultNavItem(navItem) && navItem.items && navItem.items.length > 0) { + [...items, ...footerItems].reduce((acc, navItem) => { + if (isDefaultItem(navItem) && navItem.items && navItem.items.length > 0) { acc[navItem.id] = { title: navItem.label, - subItems: navItem.items, + panelItems: navItem.items, + categories: navItem.categories, }; } return acc; @@ -82,67 +97,20 @@ export const SolutionGroupedNavComponent: React.FC = ({ ); const portalNav = useMemo(() => { - if (activePortalNavId == null || !navItemsById[activePortalNavId]) { + if (activePanelNavId == null || !navItemsById[activePanelNavId]) { return null; } - const { subItems, title } = navItemsById[activePortalNavId]; - return ; - }, [activePortalNavId, navItemsById, onClosePortalNav]); - - const renderNavItem = useCallback( - (navItem: NavItem) => { - if (isCustomNavItem(navItem)) { - return {navItem.render()}; - } - const { id, href, label, onClick } = navItem; - const isActive = activePortalNavId === id; - const isCurrentNav = selectedId === id; - - const itemClassNames = classNames('solutionGroupedNavItem', { - 'solutionGroupedNavItem--isActive': isActive, - 'solutionGroupedNavItem--isPrimary': isCurrentNav, - }); - const buttonClassNames = classNames('solutionGroupedNavItemButton'); - - return ( - // eslint-disable-next-line @elastic/eui/href-or-on-click - - { - ev.preventDefault(); - ev.stopPropagation(); - openPortalNav(id); - }, - iconType: EuiIconSpaces, - iconSize: 'm', - 'aria-label': 'Toggle group nav', - 'data-test-subj': `groupedNavItemButton-${id}`, - alwaysShow: true, - }, - } - : {})} - /> - - ); - }, - [activePortalNavId, isMobileSize, navItemsById, selectedId] - ); + const { panelItems, title, categories } = navItemsById[activePanelNavId]; + return ( + + ); + }, [activePanelNavId, navItemsById, onClosePanelNav, onOutsidePanelClick]); return ( <> @@ -150,10 +118,28 @@ export const SolutionGroupedNavComponent: React.FC = ({ - {items.map(renderNavItem)} + + + - {footerItems.map(renderNavItem)} + + + @@ -163,5 +149,84 @@ export const SolutionGroupedNavComponent: React.FC = ({ ); }; - export const SolutionGroupedNav = React.memo(SolutionGroupedNavComponent); + +const SolutionNavItems: React.FC = ({ + items, + selectedId, + activePanelNavId, + isMobileSize, + navItemsById, + onOpenPanelNav, +}) => ( + <> + {items.map((item) => ( + + ))} + +); + +const SolutionNavItemComponent: React.FC = ({ + item, + isSelected, + isActive, + hasPanelNav, + onOpenPanelNav, +}) => { + if (isCustomItem(item)) { + return {item.render(isSelected)}; + } + const { id, href, label, onClick } = item; + + const itemClassNames = classNames('solutionGroupedNavItem', { + 'solutionGroupedNavItem--isActive': isActive, + 'solutionGroupedNavItem--isPrimary': isSelected, + }); + const buttonClassNames = classNames('solutionGroupedNavItemButton'); + + const onButtonClick: React.MouseEventHandler = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + onOpenPanelNav(id); + }; + + return ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + + + ); +}; +const SolutionNavItem = React.memo(SolutionNavItemComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx deleted file mode 100644 index df7e08ad46f95..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { useGetSecuritySolutionLinkProps } from '../../links'; -import { SecurityPageName } from '../../../../../common/constants'; - -export type NavItemCategories = Array<{ label: string; itemIds: string[] }>; -export interface DefaultNavItem { - id: string; - label: string; - href: string; - onClick?: React.MouseEventHandler; - items?: PortalNavItem[]; - categories?: NavItemCategories; -} - -export interface CustomNavItem { - id: string; - render: () => React.ReactNode; -} - -export type NavItem = DefaultNavItem | CustomNavItem; - -export interface PortalNavItem { - id: string; - label: string; - href: string; - onClick?: React.MouseEventHandler; - description?: string; -} - -export const isCustomNavItem = (navItem: NavItem): navItem is CustomNavItem => 'render' in navItem; -export const isDefaultNavItem = (navItem: NavItem): navItem is DefaultNavItem => - !isCustomNavItem(navItem); - -export const useNavItems: () => NavItem[] = () => { - const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); - return [ - { - id: SecurityPageName.dashboardsLanding, - label: 'Dashboards', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.dashboardsLanding }), - items: [ - { - id: 'overview', - label: 'Overview', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.overview }), - }, - { - id: 'detection_response', - label: 'Detection & Response', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.detectionAndResponse }), - }, - // TODO: add the cloudPostureFindings to the config here - // { - // id: SecurityPageName.cloudPostureFindings, - // label: 'Cloud Posture Findings', - // description: 'The description goes here', - // ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.cloudPostureFindings }), - // }, - ], - }, - { - id: SecurityPageName.alerts, - label: 'Alerts', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.alerts }), - }, - { - id: SecurityPageName.timelines, - label: 'Timelines', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.timelines }), - }, - { - id: SecurityPageName.case, - label: 'Cases', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.case }), - }, - { - id: SecurityPageName.threatHuntingLanding, - label: 'Threat Hunting', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.threatHuntingLanding }), - items: [ - { - id: SecurityPageName.hosts, - label: 'Hosts', - description: - 'Computer or other device that communicates with other hosts on a network. Hosts on a network include clients and servers -- that send or receive data, services or applications.', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.hosts }), - }, - { - id: SecurityPageName.network, - label: 'Network', - description: - 'The action or process of interacting with others to exchange information and develop professional or social contacts.', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.network }), - }, - { - id: SecurityPageName.users, - label: 'Users', - description: 'Sudo commands dashboard from the Logs System integration.', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.users }), - }, - ], - }, - // TODO: implement footer and move management - { - id: SecurityPageName.administration, - label: 'Manage', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.administration }), - categories: [ - { label: 'SIEM', itemIds: [SecurityPageName.rules, SecurityPageName.exceptions] }, - { - label: 'ENDPOINTS', - itemIds: [ - SecurityPageName.endpoints, - SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.eventFilters, - SecurityPageName.blocklist, - SecurityPageName.hostIsolationExceptions, - ], - }, - ], - items: [ - { - id: SecurityPageName.rules, - label: 'Rules', - description: 'The description here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.rules }), - }, - { - id: SecurityPageName.exceptions, - label: 'Exceptions', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.exceptions }), - }, - { - id: SecurityPageName.endpoints, - label: 'Endpoints', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.endpoints }), - }, - { - id: SecurityPageName.policies, - label: 'Policies', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.policies }), - }, - { - id: SecurityPageName.trustedApps, - label: 'Trusted applications', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.trustedApps }), - }, - { - id: SecurityPageName.eventFilters, - label: 'Event filters', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.eventFilters }), - }, - { - id: SecurityPageName.blocklist, - label: 'Blocklist', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.blocklist }), - }, - { - id: SecurityPageName.hostIsolationExceptions, - label: 'Host Isolation IP exceptions', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.hostIsolationExceptions }), - }, - ], - }, - ]; -}; - -export const useFooterNavItems: () => NavItem[] = () => { - // TODO: implement footer items - return []; -}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx index 93d46c35d6bed..8215d9c0b9f40 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx @@ -9,18 +9,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { SecurityPageName } from '../../../../app/types'; import { TestProviders } from '../../../mock'; -import { PortalNavItem } from './solution_grouped_nav_item'; -import { - SolutionGroupedNavPanel, - SolutionGroupedNavPanelProps, -} from './solution_grouped_nav_panel'; +import { SolutionNavPanel, SolutionNavPanelProps } from './solution_grouped_nav_panel'; +import { DefaultSideNavItem } from './types'; const mockUseShowTimeline = jest.fn((): [boolean] => [false]); jest.mock('../../../utils/timeline/use_show_timeline', () => ({ useShowTimeline: () => mockUseShowTimeline(), })); -const mockItems: PortalNavItem[] = [ +const mockItems: DefaultSideNavItem[] = [ { id: SecurityPageName.hosts, label: 'Hosts', @@ -37,14 +34,16 @@ const mockItems: PortalNavItem[] = [ const PANEL_TITLE = 'test title'; const mockOnClose = jest.fn(); -const renderNavPanel = (props: Partial = {}) => +const mockOnOutsideClick = jest.fn(); +const renderNavPanel = (props: Partial = {}) => render( <>
- , @@ -112,7 +111,7 @@ describe('SolutionGroupedNav', () => { const result = renderNavPanel(); result.getByTestId('outsideClickDummy').click(); waitFor(() => { - expect(mockOnClose).toHaveBeenCalled(); + expect(mockOnOutsideClick).toHaveBeenCalled(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx index c1615a97264eb..a418f666d2782 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx @@ -13,8 +13,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiFocusTrap, + EuiHorizontalRule, EuiOutsideClickDetector, EuiPortal, + EuiSpacer, EuiTitle, EuiWindowEvent, keys, @@ -22,18 +24,39 @@ import { } from '@elastic/eui'; import classNames from 'classnames'; import { EuiPanelStyled } from './solution_grouped_nav_panel.styles'; -import { PortalNavItem } from './solution_grouped_nav_item'; import { useShowTimeline } from '../../../utils/timeline/use_show_timeline'; +import type { DefaultSideNavItem } from './types'; +import type { LinkCategories } from '../../../links/types'; -export interface SolutionGroupedNavPanelProps { +export interface SolutionNavPanelProps { onClose: () => void; + onOutsideClick: () => void; title: string; - items: PortalNavItem[]; + items: DefaultSideNavItem[]; + categories?: LinkCategories; +} +export interface SolutionNavPanelCategoriesProps { + categories: LinkCategories; + items: DefaultSideNavItem[]; + onClose: () => void; +} +export interface SolutionNavPanelItemsProps { + items: DefaultSideNavItem[]; + onClose: () => void; +} +export interface SolutionNavPanelItemProps { + item: DefaultSideNavItem; + onClose: () => void; } -const SolutionGroupedNavPanelComponent: React.FC = ({ +/** + * Renders the side navigation panel for secondary links + */ +const SolutionNavPanelComponent: React.FC = ({ onClose, + onOutsideClick, title, + categories, items, }) => { const [hasTimelineBar] = useShowTimeline(); @@ -41,9 +64,7 @@ const SolutionGroupedNavPanelComponent: React.FC = const isTimelineVisible = hasTimelineBar && isLargerBreakpoint; const panelClasses = classNames('eui-yScroll'); - /** - * ESC key closes SideNav - */ + // ESC key closes PanelNav const onKeyDown = useCallback( (ev: KeyboardEvent) => { if (ev.key === keys.ESCAPE) { @@ -58,7 +79,7 @@ const SolutionGroupedNavPanelComponent: React.FC = - onClose()}> + = - {items.map(({ id, href, onClick, label, description }: PortalNavItem) => ( - - - { - onClose(); - if (onClick) { - onClick(ev); - } - }} - > - {label} - - - {description} - - ))} + {categories ? ( + + ) : ( + + )} @@ -105,5 +116,61 @@ const SolutionGroupedNavPanelComponent: React.FC = ); }; +export const SolutionNavPanel = React.memo(SolutionNavPanelComponent); + +const SolutionNavPanelCategories: React.FC = ({ + categories, + items, + onClose, +}) => { + const itemsMap = new Map(items.map((item) => [item.id, item])); + + return ( + <> + {categories.map(({ label, linkIds }) => { + const links = linkIds.reduce((acc, linkId) => { + const link = itemsMap.get(linkId); + if (link) { + acc.push(link); + } + return acc; + }, []); + + return ( + + +

{label}

+
+ + + +
+ ); + })} + + ); +}; -export const SolutionGroupedNavPanel = React.memo(SolutionGroupedNavPanelComponent); +const SolutionNavPanelItems: React.FC = ({ items, onClose }) => ( + <> + {items.map(({ id, href, onClick, label, description }) => ( + + + { + onClose(); + if (onClick) { + onClick(ev); + } + }} + > + {label} + + + {description} + + ))} + +); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts new file mode 100644 index 0000000000000..a16bad9126d09 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts @@ -0,0 +1,32 @@ +/* + * 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 type { SecurityPageName } from '../../../../app/types'; +import type { LinkCategories } from '../../../links/types'; + +export interface DefaultSideNavItem { + id: SecurityPageName; + label: string; + href: string; + onClick?: React.MouseEventHandler; + description?: string; + items?: DefaultSideNavItem[]; + categories?: LinkCategories; +} + +export interface CustomSideNavItem { + id: string; + render: (isSelected: boolean) => React.ReactNode; +} + +export type SideNavItem = DefaultSideNavItem | CustomSideNavItem; + +export const isCustomItem = (navItem: SideNavItem): navItem is CustomSideNavItem => + 'render' in navItem; +export const isDefaultItem = (navItem: SideNavItem): navItem is DefaultSideNavItem => + !isCustomItem(navItem); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 91edd1feea2da..85d504165484b 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -5,10 +5,12 @@ * 2.0. */ +import { IconType } from '@elastic/eui'; import { UrlStateType } from '../url_state/constants'; import { SecurityPageName } from '../../../app/types'; import { UrlState } from '../url_state/types'; import { SiemRouteType } from '../../utils/route/types'; +import { LinkCategories } from '../../links'; export interface TabNavigationComponentProps { pageName: string; @@ -76,10 +78,14 @@ export type GetUrlForApp = ( ) => string; export type NavigateToUrl = (url: string) => void; - -export interface NavigationCategory { - label: string; - linkIds: readonly SecurityPageName[]; +export interface NavLinkItem { + categories?: LinkCategories; + description?: string; + disabled?: boolean; + icon?: IconType; + id: SecurityPageName; + links?: NavLinkItem[]; + image?: string; + title: string; + skipUrlState?: boolean; } - -export type NavigationCategories = Readonly; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap index cadb9057ccbcc..d50b07ca56089 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap @@ -14,7 +14,7 @@ Object { "href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", "id": "get_started", "isSelected": false, - "name": "Getting started", + "name": "Get started", "onClick": [Function], }, Object { diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx index 1dbcf929ed81f..1123fd50a53e6 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx @@ -11,9 +11,8 @@ import { i18n } from '@kbn/i18n'; import { KibanaPageTemplateProps } from '@kbn/shared-ux-components'; import { PrimaryNavigationProps } from './types'; import { usePrimaryNavigationItems } from './use_navigation_items'; -import { SolutionGroupedNav } from '../solution_grouped_nav'; -import { useNavItems } from '../solution_grouped_nav/solution_grouped_nav_item'; import { useIsGroupedNavigationEnabled } from '../helpers'; +import { SecuritySideNav } from '../security_side_nav'; const translatedNavTitle = i18n.translate('xpack.securitySolution.navigation.mainLabel', { defaultMessage: 'Security', @@ -48,7 +47,6 @@ export const usePrimaryNavigation = ({ // we do need navTabs in case the selectedTabId appears after initial load (ex. checking permissions for anomalies) }, [pageName, navTabs, mapLocationToTab, selectedTabId]); - const navLinkItems = useNavItems(); const navItems = usePrimaryNavigationItems({ navTabs, selectedTabId, @@ -65,7 +63,7 @@ export const usePrimaryNavigation = ({ icon: 'logoSecurity', ...(isGroupedNavigationEnabled ? { - children: , + children: , closeFlyoutButtonPosition: 'inside', } : { items: navItems }), diff --git a/x-pack/plugins/security_solution/public/common/links/app_links.ts b/x-pack/plugins/security_solution/public/common/links/app_links.ts index 1a78444012334..45a7ed373222f 100644 --- a/x-pack/plugins/security_solution/public/common/links/app_links.ts +++ b/x-pack/plugins/security_solution/public/common/links/app_links.ts @@ -4,48 +4,30 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { i18n } from '@kbn/i18n'; -import { SecurityPageName, THREAT_HUNTING_PATH } from '../../../common/constants'; -import { THREAT_HUNTING } from '../../app/translations'; -import { FEATURE, LinkItem, UserPermissions } from './types'; -import { links as hostsLinks } from '../../hosts/links'; +import { CoreStart } from '@kbn/core/public'; +import { AppLinkItems } from './types'; import { links as detectionLinks } from '../../detections/links'; -import { links as networkLinks } from '../../network/links'; -import { links as usersLinks } from '../../users/links'; import { links as timelinesLinks } from '../../timelines/links'; import { getCasesLinkItems } from '../../cases/links'; -import { links as managementLinks } from '../../management/links'; -import { gettingStartedLinks, dashboardsLandingLinks } from '../../overview/links'; +import { getManagementLinkItems } from '../../management/links'; +import { dashboardsLandingLinks, threatHuntingLandingLinks } from '../../landing_pages/links'; +import { gettingStartedLinks } from '../../overview/links'; +import { StartPlugins } from '../../types'; -export const appLinks: Readonly = Object.freeze([ - gettingStartedLinks, - dashboardsLandingLinks, - detectionLinks, - { - id: SecurityPageName.threatHuntingLanding, - title: THREAT_HUNTING, - path: THREAT_HUNTING_PATH, - globalNavEnabled: false, - features: [FEATURE.general], - globalSearchKeywords: [ - i18n.translate('xpack.securitySolution.appLinks.threatHunting', { - defaultMessage: 'Threat hunting', - }), - ], - links: [hostsLinks, networkLinks, usersLinks], - skipUrlState: true, - hideTimeline: true, - }, - timelinesLinks, - getCasesLinkItems(), - managementLinks, -]); +export const getAppLinks = async ( + core: CoreStart, + plugins: StartPlugins +): Promise => { + const managementLinks = await getManagementLinkItems(core, plugins); + const casesLinks = getCasesLinkItems(); -export const getAppLinks = async ({ - enableExperimental, - license, - capabilities, -}: UserPermissions) => { - // OLM team, implement async behavior here - return appLinks; + return Object.freeze([ + dashboardsLandingLinks, + detectionLinks, + timelinesLinks, + casesLinks, + threatHuntingLandingLinks, + gettingStartedLinks, + managementLinks, + ]); }; diff --git a/x-pack/plugins/security_solution/public/common/links/index.tsx b/x-pack/plugins/security_solution/public/common/links/index.tsx index 6d8e99cd416d2..e4e4de0b49430 100644 --- a/x-pack/plugins/security_solution/public/common/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/links/index.tsx @@ -6,3 +6,4 @@ */ export * from './links'; +export * from './types'; diff --git a/x-pack/plugins/security_solution/public/common/links/links.test.ts b/x-pack/plugins/security_solution/public/common/links/links.test.ts index b68ae3d863de3..896f9357077c8 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.test.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.test.ts @@ -5,399 +5,223 @@ * 2.0. */ +import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; +import { Capabilities } from '@kbn/core/types'; +import { mockGlobalState, TestProviders } from '../mock'; +import { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types'; +import { AppLinkItems } from './types'; +import { act, renderHook } from '@testing-library/react-hooks'; import { + useAppLinks, getAncestorLinksInfo, - getDeepLinks, - getInitialDeepLinks, getLinkInfo, - getNavLinkItems, needsUrlState, + updateAppLinks, + excludeAppLink, } from './links'; -import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; -import { Capabilities } from '@kbn/core/types'; -import { AppDeepLink } from '@kbn/core/public'; -import { mockGlobalState } from '../mock'; -import { NavLinkItem } from './types'; -import { LicenseType } from '@kbn/licensing-plugin/common/types'; -import { LicenseService } from '../../../common/license'; + +const defaultAppLinks: AppLinkItems = [ + { + id: SecurityPageName.hosts, + title: 'Hosts', + path: '/hosts', + links: [ + { + id: SecurityPageName.hostsAuthentications, + title: 'Authentications', + path: `/hosts/authentications`, + }, + { + id: SecurityPageName.hostsEvents, + title: 'Events', + path: `/hosts/events`, + skipUrlState: true, + }, + ], + }, +]; const mockExperimentalDefaults = mockGlobalState.app.enableExperimental; + const mockCapabilities = { [CASES_FEATURE_ID]: { read_cases: true, crud_cases: true }, [SERVER_APP_ID]: { show: true }, } as unknown as Capabilities; -const findDeepLink = (id: string, deepLinks: AppDeepLink[]): AppDeepLink | null => - deepLinks.reduce((deepLinkFound: AppDeepLink | null, deepLink) => { - if (deepLinkFound !== null) { - return deepLinkFound; - } - if (deepLink.id === id) { - return deepLink; - } - if (deepLink.deepLinks) { - return findDeepLink(id, deepLink.deepLinks); - } - return null; - }, null); - -const findNavLink = (id: SecurityPageName, navLinks: NavLinkItem[]): NavLinkItem | null => - navLinks.reduce((deepLinkFound: NavLinkItem | null, deepLink) => { - if (deepLinkFound !== null) { - return deepLinkFound; - } - if (deepLink.id === id) { - return deepLink; - } - if (deepLink.links) { - return findNavLink(id, deepLink.links); - } - return null; - }, null); - -// remove filter once new nav is live -const allPages = Object.values(SecurityPageName).filter( - (pageName) => - pageName !== SecurityPageName.explore && - pageName !== SecurityPageName.detections && - pageName !== SecurityPageName.investigate -); -const casesPages = [ - SecurityPageName.case, - SecurityPageName.caseConfigure, - SecurityPageName.caseCreate, -]; -const featureFlagPages = [ - SecurityPageName.detectionAndResponse, - SecurityPageName.hostsAuthentications, - SecurityPageName.hostsRisk, - SecurityPageName.usersRisk, -]; -const premiumPages = [ - SecurityPageName.caseConfigure, - SecurityPageName.hostsAnomalies, - SecurityPageName.networkAnomalies, - SecurityPageName.usersAnomalies, - SecurityPageName.detectionAndResponse, - SecurityPageName.hostsRisk, - SecurityPageName.usersRisk, -]; -const nonCasesPages = allPages.reduce( - (acc: SecurityPageName[], p) => - casesPages.includes(p) || featureFlagPages.includes(p) ? acc : [p, ...acc], - [] -); - const licenseBasicMock = jest.fn().mockImplementation((arg: LicenseType) => arg === 'basic'); const licensePremiumMock = jest.fn().mockReturnValue(true); const mockLicense = { - isAtLeast: licensePremiumMock, -} as unknown as LicenseService; - -const threatHuntingLinkInfo = { - features: ['siem.show'], - globalNavEnabled: false, - globalSearchKeywords: ['Threat hunting'], - id: 'threat_hunting', - path: '/threat_hunting', - title: 'Threat Hunting', - hideTimeline: true, - skipUrlState: true, -}; + hasAtLeast: licensePremiumMock, +} as unknown as ILicense; -const hostsLinkInfo = { - globalNavEnabled: true, - globalNavOrder: 9002, - globalSearchEnabled: true, - globalSearchKeywords: ['Hosts'], - id: 'hosts', - path: '/hosts', - title: 'Hosts', - landingImage: 'test-file-stub', - description: 'A comprehensive overview of all hosts and host-related security events.', -}; +const renderUseAppLinks = () => + renderHook<{}, AppLinkItems>(() => useAppLinks(), { wrapper: TestProviders }); -describe('security app link helpers', () => { +describe('Security app links', () => { beforeEach(() => { - mockLicense.isAtLeast = licensePremiumMock; - }); - describe('getInitialDeepLinks', () => { - it('should return all pages in the app', () => { - const links = getInitialDeepLinks(); - allPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); - }); - }); - describe('getDeepLinks', () => { - it('basicLicense should return only basic links', async () => { - mockLicense.isAtLeast = licenseBasicMock; + mockLicense.hasAtLeast = licensePremiumMock; - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); - allPages.forEach((page) => { - if (premiumPages.includes(page)) { - return expect(findDeepLink(page, links)).toBeFalsy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findDeepLink(page, links)).toBeTruthy(); - }); - }); - it('platinumLicense should return all links', async () => { - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - allPages.forEach((page) => { - if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { - return expect(findDeepLink(page, links)).toBeTruthy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findDeepLink(page, links)).toBeTruthy(); - }); - }); - it('hideWhenExperimentalKey hides entry when key = true', async () => { - const links = await getDeepLinks({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); - }); - it('hideWhenExperimentalKey shows entry when key = false', async () => { - const links = await getDeepLinks({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); - }); - it('experimentalKey shows entry when key = false', async () => { - const links = await getDeepLinks({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: false, - riskyUsersEnabled: false, - detectionResponseEnabled: false, - }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); - expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeFalsy(); - expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); - }); - it('experimentalKey shows entry when key = true', async () => { - const links = await getDeepLinks({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: true, - riskyUsersEnabled: true, - detectionResponseEnabled: true, - }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); - expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeTruthy(); - expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); + updateAppLinks(defaultAppLinks, { + capabilities: mockCapabilities, + experimentalFeatures: mockExperimentalDefaults, + license: mockLicense, }); + }); - it('Removes siem features when siem capabilities are false', async () => { - const capabilities = { - ...mockCapabilities, - [SERVER_APP_ID]: { show: false }, - } as unknown as Capabilities; - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => { - // investigate is active for both Cases and Timelines pages - if (page === SecurityPageName.investigate) { - return expect(findDeepLink(page, links)).toBeTruthy(); - } - return expect(findDeepLink(page, links)).toBeFalsy(); - }); - casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); - }); - it('Removes cases features when cases capabilities are false', async () => { - const capabilities = { - ...mockCapabilities, - [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, - } as unknown as Capabilities; - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); - casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeFalsy()); + describe('useAppLinks', () => { + it('should return initial appLinks', () => { + const { result } = renderUseAppLinks(); + expect(result.current).toStrictEqual(defaultAppLinks); + }); + + it('should filter not allowed links', async () => { + const { result, waitForNextUpdate } = renderUseAppLinks(); + // this link should not be excluded, the test checks all conditions are passed + const networkLinkItem = { + id: SecurityPageName.network, + title: 'Network', + path: '/network', + capabilities: [`${CASES_FEATURE_ID}.read_cases`, `${SERVER_APP_ID}.show`], + experimentalKey: 'flagEnabled' as unknown as keyof typeof mockExperimentalDefaults, + hideWhenExperimentalKey: 'flagDisabled' as unknown as keyof typeof mockExperimentalDefaults, + licenseType: 'basic' as const, + }; + + await act(async () => { + updateAppLinks( + [ + { + ...networkLinkItem, + // all its links should be filtered for all different criteria + links: [ + { + id: SecurityPageName.networkExternalAlerts, + title: 'external alerts', + path: '/external_alerts', + experimentalKey: + 'flagDisabled' as unknown as keyof typeof mockExperimentalDefaults, + }, + { + id: SecurityPageName.networkDns, + title: 'dns', + path: '/dns', + hideWhenExperimentalKey: + 'flagEnabled' as unknown as keyof typeof mockExperimentalDefaults, + }, + { + id: SecurityPageName.networkAnomalies, + title: 'Anomalies', + path: '/anomalies', + capabilities: [ + `${CASES_FEATURE_ID}.read_cases`, + `${CASES_FEATURE_ID}.write_cases`, + ], + }, + { + id: SecurityPageName.networkHttp, + title: 'Http', + path: '/http', + licenseType: 'gold', + }, + ], + }, + { + // should be excluded by license with all its links + id: SecurityPageName.hosts, + title: 'Hosts', + path: '/hosts', + licenseType: 'platinum', + links: [ + { + id: SecurityPageName.hostsEvents, + title: 'Events', + path: '/events', + }, + ], + }, + ], + { + capabilities: { + ...mockCapabilities, + [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, + }, + experimentalFeatures: { + flagEnabled: true, + flagDisabled: false, + } as unknown as typeof mockExperimentalDefaults, + license: { hasAtLeast: licenseBasicMock } as unknown as ILicense, + } + ); + await waitForNextUpdate(); + }); + + expect(result.current).toStrictEqual([networkLinkItem]); }); }); - describe('getNavLinkItems', () => { - it('basicLicense should return only basic links', () => { - mockLicense.isAtLeast = licenseBasicMock; - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); - allPages.forEach((page) => { - if (premiumPages.includes(page)) { - return expect(findNavLink(page, links)).toBeFalsy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findNavLink(page, links)).toBeTruthy(); - }); - }); - it('platinumLicense should return all links', () => { - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - allPages.forEach((page) => { - if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { - return expect(findNavLink(page, links)).toBeTruthy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findNavLink(page, links)).toBeTruthy(); - }); - }); - it('hideWhenExperimentalKey hides entry when key = true', () => { - const links = getNavLinkItems({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, - license: mockLicense, - capabilities: mockCapabilities, + describe('excludeAppLink', () => { + it('should exclude link from app links', async () => { + const { result, waitForNextUpdate } = renderUseAppLinks(); + await act(async () => { + excludeAppLink(SecurityPageName.hostsEvents); + await waitForNextUpdate(); }); - expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); - }); - it('hideWhenExperimentalKey shows entry when key = false', () => { - const links = getNavLinkItems({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); - }); - it('experimentalKey shows entry when key = false', () => { - const links = getNavLinkItems({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: false, - riskyUsersEnabled: false, - detectionResponseEnabled: false, - }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); - expect(findNavLink(SecurityPageName.usersRisk, links)).toBeFalsy(); - expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); - }); - it('experimentalKey shows entry when key = true', () => { - const links = getNavLinkItems({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: true, - riskyUsersEnabled: true, - detectionResponseEnabled: true, + expect(result.current).toStrictEqual([ + { + id: SecurityPageName.hosts, + title: 'Hosts', + path: '/hosts', + links: [ + { + id: SecurityPageName.hostsAuthentications, + title: 'Authentications', + path: `/hosts/authentications`, + }, + ], }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); - expect(findNavLink(SecurityPageName.usersRisk, links)).toBeTruthy(); - expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); - }); - - it('Removes siem features when siem capabilities are false', () => { - const capabilities = { - ...mockCapabilities, - [SERVER_APP_ID]: { show: false }, - } as unknown as Capabilities; - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => { - // investigate is active for both Cases and Timelines pages - if (page === SecurityPageName.investigate) { - return expect(findNavLink(page, links)).toBeTruthy(); - } - return expect(findNavLink(page, links)).toBeFalsy(); - }); - casesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); - }); - it('Removes cases features when cases capabilities are false', () => { - const capabilities = { - ...mockCapabilities, - [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, - } as unknown as Capabilities; - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); - casesPages.forEach((page) => expect(findNavLink(page, links)).toBeFalsy()); + ]); }); }); describe('getAncestorLinksInfo', () => { - it('finds flattened links for hosts', () => { - const hierarchy = getAncestorLinksInfo(SecurityPageName.hosts); - expect(hierarchy).toEqual([threatHuntingLinkInfo, hostsLinkInfo]); - }); - it('finds flattened links for uncommonProcesses', () => { - const hierarchy = getAncestorLinksInfo(SecurityPageName.uncommonProcesses); - expect(hierarchy).toEqual([ - threatHuntingLinkInfo, - hostsLinkInfo, + it('should find ancestors flattened links', () => { + const hierarchy = getAncestorLinksInfo(SecurityPageName.hostsEvents); + expect(hierarchy).toStrictEqual([ { - id: 'uncommon_processes', - path: '/hosts/uncommonProcesses', - title: 'Uncommon Processes', + id: SecurityPageName.hosts, + path: '/hosts', + title: 'Hosts', + }, + { + id: SecurityPageName.hostsEvents, + path: '/hosts/events', + skipUrlState: true, + title: 'Events', }, ]); }); }); describe('needsUrlState', () => { - it('returns true when url state exists for page', () => { + it('should return true when url state exists for page', () => { const needsUrl = needsUrlState(SecurityPageName.hosts); expect(needsUrl).toEqual(true); }); - it('returns false when url state does not exist for page', () => { - const needsUrl = needsUrlState(SecurityPageName.landing); + it('should return false when url state does not exist for page', () => { + const needsUrl = needsUrlState(SecurityPageName.hostsEvents); expect(needsUrl).toEqual(false); }); }); describe('getLinkInfo', () => { - it('gets information for an individual link', () => { - const linkInfo = getLinkInfo(SecurityPageName.hosts); - expect(linkInfo).toEqual(hostsLinkInfo); + it('should get information for an individual link', () => { + const linkInfo = getLinkInfo(SecurityPageName.hostsEvents); + expect(linkInfo).toStrictEqual({ + id: SecurityPageName.hostsEvents, + path: '/hosts/events', + skipUrlState: true, + title: 'Events', + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/links/links.ts b/x-pack/plugins/security_solution/public/common/links/links.ts index 57965bdeba0c0..384861a9dc5e7 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.ts @@ -5,169 +5,120 @@ * 2.0. */ -import { AppDeepLink, AppNavLinkStatus, Capabilities } from '@kbn/core/public'; +import type { Capabilities } from '@kbn/core/public'; import { get } from 'lodash'; +import { useEffect, useState } from 'react'; +import { BehaviorSubject } from 'rxjs'; import { SecurityPageName } from '../../../common/constants'; -import { appLinks, getAppLinks } from './app_links'; -import { - Feature, +import type { + AppLinkItems, LinkInfo, LinkItem, - NavLinkItem, NormalizedLink, NormalizedLinks, - UserPermissions, + LinksPermissions, } from './types'; -const createDeepLink = (link: LinkItem, linkProps?: UserPermissions): AppDeepLink => ({ - id: link.id, - path: link.path, - title: link.title, - ...(link.links && link.links.length - ? { - deepLinks: reduceLinks({ - links: link.links, - linkProps, - formatFunction: createDeepLink, - }), - } - : {}), - ...(link.globalSearchKeywords != null ? { keywords: link.globalSearchKeywords } : {}), - ...(link.globalNavEnabled != null - ? { navLinkStatus: link.globalNavEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden } - : {}), - ...(link.globalNavOrder != null ? { order: link.globalNavOrder } : {}), - ...(link.globalSearchEnabled != null ? { searchable: link.globalSearchEnabled } : {}), +/** + * App links updater, it keeps the value of the app links in sync with all application. + * It can be updated using `updateAppLinks` or `excludeAppLink` + * Read it using `subscribeAppLinks` or `useAppLinks` hook. + */ +const appLinksUpdater$ = new BehaviorSubject<{ + links: AppLinkItems; + normalizedLinks: NormalizedLinks; +}>({ + links: [], // stores the appLinkItems recursive hierarchy + normalizedLinks: {}, // stores a flatten normalized object for direct id access }); -const createNavLinkItem = (link: LinkItem, linkProps?: UserPermissions): NavLinkItem => ({ - id: link.id, - path: link.path, - title: link.title, - ...(link.description != null ? { description: link.description } : {}), - ...(link.landingIcon != null ? { icon: link.landingIcon } : {}), - ...(link.landingImage != null ? { image: link.landingImage } : {}), - ...(link.links && link.links.length - ? { - links: reduceLinks({ - links: link.links, - linkProps, - formatFunction: createNavLinkItem, - }), - } - : {}), - ...(link.skipUrlState != null ? { skipUrlState: link.skipUrlState } : {}), -}); +const getAppLinksValue = (): AppLinkItems => appLinksUpdater$.getValue().links; +const getNormalizedLinksValue = (): NormalizedLinks => appLinksUpdater$.getValue().normalizedLinks; -const hasFeaturesCapability = ( - features: Feature[] | undefined, - capabilities: Capabilities -): boolean => { - if (!features) { - return true; - } - return features.some((featureKey) => get(capabilities, featureKey, false)); -}; +/** + * Subscribes to the updater to get the app links updates + */ +export const subscribeAppLinks = (onChange: (links: AppLinkItems) => void) => + appLinksUpdater$.subscribe(({ links }) => onChange(links)); -const isLinkAllowed = (link: LinkItem, linkProps?: UserPermissions) => - !( - linkProps != null && - // exclude link when license is basic and link is premium - ((linkProps.license && !linkProps.license.isAtLeast(link.licenseType ?? 'basic')) || - // exclude link when enableExperimental[hideWhenExperimentalKey] is enabled and link has hideWhenExperimentalKey - (link.hideWhenExperimentalKey != null && - linkProps.enableExperimental[link.hideWhenExperimentalKey]) || - // exclude link when enableExperimental[experimentalKey] is disabled and link has experimentalKey - (link.experimentalKey != null && !linkProps.enableExperimental[link.experimentalKey]) || - // exclude link when link is not part of enabled feature capabilities - (linkProps.capabilities != null && - !hasFeaturesCapability(link.features, linkProps.capabilities))) - ); - -export function reduceLinks({ - links, - linkProps, - formatFunction, -}: { - links: Readonly; - linkProps?: UserPermissions; - formatFunction: (link: LinkItem, linkProps?: UserPermissions) => T; -}): T[] { - return links.reduce( - (deepLinks: T[], link: LinkItem) => - isLinkAllowed(link, linkProps) ? [...deepLinks, formatFunction(link, linkProps)] : deepLinks, - [] - ); -} - -export const getInitialDeepLinks = (): AppDeepLink[] => { - return appLinks.map((link) => createDeepLink(link)); -}; +/** + * Hook to get the app links updated value + */ +export const useAppLinks = (): AppLinkItems => { + const [appLinks, setAppLinks] = useState(getAppLinksValue); -export const getDeepLinks = async ({ - enableExperimental, - license, - capabilities, -}: UserPermissions): Promise => { - const links = await getAppLinks({ enableExperimental, license, capabilities }); - return reduceLinks({ - links, - linkProps: { enableExperimental, license, capabilities }, - formatFunction: createDeepLink, - }); -}; + useEffect(() => { + const linksSubscription = subscribeAppLinks((newAppLinks) => { + setAppLinks(newAppLinks); + }); + return () => linksSubscription.unsubscribe(); + }, []); -export const getNavLinkItems = ({ - enableExperimental, - license, - capabilities, -}: UserPermissions): NavLinkItem[] => - reduceLinks({ - links: appLinks, - linkProps: { enableExperimental, license, capabilities }, - formatFunction: createNavLinkItem, - }); + return appLinks; +}; /** - * Recursive function to create the `NormalizedLinks` structure from a `LinkItem` array parameter + * Updates the app links applying the filter by permissions */ -const getNormalizedLinks = ( - currentLinks: Readonly, - parentId?: SecurityPageName -): NormalizedLinks => { - const result = currentLinks.reduce>( - (normalized, { links, ...currentLink }) => { - normalized[currentLink.id] = { - ...currentLink, - parentId, - }; - if (links && links.length > 0) { - Object.assign(normalized, getNormalizedLinks(links, currentLink.id)); - } - return normalized; - }, - {} - ); - return result as NormalizedLinks; +export const updateAppLinks = ( + appLinksToUpdate: AppLinkItems, + linksPermissions: LinksPermissions +) => { + const filteredAppLinks = getFilteredAppLinks(appLinksToUpdate, linksPermissions); + appLinksUpdater$.next({ + links: Object.freeze(filteredAppLinks), + normalizedLinks: Object.freeze(getNormalizedLinks(filteredAppLinks)), + }); }; /** - * Normalized indexed version of the global `links` array, referencing the parent by id, instead of having nested links children - */ -const normalizedLinks: Readonly = Object.freeze(getNormalizedLinks(appLinks)); -/** - * Returns the `NormalizedLink` from a link id parameter. - * The object reference is frozen to make sure it is not mutated by the caller. + * Excludes a link by id from the current app links + * @deprecated this function will not be needed when async link filtering is migrated to the main getAppLinks functions */ -const getNormalizedLink = (id: SecurityPageName): Readonly => - Object.freeze(normalizedLinks[id]); +export const excludeAppLink = (linkId: SecurityPageName) => { + const { links, normalizedLinks } = appLinksUpdater$.getValue(); + if (!normalizedLinks[linkId]) { + return; + } + + let found = false; + const excludeRec = (currentLinks: AppLinkItems): LinkItem[] => + currentLinks.reduce((acc, link) => { + if (!found) { + if (link.id === linkId) { + found = true; + return acc; + } + if (link.links) { + const excludedLinks = excludeRec(link.links); + if (excludedLinks.length > 0) { + acc.push({ ...link, links: excludedLinks }); + return acc; + } + } + } + acc.push(link); + return acc; + }, []); + + const excludedLinks = excludeRec(links); + + appLinksUpdater$.next({ + links: Object.freeze(excludedLinks), + normalizedLinks: Object.freeze(getNormalizedLinks(excludedLinks)), + }); +}; /** * Returns the `LinkInfo` from a link id parameter */ -export const getLinkInfo = (id: SecurityPageName): LinkInfo => { +export const getLinkInfo = (id: SecurityPageName): LinkInfo | undefined => { + const normalizedLink = getNormalizedLink(id); + if (!normalizedLink) { + return undefined; + } // discards the parentId and creates the linkInfo copy. - const { parentId, ...linkInfo } = getNormalizedLink(id); + const { parentId, ...linkInfo } = normalizedLink; return linkInfo; }; @@ -178,9 +129,14 @@ export const getAncestorLinksInfo = (id: SecurityPageName): LinkInfo[] => { const ancestors: LinkInfo[] = []; let currentId: SecurityPageName | undefined = id; while (currentId) { - const { parentId, ...linkInfo } = getNormalizedLink(currentId); - ancestors.push(linkInfo); - currentId = parentId; + const normalizedLink = getNormalizedLink(currentId); + if (normalizedLink) { + const { parentId, ...linkInfo } = normalizedLink; + ancestors.push(linkInfo); + currentId = parentId; + } else { + currentId = undefined; + } } return ancestors.reverse(); }; @@ -190,9 +146,82 @@ export const getAncestorLinksInfo = (id: SecurityPageName): LinkInfo[] => { * Defaults to `true` if the `skipUrlState` property of the `LinkItem` is `undefined`. */ export const needsUrlState = (id: SecurityPageName): boolean => { - return !getNormalizedLink(id).skipUrlState; + return !getNormalizedLink(id)?.skipUrlState; +}; + +// Internal functions + +/** + * Creates the `NormalizedLinks` structure from a `LinkItem` array + */ +const getNormalizedLinks = ( + currentLinks: AppLinkItems, + parentId?: SecurityPageName +): NormalizedLinks => { + return currentLinks.reduce((normalized, { links, ...currentLink }) => { + normalized[currentLink.id] = { + ...currentLink, + parentId, + }; + if (links && links.length > 0) { + Object.assign(normalized, getNormalizedLinks(links, currentLink.id)); + } + return normalized; + }, {}); +}; + +const getNormalizedLink = (id: SecurityPageName): Readonly | undefined => + getNormalizedLinksValue()[id]; + +const getFilteredAppLinks = ( + appLinkToFilter: AppLinkItems, + linksPermissions: LinksPermissions +): LinkItem[] => + appLinkToFilter.reduce((acc, { links, ...appLink }) => { + if (!isLinkAllowed(appLink, linksPermissions)) { + return acc; + } + if (links) { + const childrenLinks = getFilteredAppLinks(links, linksPermissions); + if (childrenLinks.length > 0) { + acc.push({ ...appLink, links: childrenLinks }); + } else { + acc.push(appLink); + } + } else { + acc.push(appLink); + } + return acc; + }, []); + +// It checks if the user has at least one of the link capabilities needed +const hasCapabilities = (linkCapabilities: string[], userCapabilities: Capabilities): boolean => + linkCapabilities.some((linkCapability) => get(userCapabilities, linkCapability, false)); + +const isLinkAllowed = ( + link: LinkItem, + { license, experimentalFeatures, capabilities }: LinksPermissions +) => { + const linkLicenseType = link.licenseType ?? 'basic'; + if (license) { + if (!license.hasAtLeast(linkLicenseType)) { + return false; + } + } else if (linkLicenseType !== 'basic') { + return false; + } + if (link.hideWhenExperimentalKey && experimentalFeatures[link.hideWhenExperimentalKey]) { + return false; + } + if (link.experimentalKey && !experimentalFeatures[link.experimentalKey]) { + return false; + } + if (link.capabilities && !hasCapabilities(link.capabilities, capabilities)) { + return false; + } + return true; }; export const getLinksWithHiddenTimeline = (): LinkInfo[] => { - return Object.values(normalizedLinks).filter((link) => link.hideTimeline); + return Object.values(getNormalizedLinksValue()).filter((link) => link.hideTimeline); }; diff --git a/x-pack/plugins/security_solution/public/common/links/types.ts b/x-pack/plugins/security_solution/public/common/links/types.ts index bfa87851306ff..323873cafc23c 100644 --- a/x-pack/plugins/security_solution/public/common/links/types.ts +++ b/x-pack/plugins/security_solution/public/common/links/types.ts @@ -6,43 +6,73 @@ */ import { Capabilities } from '@kbn/core/types'; -import { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types'; import { IconType } from '@elastic/eui'; -import { LicenseService } from '../../../common/license'; import { ExperimentalFeatures } from '../../../common/experimental_features'; -import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; +import { SecurityPageName } from '../../../common/constants'; -export const FEATURE = { - general: `${SERVER_APP_ID}.show`, - casesRead: `${CASES_FEATURE_ID}.read_cases`, - casesCrud: `${CASES_FEATURE_ID}.crud_cases`, -}; - -export type Feature = Readonly; +/** + * Permissions related parameters needed for the links to be filtered + */ +export interface LinksPermissions { + capabilities: Capabilities; + experimentalFeatures: Readonly; + license?: ILicense; +} -export interface UserPermissions { - enableExperimental: ExperimentalFeatures; - license?: LicenseService; - capabilities?: Capabilities; +export interface LinkCategory { + label: string; + linkIds: readonly SecurityPageName[]; } +export type LinkCategories = Readonly; + export interface LinkItem { + /** + * The description of the link content + */ description?: string; - disabled?: boolean; // default false /** - * Displays deep link when feature flag is enabled. + * Experimental flag needed to enable the link */ experimentalKey?: keyof ExperimentalFeatures; - features?: Feature[]; /** - * Hides deep link when feature flag is enabled. + * Capabilities strings (using object dot notation) to enable the link. + * Uses "or" conditional, only one enabled capability is needed to activate the link + */ + capabilities?: string[]; + /** + * Categories to display in the navigation + */ + categories?: LinkCategories; + /** + * Enables link in the global navigation. Defaults to false. + */ + globalNavEnabled?: boolean; + /** + * Global navigation order number */ - globalNavEnabled?: boolean; // default false globalNavOrder?: number; - globalSearchEnabled?: boolean; + /** + * Disables link in the global search. Defaults to false. + */ + globalSearchDisabled?: boolean; + /** + * Keywords for the global search to search. + */ globalSearchKeywords?: string[]; + /** + * Experimental flag needed to disable the link. Opposite of experimentalKey + */ hideWhenExperimentalKey?: keyof ExperimentalFeatures; + /** + * Link id. Refers to a SecurityPageName + */ id: SecurityPageName; + /** + * Displays the "Beta" badge + */ + isBeta?: boolean; /** * Icon that is displayed on menu navigation landing page. * Only required for pages that are displayed inside a landing page. @@ -53,26 +83,38 @@ export interface LinkItem { * Only required for pages that are displayed inside a landing page. */ landingImage?: string; - isBeta?: boolean; + /** + * Minimum license required to enable the link + */ licenseType?: LicenseType; + /** + * Nested links + */ links?: LinkItem[]; + /** + * Link path relative to security root + */ path: string; - skipUrlState?: boolean; // defaults to false + /** + * Disables link in the side navigation. Defaults to false. + */ + sideNavDisabled?: boolean; + /** + * Disables the state query string in the URL. Defaults to false. + */ + skipUrlState?: boolean; + /** + * Disables the timeline call to action on the bottom of the page. Defaults to false. + */ hideTimeline?: boolean; // defaults to false + /** + * Title of the link + */ title: string; } -export interface NavLinkItem { - description?: string; - icon?: IconType; - id: SecurityPageName; - links?: NavLinkItem[]; - image?: string; - path: string; - title: string; - skipUrlState?: boolean; // default to false -} +export type AppLinkItems = Readonly; export type LinkInfo = Omit; export type NormalizedLink = LinkInfo & { parentId?: SecurityPageName }; -export type NormalizedLinks = Record; +export type NormalizedLinks = Partial>; diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx index 33a9f3a37a42f..ca9029c6c0939 100644 --- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx @@ -6,7 +6,12 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; +import { coreMock } from '@kbn/core/public/mocks'; +import { allowedExperimentalValues } from '../../../../common/experimental_features'; +import { updateAppLinks } from '../../links'; +import { getAppLinks } from '../../links/app_links'; import { useShowTimeline } from './use_show_timeline'; +import { StartPlugins } from '../../../types'; const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/overview' }); jest.mock('react-router-dom', () => { @@ -24,6 +29,23 @@ jest.mock('../../components/navigation/helpers', () => ({ })); describe('use show timeline', () => { + beforeAll(async () => { + // initialize all App links before running test + const appLinks = await getAppLinks(coreMock.createStart(), {} as StartPlugins); + updateAppLinks(appLinks, { + experimentalFeatures: allowedExperimentalValues, + capabilities: { + navLinks: {}, + management: {}, + catalogue: {}, + actions: { show: true, crud: true }, + siem: { + show: true, + crud: true, + }, + }, + }); + }); describe('useIsGroupedNavigationEnabled false', () => { beforeAll(() => { mockedUseIsGroupedNavigationEnabled.mockReturnValue(false); diff --git a/x-pack/plugins/security_solution/public/detections/links.ts b/x-pack/plugins/security_solution/public/detections/links.ts index 1cfac62d80e6e..df9d32fcb57ed 100644 --- a/x-pack/plugins/security_solution/public/detections/links.ts +++ b/x-pack/plugins/security_solution/public/detections/links.ts @@ -5,21 +5,20 @@ * 2.0. */ import { i18n } from '@kbn/i18n'; -import { ALERTS_PATH, SecurityPageName } from '../../common/constants'; +import { ALERTS_PATH, SecurityPageName, SERVER_APP_ID } from '../../common/constants'; import { ALERTS } from '../app/translations'; -import { LinkItem, FEATURE } from '../common/links/types'; +import { LinkItem } from '../common/links/types'; export const links: LinkItem = { id: SecurityPageName.alerts, title: ALERTS, path: ALERTS_PATH, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalNavEnabled: true, globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.alerts', { defaultMessage: 'Alerts', }), ], - globalSearchEnabled: true, globalNavOrder: 9001, }; diff --git a/x-pack/plugins/security_solution/public/hosts/links.ts b/x-pack/plugins/security_solution/public/hosts/links.ts index d1bc26c5fb3f2..dcdeb73ac1219 100644 --- a/x-pack/plugins/security_solution/public/hosts/links.ts +++ b/x-pack/plugins/security_solution/public/hosts/links.ts @@ -24,7 +24,6 @@ export const links: LinkItem = { defaultMessage: 'Hosts', }), ], - globalSearchEnabled: true, globalNavOrder: 9002, links: [ { diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx index 81b72527500ad..57aee98af4e9d 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx @@ -8,7 +8,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; import { TestProviders } from '../../common/mock'; import { LandingLinksIcons } from './landing_links_icons'; @@ -17,7 +17,6 @@ const DEFAULT_NAV_ITEM: NavLinkItem = { title: 'TEST LABEL', description: 'TEST DESCRIPTION', icon: 'myTestIcon', - path: '', }; const mockNavigateTo = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx index 04a3e20b1f178..b30d4f404b163 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx @@ -12,7 +12,7 @@ import { SecuritySolutionLinkAnchor, withSecuritySolutionLink, } from '../../common/components/links'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; interface LandingLinksImagesProps { items: NavLinkItem[]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx index c44374852f29b..81881a3796f0b 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx @@ -8,7 +8,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; import { TestProviders } from '../../common/mock'; import { LandingLinksImages } from './landing_links_images'; @@ -17,7 +17,6 @@ const DEFAULT_NAV_ITEM: NavLinkItem = { title: 'TEST LABEL', description: 'TEST DESCRIPTION', image: 'TEST_IMAGE.png', - path: '', }; jest.mock('../../common/lib/kibana/kibana_react', () => { diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx index 22bcc0f1aa251..4cf8db26bbe7a 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx @@ -8,7 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiPanel, EuiText, EuiTitle } from import React from 'react'; import styled from 'styled-components'; import { withSecuritySolutionLink } from '../../common/components/links'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; interface LandingLinksImagesProps { items: NavLinkItem[]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/constants.ts b/x-pack/plugins/security_solution/public/landing_pages/constants.ts deleted file mode 100644 index a6b72a5e7db4f..0000000000000 --- a/x-pack/plugins/security_solution/public/landing_pages/constants.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { SecurityPageName } from '../app/types'; - -export interface LandingNavGroup { - label: string; - itemIds: SecurityPageName[]; -} - -export const MANAGE_NAVIGATION_CATEGORIES: LandingNavGroup[] = [ - { - label: i18n.translate('xpack.securitySolution.landing.siemTitle', { - defaultMessage: 'SIEM', - }), - itemIds: [SecurityPageName.rules, SecurityPageName.exceptions], - }, - { - label: i18n.translate('xpack.securitySolution.landing.endpointsTitle', { - defaultMessage: 'ENDPOINTS', - }), - itemIds: [ - SecurityPageName.endpoints, - SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.eventFilters, - SecurityPageName.blocklist, - SecurityPageName.hostIsolationExceptions, - ], - }, -]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/links.ts b/x-pack/plugins/security_solution/public/landing_pages/links.ts new file mode 100644 index 0000000000000..48cd31485ea7f --- /dev/null +++ b/x-pack/plugins/security_solution/public/landing_pages/links.ts @@ -0,0 +1,52 @@ +/* + * 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 { + DASHBOARDS_PATH, + SecurityPageName, + SERVER_APP_ID, + THREAT_HUNTING_PATH, +} from '../../common/constants'; +import { DASHBOARDS, THREAT_HUNTING } from '../app/translations'; +import { LinkItem } from '../common/links/types'; +import { overviewLinks, detectionResponseLinks } from '../overview/links'; +import { links as hostsLinks } from '../hosts/links'; +import { links as networkLinks } from '../network/links'; +import { links as usersLinks } from '../users/links'; + +export const dashboardsLandingLinks: LinkItem = { + id: SecurityPageName.dashboardsLanding, + title: DASHBOARDS, + path: DASHBOARDS_PATH, + globalNavEnabled: false, + capabilities: [`${SERVER_APP_ID}.show`], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.dashboards', { + defaultMessage: 'Dashboards', + }), + ], + links: [overviewLinks, detectionResponseLinks], + skipUrlState: true, + hideTimeline: true, +}; + +export const threatHuntingLandingLinks: LinkItem = { + id: SecurityPageName.threatHuntingLanding, + title: THREAT_HUNTING, + path: THREAT_HUNTING_PATH, + globalNavEnabled: false, + capabilities: [`${SERVER_APP_ID}.show`], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.threatHunting', { + defaultMessage: 'Threat hunting', + }), + ], + links: [hostsLinks, networkLinks, usersLinks], + skipUrlState: true, + hideTimeline: true, +}; diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx index 1955d56c0a151..a09db6ebf5eaa 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx @@ -9,53 +9,58 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; import { TestProviders } from '../../common/mock'; -import { LandingCategories } from './manage'; -import { NavLinkItem } from '../../common/links/types'; +import { ManagementCategories } from './manage'; +import { NavLinkItem } from '../../common/components/navigation/types'; const RULES_ITEM_LABEL = 'elastic rules!'; const EXCEPTIONS_ITEM_LABEL = 'exceptional!'; +const CATEGORY_1_LABEL = 'first tests category'; +const CATEGORY_2_LABEL = 'second tests category'; -const mockAppManageLink: NavLinkItem = { +const defaultAppManageLink: NavLinkItem = { id: SecurityPageName.administration, - path: '', title: 'admin', + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.rules], + }, + { + label: CATEGORY_2_LABEL, + linkIds: [SecurityPageName.exceptions], + }, + ], links: [ { id: SecurityPageName.rules, title: RULES_ITEM_LABEL, description: '', icon: 'testIcon1', - path: '', }, { id: SecurityPageName.exceptions, title: EXCEPTIONS_ITEM_LABEL, description: '', icon: 'testIcon2', - path: '', }, ], }; + +const mockedUseCanSeeHostIsolationExceptionsMenu = jest.fn(); +jest.mock('../../management/pages/host_isolation_exceptions/view/hooks', () => ({ + useCanSeeHostIsolationExceptionsMenu: () => mockedUseCanSeeHostIsolationExceptionsMenu(), +})); + +const mockAppManageLink = jest.fn(() => defaultAppManageLink); jest.mock('../../common/components/navigation/nav_links', () => ({ - useAppRootNavLink: jest.fn(() => mockAppManageLink), + useAppRootNavLink: () => mockAppManageLink(), })); -describe('LandingCategories', () => { - it('renders items', () => { +describe('ManagementCategories', () => { + it('should render items', () => { const { queryByText } = render( - + ); @@ -63,17 +68,19 @@ describe('LandingCategories', () => { expect(queryByText(EXCEPTIONS_ITEM_LABEL)).toBeInTheDocument(); }); - it('renders items in the same order as defined', () => { + it('should render items in the same order as defined', () => { + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: '', + linkIds: [SecurityPageName.exceptions, SecurityPageName.rules], + }, + ], + }); const { queryAllByTestId } = render( - + ); @@ -82,4 +89,109 @@ describe('LandingCategories', () => { expect(renderedItems[0]).toHaveTextContent(EXCEPTIONS_ITEM_LABEL); expect(renderedItems[1]).toHaveTextContent(RULES_ITEM_LABEL); }); + + it('should not render category items filtered', () => { + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], + }, + ], + links: [ + { + id: SecurityPageName.rules, + title: RULES_ITEM_LABEL, + description: '', + icon: 'testIcon1', + }, + ], + }); + const { queryAllByTestId } = render( + + + + ); + + const renderedItems = queryAllByTestId('LandingItem'); + + expect(renderedItems).toHaveLength(1); + expect(renderedItems[0]).toHaveTextContent(RULES_ITEM_LABEL); + }); + + it('should not render category if all items filtered', () => { + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + links: [], + }); + const { queryByText } = render( + + + + ); + + expect(queryByText(CATEGORY_1_LABEL)).not.toBeInTheDocument(); + expect(queryByText(CATEGORY_2_LABEL)).not.toBeInTheDocument(); + }); + + it('should not render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is false', () => { + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValueOnce(false); + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.hostIsolationExceptions], + }, + ], + links: [ + { + id: SecurityPageName.hostIsolationExceptions, + title: 'test hostIsolationExceptions title', + description: 'test hostIsolationExceptions description', + icon: 'testIcon1', + }, + ], + }); + const { queryByText } = render( + + + + ); + + expect(queryByText(CATEGORY_1_LABEL)).not.toBeInTheDocument(); + }); + + it('should render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is true', () => { + const HOST_ISOLATION_EXCEPTIONS_ITEM_LABEL = 'test hostIsolationExceptions title'; + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValueOnce(true); + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.hostIsolationExceptions], + }, + ], + links: [ + { + id: SecurityPageName.hostIsolationExceptions, + title: HOST_ISOLATION_EXCEPTIONS_ITEM_LABEL, + description: 'test hostIsolationExceptions description', + icon: 'testIcon1', + }, + ], + }); + const { queryAllByTestId } = render( + + + + ); + + const renderedItems = queryAllByTestId('LandingItem'); + + expect(renderedItems).toHaveLength(1); + expect(renderedItems[0]).toHaveTextContent(HOST_ISOLATION_EXCEPTIONS_ITEM_LABEL); + }); }); diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx index f0e6094d5113f..d484e5fe90a52 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx @@ -11,18 +11,18 @@ import styled from 'styled-components'; import { SecurityPageName } from '../../app/types'; import { HeaderPage } from '../../common/components/header_page'; import { useAppRootNavLink } from '../../common/components/navigation/nav_links'; -import { NavigationCategories } from '../../common/components/navigation/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { navigationCategories } from '../../management/links'; +import { useCanSeeHostIsolationExceptionsMenu } from '../../management/pages/host_isolation_exceptions/view/hooks'; import { LandingLinksIcons } from '../components/landing_links_icons'; import { MANAGE_PAGE_TITLE } from './translations'; export const ManageLandingPage = () => ( - - + + ); @@ -31,37 +31,52 @@ const StyledEuiHorizontalRule = styled(EuiHorizontalRule)` margin-bottom: ${({ theme }) => theme.eui.paddingSizes.l}; `; -const useGetManageNavLinks = () => { - const manageNavLinks = useAppRootNavLink(SecurityPageName.administration)?.links ?? []; +type ManagementCategories = Array<{ label: string; links: NavLinkItem[] }>; +const useManagementCategories = (): ManagementCategories => { + const hideHostIsolationExceptions = !useCanSeeHostIsolationExceptionsMenu(); + const { links = [], categories = [] } = useAppRootNavLink(SecurityPageName.administration) ?? {}; - const manageLinksById = Object.fromEntries(manageNavLinks.map((link) => [link.id, link])); - return (linkIds: readonly SecurityPageName[]) => linkIds.map((linkId) => manageLinksById[linkId]); + const manageLinksById = Object.fromEntries(links.map((link) => [link.id, link])); + + return categories.reduce((acc, { label, linkIds }) => { + const linksItem = linkIds.reduce((linksAcc, linkId) => { + if ( + manageLinksById[linkId] && + !(linkId === SecurityPageName.hostIsolationExceptions && hideHostIsolationExceptions) + ) { + linksAcc.push(manageLinksById[linkId]); + } + return linksAcc; + }, []); + if (linksItem.length > 0) { + acc.push({ label, links: linksItem }); + } + return acc; + }, []); }; -export const LandingCategories = React.memo( - ({ categories }: { categories: NavigationCategories }) => { - const getManageNavLinks = useGetManageNavLinks(); +export const ManagementCategories = () => { + const managementCategories = useManagementCategories(); - return ( - <> - {categories.map(({ label, linkIds }, index) => ( -
- {index > 0 && ( - <> - - - - )} - -

{label}

-
- - -
- ))} - - ); - } -); + return ( + <> + {managementCategories.map(({ label, links }, index) => ( +
+ {index > 0 && ( + <> + + + + )} + +

{label}

+
+ + +
+ ))} + + ); +}; -LandingCategories.displayName = 'LandingCategories'; +ManagementCategories.displayName = 'ManagementCategories'; diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index ee60274cbb83d..9316f92a0d0b8 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { BLOCKLIST_PATH, @@ -17,6 +18,7 @@ import { RULES_CREATE_PATH, RULES_PATH, SecurityPageName, + SERVER_APP_ID, TRUSTED_APPS_PATH, } from '../../common/constants'; import { @@ -31,8 +33,8 @@ import { RULES, TRUSTED_APPLICATIONS, } from '../app/translations'; -import { NavigationCategories } from '../common/components/navigation/types'; -import { FEATURE, LinkItem } from '../common/links/types'; +import { LinkItem } from '../common/links/types'; +import { StartPlugins } from '../types'; import { IconBlocklist } from './icons/blocklist'; import { IconEndpoints } from './icons/endpoints'; @@ -43,19 +45,42 @@ import { IconHostIsolation } from './icons/host_isolation'; import { IconSiemRules } from './icons/siem_rules'; import { IconTrustedApplications } from './icons/trusted_applications'; -export const links: LinkItem = { +const categories = [ + { + label: i18n.translate('xpack.securitySolution.appLinks.category.siem', { + defaultMessage: 'SIEM', + }), + linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], + }, + { + label: i18n.translate('xpack.securitySolution.appLinks.category.endpoints', { + defaultMessage: 'ENDPOINTS', + }), + linkIds: [ + SecurityPageName.endpoints, + SecurityPageName.policies, + SecurityPageName.trustedApps, + SecurityPageName.eventFilters, + SecurityPageName.hostIsolationExceptions, + SecurityPageName.blocklist, + ], + }, +]; + +const links: LinkItem = { id: SecurityPageName.administration, title: MANAGE, path: MANAGE_PATH, skipUrlState: true, hideTimeline: true, globalNavEnabled: false, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.manage', { defaultMessage: 'Manage', }), ], + categories, links: [ { id: SecurityPageName.rules, @@ -73,7 +98,6 @@ export const links: LinkItem = { defaultMessage: 'Rules', }), ], - globalSearchEnabled: true, links: [ { id: SecurityPageName.rulesCreate, @@ -99,7 +123,6 @@ export const links: LinkItem = { defaultMessage: 'Exception lists', }), ], - globalSearchEnabled: true, }, { id: SecurityPageName.endpoints, @@ -178,24 +201,7 @@ export const links: LinkItem = { ], }; -export const navigationCategories: NavigationCategories = [ - { - label: i18n.translate('xpack.securitySolution.appLinks.category.siem', { - defaultMessage: 'SIEM', - }), - linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], - }, - { - label: i18n.translate('xpack.securitySolution.appLinks.category.endpoints', { - defaultMessage: 'ENDPOINTS', - }), - linkIds: [ - SecurityPageName.endpoints, - SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.eventFilters, - SecurityPageName.blocklist, - SecurityPageName.hostIsolationExceptions, - ], - }, -] as const; +export const getManagementLinkItems = async (core: CoreStart, plugins: StartPlugins) => { + // TODO: implement async logic to exclude links + return links; +}; diff --git a/x-pack/plugins/security_solution/public/overview/links.ts b/x-pack/plugins/security_solution/public/overview/links.ts index 9fd06b523347f..dbcc04b5c6d8e 100644 --- a/x-pack/plugins/security_solution/public/overview/links.ts +++ b/x-pack/plugins/security_solution/public/overview/links.ts @@ -7,14 +7,14 @@ import { i18n } from '@kbn/i18n'; import { - DASHBOARDS_PATH, DETECTION_RESPONSE_PATH, LANDING_PATH, OVERVIEW_PATH, SecurityPageName, + SERVER_APP_ID, } from '../../common/constants'; -import { DASHBOARDS, DETECTION_RESPONSE, GETTING_STARTED, OVERVIEW } from '../app/translations'; -import { FEATURE, LinkItem } from '../common/links/types'; +import { DETECTION_RESPONSE, GETTING_STARTED, OVERVIEW } from '../app/translations'; +import { LinkItem } from '../common/links/types'; import overviewPageImg from '../common/images/overview_page.png'; import detectionResponsePageImg from '../common/images/detection_response_page.png'; @@ -27,7 +27,7 @@ export const overviewLinks: LinkItem = { }), path: OVERVIEW_PATH, globalNavEnabled: true, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.overview', { defaultMessage: 'Overview', @@ -41,7 +41,7 @@ export const gettingStartedLinks: LinkItem = { title: GETTING_STARTED, path: LANDING_PATH, globalNavEnabled: false, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.getStarted', { defaultMessage: 'Getting started', @@ -62,26 +62,10 @@ export const detectionResponseLinks: LinkItem = { path: DETECTION_RESPONSE_PATH, globalNavEnabled: false, experimentalKey: 'detectionResponseEnabled', - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.detectionAndResponse', { defaultMessage: 'Detection & Response', }), ], }; - -export const dashboardsLandingLinks: LinkItem = { - id: SecurityPageName.dashboardsLanding, - title: DASHBOARDS, - path: DASHBOARDS_PATH, - globalNavEnabled: false, - features: [FEATURE.general], - globalSearchKeywords: [ - i18n.translate('xpack.securitySolution.appLinks.dashboards', { - defaultMessage: 'Dashboards', - }), - ], - links: [overviewLinks, detectionResponseLinks], - skipUrlState: true, - hideTimeline: true, -}; diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 4b49c04f295a5..1716e08febd40 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -45,9 +45,11 @@ import { DETECTION_ENGINE_INDEX_URL, SERVER_APP_ID, SOURCERER_API_URL, + ENABLE_GROUPED_NAVIGATION, } from '../common/constants'; -import { getDeepLinks } from './app/deep_links'; +import { getDeepLinks, registerDeepLinksUpdater } from './app/deep_links'; +import { AppLinkItems, subscribeAppLinks, updateAppLinks } from './common/links'; import { getSubPluginRoutesByCapabilities, manageOldSiemRoutes } from './helpers'; import { SecurityAppStore } from './common/store/store'; import { licenseService } from './common/hooks/use_license'; @@ -140,7 +142,6 @@ export class Plugin implements IPlugin { // required to show the alert table inside cases const { alertsTableConfigurationRegistry } = plugins.triggersActionsUi; @@ -171,7 +172,15 @@ export class Plugin implements IPlugin { const [coreStart] = await core.getStartServices(); - manageOldSiemRoutes(coreStart); + + const subscription = subscribeAppLinks((links: AppLinkItems) => { + // It has to be called once after deep links are initialized + if (links.length > 0) { + manageOldSiemRoutes(coreStart); + subscription.unsubscribe(); + } + }); + return () => true; }, }); @@ -220,35 +229,65 @@ export class Plugin implements IPlugin { - if (currentLicense.type !== undefined) { - this.appUpdater$.next(() => ({ - navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed - deepLinks: getDeepLinks( - this.experimentalFeatures, - currentLicense.type, - core.application.capabilities - ), - })); + + if (newNavEnabled) { + registerDeepLinksUpdater(this.appUpdater$); + } + + // Not using await to prevent blocking start execution + this.lazyApplicationLinks().then(({ getAppLinks }) => { + getAppLinks(core, plugins).then((appLinks) => { + if (licensing !== null) { + this.licensingSubscription = licensing.subscribe((currentLicense) => { + if (currentLicense.type !== undefined) { + updateAppLinks(appLinks, { + experimentalFeatures: this.experimentalFeatures, + license: currentLicense, + capabilities: core.application.capabilities, + }); + + if (!newNavEnabled) { + // TODO: remove block when nav flag no longer needed + this.appUpdater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed + deepLinks: getDeepLinks( + this.experimentalFeatures, + currentLicense.type, + core.application.capabilities + ), + })); + } + } + }); + } else { + updateAppLinks(appLinks, { + experimentalFeatures: this.experimentalFeatures, + capabilities: core.application.capabilities, + }); + + if (!newNavEnabled) { + // TODO: remove block when nav flag no longer needed + this.appUpdater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed + deepLinks: getDeepLinks( + this.experimentalFeatures, + undefined, + core.application.capabilities + ), + })); + } } }); - } else { - this.appUpdater$.next(() => ({ - navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed - deepLinks: getDeepLinks( - this.experimentalFeatures, - undefined, - core.application.capabilities - ), - })); - } + }); return {}; } @@ -296,11 +335,22 @@ export class Plugin implements IPlugin Date: Fri, 20 May 2022 16:27:14 +0300 Subject: [PATCH 107/150] [XY] `pointsRadius`, `showPoints` and `lineWidth`. (#130391) * Added pointsRadius, showPoints and lineWidth. * Added tests. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../extended_data_layer.test.ts.snap | 6 ++ .../common_data_layer_args.ts | 12 +++ .../extended_data_layer.test.ts | 94 ++++++++++++------- .../extended_data_layer_fn.ts | 10 +- .../reference_line_layer.ts | 17 +--- .../reference_line_layer_fn.ts | 24 +++++ .../common/expression_functions/validate.ts | 51 ++++++++++ .../expression_functions/xy_vis.test.ts | 1 + .../common/expression_functions/xy_vis_fn.ts | 12 +++ .../expression_xy/common/i18n/index.tsx | 12 +++ .../common/types/expression_functions.ts | 8 +- .../public/components/xy_chart.test.tsx | 69 ++++++++++++++ .../public/helpers/data_layers.tsx | 56 ++++++++--- 13 files changed, 313 insertions(+), 59 deletions(-) create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer_fn.ts diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap index 68262f8a4f3de..9abd76c669b8f 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap @@ -1,5 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`extendedDataLayerConfig throws the error if lineWidth is provided to the not line/area chart 1`] = `"\`lineWidth\` can be applied only for line or area charts"`; + exports[`extendedDataLayerConfig throws the error if markSizeAccessor doesn't have the corresponding column in the table 1`] = `"Provided column name or index is invalid: nonsense"`; exports[`extendedDataLayerConfig throws the error if markSizeAccessor is provided to the not line/area chart 1`] = `"\`markSizeAccessor\` can't be used. Dots are applied only for line or area charts"`; + +exports[`extendedDataLayerConfig throws the error if pointsRadius is provided to the not line/area chart 1`] = `"\`pointsRadius\` can be applied only for line or area charts"`; + +exports[`extendedDataLayerConfig throws the error if showPoints is provided to the not line/area chart 1`] = `"\`showPoints\` can be applied only for line or area charts"`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts index f4543c5236ce2..c7f2da8ec1543 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts @@ -43,6 +43,18 @@ export const commonDataLayerArgs: Omit< default: false, help: strings.getIsHistogramHelp(), }, + lineWidth: { + types: ['number'], + help: strings.getLineWidthHelp(), + }, + showPoints: { + types: ['boolean'], + help: strings.getShowPointsHelp(), + }, + pointsRadius: { + types: ['number'], + help: strings.getPointsRadiusHelp(), + }, yConfig: { types: [Y_CONFIG], help: strings.getYConfigHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts index 5b943b0790313..7f513168a8607 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts @@ -13,62 +13,92 @@ import { LayerTypes } from '../constants'; import { extendedDataLayerFunction } from './extended_data_layer'; describe('extendedDataLayerConfig', () => { + const args: ExtendedDataLayerArgs = { + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + isHistogram: false, + palette: mockPaletteOutput, + }; + test('produces the correct arguments', async () => { const { data } = sampleArgs(); - const args: ExtendedDataLayerArgs = { - seriesType: 'line', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - xScaleType: 'linear', - isHistogram: false, - palette: mockPaletteOutput, + const fullArgs: ExtendedDataLayerArgs = { + ...args, markSizeAccessor: 'b', + showPoints: true, + lineWidth: 10, + pointsRadius: 10, }; - const result = await extendedDataLayerFunction.fn(data, args, createMockExecutionContext()); + const result = await extendedDataLayerFunction.fn(data, fullArgs, createMockExecutionContext()); expect(result).toEqual({ type: 'extendedDataLayer', layerType: LayerTypes.DATA, - ...args, + ...fullArgs, table: data, }); }); test('throws the error if markSizeAccessor is provided to the not line/area chart', async () => { const { data } = sampleArgs(); - const args: ExtendedDataLayerArgs = { - seriesType: 'bar', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - xScaleType: 'linear', - isHistogram: false, - palette: mockPaletteOutput, - markSizeAccessor: 'b', - }; expect( - extendedDataLayerFunction.fn(data, args, createMockExecutionContext()) + extendedDataLayerFunction.fn( + data, + { ...args, seriesType: 'bar', markSizeAccessor: 'b' }, + createMockExecutionContext() + ) ).rejects.toThrowErrorMatchingSnapshot(); }); test("throws the error if markSizeAccessor doesn't have the corresponding column in the table", async () => { const { data } = sampleArgs(); - const args: ExtendedDataLayerArgs = { - seriesType: 'line', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - xScaleType: 'linear', - isHistogram: false, - palette: mockPaletteOutput, - markSizeAccessor: 'nonsense', - }; expect( - extendedDataLayerFunction.fn(data, args, createMockExecutionContext()) + extendedDataLayerFunction.fn( + data, + { ...args, markSizeAccessor: 'nonsense' }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('throws the error if lineWidth is provided to the not line/area chart', async () => { + const { data } = sampleArgs(); + expect( + extendedDataLayerFunction.fn( + data, + { ...args, seriesType: 'bar', lineWidth: 10 }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('throws the error if showPoints is provided to the not line/area chart', async () => { + const { data } = sampleArgs(); + + expect( + extendedDataLayerFunction.fn( + data, + { ...args, seriesType: 'bar', showPoints: true }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('throws the error if pointsRadius is provided to the not line/area chart', async () => { + const { data } = sampleArgs(); + + expect( + extendedDataLayerFunction.fn( + data, + { ...args, seriesType: 'bar', pointsRadius: 10 }, + createMockExecutionContext() + ) ).rejects.toThrowErrorMatchingSnapshot(); }); }); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts index 8e5019e065133..f45aea7e86d8d 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts @@ -10,7 +10,12 @@ import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; import { ExtendedDataLayerArgs, ExtendedDataLayerFn } from '../types'; import { EXTENDED_DATA_LAYER, LayerTypes } from '../constants'; import { getAccessors, normalizeTable } from '../helpers'; -import { validateMarkSizeForChartType } from './validate'; +import { + validateLineWidthForChartType, + validateMarkSizeForChartType, + validatePointsRadiusForChartType, + validateShowPointsForChartType, +} from './validate'; export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, context) => { const table = args.table ?? data; @@ -21,6 +26,9 @@ export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, accessors.accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); validateMarkSizeForChartType(args.markSizeAccessor, args.seriesType); validateAccessor(args.markSizeAccessor, table.columns); + validateLineWidthForChartType(args.lineWidth, args.seriesType); + validateShowPointsForChartType(args.showPoints, args.seriesType); + validatePointsRadiusForChartType(args.pointsRadius, args.seriesType); const normalizedTable = normalizeTable(table, accessors.xAccessor); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts index 6b51edd2d209e..234001015d73a 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; -import { LayerTypes, REFERENCE_LINE_LAYER, EXTENDED_Y_CONFIG } from '../constants'; +import { REFERENCE_LINE_LAYER, EXTENDED_Y_CONFIG } from '../constants'; import { ReferenceLineLayerFn } from '../types'; import { strings } from '../i18n'; @@ -41,16 +40,8 @@ export const referenceLineLayerFunction: ReferenceLineLayerFn = { help: strings.getLayerIdHelp(), }, }, - fn(input, args) { - const table = args.table ?? input; - const accessors = args.accessors ?? []; - accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); - - return { - type: REFERENCE_LINE_LAYER, - ...args, - layerType: LayerTypes.REFERENCELINE, - table: args.table ?? input, - }; + async fn(input, args, context) { + const { referenceLineLayerFn } = await import('./reference_line_layer_fn'); + return await referenceLineLayerFn(input, args, context); }, }; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer_fn.ts new file mode 100644 index 0000000000000..8b6d1cc531447 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer_fn.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { LayerTypes, REFERENCE_LINE_LAYER } from '../constants'; +import { ReferenceLineLayerFn } from '../types'; + +export const referenceLineLayerFn: ReferenceLineLayerFn['fn'] = async (input, args, handlers) => { + const table = args.table ?? input; + const accessors = args.accessors ?? []; + accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); + + return { + type: REFERENCE_LINE_LAYER, + ...args, + layerType: LayerTypes.REFERENCELINE, + table: args.table ?? input, + }; +}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts index df7f9ee08632e..de01b149802b9 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts @@ -34,6 +34,27 @@ export const errors = { i18n.translate('expressionXY.reusable.function.xyVis.errors.markSizeLimitsError', { defaultMessage: 'Mark size ratio must be greater or equal to 1 and less or equal to 100', }), + lineWidthForNonLineOrAreaChartError: () => + i18n.translate( + 'expressionXY.reusable.function.xyVis.errors.lineWidthForNonLineOrAreaChartError', + { + defaultMessage: '`lineWidth` can be applied only for line or area charts', + } + ), + showPointsForNonLineOrAreaChartError: () => + i18n.translate( + 'expressionXY.reusable.function.xyVis.errors.showPointsForNonLineOrAreaChartError', + { + defaultMessage: '`showPoints` can be applied only for line or area charts', + } + ), + pointsRadiusForNonLineOrAreaChartError: () => + i18n.translate( + 'expressionXY.reusable.function.xyVis.errors.pointsRadiusForNonLineOrAreaChartError', + { + defaultMessage: '`pointsRadius` can be applied only for line or area charts', + } + ), markSizeRatioWithoutAccessor: () => i18n.translate('expressionXY.reusable.function.xyVis.errors.markSizeRatioWithoutAccessor', { defaultMessage: 'Mark size ratio can be applied only with `markSizeAccessor`', @@ -140,6 +161,9 @@ export const validateValueLabels = ( } }; +const isAreaOrLineChart = (seriesType: SeriesType) => + seriesType.includes('line') || seriesType.includes('area'); + export const validateAddTimeMarker = ( dataLayers: Array, addTimeMarker?: boolean @@ -164,6 +188,33 @@ export const validateMarkSizeRatioLimits = (markSizeRatio?: number) => { } }; +export const validateLineWidthForChartType = ( + lineWidth: number | undefined, + seriesType: SeriesType +) => { + if (lineWidth !== undefined && !isAreaOrLineChart(seriesType)) { + throw new Error(errors.lineWidthForNonLineOrAreaChartError()); + } +}; + +export const validateShowPointsForChartType = ( + showPoints: boolean | undefined, + seriesType: SeriesType +) => { + if (showPoints !== undefined && !isAreaOrLineChart(seriesType)) { + throw new Error(errors.showPointsForNonLineOrAreaChartError()); + } +}; + +export const validatePointsRadiusForChartType = ( + pointsRadius: number | undefined, + seriesType: SeriesType +) => { + if (pointsRadius !== undefined && !isAreaOrLineChart(seriesType)) { + throw new Error(errors.pointsRadiusForNonLineOrAreaChartError()); + } +}; + export const validateMarkSizeRatioWithAccessor = ( markSizeRatio: number | undefined, markSizeAccessor: ExpressionValueVisDimension | string | undefined diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts index 8a327ccca9e20..174ff908eeaa1 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts @@ -65,6 +65,7 @@ describe('xyVis', () => { ) ).rejects.toThrowErrorMatchingSnapshot(); }); + test('it should throw error if minTimeBarInterval is invalid', async () => { const { data, args } = sampleArgs(); const { layers, ...rest } = args; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index 4c25e3378d523..afe569a86f894 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -29,6 +29,9 @@ import { validateMinTimeBarInterval, validateMarkSizeForChartType, validateMarkSizeRatioWithAccessor, + validateShowPointsForChartType, + validateLineWidthForChartType, + validatePointsRadiusForChartType, } from './validate'; const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult => { @@ -43,6 +46,9 @@ const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult isHistogram: args.isHistogram, palette: args.palette, yConfig: args.yConfig, + showPoints: args.showPoints, + pointsRadius: args.pointsRadius, + lineWidth: args.lineWidth, layerType: LayerTypes.DATA, table: normalizedTable, ...accessors, @@ -68,6 +74,9 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { yConfig, palette, markSizeAccessor, + showPoints, + pointsRadius, + lineWidth, ...restArgs } = args; @@ -116,6 +125,9 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateValueLabels(args.valueLabels, hasBar, hasNotHistogramBars); validateMarkSizeRatioWithAccessor(args.markSizeRatio, dataLayers[0].markSizeAccessor); validateMarkSizeRatioLimits(args.markSizeRatio); + validateLineWidthForChartType(lineWidth, args.seriesType); + validateShowPointsForChartType(showPoints, args.seriesType); + validatePointsRadiusForChartType(pointsRadius, args.seriesType); return { type: 'render', diff --git a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx index ed2ef4a7a57ce..4f94d5805396d 100644 --- a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx @@ -181,6 +181,18 @@ export const strings = { i18n.translate('expressionXY.dataLayer.markSizeAccessor.help', { defaultMessage: 'Mark size accessor', }), + getLineWidthHelp: () => + i18n.translate('expressionXY.dataLayer.lineWidth.help', { + defaultMessage: 'Line width', + }), + getShowPointsHelp: () => + i18n.translate('expressionXY.dataLayer.showPoints.help', { + defaultMessage: 'Show points', + }), + getPointsRadiusHelp: () => + i18n.translate('expressionXY.dataLayer.pointsRadius.help', { + defaultMessage: 'Points radius', + }), getYConfigHelp: () => i18n.translate('expressionXY.dataLayer.yConfig.help', { defaultMessage: 'Additional configuration for y axes', diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index c0336fc67536f..05447607bc194 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -102,6 +102,9 @@ export interface DataLayerArgs { hide?: boolean; splitAccessor?: string | ExpressionValueVisDimension; markSizeAccessor?: string | ExpressionValueVisDimension; + lineWidth?: number; + showPoints?: boolean; + pointsRadius?: number; columnToLabel?: string; // Actually a JSON key-value pair xScaleType: XScaleType; isHistogram: boolean; @@ -121,6 +124,9 @@ export interface ExtendedDataLayerArgs { hide?: boolean; splitAccessor?: string; markSizeAccessor?: string; + lineWidth?: number; + showPoints?: boolean; + pointsRadius?: number; columnToLabel?: string; // Actually a JSON key-value pair xScaleType: XScaleType; isHistogram: boolean; @@ -416,7 +422,7 @@ export type ReferenceLineLayerFn = ExpressionFunctionDefinition< typeof REFERENCE_LINE_LAYER, Datatable, ReferenceLineLayerArgs, - ReferenceLineLayerConfigResult + Promise >; export type YConfigFn = ExpressionFunctionDefinition; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx index 91e5ae8ad1484..f46213fe41ba3 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx @@ -722,6 +722,75 @@ describe('XYChart component', () => { expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); }); + test('applies the line width to the chart', () => { + const { args } = sampleArgs(); + const lineWidthArg = { lineWidth: 10 }; + const component = shallow( + + ); + const dataLayers = component.find(DataLayers).dive(); + const lineArea = dataLayers.find(LineSeries).at(0); + const expectedSeriesStyle = expect.objectContaining({ + line: { strokeWidth: lineWidthArg.lineWidth }, + }); + + expect(lineArea.prop('areaSeriesStyle')).toEqual(expectedSeriesStyle); + expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); + }); + + test('applies showPoints to the chart', () => { + const checkIfPointsVisibilityIsApplied = (showPoints: boolean) => { + const { args } = sampleArgs(); + const component = shallow( + + ); + const dataLayers = component.find(DataLayers).dive(); + const lineArea = dataLayers.find(LineSeries).at(0); + const expectedSeriesStyle = expect.objectContaining({ + point: expect.objectContaining({ + visible: showPoints, + }), + }); + expect(lineArea.prop('areaSeriesStyle')).toEqual(expectedSeriesStyle); + expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); + }; + + checkIfPointsVisibilityIsApplied(true); + checkIfPointsVisibilityIsApplied(false); + }); + + test('applies point radius to the chart', () => { + const pointsRadius = 10; + const { args } = sampleArgs(); + const component = shallow( + + ); + const dataLayers = component.find(DataLayers).dive(); + const lineArea = dataLayers.find(LineSeries).at(0); + const expectedSeriesStyle = expect.objectContaining({ + point: expect.objectContaining({ + radius: pointsRadius, + }), + }); + expect(lineArea.prop('areaSeriesStyle')).toEqual(expectedSeriesStyle); + expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); + }); + test('it renders bar', () => { const { args } = sampleArgs(); const component = shallow( diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx index 08761f633f851..34e5e36091ae1 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx @@ -8,6 +8,7 @@ import { AreaSeriesProps, + AreaSeriesStyle, BarSeriesProps, ColorVariant, LineSeriesProps, @@ -80,6 +81,14 @@ type GetColorFn = ( } ) => string | null; +type GetLineConfigFn = (config: { + xAccessor: string | undefined; + markSizeAccessor: string | undefined; + emphasizeFitting?: boolean; + showPoints?: boolean; + pointsRadius?: number; +}) => Partial; + export interface DatatableWithFormatInfo { table: Datatable; formattedColumns: Record; @@ -227,17 +236,26 @@ const getSeriesName: GetSeriesNameFn = ( return splitColumnId ? data.seriesKeys[0] : columnToLabelMap[data.seriesKeys[0]] ?? null; }; -const getPointConfig = ( - xAccessor: string | undefined, - markSizeAccessor: string | undefined, - emphasizeFitting?: boolean -) => ({ - visible: !xAccessor || markSizeAccessor !== undefined, - radius: xAccessor && !emphasizeFitting ? 5 : 0, +const getPointConfig: GetLineConfigFn = ({ + xAccessor, + markSizeAccessor, + emphasizeFitting, + showPoints, + pointsRadius, +}) => ({ + visible: showPoints !== undefined ? showPoints : !xAccessor || markSizeAccessor !== undefined, + radius: pointsRadius !== undefined ? pointsRadius : xAccessor && !emphasizeFitting ? 5 : 0, fill: markSizeAccessor ? ColorVariant.Series : undefined, }); -const getLineConfig = () => ({ visible: true, stroke: ColorVariant.Series, opacity: 1, dash: [] }); +const getFitLineConfig = () => ({ + visible: true, + stroke: ColorVariant.Series, + opacity: 1, + dash: [], +}); + +const getLineConfig = (strokeWidth?: number) => ({ strokeWidth }); const getColor: GetColorFn = ( { yAccessor, seriesKeys }, @@ -363,15 +381,29 @@ export const getSeriesProps: GetSeriesPropsFn = ({ stackMode: isPercentage ? StackMode.Percentage : undefined, timeZone, areaSeriesStyle: { - point: getPointConfig(xColumnId, markSizeColumnId, emphasizeFitting), + point: getPointConfig({ + xAccessor: xColumnId, + markSizeAccessor: markSizeColumnId, + emphasizeFitting, + showPoints: layer.showPoints, + pointsRadius: layer.pointsRadius, + }), ...(fillOpacity && { area: { opacity: fillOpacity } }), ...(emphasizeFitting && { - fit: { area: { opacity: fillOpacity || 0.5 }, line: getLineConfig() }, + fit: { area: { opacity: fillOpacity || 0.5 }, line: getFitLineConfig() }, }), + line: getLineConfig(layer.lineWidth), }, lineSeriesStyle: { - point: getPointConfig(xColumnId, markSizeColumnId, emphasizeFitting), - ...(emphasizeFitting && { fit: { line: getLineConfig() } }), + point: getPointConfig({ + xAccessor: xColumnId, + markSizeAccessor: markSizeColumnId, + emphasizeFitting, + showPoints: layer.showPoints, + pointsRadius: layer.pointsRadius, + }), + ...(emphasizeFitting && { fit: { line: getFitLineConfig() } }), + line: getLineConfig(layer.lineWidth), }, name(d) { return getSeriesName(d, { From 2e51140d9c297abfd6394d61bff85aa0b93d9006 Mon Sep 17 00:00:00 2001 From: Katerina Patticha Date: Fri, 20 May 2022 15:34:29 +0200 Subject: [PATCH 108/150] Show service group icon only when there are service groups (#131138) * Show service group icon when there are service groups * Fix fix errors * Remove additional request and display icon only for the service groups * Revert "Remove additional request and display icon only for the service groups" This reverts commit 7ff2bc97f48914a4487998e6e66370ad8beba506. * Add dependencies Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../templates/service_group_template.tsx | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx index bcf0b44814089..006b3cc67bd5e 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx @@ -11,6 +11,7 @@ import { EuiFlexItem, EuiButtonIcon, EuiLoadingContent, + EuiLoadingSpinner, } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; @@ -19,7 +20,7 @@ import { KibanaPageTemplateProps, } from '@kbn/kibana-react-plugin/public'; import { enableServiceGroups } from '@kbn/observability-plugin/public'; -import { useFetcher } from '../../../hooks/use_fetcher'; +import { useFetcher, FETCH_STATUS } from '../../../hooks/use_fetcher'; import { ApmPluginStartDeps } from '../../../plugin'; import { useApmRouter } from '../../../hooks/use_apm_router'; import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; @@ -51,17 +52,29 @@ export function ServiceGroupTemplate({ query: { serviceGroup: serviceGroupId }, } = useAnyOfApmParams('/services', '/service-map'); - const { data } = useFetcher((callApmApi) => { - if (serviceGroupId) { - return callApmApi('GET /internal/apm/service-group', { - params: { query: { serviceGroup: serviceGroupId } }, - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const { data } = useFetcher( + (callApmApi) => { + if (serviceGroupId) { + return callApmApi('GET /internal/apm/service-group', { + params: { query: { serviceGroup: serviceGroupId } }, + }); + } + }, + [serviceGroupId] + ); + + const { data: serviceGroupsData, status: serviceGroupsStatus } = useFetcher( + (callApmApi) => { + if (!serviceGroupId && isServiceGroupsEnabled) { + return callApmApi('GET /internal/apm/service-groups'); + } + }, + [serviceGroupId, isServiceGroupsEnabled] + ); const serviceGroupName = data?.serviceGroup.groupName; const loadingServiceGroupName = !!serviceGroupId && !serviceGroupName; + const hasServiceGroups = !!serviceGroupsData?.serviceGroups.length; const serviceGroupsLink = router.link('/service-groups', { query: { ...query, serviceGroup: '' }, }); @@ -74,15 +87,22 @@ export function ServiceGroupTemplate({ justifyContent="flexStart" responsive={false} > - - - + {serviceGroupsStatus === FETCH_STATUS.LOADING && ( + + + + )} + {(serviceGroupId || hasServiceGroups) && ( + + + + )} {loadingServiceGroupName ? ( From 7c37eda9ed8dfc7dd50b506ee57315a0babd779a Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Fri, 20 May 2022 15:42:28 +0200 Subject: [PATCH 109/150] [Osquery] Fix pagination issue on Alert's Osquery Flyout (#132611) --- x-pack/plugins/osquery/public/results/results_table.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/osquery/public/results/results_table.tsx b/x-pack/plugins/osquery/public/results/results_table.tsx index 229714eaaed99..ae0baaea7f586 100644 --- a/x-pack/plugins/osquery/public/results/results_table.tsx +++ b/x-pack/plugins/osquery/public/results/results_table.tsx @@ -315,8 +315,11 @@ const ResultsTableComponent: React.FC = ({ id: 'timeline', width: 38, headerCellRender: () => null, - rowCellRender: (actionProps: EuiDataGridCellValueElementProps) => { - const eventId = data[actionProps.rowIndex]._id; + rowCellRender: (actionProps) => { + const { visibleRowIndex } = actionProps as EuiDataGridCellValueElementProps & { + visibleRowIndex: number; + }; + const eventId = data[visibleRowIndex]._id; return addToTimeline({ query: ['_id', eventId], isIcon: true }); }, From 1d8bc7ede1e6e9aa4415adabfdc457a629e5cf6e Mon Sep 17 00:00:00 2001 From: Shivindera Singh Date: Fri, 20 May 2022 15:53:00 +0200 Subject: [PATCH 110/150] hasData service - hit search api in case of an error with resolve api (#132618) --- src/plugins/data_views/public/index.ts | 1 + .../data_views/public/services/has_data.ts | 61 ++++++++++++++++--- src/plugins/data_views/public/types.ts | 4 ++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/plugins/data_views/public/index.ts b/src/plugins/data_views/public/index.ts index f6a0843babed6..5b14ca9d25030 100644 --- a/src/plugins/data_views/public/index.ts +++ b/src/plugins/data_views/public/index.ts @@ -57,6 +57,7 @@ export type { HasDataViewsResponse, IndicesResponse, IndicesResponseModified, + IndicesViaSearchResponse, } from './types'; // Export plugin after all other imports diff --git a/src/plugins/data_views/public/services/has_data.ts b/src/plugins/data_views/public/services/has_data.ts index 76f6b39ec4982..d10f6a3d446f8 100644 --- a/src/plugins/data_views/public/services/has_data.ts +++ b/src/plugins/data_views/public/services/has_data.ts @@ -8,7 +8,12 @@ import { CoreStart, HttpStart } from '@kbn/core/public'; import { DEFAULT_ASSETS_TO_IGNORE } from '../../common'; -import { HasDataViewsResponse, IndicesResponse, IndicesResponseModified } from '..'; +import { + HasDataViewsResponse, + IndicesResponse, + IndicesResponseModified, + IndicesViaSearchResponse, +} from '..'; export class HasData { private removeAliases = (source: IndicesResponseModified): boolean => !source.item.indices; @@ -77,6 +82,41 @@ export class HasData { return source; }; + private getIndicesViaSearch = async ({ + http, + pattern, + showAllIndices, + }: { + http: HttpStart; + pattern: string; + showAllIndices: boolean; + }): Promise => + http + .post(`/internal/search/ese`, { + body: JSON.stringify({ + params: { + ignore_unavailable: true, + expand_wildcards: showAllIndices ? 'all' : 'open', + index: pattern, + body: { + size: 0, // no hits + aggs: { + indices: { + terms: { + field: '_index', + size: 200, + }, + }, + }, + }, + }, + }), + }) + .then((resp) => { + return !!(resp && resp.total >= 0); + }) + .catch(() => false); + private getIndices = async ({ http, pattern, @@ -96,26 +136,29 @@ export class HasData { } else { return this.responseToItemArray(response); } - }) - .catch(() => []); + }); private checkLocalESData = (http: HttpStart): Promise => this.getIndices({ http, pattern: '*', showAllIndices: false, - }).then((dataSources: IndicesResponseModified[]) => { - return dataSources.some(this.isUserDataIndex); - }); + }) + .then((dataSources: IndicesResponseModified[]) => { + return dataSources.some(this.isUserDataIndex); + }) + .catch(() => this.getIndicesViaSearch({ http, pattern: '*', showAllIndices: false })); private checkRemoteESData = (http: HttpStart): Promise => this.getIndices({ http, pattern: '*:*', showAllIndices: false, - }).then((dataSources: IndicesResponseModified[]) => { - return !!dataSources.filter(this.removeAliases).length; - }); + }) + .then((dataSources: IndicesResponseModified[]) => { + return !!dataSources.filter(this.removeAliases).length; + }) + .catch(() => this.getIndicesViaSearch({ http, pattern: '*:*', showAllIndices: false })); // Data Views diff --git a/src/plugins/data_views/public/types.ts b/src/plugins/data_views/public/types.ts index 612f22335e72a..f2d34961ab6e0 100644 --- a/src/plugins/data_views/public/types.ts +++ b/src/plugins/data_views/public/types.ts @@ -56,6 +56,10 @@ export interface IndicesResponse { data_streams?: IndicesResponseItemDataStream[]; } +export interface IndicesViaSearchResponse { + total: number; +} + export interface HasDataViewsResponse { hasDataView: boolean; hasUserDataView: boolean; From d34408876a67c7158f972f9ec0e493fe4f9a4e7b Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 20 May 2022 08:06:25 -0600 Subject: [PATCH 111/150] [maps] Use label features from ES vector tile search API to fix multiple labels (#132080) * [maps] mvt labels * eslint * only request labels when needed * update vector tile integration tests for hasLabels parameter * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * fix tests * fix test * only add _mvt_label_position filter when vector tiles are from ES vector tile search API * review feedback * include hasLabels in source data * fix jest test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/maps/common/mvt_request_body.ts | 6 ++ .../layers/heatmap_layer/heatmap_layer.ts | 1 + .../mvt_vector_layer/mvt_source_data.test.ts | 57 +++++++++++++++++++ .../mvt_vector_layer/mvt_source_data.ts | 11 +++- .../mvt_vector_layer/mvt_vector_layer.tsx | 1 + .../layers/vector_layer/vector_layer.tsx | 4 ++ .../es_geo_grid_source.test.ts | 4 +- .../es_geo_grid_source/es_geo_grid_source.tsx | 7 ++- .../es_search_source/es_search_source.test.ts | 4 +- .../es_search_source/es_search_source.tsx | 7 ++- .../vector_source/mvt_vector_source.ts | 6 +- .../classes/styles/vector/style_util.ts | 4 +- .../classes/styles/vector/vector_style.tsx | 14 ++++- .../classes/util/mb_filter_expressions.ts | 23 +++++--- .../components/get_tile_request.test.ts | 6 +- .../components/get_tile_request.ts | 6 ++ x-pack/plugins/maps/server/mvt/mvt_routes.ts | 4 ++ .../apis/maps/get_grid_tile.js | 37 ++++++++++++ .../api_integration/apis/maps/get_tile.js | 47 +++++++++++++++ .../apps/maps/group4/mvt_geotile_grid.js | 1 + .../apps/maps/group4/mvt_scaling.js | 1 + 21 files changed, 229 insertions(+), 22 deletions(-) diff --git a/x-pack/plugins/maps/common/mvt_request_body.ts b/x-pack/plugins/maps/common/mvt_request_body.ts index e5517b23e0cba..c2d367f89fa8a 100644 --- a/x-pack/plugins/maps/common/mvt_request_body.ts +++ b/x-pack/plugins/maps/common/mvt_request_body.ts @@ -21,6 +21,7 @@ export function getAggsTileRequest({ encodedRequestBody, geometryFieldName, gridPrecision, + hasLabels, index, renderAs = RENDER_AS.POINT, x, @@ -30,6 +31,7 @@ export function getAggsTileRequest({ encodedRequestBody: string; geometryFieldName: string; gridPrecision: number; + hasLabels: boolean; index: string; renderAs: RENDER_AS; x: number; @@ -50,6 +52,7 @@ export function getAggsTileRequest({ aggs: requestBody.aggs, fields: requestBody.fields, runtime_mappings: requestBody.runtime_mappings, + with_labels: hasLabels, }, }; } @@ -57,6 +60,7 @@ export function getAggsTileRequest({ export function getHitsTileRequest({ encodedRequestBody, geometryFieldName, + hasLabels, index, x, y, @@ -64,6 +68,7 @@ export function getHitsTileRequest({ }: { encodedRequestBody: string; geometryFieldName: string; + hasLabels: boolean; index: string; x: number; y: number; @@ -86,6 +91,7 @@ export function getHitsTileRequest({ ), runtime_mappings: requestBody.runtime_mappings, track_total_hits: typeof requestBody.size === 'number' ? requestBody.size + 1 : false, + with_labels: hasLabels, }, }; } diff --git a/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts b/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts index e796ecad332ca..ec9cec3a914ba 100644 --- a/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts @@ -87,6 +87,7 @@ export class HeatmapLayer extends AbstractLayer { async syncData(syncContext: DataRequestContext) { await syncMvtSourceData({ + hasLabels: false, layerId: this.getId(), layerName: await this.getDisplayName(), prevDataRequest: this.getSourceDataRequest(), diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts index 1f710879d9dd7..dae0f5343dcc9 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts @@ -52,6 +52,7 @@ describe('syncMvtSourceData', () => { const syncContext = new MockSyncContext({ dataFilters: {} }); await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: undefined, @@ -82,6 +83,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf', refreshToken: '12345', + hasLabels: false, }); }); @@ -99,6 +101,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -112,6 +115,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -142,6 +146,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -155,6 +160,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -182,6 +188,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -195,6 +202,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -230,6 +238,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -243,6 +252,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'barfoo', // tileSourceLayer is different then mockSource tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -270,6 +280,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -283,6 +294,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -310,6 +322,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -323,6 +336,49 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, + }; + }, + } as unknown as DataRequest, + requestMeta: { ...prevRequestMeta }, + source: mockSource, + syncContext, + }); + // @ts-expect-error + sinon.assert.calledOnce(syncContext.startLoading); + // @ts-expect-error + sinon.assert.calledOnce(syncContext.stopLoading); + }); + + test('Should re-sync when hasLabel state changes', async () => { + const syncContext = new MockSyncContext({ dataFilters: {} }); + const prevRequestMeta = { + ...syncContext.dataFilters, + applyGlobalQuery: true, + applyGlobalTime: true, + applyForceRefresh: true, + fieldNames: [], + sourceMeta: {}, + isForceRefresh: false, + isFeatureEditorOpenForLayer: false, + }; + + await syncMvtSourceData({ + hasLabels: true, + layerId: 'layer1', + layerName: 'my layer', + prevDataRequest: { + getMeta: () => { + return prevRequestMeta; + }, + getData: () => { + return { + tileMinZoom: 4, + tileMaxZoom: 14, + tileSourceLayer: 'aggs', + tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', + refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -340,6 +396,7 @@ describe('syncMvtSourceData', () => { const syncContext = new MockSyncContext({ dataFilters: {} }); await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: undefined, diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts index 76550090109a1..19ad39e41a238 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts @@ -20,9 +20,11 @@ export interface MvtSourceData { tileMaxZoom: number; tileUrl: string; refreshToken: string; + hasLabels: boolean; } export async function syncMvtSourceData({ + hasLabels, layerId, layerName, prevDataRequest, @@ -30,6 +32,7 @@ export async function syncMvtSourceData({ source, syncContext, }: { + hasLabels: boolean; layerId: string; layerName: string; prevDataRequest: DataRequest | undefined; @@ -56,7 +59,10 @@ export async function syncMvtSourceData({ }, }); const canSkip = - !syncContext.forceRefreshDueToDrawing && noChangesInSourceState && noChangesInSearchState; + !syncContext.forceRefreshDueToDrawing && + noChangesInSourceState && + noChangesInSearchState && + prevData.hasLabels === hasLabels; if (canSkip) { return; @@ -72,7 +78,7 @@ export async function syncMvtSourceData({ ? uuid() : prevData.refreshToken; - const tileUrl = await source.getTileUrl(requestMeta, refreshToken); + const tileUrl = await source.getTileUrl(requestMeta, refreshToken, hasLabels); if (source.isESSource()) { syncContext.inspectorAdapters.vectorTiles.addLayer(layerId, layerName, tileUrl); } @@ -82,6 +88,7 @@ export async function syncMvtSourceData({ tileMinZoom: source.getMinZoom(), tileMaxZoom: source.getMaxZoom(), refreshToken, + hasLabels, }; syncContext.stopLoading(SOURCE_DATA_REQUEST_ID, requestToken, sourceData, {}); } catch (error) { diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx index 462ea5b0cc8f1..7eaec94eac0a2 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx @@ -219,6 +219,7 @@ export class MvtVectorLayer extends AbstractVectorLayer { await this._syncSupportsFeatureEditing({ syncContext, source: this.getSource() }); await syncMvtSourceData({ + hasLabels: this.getCurrentStyle().hasLabels(), layerId: this.getId(), layerName: await this.getDisplayName(), prevDataRequest: this.getSourceDataRequest(), diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index 82ca62c7f33df..73e036b105730 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -736,7 +736,10 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { } } + const isSourceGeoJson = !this.getSource().isMvt(); const filterExpr = getPointFilterExpression( + isSourceGeoJson, + this.getSource().isESSource(), this._getJoinFilterExpression(), timesliceMaskConfig ); @@ -843,6 +846,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { const isSourceGeoJson = !this.getSource().isMvt(); const filterExpr = getLabelFilterExpression( isSourceGeoJson, + this.getSource().isESSource(), this._getJoinFilterExpression(), timesliceMaskConfig ); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts index b08b95a58a495..831dc90871dff 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts @@ -306,10 +306,10 @@ describe('ESGeoGridSource', () => { }); it('getTileUrl', async () => { - const tileUrl = await mvtGeogridSource.getTileUrl(vectorSourceRequestMeta, '1234'); + const tileUrl = await mvtGeogridSource.getTileUrl(vectorSourceRequestMeta, '1234', false); expect(tileUrl).toEqual( - "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&gridPrecision=8&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'1'%3A('0'%3Asize%2C'1'%3A0)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A''))%2C'6'%3A('0'%3Aaggs%2C'1'%3A())))&renderAs=heatmap&token=1234" + "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&gridPrecision=8&hasLabels=false&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'1'%3A('0'%3Asize%2C'1'%3A0)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A''))%2C'6'%3A('0'%3Aaggs%2C'1'%3A())))&renderAs=heatmap&token=1234" ); }); }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx index 66a07804c0105..1680b1d2fb55c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx @@ -471,7 +471,11 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo return 'aggs'; } - async getTileUrl(searchFilters: VectorSourceRequestMeta, refreshToken: string): Promise { + async getTileUrl( + searchFilters: VectorSourceRequestMeta, + refreshToken: string, + hasLabels: boolean + ): Promise { const indexPattern = await this.getIndexPattern(); const searchSource = await this.makeSearchSource(searchFilters, 0); searchSource.setField('aggs', this.getValueAggsDsl(indexPattern)); @@ -484,6 +488,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo ?geometryFieldName=${this._descriptor.geoField}\ &index=${indexPattern.title}\ &gridPrecision=${this._getGeoGridPrecisionResolutionDelta()}\ +&hasLabels=${hasLabels}\ &requestBody=${encodeMvtResponseBody(searchSource.getSearchRequestBody())}\ &renderAs=${this._descriptor.requestType}\ &token=${refreshToken}`; diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts index 2df2e119df30c..24470ae0fade7 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts @@ -114,9 +114,9 @@ describe('ESSearchSource', () => { geoField: geoFieldName, indexPatternId: 'ipId', }); - const tileUrl = await esSearchSource.getTileUrl(searchFilters, '1234'); + const tileUrl = await esSearchSource.getTileUrl(searchFilters, '1234', false); expect(tileUrl).toBe( - `rootdir/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=foobar-title-*&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()%2Ctitle%3A'foobar-title-*'))%2C'1'%3A('0'%3Asize%2C'1'%3A1000)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()%2Ctitle%3A'foobar-title-*'))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A'tooltipField%3A%20foobar'))%2C'6'%3A('0'%3AfieldsFromSource%2C'1'%3A!(tooltipField%2CstyleField))%2C'7'%3A('0'%3Asource%2C'1'%3A!(tooltipField%2CstyleField))))&token=1234` + `rootdir/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=foobar-title-*&hasLabels=false&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()%2Ctitle%3A'foobar-title-*'))%2C'1'%3A('0'%3Asize%2C'1'%3A1000)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()%2Ctitle%3A'foobar-title-*'))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A'tooltipField%3A%20foobar'))%2C'6'%3A('0'%3AfieldsFromSource%2C'1'%3A!(tooltipField%2CstyleField))%2C'7'%3A('0'%3Asource%2C'1'%3A!(tooltipField%2CstyleField))))&token=1234` ); }); }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index 52b9675cdbb39..b8982042b2365 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -810,7 +810,11 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource return 'hits'; } - async getTileUrl(searchFilters: VectorSourceRequestMeta, refreshToken: string): Promise { + async getTileUrl( + searchFilters: VectorSourceRequestMeta, + refreshToken: string, + hasLabels: boolean + ): Promise { const indexPattern = await this.getIndexPattern(); const indexSettings = await loadIndexSettings(indexPattern.title); @@ -847,6 +851,7 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource return `${mvtUrlServicePath}\ ?geometryFieldName=${this._descriptor.geoField}\ &index=${indexPattern.title}\ +&hasLabels=${hasLabels}\ &requestBody=${encodeMvtResponseBody(searchSource.getSearchRequestBody())}\ &token=${refreshToken}`; } diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/mvt_vector_source.ts b/x-pack/plugins/maps/public/classes/sources/vector_source/mvt_vector_source.ts index fca72af193ca3..c6f55436efc15 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/mvt_vector_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/mvt_vector_source.ts @@ -13,7 +13,11 @@ export interface IMvtVectorSource extends IVectorSource { * IMvtVectorSource.getTileUrl returns the tile source URL. * Append refreshToken as a URL parameter to force tile re-fetch on refresh (not required) */ - getTileUrl(searchFilters: VectorSourceRequestMeta, refreshToken: string): Promise; + getTileUrl( + searchFilters: VectorSourceRequestMeta, + refreshToken: string, + hasLabels: boolean + ): Promise; /* * Tile vector sources can contain multiple layers. For example, elasticsearch _mvt tiles contain the layers "hits", "aggs", and "meta". diff --git a/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts b/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts index 5d4d5bc3ecbfb..905bc63fb078b 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts @@ -94,9 +94,9 @@ export function makeMbClampedNumberExpression({ ]; } -export function getHasLabel(label: StaticTextProperty | DynamicTextProperty) { +export function getHasLabel(label: StaticTextProperty | DynamicTextProperty): boolean { return label.isDynamic() ? label.isComplete() : (label as StaticTextProperty).getOptions().value != null && - (label as StaticTextProperty).getOptions().value.length; + (label as StaticTextProperty).getOptions().value.length > 0; } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx index d9a296031b5a1..7ce9673fdc10e 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx @@ -115,6 +115,12 @@ export interface IVectorStyle extends IStyle { mbMap: MbMap, mbSourceId: string ) => boolean; + + /* + * Returns true when "Label" style configuration is complete and map shows a label for layer features. + */ + hasLabels: () => boolean; + arePointsSymbolizedAsCircles: () => boolean; setMBPaintProperties: ({ alpha, @@ -674,14 +680,14 @@ export class VectorStyle implements IVectorStyle { } _getLegendDetailStyleProperties = () => { - const hasLabel = getHasLabel(this._labelStyleProperty); + const hasLabels = this.hasLabels(); return this.getDynamicPropertiesArray().filter((styleProperty) => { const styleName = styleProperty.getStyleName(); if ([VECTOR_STYLES.ICON_ORIENTATION, VECTOR_STYLES.LABEL_TEXT].includes(styleName)) { return false; } - if (!hasLabel && LABEL_STYLES.includes(styleName)) { + if (!hasLabels && LABEL_STYLES.includes(styleName)) { // do not render legend for label styles when there is no label return false; } @@ -768,6 +774,10 @@ export class VectorStyle implements IVectorStyle { return !this._symbolizeAsStyleProperty.isSymbolizedAsIcon(); } + hasLabels() { + return getHasLabel(this._labelStyleProperty); + } + setMBPaintProperties({ alpha, mbMap, diff --git a/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts b/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts index 2f25dc84fe224..a86ca84901cd9 100644 --- a/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts +++ b/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts @@ -55,7 +55,7 @@ export function getFillFilterExpression( ): FilterSpecification { return getFilterExpression( [ - // explicit EXCLUDE_CENTROID_FEATURES filter not needed. Centroids are points and are filtered out by geometry narrowing + // explicit "exclude centroid features" filter not needed. Label features are points and are filtered out by geometry narrowing [ 'any', ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], @@ -73,7 +73,7 @@ export function getLineFilterExpression( ): FilterSpecification { return getFilterExpression( [ - // explicit EXCLUDE_CENTROID_FEATURES filter not needed. Centroids are points and are filtered out by geometry narrowing + // explicit "exclude centroid features" filter not needed. Label features are points and are filtered out by geometry narrowing [ 'any', ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], @@ -94,18 +94,25 @@ const IS_POINT_FEATURE = [ ]; export function getPointFilterExpression( + isSourceGeoJson: boolean, + isESSource: boolean, joinFilter?: FilterSpecification, timesliceMaskConfig?: TimesliceMaskConfig ): FilterSpecification { - return getFilterExpression( - [EXCLUDE_CENTROID_FEATURES, IS_POINT_FEATURE], - joinFilter, - timesliceMaskConfig - ); + const filters: FilterSpecification[] = []; + if (isSourceGeoJson) { + filters.push(EXCLUDE_CENTROID_FEATURES); + } else if (!isSourceGeoJson && isESSource) { + filters.push(['!=', ['get', '_mvt_label_position'], true]); + } + filters.push(IS_POINT_FEATURE); + + return getFilterExpression(filters, joinFilter, timesliceMaskConfig); } export function getLabelFilterExpression( isSourceGeoJson: boolean, + isESSource: boolean, joinFilter?: FilterSpecification, timesliceMaskConfig?: TimesliceMaskConfig ): FilterSpecification { @@ -116,6 +123,8 @@ export function getLabelFilterExpression( // For GeoJSON sources, show label for centroid features or point/multi-point features only. // no explicit isCentroidFeature filter is needed, centroids are points and are included in the geometry filter. filters.push(IS_POINT_FEATURE); + } else if (!isSourceGeoJson && isESSource) { + filters.push(['==', ['get', '_mvt_label_position'], true]); } return getFilterExpression(filters, joinFilter, timesliceMaskConfig); diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts index a45be3cf80ec0..4534c8047409d 100644 --- a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts @@ -11,7 +11,7 @@ test('Should return elasticsearch vector tile request for aggs tiles', () => { expect( getTileRequest({ layerId: '1', - tileUrl: `/pof/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&index=kibana_sample_data_logs&gridPrecision=8&requestBody=(_source%3A(excludes%3A!())%2Caggs%3A()%2Cfields%3A!((field%3A'%40timestamp'%2Cformat%3Adate_time)%2C(field%3Atimestamp%2Cformat%3Adate_time)%2C(field%3Autc_time%2Cformat%3Adate_time))%2Cquery%3A(bool%3A(filter%3A!((match_phrase%3A(machine.os.keyword%3Aios))%2C(range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A'2022-04-22T16%3A46%3A00.744Z'%2Clte%3A'2022-04-29T16%3A46%3A05.345Z'))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A'emit(doc%5B!'timestamp!'%5D.value.getHour())%3B')%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A0%2Cstored_fields%3A!('*'))&renderAs=heatmap&token=e8bff005-ccea-464a-ae56-2061b4f8ce68`, + tileUrl: `/pof/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&hasLabels=false&index=kibana_sample_data_logs&gridPrecision=8&requestBody=(_source%3A(excludes%3A!())%2Caggs%3A()%2Cfields%3A!((field%3A'%40timestamp'%2Cformat%3Adate_time)%2C(field%3Atimestamp%2Cformat%3Adate_time)%2C(field%3Autc_time%2Cformat%3Adate_time))%2Cquery%3A(bool%3A(filter%3A!((match_phrase%3A(machine.os.keyword%3Aios))%2C(range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A'2022-04-22T16%3A46%3A00.744Z'%2Clte%3A'2022-04-29T16%3A46%3A05.345Z'))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A'emit(doc%5B!'timestamp!'%5D.value.getHour())%3B')%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A0%2Cstored_fields%3A!('*'))&renderAs=heatmap&token=e8bff005-ccea-464a-ae56-2061b4f8ce68`, x: 3, y: 0, z: 2, @@ -71,6 +71,7 @@ test('Should return elasticsearch vector tile request for aggs tiles', () => { type: 'long', }, }, + with_labels: false, }, }); }); @@ -79,7 +80,7 @@ test('Should return elasticsearch vector tile request for hits tiles', () => { expect( getTileRequest({ layerId: '1', - tileUrl: `http://localhost:5601/pof/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&index=kibana_sample_data_logs&requestBody=(_source%3A!f%2Cdocvalue_fields%3A!()%2Cquery%3A(bool%3A(filter%3A!((range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A%272022-04-22T16%3A46%3A00.744Z%27%2Clte%3A%272022-04-29T16%3A46%3A05.345Z%27))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A%27emit(doc%5B!%27timestamp!%27%5D.value.getHour())%3B%27)%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A10000%2Cstored_fields%3A!(geo.coordinates))&token=415049b6-bb0a-444a-a7b9-89717db5183c`, + tileUrl: `http://localhost:5601/pof/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&hasLabels=true&index=kibana_sample_data_logs&requestBody=(_source%3A!f%2Cdocvalue_fields%3A!()%2Cquery%3A(bool%3A(filter%3A!((range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A%272022-04-22T16%3A46%3A00.744Z%27%2Clte%3A%272022-04-29T16%3A46%3A05.345Z%27))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A%27emit(doc%5B!%27timestamp!%27%5D.value.getHour())%3B%27)%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A10000%2Cstored_fields%3A!(geo.coordinates))&token=415049b6-bb0a-444a-a7b9-89717db5183c`, x: 0, y: 0, z: 2, @@ -118,6 +119,7 @@ test('Should return elasticsearch vector tile request for hits tiles', () => { }, }, track_total_hits: 10001, + with_labels: true, }, }); }); diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts index f483dfda23409..c79ef7c64fdd1 100644 --- a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts @@ -35,11 +35,16 @@ export function getTileRequest(tileRequest: TileRequest): { path?: string; body? } const geometryFieldName = searchParams.get('geometryFieldName') as string; + const hasLabels = searchParams.has('hasLabels') + ? searchParams.get('hasLabels') === 'true' + : false; + if (tileRequest.tileUrl.includes(MVT_GETGRIDTILE_API_PATH)) { return getAggsTileRequest({ encodedRequestBody, geometryFieldName, gridPrecision: parseInt(searchParams.get('gridPrecision') as string, 10), + hasLabels, index, renderAs: searchParams.get('renderAs') as RENDER_AS, x: tileRequest.x, @@ -52,6 +57,7 @@ export function getTileRequest(tileRequest: TileRequest): { path?: string; body? return getHitsTileRequest({ encodedRequestBody, geometryFieldName, + hasLabels, index, x: tileRequest.x, y: tileRequest.y, diff --git a/x-pack/plugins/maps/server/mvt/mvt_routes.ts b/x-pack/plugins/maps/server/mvt/mvt_routes.ts index 8af26548b1d28..6fd7374fb69c1 100644 --- a/x-pack/plugins/maps/server/mvt/mvt_routes.ts +++ b/x-pack/plugins/maps/server/mvt/mvt_routes.ts @@ -44,6 +44,7 @@ export function initMVTRoutes({ }), query: schema.object({ geometryFieldName: schema.string(), + hasLabels: schema.boolean(), requestBody: schema.string(), index: schema.string(), token: schema.maybe(schema.string()), @@ -65,6 +66,7 @@ export function initMVTRoutes({ tileRequest = getHitsTileRequest({ encodedRequestBody: query.requestBody as string, geometryFieldName: query.geometryFieldName as string, + hasLabels: query.hasLabels as boolean, index: query.index as string, x, y, @@ -102,6 +104,7 @@ export function initMVTRoutes({ }), query: schema.object({ geometryFieldName: schema.string(), + hasLabels: schema.boolean(), requestBody: schema.string(), index: schema.string(), renderAs: schema.string(), @@ -126,6 +129,7 @@ export function initMVTRoutes({ encodedRequestBody: query.requestBody as string, geometryFieldName: query.geometryFieldName as string, gridPrecision: parseInt(query.gridPrecision, 10), + hasLabels: query.hasLabels as boolean, index: query.index as string, renderAs: query.renderAs as RENDER_AS, x, diff --git a/x-pack/test/api_integration/apis/maps/get_grid_tile.js b/x-pack/test/api_integration/apis/maps/get_grid_tile.js index 46fdda09ec476..26ba8c24ce71a 100644 --- a/x-pack/test/api_integration/apis/maps/get_grid_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_grid_tile.js @@ -9,12 +9,22 @@ import { VectorTile } from '@mapbox/vector-tile'; import Protobuf from 'pbf'; import expect from '@kbn/expect'; +function findFeature(layer, callbackFn) { + for (let i = 0; i < layer.length; i++) { + const feature = layer.feature(i); + if (callbackFn(feature)) { + return feature; + } + } +} + export default function ({ getService }) { const supertest = getService('supertest'); describe('getGridTile', () => { const URL = `/api/maps/mvt/getGridTile/3/2/3.pbf\ ?geometryFieldName=geo.coordinates\ +&hasLabels=false\ &index=logstash-*\ &gridPrecision=8\ &requestBody=(_source:(excludes:!()),aggs:(avg_of_bytes:(avg:(field:bytes))),fields:!((field:%27@timestamp%27,format:date_time),(field:%27relatedContent.article:modified_time%27,format:date_time),(field:%27relatedContent.article:published_time%27,format:date_time),(field:utc_time,format:date_time)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(hour_of_day:(script:(lang:painless,source:%27doc[!%27@timestamp!%27].value.getHour()%27))),size:0,stored_fields:!(%27*%27))`; @@ -152,6 +162,33 @@ export default function ({ getService }) { ]); }); + it('should return vector tile containing label features when hasLabels is true', async () => { + const resp = await supertest + .get(URL.replace('hasLabels=false', 'hasLabels=true') + '&renderAs=hex') + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); + + const jsonTile = new VectorTile(new Protobuf(resp.body)); + const layer = jsonTile.layers.aggs; + expect(layer.length).to.be(2); + + const labelFeature = findFeature(layer, (feature) => { + return feature.properties._mvt_label_position === true; + }); + expect(labelFeature).not.to.be(undefined); + expect(labelFeature.type).to.be(1); + expect(labelFeature.extent).to.be(4096); + expect(labelFeature.id).to.be(undefined); + expect(labelFeature.properties).to.eql({ + _count: 1, + _key: '85264a33fffffff', + 'avg_of_bytes.value': 9252, + _mvt_label_position: true, + }); + expect(labelFeature.loadGeometry()).to.eql([[{ x: 93, y: 667 }]]); + }); + it('should return vector tile with meta layer', async () => { const resp = await supertest .get(URL + '&renderAs=point') diff --git a/x-pack/test/api_integration/apis/maps/get_tile.js b/x-pack/test/api_integration/apis/maps/get_tile.js index 09b8bf1d8b862..6803b5e404ab0 100644 --- a/x-pack/test/api_integration/apis/maps/get_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_tile.js @@ -27,6 +27,7 @@ export default function ({ getService }) { .get( `/api/maps/mvt/getTile/2/1/1.pbf\ ?geometryFieldName=geo.coordinates\ +&hasLabels=false\ &index=logstash-*\ &requestBody=(_source:!f,docvalue_fields:!(bytes,geo.coordinates,machine.os.raw,(field:'@timestamp',format:epoch_millis)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(bytes,geo.coordinates,machine.os.raw,'@timestamp'))` ) @@ -85,11 +86,57 @@ export default function ({ getService }) { ]); }); + it('should return ES vector tile containing label features when hasLabels is true', async () => { + const resp = await supertest + .get( + `/api/maps/mvt/getTile/2/1/1.pbf\ +?geometryFieldName=geo.coordinates\ +&hasLabels=true\ +&index=logstash-*\ +&requestBody=(_source:!f,docvalue_fields:!(bytes,geo.coordinates,machine.os.raw,(field:'@timestamp',format:epoch_millis)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(bytes,geo.coordinates,machine.os.raw,'@timestamp'))` + ) + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); + + expect(resp.headers['content-encoding']).to.be('gzip'); + expect(resp.headers['content-disposition']).to.be('inline'); + expect(resp.headers['content-type']).to.be('application/x-protobuf'); + expect(resp.headers['cache-control']).to.be('public, max-age=3600'); + + const jsonTile = new VectorTile(new Protobuf(resp.body)); + const layer = jsonTile.layers.hits; + expect(layer.length).to.be(4); // 2 docs + 2 label features + + // Verify ES document + + const feature = findFeature(layer, (feature) => { + return ( + feature.properties._id === 'AU_x3_BsGFA8no6Qjjug' && + feature.properties._mvt_label_position === true + ); + }); + expect(feature).not.to.be(undefined); + expect(feature.type).to.be(1); + expect(feature.extent).to.be(4096); + expect(feature.id).to.be(undefined); + expect(feature.properties).to.eql({ + '@timestamp': '1442709961071', + _id: 'AU_x3_BsGFA8no6Qjjug', + _index: 'logstash-2015.09.20', + bytes: 9252, + 'machine.os.raw': 'ios', + _mvt_label_position: true, + }); + expect(feature.loadGeometry()).to.eql([[{ x: 44, y: 2382 }]]); + }); + it('should return error when index does not exist', async () => { await supertest .get( `/api/maps/mvt/getTile/2/1/1.pbf\ ?geometryFieldName=geo.coordinates\ +&hasLabels=false\ &index=notRealIndex\ &requestBody=(_source:!f,docvalue_fields:!(bytes,geo.coordinates,machine.os.raw,(field:'@timestamp',format:epoch_millis)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(bytes,geo.coordinates,machine.os.raw,'@timestamp'))` ) diff --git a/x-pack/test/functional/apps/maps/group4/mvt_geotile_grid.js b/x-pack/test/functional/apps/maps/group4/mvt_geotile_grid.js index 40dfa5ac8e571..66eb54278e580 100644 --- a/x-pack/test/functional/apps/maps/group4/mvt_geotile_grid.js +++ b/x-pack/test/functional/apps/maps/group4/mvt_geotile_grid.js @@ -45,6 +45,7 @@ export default function ({ getPageObjects, getService }) { expect(searchParams).to.eql({ geometryFieldName: 'geo.coordinates', + hasLabels: 'false', index: 'logstash-*', gridPrecision: 8, renderAs: 'grid', diff --git a/x-pack/test/functional/apps/maps/group4/mvt_scaling.js b/x-pack/test/functional/apps/maps/group4/mvt_scaling.js index 0f74752d01136..5f740e9137cdb 100644 --- a/x-pack/test/functional/apps/maps/group4/mvt_scaling.js +++ b/x-pack/test/functional/apps/maps/group4/mvt_scaling.js @@ -50,6 +50,7 @@ export default function ({ getPageObjects, getService }) { expect(searchParams).to.eql({ geometryFieldName: 'geometry', + hasLabels: 'false', index: 'geo_shapes*', requestBody: '(_source:!f,docvalue_fields:!(prop1),query:(bool:(filter:!(),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10001,stored_fields:!(geometry,prop1))', From bc31053dc9e5cca9bdf344f8690bf9a0e3c043ac Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> Date: Fri, 20 May 2022 17:09:20 +0300 Subject: [PATCH 112/150] [Discover][Alerting] Implement editing of dataView, query & filters (#131688) * [Discover] introduce params editing using unified search * [Discover] fix unit tests * [Discover] fix functional tests * [Discover] fix unit tests * [Discover] return test subject name * [Discover] fix alert functional test * Update x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx Co-authored-by: Julia Rechkunova * Update x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx Co-authored-by: Matthias Wilhelm * [Discover] hide filter panel options * [Discover] improve functional test * [Discover] apply suggestions * [Discover] change data view selector * [Discover] fix tests * [Discover] apply suggestions, fix lang mode toggler * [Discover] mote interface to types file, clean up diff * [Discover] fix saved query issue * Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts Co-authored-by: Matthias Wilhelm * [Discover] remove zIndex * [Discover] omit null searchType from esQuery completely, add isEsQueryAlert check for useSavedObjectReferences hook * [Discover] set searchType to esQuery when needed * [Discover] fix unit tests * Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts Co-authored-by: Matthias Wilhelm * Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts Co-authored-by: Matthias Wilhelm Co-authored-by: Julia Rechkunova Co-authored-by: Matthias Wilhelm --- src/plugins/data/public/mocks.ts | 1 + src/plugins/data/public/query/mocks.ts | 2 +- src/plugins/data_views/public/mocks.ts | 1 + .../components/top_nav/get_top_nav_links.tsx | 1 + .../top_nav/open_alerts_popover.tsx | 14 +- .../filter_bar/filter_item/filter_item.tsx | 14 - .../public/filter_bar/filter_view/index.tsx | 62 ++-- .../query_string_input/query_bar_top_row.tsx | 3 + .../public/search_bar/search_bar.tsx | 4 + x-pack/plugins/stack_alerts/kibana.json | 3 +- .../data_view_select_popover.test.tsx | 75 +++++ .../components/data_view_select_popover.tsx | 120 ++++++++ .../public/alert_types/es_query/constants.ts | 15 + .../expression/es_query_expression.tsx | 1 + .../es_query/expression/expression.tsx | 41 +-- .../expression/read_only_filter_items.tsx | 66 ----- .../search_source_expression.test.tsx | 133 +++++---- .../expression/search_source_expression.tsx | 219 ++++---------- .../search_source_expression_form.tsx | 269 ++++++++++++++++++ .../public/alert_types/es_query/types.ts | 20 +- .../public/alert_types/es_query/util.ts | 5 +- .../public/alert_types/es_query/validation.ts | 16 +- ...inment_alert_type_expression.test.tsx.snap | 3 + .../es_query/action_context.test.ts | 2 + .../alert_types/es_query/alert_type.test.ts | 9 + .../server/alert_types/es_query/alert_type.ts | 24 +- .../es_query/alert_type_params.test.ts | 1 + .../alert_types/es_query/alert_type_params.ts | 29 +- .../alert_types/es_query/executor.test.ts | 1 + .../server/alert_types/es_query/executor.ts | 7 +- .../server/alert_types/es_query/types.ts | 4 +- .../server/alert_types/es_query/util.ts | 12 + .../sections/rule_form/rule_form.tsx | 6 +- .../apps/discover/search_source_alert.ts | 31 +- 34 files changed, 799 insertions(+), 415 deletions(-) create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx create mode 100644 x-pack/plugins/stack_alerts/server/alert_types/es_query/util.ts diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index 27e365ce0cb37..e1b42b7c193e2 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -38,6 +38,7 @@ const createStartContract = (): Start => { }), get: jest.fn().mockReturnValue(Promise.resolve({})), clearCache: jest.fn(), + getIdsWithTitle: jest.fn(), } as unknown as DataViewsContract; return { diff --git a/src/plugins/data/public/query/mocks.ts b/src/plugins/data/public/query/mocks.ts index a2d73e5b5ce34..296a61afef2fd 100644 --- a/src/plugins/data/public/query/mocks.ts +++ b/src/plugins/data/public/query/mocks.ts @@ -32,7 +32,7 @@ const createStartContractMock = () => { addToQueryLog: jest.fn(), filterManager: createFilterManagerMock(), queryString: queryStringManagerMock.createStartContract(), - savedQueries: jest.fn() as any, + savedQueries: { getSavedQuery: jest.fn() } as any, state$: new Observable(), getState: jest.fn(), timefilter: timefilterServiceMock.createStartContract(), diff --git a/src/plugins/data_views/public/mocks.ts b/src/plugins/data_views/public/mocks.ts index 61713c9406c23..3767c93be10e6 100644 --- a/src/plugins/data_views/public/mocks.ts +++ b/src/plugins/data_views/public/mocks.ts @@ -28,6 +28,7 @@ const createStartContract = (): Start => { get: jest.fn().mockReturnValue(Promise.resolve({})), clearCache: jest.fn(), getCanSaveSync: jest.fn(), + getIdsWithTitle: jest.fn(), } as unknown as jest.Mocked; }; diff --git a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx index f2ac0d2bfa060..ee35e10b6631a 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx @@ -74,6 +74,7 @@ export const getTopNavLinks = ({ anchorElement, searchSource: savedSearch.searchSource, services, + savedQueryId: state.appStateContainer.getState().savedQuery, }); }, testId: 'discoverAlertsButton', diff --git a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx index d414919e567f9..71a0ef3df1b8c 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx @@ -26,9 +26,15 @@ interface AlertsPopoverProps { onClose: () => void; anchorElement: HTMLElement; searchSource: ISearchSource; + savedQueryId?: string; } -export function AlertsPopover({ searchSource, anchorElement, onClose }: AlertsPopoverProps) { +export function AlertsPopover({ + searchSource, + anchorElement, + savedQueryId, + onClose, +}: AlertsPopoverProps) { const dataView = searchSource.getField('index')!; const services = useDiscoverServices(); const { triggersActionsUi } = services; @@ -49,8 +55,9 @@ export function AlertsPopover({ searchSource, anchorElement, onClose }: AlertsPo return { searchType: 'searchSource', searchConfiguration: nextSearchSource.getSerializedFields(), + savedQueryId, }; - }, [searchSource, services]); + }, [savedQueryId, searchSource, services]); const SearchThresholdAlertFlyout = useMemo(() => { if (!alertFlyoutVisible) { @@ -156,11 +163,13 @@ export function openAlertsPopover({ anchorElement, searchSource, services, + savedQueryId, }: { I18nContext: I18nStart['Context']; anchorElement: HTMLElement; searchSource: ISearchSource; services: DiscoverServices; + savedQueryId?: string; }) { if (isOpen) { closeAlertsPopover(); @@ -177,6 +186,7 @@ export function openAlertsPopover({ onClose={closeAlertsPopover} anchorElement={anchorElement} searchSource={searchSource} + savedQueryId={savedQueryId} /> diff --git a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx index 387b5e751ff44..847140fd8e272 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx @@ -42,7 +42,6 @@ export interface FilterItemProps { uiSettings: IUiSettingsClient; hiddenPanelOptions?: FilterPanelOption[]; timeRangeForSuggestionsOverride?: boolean; - readonly?: boolean; } type FilterPopoverProps = HTMLAttributes & EuiPopoverProps; @@ -364,7 +363,6 @@ export function FilterItem(props: FilterItemProps) { iconOnClick: handleIconClick, onClick: handleBadgeClick, 'data-test-subj': getDataTestSubj(valueLabelConfig), - readonly: props.readonly, }; const popoverProps: FilterPopoverProps = { @@ -379,18 +377,6 @@ export function FilterItem(props: FilterItemProps) { panelPaddingSize: 'none', }; - if (props.readonly) { - return ( - - - - ); - } - return ( {renderedComponent === 'menu' ? ( diff --git a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx index d399bb0025a10..0e10766139820 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx @@ -19,7 +19,6 @@ interface Props { fieldLabel?: string; filterLabelStatus: FilterLabelStatus; errorMessage?: string; - readonly?: boolean; hideAlias?: boolean; [propName: string]: any; } @@ -32,7 +31,6 @@ export const FilterView: FC = ({ fieldLabel, errorMessage, filterLabelStatus, - readonly, hideAlias, ...rest }: Props) => { @@ -56,45 +54,29 @@ export const FilterView: FC = ({ })} ${title}`; } - const badgeProps: EuiBadgeProps = readonly - ? { - title, - color: 'hollow', - onClick, - onClickAriaLabel: i18n.translate( - 'unifiedSearch.filter.filterBar.filterItemReadOnlyBadgeAriaLabel', - { - defaultMessage: 'Filter entry', - } - ), - iconOnClick, + const badgeProps: EuiBadgeProps = { + title, + color: 'hollow', + iconType: 'cross', + iconSide: 'right', + closeButtonProps: { + // Removing tab focus on close button because the same option can be obtained through the context menu + // Also, we may want to add a `DEL` keyboard press functionality + tabIndex: -1, + }, + iconOnClick, + iconOnClickAriaLabel: i18n.translate( + 'unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel', + { + defaultMessage: 'Delete {filter}', + values: { filter: innerText }, } - : { - title, - color: 'hollow', - iconType: 'cross', - iconSide: 'right', - closeButtonProps: { - // Removing tab focus on close button because the same option can be obtained through the context menu - // Also, we may want to add a `DEL` keyboard press functionality - tabIndex: -1, - }, - iconOnClick, - iconOnClickAriaLabel: i18n.translate( - 'unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel', - { - defaultMessage: 'Delete {filter}', - values: { filter: innerText }, - } - ), - onClick, - onClickAriaLabel: i18n.translate( - 'unifiedSearch.filter.filterBar.filterItemBadgeAriaLabel', - { - defaultMessage: 'Filter actions', - } - ), - }; + ), + onClick, + onClickAriaLabel: i18n.translate('unifiedSearch.filter.filterBar.filterItemBadgeAriaLabel', { + defaultMessage: 'Filter actions', + }), + }; return ( diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx index 0ad4756e9177b..d62a7f79c82de 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx @@ -43,6 +43,7 @@ import { shallowEqual } from '../utils/shallow_equal'; import { AddFilterPopover } from './add_filter_popover'; import { DataViewPicker, DataViewPickerProps } from '../dataview_picker'; import { FilterButtonGroup } from '../filter_bar/filter_button_group/filter_button_group'; +import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import './query_bar.scss'; const SuperDatePicker = React.memo( @@ -88,6 +89,7 @@ export interface QueryBarTopRowProps { filterBar?: React.ReactNode; showDatePickerAsBadge?: boolean; showSubmitButton?: boolean; + suggestionsSize?: SuggestionsListSize; isScreenshotMode?: boolean; } @@ -483,6 +485,7 @@ export const QueryBarTopRow = React.memo( timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride} disableLanguageSwitcher={true} prepend={renderFilterMenuOnly() && renderFilterButtonGroup()} + size={props.suggestionsSize} /> )} diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index a6ca444612402..9d96ba936f708 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -29,6 +29,7 @@ import { QueryBarMenu, QueryBarMenuProps } from '../query_string_input/query_bar import type { DataViewPickerProps } from '../dataview_picker'; import QueryBarTopRow from '../query_string_input/query_bar_top_row'; import { FilterBar, FilterItems } from '../filter_bar'; +import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import { searchBarStyles } from './search_bar.styles'; export interface SearchBarInjectedDeps { @@ -88,6 +89,8 @@ export interface SearchBarOwnProps { fillSubmitButton?: boolean; dataViewPickerComponentProps?: DataViewPickerProps; showSubmitButton?: boolean; + // defines size of suggestions query popover + suggestionsSize?: SuggestionsListSize; isScreenshotMode?: boolean; } @@ -485,6 +488,7 @@ class SearchBarUI extends Component { dataViewPickerComponentProps={this.props.dataViewPickerComponentProps} showDatePickerAsBadge={this.shouldShowDatePickerAsBadge()} filterBar={filterBar} + suggestionsSize={this.props.suggestionsSize} isScreenshotMode={this.props.isScreenshotMode} />
diff --git a/x-pack/plugins/stack_alerts/kibana.json b/x-pack/plugins/stack_alerts/kibana.json index abafba8010fbc..ff436ef53fae7 100644 --- a/x-pack/plugins/stack_alerts/kibana.json +++ b/x-pack/plugins/stack_alerts/kibana.json @@ -14,7 +14,8 @@ "triggersActionsUi", "kibanaReact", "savedObjects", - "data" + "data", + "kibanaUtils" ], "configPath": ["xpack", "stack_alerts"], "requiredBundles": ["esUiShared"], diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx new file mode 100644 index 0000000000000..94e6a6b0c0cd4 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx @@ -0,0 +1,75 @@ +/* + * 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 { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; +import { DataViewSelectPopover } from './data_view_select_popover'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { act } from 'react-dom/test-utils'; + +const props = { + onSelectDataView: () => {}, + initialDataViewTitle: 'kibana_sample_data_logs', + initialDataViewId: 'mock-data-logs-id', +}; + +const dataViewOptions = [ + { + id: 'mock-data-logs-id', + namespaces: ['default'], + title: 'kibana_sample_data_logs', + }, + { + id: 'mock-flyghts-id', + namespaces: ['default'], + title: 'kibana_sample_data_flights', + }, + { + id: 'mock-ecommerce-id', + namespaces: ['default'], + title: 'kibana_sample_data_ecommerce', + typeMeta: {}, + }, + { + id: 'mock-test-id', + namespaces: ['default'], + title: 'test', + typeMeta: {}, + }, +]; + +const mount = () => { + const dataViewsMock = dataViewPluginMocks.createStartContract(); + dataViewsMock.getIdsWithTitle.mockImplementation(() => Promise.resolve(dataViewOptions)); + + return { + wrapper: mountWithIntl( + + + + ), + dataViewsMock, + }; +}; + +describe('DataViewSelectPopover', () => { + test('renders properly', async () => { + const { wrapper, dataViewsMock } = mount(); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(dataViewsMock.getIdsWithTitle).toHaveBeenCalled(); + expect(wrapper.find('[data-test-subj="selectDataViewExpression"]').exists()).toBeTruthy(); + + const getIdsWithTitleResult = await dataViewsMock.getIdsWithTitle.mock.results[0].value; + expect(getIdsWithTitleResult).toBe(dataViewOptions); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx new file mode 100644 index 0000000000000..a62b640e0d8eb --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButtonIcon, + EuiExpression, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import { DataViewsList } from '@kbn/unified-search-plugin/public'; +import { DataViewListItem } from '@kbn/data-views-plugin/public'; +import { useTriggersAndActionsUiDeps } from '../es_query/util'; + +interface DataViewSelectPopoverProps { + onSelectDataView: (newDataViewId: string) => void; + initialDataViewTitle: string; + initialDataViewId?: string; +} + +export const DataViewSelectPopover: React.FunctionComponent = ({ + onSelectDataView, + initialDataViewTitle, + initialDataViewId, +}) => { + const { data } = useTriggersAndActionsUiDeps(); + const [dataViewItems, setDataViewsItems] = useState(); + const [dataViewPopoverOpen, setDataViewPopoverOpen] = useState(false); + + const [selectedDataViewId, setSelectedDataViewId] = useState(initialDataViewId); + const [selectedTitle, setSelectedTitle] = useState(initialDataViewTitle); + + useEffect(() => { + const initDataViews = async () => { + const fetchedDataViewItems = await data.dataViews.getIdsWithTitle(); + setDataViewsItems(fetchedDataViewItems); + }; + initDataViews(); + }, [data.dataViews]); + + const closeDataViewPopover = useCallback(() => setDataViewPopoverOpen(false), []); + + if (!dataViewItems) { + return null; + } + + return ( + { + setDataViewPopoverOpen(true); + }} + isInvalid={!selectedTitle} + /> + } + isOpen={dataViewPopoverOpen} + closePopover={closeDataViewPopover} + ownFocus + anchorPosition="downLeft" + display="block" + > +
+ + + + {i18n.translate('xpack.stackAlerts.components.ui.alertParams.dataViewPopoverTitle', { + defaultMessage: 'Data view', + })} + + + + + + + + { + setSelectedDataViewId(newId); + const newTitle = dataViewItems?.find(({ id }) => id === newId)?.title; + if (newTitle) { + setSelectedTitle(newTitle); + } + + onSelectDataView(newId); + closeDataViewPopover(); + }} + currentDataViewId={selectedDataViewId} + /> + +
+
+ ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts index bceb39ba08cf9..da85c878f3281 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts @@ -6,6 +6,7 @@ */ import { COMPARATORS } from '@kbn/triggers-actions-ui-plugin/public'; +import { ErrorKey } from './types'; export const DEFAULT_VALUES = { THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN, @@ -19,3 +20,17 @@ export const DEFAULT_VALUES = { TIME_WINDOW_UNIT: 'm', THRESHOLD: [1000], }; + +export const EXPRESSION_ERRORS = { + index: new Array(), + size: new Array(), + timeField: new Array(), + threshold0: new Array(), + threshold1: new Array(), + esQuery: new Array(), + thresholdComparator: new Array(), + timeWindowSize: new Array(), + searchConfiguration: new Array(), +}; + +export const EXPRESSION_ERROR_KEYS = Object.keys(EXPRESSION_ERRORS) as ErrorKey[]; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx index 10b774648d735..afb45f90c6e52 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx @@ -83,6 +83,7 @@ export const EsQueryExpression = ({ thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, size: size ?? DEFAULT_VALUES.SIZE, esQuery: esQuery ?? DEFAULT_VALUES.QUERY, + searchType: 'esQuery', }); const setParam = useCallback( diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx index df44a8923183c..3b5e978b999c8 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx @@ -5,29 +5,33 @@ * 2.0. */ -import React from 'react'; +import React, { memo, PropsWithChildren } from 'react'; import { i18n } from '@kbn/i18n'; +import deepEqual from 'fast-deep-equal'; import 'brace/theme/github'; import { EuiSpacer, EuiCallOut } from '@elastic/eui'; import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; -import { EsQueryAlertParams } from '../types'; -import { SearchSourceExpression } from './search_source_expression'; +import { ErrorKey, EsQueryAlertParams } from '../types'; +import { SearchSourceExpression, SearchSourceExpressionProps } from './search_source_expression'; import { EsQueryExpression } from './es_query_expression'; import { isSearchSourceAlert } from '../util'; +import { EXPRESSION_ERROR_KEYS } from '../constants'; -const expressionFieldsWithValidation = [ - 'index', - 'size', - 'timeField', - 'threshold0', - 'threshold1', - 'timeWindowSize', - 'searchType', - 'esQuery', - 'searchConfiguration', -]; +function areSearchSourceExpressionPropsEqual( + prevProps: Readonly>, + nextProps: Readonly> +) { + const areErrorsEqual = deepEqual(prevProps.errors, nextProps.errors); + const areRuleParamsEqual = deepEqual(prevProps.ruleParams, nextProps.ruleParams); + return areErrorsEqual && areRuleParamsEqual; +} + +const SearchSourceExpressionMemoized = memo( + SearchSourceExpression, + areSearchSourceExpressionPropsEqual +); export const EsQueryAlertTypeExpression: React.FunctionComponent< RuleTypeParamsExpressionProps @@ -35,11 +39,11 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent< const { ruleParams, errors } = props; const isSearchSource = isSearchSourceAlert(ruleParams); - const hasExpressionErrors = !!Object.keys(errors).find((errorKey) => { + const hasExpressionErrors = Object.keys(errors).some((errorKey) => { return ( - expressionFieldsWithValidation.includes(errorKey) && + EXPRESSION_ERROR_KEYS.includes(errorKey as ErrorKey) && errors[errorKey].length >= 1 && - ruleParams[errorKey as keyof EsQueryAlertParams] !== undefined + ruleParams[errorKey] !== undefined ); }); @@ -54,14 +58,13 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent< <> {hasExpressionErrors && ( <> - )} {isSearchSource ? ( - + ) : ( )} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx deleted file mode 100644 index 6747c60bb840c..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { injectI18n } from '@kbn/i18n-react'; -import { css } from '@emotion/react'; - -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { getDisplayValueFromFilter } from '@kbn/data-plugin/public'; -import { Filter } from '@kbn/data-plugin/common'; -import { DataView } from '@kbn/data-views-plugin/public'; -import { FilterItem } from '@kbn/unified-search-plugin/public'; - -const FilterItemComponent = injectI18n(FilterItem); - -interface ReadOnlyFilterItemsProps { - filters: Filter[]; - indexPatterns: DataView[]; -} - -const noOp = () => {}; - -export const ReadOnlyFilterItems = ({ filters, indexPatterns }: ReadOnlyFilterItemsProps) => { - const { uiSettings } = useKibana().services; - - const filterList = filters.map((filter, index) => { - const filterValue = getDisplayValueFromFilter(filter, indexPatterns); - return ( - - - - ); - }); - - return ( - - {filterList} - - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx index 7041bba0fe2ff..d12833a3f258f 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx @@ -10,18 +10,12 @@ import React from 'react'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; -import { DataPublicPluginStart, ISearchStart } from '@kbn/data-plugin/public'; import { EsQueryAlertParams, SearchType } from '../types'; import { SearchSourceExpression } from './search_source_expression'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { act } from 'react-dom/test-utils'; -import { EuiCallOut, EuiLoadingSpinner } from '@elastic/eui'; -import { ReactWrapper } from 'enzyme'; - -const dataMock = dataPluginMock.createStartContract() as DataPublicPluginStart & { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - search: ISearchStart & { searchSource: { create: jest.MockedFunction } }; -}; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; const dataViewPluginMock = dataViewPluginMocks.createStartContract(); const chartsStartMock = chartPluginMock.createStartContract(); @@ -40,6 +34,18 @@ const defaultSearchSourceExpressionParams: EsQueryAlertParams { if (name === 'filter') { return []; @@ -48,7 +54,33 @@ const searchSourceMock = { }, }; -const setup = async (alertParams: EsQueryAlertParams) => { +const savedQueryMock = { + id: 'test-id', + attributes: { + title: 'test-filter-set', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, +}; + +jest.mock('./search_source_expression_form', () => ({ + SearchSourceExpressionForm: () =>
search source expression form mock
, +})); + +const dataMock = dataPluginMock.createStartContract(); +(dataMock.search.searchSource.create as jest.Mock).mockImplementation(() => + Promise.resolve(searchSourceMock) +); +(dataMock.dataViews.getIdsWithTitle as jest.Mock).mockImplementation(() => Promise.resolve([])); +(dataMock.query.savedQueries.getSavedQuery as jest.Mock).mockImplementation(() => + Promise.resolve(savedQueryMock) +); + +const setup = (alertParams: EsQueryAlertParams) => { const errors = { size: [], timeField: [], @@ -57,67 +89,58 @@ const setup = async (alertParams: EsQueryAlertParams) = }; const wrapper = mountWithIntl( - {}} - setRuleProperty={() => {}} - errors={errors} - unifiedSearch={unifiedSearchMock} - data={dataMock} - dataViews={dataViewPluginMock} - defaultActionGroupId="" - actionGroups={[]} - charts={chartsStartMock} - /> + + {}} + setRuleProperty={() => {}} + errors={errors} + unifiedSearch={unifiedSearchMock} + data={dataMock} + dataViews={dataViewPluginMock} + defaultActionGroupId="" + actionGroups={[]} + charts={chartsStartMock} + /> + ); return wrapper; }; -const rerender = async (wrapper: ReactWrapper) => { - const update = async () => +describe('SearchSourceAlertTypeExpression', () => { + test('should render correctly', async () => { + let wrapper = setup(defaultSearchSourceExpressionParams).children(); + + expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy(); + await act(async () => { await nextTick(); - wrapper.update(); }); - await update(); -}; + wrapper = await wrapper.update(); -describe('SearchSourceAlertTypeExpression', () => { - test('should render loading prompt', async () => { - dataMock.search.searchSource.create.mockImplementation(() => - Promise.resolve(() => searchSourceMock) - ); - - const wrapper = await setup(defaultSearchSourceExpressionParams); - - expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy(); + expect(wrapper.text().includes('search source expression form mock')).toBeTruthy(); }); test('should render error prompt', async () => { - dataMock.search.searchSource.create.mockImplementation(() => - Promise.reject(() => 'test error') + (dataMock.search.searchSource.create as jest.Mock).mockImplementationOnce(() => + Promise.reject(new Error('Cant find searchSource')) ); + let wrapper = setup(defaultSearchSourceExpressionParams).children(); - const wrapper = await setup(defaultSearchSourceExpressionParams); - await rerender(wrapper); - - expect(wrapper.find(EuiCallOut).exists()).toBeTruthy(); - }); - - test('should render SearchSourceAlertTypeExpression with expected components', async () => { - dataMock.search.searchSource.create.mockImplementation(() => - Promise.resolve(() => searchSourceMock) - ); + expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy(); - const wrapper = await setup(defaultSearchSourceExpressionParams); - await rerender(wrapper); + await act(async () => { + await nextTick(); + }); + wrapper = await wrapper.update(); - expect(wrapper.find('[data-test-subj="sizeValueExpression"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="thresholdExpression"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="forLastExpression"]').exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeTruthy(); }); }); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx index 1d54609223aaf..26b2d074bfd8b 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx @@ -5,36 +5,27 @@ * 2.0. */ -import React, { Fragment, useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import './search_source_expression.scss'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiSpacer, - EuiTitle, - EuiExpression, - EuiLoadingSpinner, - EuiEmptyPrompt, - EuiCallOut, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { Filter, ISearchSource } from '@kbn/data-plugin/common'; -import { - ForLastExpression, - RuleTypeParamsExpressionProps, - ThresholdExpression, - ValueExpression, -} from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiSpacer, EuiLoadingSpinner, EuiEmptyPrompt, EuiCallOut } from '@elastic/eui'; +import { ISearchSource } from '@kbn/data-plugin/common'; +import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { SavedQuery } from '@kbn/data-plugin/public'; import { EsQueryAlertParams, SearchType } from '../types'; +import { useTriggersAndActionsUiDeps } from '../util'; +import { SearchSourceExpressionForm } from './search_source_expression_form'; import { DEFAULT_VALUES } from '../constants'; -import { ReadOnlyFilterItems } from './read_only_filter_items'; + +export type SearchSourceExpressionProps = RuleTypeParamsExpressionProps< + EsQueryAlertParams +>; export const SearchSourceExpression = ({ ruleParams, + errors, setRuleParams, setRuleProperty, - data, - errors, -}: RuleTypeParamsExpressionProps>) => { +}: SearchSourceExpressionProps) => { const { searchConfiguration, thresholdComparator, @@ -43,48 +34,43 @@ export const SearchSourceExpression = ({ timeWindowUnit, size, } = ruleParams; - const [usedSearchSource, setUsedSearchSource] = useState(); - const [paramsError, setParamsError] = useState(); + const { data } = useTriggersAndActionsUiDeps(); - const [currentAlertParams, setCurrentAlertParams] = useState< - EsQueryAlertParams - >({ - searchConfiguration, - searchType: SearchType.searchSource, - timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE, - timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT, - threshold: threshold ?? DEFAULT_VALUES.THRESHOLD, - thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, - size: size ?? DEFAULT_VALUES.SIZE, - }); + const [searchSource, setSearchSource] = useState(); + const [savedQuery, setSavedQuery] = useState(); + const [paramsError, setParamsError] = useState(); const setParam = useCallback( - (paramField: string, paramValue: unknown) => { - setCurrentAlertParams((currentParams) => ({ - ...currentParams, - [paramField]: paramValue, - })); - setRuleParams(paramField, paramValue); - }, + (paramField: string, paramValue: unknown) => setRuleParams(paramField, paramValue), [setRuleParams] ); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => setRuleProperty('params', currentAlertParams), []); + useEffect(() => { + setRuleProperty('params', { + searchConfiguration, + searchType: SearchType.searchSource, + timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT, + threshold: threshold ?? DEFAULT_VALUES.THRESHOLD, + thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, + size: size ?? DEFAULT_VALUES.SIZE, + }); + + const initSearchSource = () => + data.search.searchSource + .create(searchConfiguration) + .then((fetchedSearchSource) => setSearchSource(fetchedSearchSource)) + .catch(setParamsError); + + initSearchSource(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data.search.searchSource, data.dataViews]); useEffect(() => { - async function initSearchSource() { - try { - const loadedSearchSource = await data.search.searchSource.create(searchConfiguration); - setUsedSearchSource(loadedSearchSource); - } catch (error) { - setParamsError(error); - } - } - if (searchConfiguration) { - initSearchSource(); + if (ruleParams.savedQueryId) { + data.query.savedQueries.getSavedQuery(ruleParams.savedQueryId).then(setSavedQuery); } - }, [data.search.searchSource, searchConfiguration]); + }, [data.query.savedQueries, ruleParams.savedQueryId]); if (paramsError) { return ( @@ -97,124 +83,17 @@ export const SearchSourceExpression = ({ ); } - if (!usedSearchSource) { + if (!searchSource) { return } />; } - const dataView = usedSearchSource.getField('index')!; - const query = usedSearchSource.getField('query')!; - const filters = (usedSearchSource.getField('filter') as Filter[]).filter( - ({ meta }) => !meta.disabled - ); - const dataViews = [dataView]; return ( - - -
- -
-
- - - } - iconType="iInCircle" - /> - - - {query.query !== '' && ( - - )} - {filters.length > 0 && ( - } - display="columns" - /> - )} - - - -
- -
-
- - - setParam('threshold', selectedThresholds) - } - onChangeSelectedThresholdComparator={(selectedThresholdComparator) => - setParam('thresholdComparator', selectedThresholdComparator) - } - /> - - setParam('timeWindowSize', selectedWindowSize) - } - onChangeWindowUnit={(selectedWindowUnit: string) => - setParam('timeWindowUnit', selectedWindowUnit) - } - /> - - -
- -
-
- - { - setParam('size', updatedValue); - }} - /> - -
+ ); }; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx new file mode 100644 index 0000000000000..afd6a156187ee --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx @@ -0,0 +1,269 @@ +/* + * 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, { Fragment, useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import deepEqual from 'fast-deep-equal'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Filter, DataView, Query, ISearchSource } from '@kbn/data-plugin/common'; +import { + ForLastExpression, + IErrorObject, + ThresholdExpression, + ValueExpression, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { SearchBar } from '@kbn/unified-search-plugin/public'; +import { mapAndFlattenFilters, SavedQuery, TimeHistory } from '@kbn/data-plugin/public'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { EsQueryAlertParams, SearchType } from '../types'; +import { DEFAULT_VALUES } from '../constants'; +import { DataViewSelectPopover } from '../../components/data_view_select_popover'; +import { useTriggersAndActionsUiDeps } from '../util'; + +interface LocalState { + index: DataView; + filter: Filter[]; + query: Query; + threshold: number[]; + timeWindowSize: number; + size: number; +} + +interface LocalStateAction { + type: SearchSourceParamsAction['type'] | ('threshold' | 'timeWindowSize' | 'size'); + payload: SearchSourceParamsAction['payload'] | (number[] | number); +} + +type LocalStateReducer = (prevState: LocalState, action: LocalStateAction) => LocalState; + +interface SearchSourceParamsAction { + type: 'index' | 'filter' | 'query'; + payload: DataView | Filter[] | Query; +} + +interface SearchSourceExpressionFormProps { + searchSource: ISearchSource; + ruleParams: EsQueryAlertParams; + errors: IErrorObject; + initialSavedQuery?: SavedQuery; + setParam: (paramField: string, paramValue: unknown) => void; +} + +const isSearchSourceParam = (action: LocalStateAction): action is SearchSourceParamsAction => { + return action.type === 'filter' || action.type === 'index' || action.type === 'query'; +}; + +export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProps) => { + const { data } = useTriggersAndActionsUiDeps(); + const { searchSource, ruleParams, errors, initialSavedQuery, setParam } = props; + const { thresholdComparator, timeWindowUnit } = ruleParams; + const [savedQuery, setSavedQuery] = useState(); + + const timeHistory = useMemo(() => new TimeHistory(new Storage(localStorage)), []); + + useEffect(() => setSavedQuery(initialSavedQuery), [initialSavedQuery]); + + const [{ index: dataView, query, filter: filters, threshold, timeWindowSize, size }, dispatch] = + useReducer( + (currentState, action) => { + if (isSearchSourceParam(action)) { + searchSource.setParent(undefined).setField(action.type, action.payload); + setParam('searchConfiguration', searchSource.getSerializedFields()); + } else { + setParam(action.type, action.payload); + } + return { ...currentState, [action.type]: action.payload }; + }, + { + index: searchSource.getField('index')!, + query: searchSource.getField('query')!, + filter: mapAndFlattenFilters(searchSource.getField('filter') as Filter[]), + threshold: ruleParams.threshold, + timeWindowSize: ruleParams.timeWindowSize, + size: ruleParams.size, + } + ); + const dataViews = useMemo(() => [dataView], [dataView]); + + const onSelectDataView = useCallback( + (newDataViewId) => + data.dataViews + .get(newDataViewId) + .then((newDataView) => dispatch({ type: 'index', payload: newDataView })), + [data.dataViews] + ); + + const onUpdateFilters = useCallback((newFilters) => { + dispatch({ type: 'filter', payload: mapAndFlattenFilters(newFilters) }); + }, []); + + const onChangeQuery = useCallback( + ({ query: newQuery }: { query?: Query }) => { + if (!deepEqual(newQuery, query)) { + dispatch({ type: 'query', payload: newQuery || { ...query, query: '' } }); + } + }, + [query] + ); + + // needs to change language mode only + const onQueryBarSubmit = ({ query: newQuery }: { query?: Query }) => { + if (newQuery?.language !== query.language) { + dispatch({ type: 'query', payload: { ...query, language: newQuery?.language } as Query }); + } + }; + + // Saved query + const onSavedQuery = useCallback((newSavedQuery: SavedQuery) => { + setSavedQuery(newSavedQuery); + const newFilters = newSavedQuery.attributes.filters; + if (newFilters) { + dispatch({ type: 'filter', payload: newFilters }); + } + }, []); + + const onClearSavedQuery = () => { + setSavedQuery(undefined); + dispatch({ type: 'query', payload: { ...query, query: '' } }); + }; + + // window size + const onChangeWindowUnit = useCallback( + (selectedWindowUnit: string) => setParam('timeWindowUnit', selectedWindowUnit), + [setParam] + ); + + const onChangeWindowSize = useCallback( + (selectedWindowSize?: number) => + selectedWindowSize && dispatch({ type: 'timeWindowSize', payload: selectedWindowSize }), + [] + ); + + // threshold + const onChangeSelectedThresholdComparator = useCallback( + (selectedThresholdComparator?: string) => + setParam('thresholdComparator', selectedThresholdComparator), + [setParam] + ); + + const onChangeSelectedThreshold = useCallback( + (selectedThresholds?: number[]) => + selectedThresholds && dispatch({ type: 'threshold', payload: selectedThresholds }), + [] + ); + + const onChangeSizeValue = useCallback( + (updatedValue: number) => dispatch({ type: 'size', payload: updatedValue }), + [] + ); + + return ( + + +
+ +
+
+ + + + + + + + + + + +
+ +
+
+ + + + + +
+ +
+
+ + + +
+ ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts index bccf6ed4ced43..703570ad5faae 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts @@ -7,6 +7,9 @@ import { RuleTypeParams } from '@kbn/alerting-plugin/common'; import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { EXPRESSION_ERRORS } from './constants'; export interface Comparator { text: string; @@ -19,7 +22,7 @@ export enum SearchType { searchSource = 'searchSource', } -export interface CommonAlertParams extends RuleTypeParams { +export interface CommonAlertParams extends RuleTypeParams { size: number; thresholdComparator?: string; threshold: number[]; @@ -28,8 +31,8 @@ export interface CommonAlertParams extends RuleTypeParams } export type EsQueryAlertParams = T extends SearchType.searchSource - ? CommonAlertParams & OnlySearchSourceAlertParams - : CommonAlertParams & OnlyEsQueryAlertParams; + ? CommonAlertParams & OnlySearchSourceAlertParams + : CommonAlertParams & OnlyEsQueryAlertParams; export interface OnlyEsQueryAlertParams { esQuery: string; @@ -39,4 +42,15 @@ export interface OnlyEsQueryAlertParams { export interface OnlySearchSourceAlertParams { searchType: 'searchSource'; searchConfiguration: SerializedSearchSourceFields; + savedQueryId?: string; +} + +export type DataViewOption = EuiComboBoxOptionOption; + +export type ExpressionErrors = typeof EXPRESSION_ERRORS; + +export type ErrorKey = keyof ExpressionErrors & unknown; + +export interface TriggersAndActionsUiDeps { + data: DataPublicPluginStart; } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts index 5b70da7cb3e80..1f57a133fa65a 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts @@ -5,10 +5,13 @@ * 2.0. */ -import { EsQueryAlertParams, SearchType } from './types'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { EsQueryAlertParams, SearchType, TriggersAndActionsUiDeps } from './types'; export const isSearchSourceAlert = ( ruleParams: EsQueryAlertParams ): ruleParams is EsQueryAlertParams => { return ruleParams.searchType === 'searchSource'; }; + +export const useTriggersAndActionsUiDeps = () => useKibana().services; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts index 914dd6a4f5f9f..8a1135e75492f 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts @@ -5,25 +5,17 @@ * 2.0. */ +import { defaultsDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ValidationResult, builtInComparators } from '@kbn/triggers-actions-ui-plugin/public'; -import { EsQueryAlertParams } from './types'; +import { EsQueryAlertParams, ExpressionErrors } from './types'; import { isSearchSourceAlert } from './util'; +import { EXPRESSION_ERRORS } from './constants'; export const validateExpression = (alertParams: EsQueryAlertParams): ValidationResult => { const { size, threshold, timeWindowSize, thresholdComparator } = alertParams; const validationResult = { errors: {} }; - const errors = { - index: new Array(), - timeField: new Array(), - esQuery: new Array(), - size: new Array(), - threshold0: new Array(), - threshold1: new Array(), - thresholdComparator: new Array(), - timeWindowSize: new Array(), - searchConfiguration: new Array(), - }; + const errors: ExpressionErrors = defaultsDeep({}, EXPRESSION_ERRORS); validationResult.errors = errors; if (!threshold || threshold.length === 0 || threshold[0] === undefined) { errors.threshold0.push( diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap index 65dff2bd3a6c6..fe53610caa316 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap @@ -30,6 +30,7 @@ exports[`should render BoundaryIndexExpression 1`] = ` "ensureDefaultIndexPattern": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "getIdsWithTitle": [MockFunction], "make": [Function], } } @@ -106,6 +107,7 @@ exports[`should render EntityIndexExpression 1`] = ` "ensureDefaultIndexPattern": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "getIdsWithTitle": [MockFunction], "make": [Function], } } @@ -188,6 +190,7 @@ exports[`should render EntityIndexExpression w/ invalid flag if invalid 1`] = ` "ensureDefaultIndexPattern": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "getIdsWithTitle": [MockFunction], "make": [Function], } } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts index 468729fb2120d..884bf606d2f90 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts @@ -20,6 +20,7 @@ describe('ActionContext', () => { timeWindowUnit: 'm', thresholdComparator: '>', threshold: [4], + searchType: 'esQuery', }) as OnlyEsQueryAlertParams; const base: EsQueryAlertActionContext = { date: '2020-01-01T00:00:00.000Z', @@ -50,6 +51,7 @@ describe('ActionContext', () => { timeWindowUnit: 'm', thresholdComparator: 'between', threshold: [4, 5], + searchType: 'esQuery', }) as OnlyEsQueryAlertParams; const base: EsQueryAlertActionContext = { date: '2020-01-01T00:00:00.000Z', diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts index 3fce895a2bfd1..3304ca5e902f7 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts @@ -110,6 +110,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.LT, threshold: [0], + searchType: 'esQuery', }; expect(alertType.validate?.params?.validate(params)).toBeTruthy(); @@ -128,6 +129,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.BETWEEN, threshold: [0], + searchType: 'esQuery', }; expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot( @@ -145,6 +147,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.BETWEEN, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -174,6 +177,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -219,6 +223,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -267,6 +272,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -309,6 +315,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -380,6 +387,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -425,6 +433,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts index 5b41d7c55fe0a..dfab69f445629 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts @@ -7,10 +7,12 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, Logger } from '@kbn/core/server'; +import { extractReferences, injectReferences } from '@kbn/data-plugin/common'; import { RuleType } from '../../types'; import { ActionContext } from './action_context'; import { EsQueryAlertParams, + EsQueryAlertParamsExtractedParams, EsQueryAlertParamsSchema, EsQueryAlertState, } from './alert_type_params'; @@ -18,13 +20,14 @@ import { STACK_ALERTS_FEATURE_ID } from '../../../common'; import { ExecutorOptions } from './types'; import { ActionGroupId, ES_QUERY_ID } from './constants'; import { executor } from './executor'; +import { isEsQueryAlert } from './util'; export function getAlertType( logger: Logger, core: CoreSetup ): RuleType< EsQueryAlertParams, - never, // Only use if defining useSavedObjectReferences hook + EsQueryAlertParamsExtractedParams, EsQueryAlertState, {}, ActionContext, @@ -159,6 +162,25 @@ export function getAlertType( { name: 'index', description: actionVariableContextIndexLabel }, ], }, + useSavedObjectReferences: { + extractReferences: (params) => { + if (isEsQueryAlert(params.searchType)) { + return { params: params as EsQueryAlertParamsExtractedParams, references: [] }; + } + const [searchConfiguration, references] = extractReferences(params.searchConfiguration); + const newParams = { ...params, searchConfiguration } as EsQueryAlertParamsExtractedParams; + return { params: newParams, references }; + }, + injectReferences: (params, references) => { + if (isEsQueryAlert(params.searchType)) { + return params; + } + return { + ...params, + searchConfiguration: injectReferences(params.searchConfiguration, references), + }; + }, + }, minimumLicenseRequired: 'basic', isExportable: true, executor: async (options: ExecutorOptions) => { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts index d6ba0468b7cbf..a1155fedb7a02 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts @@ -23,6 +23,7 @@ const DefaultParams: Writable> = { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; describe('alertType Params validate()', () => { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts index f205fbd0327ce..d32fce9debbc2 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; import { validateTimeWindowUnits } from '@kbn/triggers-actions-ui-plugin/server'; import { RuleTypeState } from '@kbn/alerting-plugin/server'; +import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { Comparator } from '../../../common/comparator_types'; import { ComparatorFnNames } from '../lib'; import { getComparatorSchemaType } from '../lib/comparator'; @@ -21,13 +22,21 @@ export interface EsQueryAlertState extends RuleTypeState { latestTimestamp: string | undefined; } +export type EsQueryAlertParamsExtractedParams = Omit & { + searchConfiguration: SerializedSearchSourceFields & { + indexRefName: string; + }; +}; + const EsQueryAlertParamsSchemaProperties = { size: schema.number({ min: 0, max: ES_QUERY_MAX_HITS_PER_EXECUTION }), timeWindowSize: schema.number({ min: 1 }), timeWindowUnit: schema.string({ validate: validateTimeWindowUnits }), threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }), thresholdComparator: getComparatorSchemaType(validateComparator), - searchType: schema.nullable(schema.literal('searchSource')), + searchType: schema.oneOf([schema.literal('searchSource'), schema.literal('esQuery')], { + defaultValue: 'esQuery', + }), // searchSource alert param only searchConfiguration: schema.conditional( schema.siblingRef('searchType'), @@ -38,21 +47,21 @@ const EsQueryAlertParamsSchemaProperties = { // esQuery alert params only esQuery: schema.conditional( schema.siblingRef('searchType'), - schema.literal('searchSource'), - schema.never(), - schema.string({ minLength: 1 }) + schema.literal('esQuery'), + schema.string({ minLength: 1 }), + schema.never() ), index: schema.conditional( schema.siblingRef('searchType'), - schema.literal('searchSource'), - schema.never(), - schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }) + schema.literal('esQuery'), + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + schema.never() ), timeField: schema.conditional( schema.siblingRef('searchType'), - schema.literal('searchSource'), - schema.never(), - schema.string({ minLength: 1 }) + schema.literal('esQuery'), + schema.string({ minLength: 1 }), + schema.never() ), }; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts index 670f76f5e19de..7b4cc7521654b 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts @@ -18,6 +18,7 @@ describe('es_query executor', () => { esQuery: '{ "query": "test-query" }', index: ['test-index'], timeField: '', + searchType: 'esQuery', }; describe('tryToParseAsDate', () => { it.each<[string | number]>([['2019-01-01T00:00:00.000Z'], [1546300800000]])( diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts index 44708a1df90fd..6e47c5f471d88 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts @@ -16,13 +16,14 @@ import { fetchEsQuery } from './lib/fetch_es_query'; import { EsQueryAlertParams } from './alert_type_params'; import { fetchSearchSourceQuery } from './lib/fetch_search_source_query'; import { Comparator } from '../../../common/comparator_types'; +import { isEsQueryAlert } from './util'; export async function executor( logger: Logger, core: CoreSetup, options: ExecutorOptions ) { - const esQueryAlert = isEsQueryAlert(options); + const esQueryAlert = isEsQueryAlert(options.params.searchType); const { alertId, name, services, params, state } = options; const { alertFactory, scopedClusterClient, searchSourceClient } = services; const currentTimestamp = new Date().toISOString(); @@ -162,10 +163,6 @@ export function tryToParseAsDate(sortValue?: string | number | null): undefined } } -export function isEsQueryAlert(options: ExecutorOptions) { - return options.params.searchType !== 'searchSource'; -} - export function getChecksum(params: EsQueryAlertParams) { return sha256.create().update(JSON.stringify(params)); } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts index 12b2ee02af171..8595870a84940 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts @@ -10,7 +10,9 @@ import { ActionContext } from './action_context'; import { EsQueryAlertParams, EsQueryAlertState } from './alert_type_params'; import { ActionGroupId } from './constants'; -export type OnlyEsQueryAlertParams = Omit; +export type OnlyEsQueryAlertParams = Omit & { + searchType: 'esQuery'; +}; export type OnlySearchSourceAlertParams = Omit< EsQueryAlertParams, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/util.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/util.ts new file mode 100644 index 0000000000000..b58a362cd27e9 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/util.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EsQueryAlertParams } from './alert_type_params'; + +export function isEsQueryAlert(searchType: EsQueryAlertParams['searchType']) { + return searchType !== 'searchSource'; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx index 1bca80a08c936..6da565b13d91e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx @@ -724,10 +724,10 @@ export const RuleForm = ({ name="interval" data-test-subj="intervalInput" onChange={(e) => { - const interval = - e.target.value !== '' ? parseInt(e.target.value, 10) : undefined; + const value = e.target.value; + const interval = value !== '' ? parseInt(value, 10) : undefined; setRuleInterval(interval); - setScheduleProperty('interval', `${e.target.value}${ruleIntervalUnit}`); + setScheduleProperty('interval', `${value}${ruleIntervalUnit}`); }} /> diff --git a/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts b/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts index bae045fc93838..2cb77ac262ca6 100644 --- a/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts +++ b/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts @@ -30,6 +30,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const supertest = getService('supertest'); const queryBar = getService('queryBar'); const security = getService('security'); + const filterBar = getService('filterBar'); const SOURCE_DATA_INDEX = 'search-source-alert'; const OUTPUT_DATA_INDEX = 'search-source-alert-output'; @@ -47,17 +48,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { mappings: { properties: { '@timestamp': { type: 'date' }, - message: { type: 'text' }, + message: { type: 'keyword' }, }, }, }, }); const generateNewDocs = async (docsNumber: number) => { - const mockMessages = new Array(docsNumber).map((current) => `msg-${current}`); + const mockMessages = Array.from({ length: docsNumber }, (_, i) => `msg-${i}`); const dateNow = new Date().toISOString(); - for (const message of mockMessages) { - await es.transport.request({ + for await (const message of mockMessages) { + es.transport.request({ path: `/${SOURCE_DATA_INDEX}/_doc`, method: 'POST', body: { @@ -212,7 +213,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await navigateToDiscover(link); }; - const openAlertRule = async () => { + const openAlertRuleInManagement = async () => { await PageObjects.common.navigateToApp('management'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -229,7 +230,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await security.testUser.setRoles(['discover_alert']); - log.debug('create source index'); + log.debug('create source indices'); await createSourceIndex(); log.debug('generate documents'); @@ -250,8 +251,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { - // delete only remaining output index - await es.transport.request({ + es.transport.request({ path: `/${OUTPUT_DATA_INDEX}`, method: 'DELETE', }); @@ -272,7 +272,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await defineSearchSourceAlert(RULE_NAME); await PageObjects.header.waitUntilLoadingHasFinished(); - await openAlertRule(); + await openAlertRuleInManagement(); await testSubjects.click('ruleDetails-viewInApp'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -298,10 +298,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should display warning about updated alert rule', async () => { - await openAlertRule(); + await openAlertRuleInManagement(); // change rule configuration await testSubjects.click('openEditRuleFlyoutButton'); + await queryBar.setQuery('message:msg-1'); + await filterBar.addFilter('message.keyword', 'is', 'msg-1'); + await testSubjects.click('thresholdPopover'); await testSubjects.setValue('alertThresholdInput', '1'); await testSubjects.click('saveEditedRuleButton'); @@ -311,7 +314,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await navigateToResults(); const { message, title } = await getLastToast(); - expect(await dataGrid.getDocCount()).to.be(5); + const queryString = await queryBar.getQueryString(); + const hasFilter = await filterBar.hasFilter('message.keyword', 'msg-1'); + + expect(queryString).to.be.equal('message:msg-1'); + expect(hasFilter).to.be.equal(true); + + expect(await dataGrid.getDocCount()).to.be(1); expect(title).to.be.equal('Alert rule has changed'); expect(message).to.be.equal( 'The displayed documents might not match the documents that triggered the alert because the rule configuration changed.' From 7e15097379841b2923a111629d53b6b560c44dd9 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Fri, 20 May 2022 07:32:27 -0700 Subject: [PATCH 113/150] [ML] Adds placeholder text for testing NLP models (#132486) --- .../test_models/models/ner/ner_inference.ts | 7 +++++-- .../models/text_classification/fill_mask_inference.ts | 5 +++-- .../models/text_classification/lang_ident_inference.ts | 9 ++++++++- .../text_classification/text_classification_inference.ts | 9 ++++++++- .../models/text_embedding/text_embedding_inference.ts | 9 ++++++++- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts index 13f07d8c88770..7d780559fb47d 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts @@ -6,7 +6,7 @@ */ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - +import { i18n } from '@kbn/i18n'; import { InferenceBase, InferResponse } from '../inference_base'; import { getGeneralInputComponent } from '../text_input'; import { getNerOutputComponent } from './ner_output'; @@ -52,7 +52,10 @@ export class NerInference extends InferenceBase { } public getInputComponent(): JSX.Element { - return getGeneralInputComponent(this); + const placeholder = i18n.translate('xpack.ml.trainedModels.testModelsFlyout.ner.inputText', { + defaultMessage: 'Enter a phrase to test', + }); + return getGeneralInputComponent(this, placeholder); } public getOutputComponent(): JSX.Element { diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts index bb4feaffffb38..b9c1c724ca348 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts @@ -55,9 +55,10 @@ export class FillMaskInference extends InferenceBase public getInputComponent(): JSX.Element { const placeholder = i18n.translate( - 'xpack.ml.trainedModels.testModelsFlyout.langIdent.inputText', + 'xpack.ml.trainedModels.testModelsFlyout.fillMask.inputText', { - defaultMessage: 'Mask token: [MASK]. e.g. Paris is the [MASK] of France.', + defaultMessage: + 'Enter a phrase to test. Use [MASK] as a placeholder for the missing words.', } ); diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts index a56d4a3598a66..155b696fa7665 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { InferenceBase, InferenceType } from '../inference_base'; import { processResponse } from './common'; import { getGeneralInputComponent } from '../text_input'; @@ -44,7 +45,13 @@ export class LangIdentInference extends InferenceBase } public getInputComponent(): JSX.Element { - return getGeneralInputComponent(this); + const placeholder = i18n.translate( + 'xpack.ml.trainedModels.testModelsFlyout.textEmbedding.inputText', + { + defaultMessage: 'Enter a phrase to test', + } + ); + return getGeneralInputComponent(this, placeholder); } public getOutputComponent(): JSX.Element { From 759f13f50f87365681c1baa98607e9b385567d60 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Fri, 20 May 2022 10:39:09 -0400 Subject: [PATCH 114/150] [Fleet] Remove reference to non removable package feature (#132458) --- .../context/fixtures/integration.nginx.ts | 1 - .../context/fixtures/integration.okta.ts | 1 - .../plugins/fleet/common/openapi/bundled.json | 3 - .../plugins/fleet/common/openapi/bundled.yaml | 2 - .../components/schemas/package_info.yaml | 2 - .../common/services/fixtures/aws_package.ts | 1 - .../plugins/fleet/common/types/models/epm.ts | 1 - .../create_package_policy_page/index.test.tsx | 1 - .../step_configure_package.test.tsx | 1 - .../edit_package_policy_page/index.test.tsx | 1 - .../epm/screens/detail/index.test.tsx | 1 - .../epm/screens/detail/settings/settings.tsx | 76 ++++++++----------- .../fleet/server/saved_objects/index.ts | 3 +- .../saved_objects/migrations/to_v8_3_0.ts | 19 +++++ .../fleet/server/services/epm/packages/get.ts | 1 - .../server/services/epm/packages/install.ts | 2 - .../server/services/epm/packages/remove.ts | 4 +- ...kage_policies_to_agent_permissions.test.ts | 1 - .../common/endpoint/generate_data.ts | 1 - .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../apis/epm/install_remove_assets.ts | 1 - .../apis/epm/update_assets.ts | 1 - .../test_packages/filetest/0.1.0/manifest.yml | 2 - .../0.1.0/manifest.yml | 2 - 26 files changed, 52 insertions(+), 79 deletions(-) create mode 100644 x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_3_0.ts diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts index d74d7656ad58e..8f47d564c44a2 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts @@ -664,6 +664,5 @@ export const item: GetInfoResponse['item'] = { github: 'elastic/integrations', }, latestVersion: '0.7.0', - removable: true, status: 'not_installed', }; diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts index 1f4b9e85043a6..8778938443661 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts @@ -263,6 +263,5 @@ export const item: GetInfoResponse['item'] = { github: 'elastic/security-external-integrations', }, latestVersion: '1.2.0', - removable: true, status: 'not_installed', }; diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index dca3fd3ccb678..ba18b78d5f768 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -3573,9 +3573,6 @@ }, "path": { "type": "string" - }, - "removable": { - "type": "boolean" } }, "required": [ diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index d1a114b35ab6c..e18fe6b8fc3f8 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -2228,8 +2228,6 @@ components: type: string path: type: string - removable: - type: boolean required: - name - title diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml index ec4f18af8a223..e61c349f3f490 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml @@ -102,8 +102,6 @@ properties: type: string path: type: string - removable: - type: boolean required: - name - title diff --git a/x-pack/plugins/fleet/common/services/fixtures/aws_package.ts b/x-pack/plugins/fleet/common/services/fixtures/aws_package.ts index 2b93cca3d4e4d..63397e484a7df 100644 --- a/x-pack/plugins/fleet/common/services/fixtures/aws_package.ts +++ b/x-pack/plugins/fleet/common/services/fixtures/aws_package.ts @@ -1921,7 +1921,6 @@ export const AWS_PACKAGE = { }, ], latestVersion: '0.5.3', - removable: true, status: 'not_installed', }; diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index c7951e86d7866..cb5d8f3bb009b 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -372,7 +372,6 @@ export interface EpmPackageAdditions { title: string; latestVersion: string; assets: AssetsGroupedByServiceByType; - removable?: boolean; notice?: string; keepPoliciesUpToDate?: boolean; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.test.tsx index 0f719f6a61585..4a13f117ec6ba 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.test.tsx @@ -164,7 +164,6 @@ describe('when on the package policy create page', () => { }, ], latestVersion: '1.3.0', - removable: true, keepPoliciesUpToDate: false, status: 'not_installed', }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_configure_package.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_configure_package.test.tsx index 543747307908e..ff4c39af799f2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_configure_package.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_configure_package.test.tsx @@ -96,7 +96,6 @@ describe('StepConfigurePackage', () => { }, ], latestVersion: '1.3.0', - removable: true, keepPoliciesUpToDate: false, status: 'not_installed', }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx index 3a5050b1b6d06..464f705811ebf 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx @@ -89,7 +89,6 @@ jest.mock('../../../hooks', () => { }, ], latestVersion: version, - removable: true, keepPoliciesUpToDate: false, status: 'not_installed', }, diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx index e4341af45cf41..9d46c636150d3 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx @@ -509,7 +509,6 @@ const mockApiCalls = ( ], owner: { github: 'elastic/integrations-services' }, latestVersion: '0.3.7', - removable: true, status: 'installed', }, } as GetInfoResponse; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx index 05ff443a7b0e6..d84fab93dc8c2 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx @@ -97,7 +97,7 @@ interface Props { } export const SettingsPage: React.FC = memo(({ packageInfo, theme$ }: Props) => { - const { name, title, removable, latestVersion, version, keepPoliciesUpToDate } = packageInfo; + const { name, title, latestVersion, version, keepPoliciesUpToDate } = packageInfo; const [dryRunData, setDryRunData] = useState(); const [isUpgradingPackagePolicies, setIsUpgradingPackagePolicies] = useState(false); const getPackageInstallStatus = useGetPackageInstallStatus(); @@ -342,41 +342,39 @@ export const SettingsPage: React.FC = memo(({ packageInfo, theme$ }: Prop
) : ( - removable && ( - <> -
- -

- -

-
- -

+ <> +

+ +

+

+
+ +

+ +

+
+ + +

+

-
- - -

- -

-
-
- - ) +
+
+ )} - {packageHasUsages && removable === true && ( + {packageHasUsages && (

= memo(({ packageInfo, theme$ }: Prop

)} - {removable === false && ( -

- - , - }} - /> - -

- )} )} {hideInstallOptions && isViewingOldPackage && !isUpdating && ( diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 2a8f14f795f7c..edcf2ed751f3e 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -38,6 +38,7 @@ import { migratePackagePolicyToV7150 } from './migrations/to_v7_15_0'; import { migrateInstallationToV7160, migratePackagePolicyToV7160 } from './migrations/to_v7_16_0'; import { migrateInstallationToV800, migrateOutputToV800 } from './migrations/to_v8_0_0'; import { migratePackagePolicyToV820 } from './migrations/to_v8_2_0'; +import { migrateInstallationToV830 } from './migrations/to_v8_3_0'; /* * Saved object types and mappings @@ -223,7 +224,6 @@ const getSavedObjectTypes = ( name: { type: 'keyword' }, version: { type: 'keyword' }, internal: { type: 'boolean' }, - removable: { type: 'boolean' }, keep_policies_up_to_date: { type: 'boolean', index: false }, es_index_patterns: { enabled: false, @@ -262,6 +262,7 @@ const getSavedObjectTypes = ( '7.14.1': migrateInstallationToV7140, '7.16.0': migrateInstallationToV7160, '8.0.0': migrateInstallationToV800, + '8.3.0': migrateInstallationToV830, }, }, [ASSETS_SAVED_OBJECT_TYPE]: { diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_3_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_3_0.ts new file mode 100644 index 0000000000000..843427f3cf862 --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_3_0.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectMigrationFn } from '@kbn/core/server'; + +import type { Installation } from '../../../common'; + +export const migrateInstallationToV830: SavedObjectMigrationFn = ( + installationDoc, + migrationContext +) => { + delete installationDoc.attributes.removable; + + return installationDoc; +}; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts index 27468e77c8e9f..acd5761919a16 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts @@ -176,7 +176,6 @@ export async function getPackageInfo({ : resolvedPkgVersion, title: packageInfo.title || nameAsTitle(packageInfo.name), assets: Registry.groupPathsByService(paths || []), - removable: true, notice: Registry.getNoticePath(paths || []), keepPoliciesUpToDate: savedObject?.attributes.keep_policies_up_to_date ?? false, }; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index c7fc01c89eb06..6bbb91ada321c 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -598,7 +598,6 @@ export async function createInstallation(options: { ? true : undefined; - // TODO cleanup removable flag and isUnremovablePackage function const created = await savedObjectsClient.create( PACKAGES_SAVED_OBJECT_TYPE, { @@ -609,7 +608,6 @@ export async function createInstallation(options: { es_index_patterns: toSaveESIndexPatterns, name: pkgName, version: pkgVersion, - removable: true, install_version: pkgVersion, install_status: 'installing', install_started_at: new Date().toISOString(), diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index 95e65acfebef6..53e001aeee8d0 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -44,11 +44,9 @@ export async function removeInstallation(options: { esClient: ElasticsearchClient; force?: boolean; }): Promise { - const { savedObjectsClient, pkgName, pkgVersion, esClient, force } = options; + const { savedObjectsClient, pkgName, pkgVersion, esClient } = options; const installation = await getInstallation({ savedObjectsClient, pkgName }); if (!installation) throw Boom.badRequest(`${pkgName} is not installed`); - if (installation.removable === false && !force) - throw Boom.badRequest(`${pkgName} is installed by default and cannot be removed`); const { total } = await packagePolicyService.list(savedObjectsClient, { kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${pkgName}`, diff --git a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts index 6bc56e8316da6..5c63d0ba5dca1 100644 --- a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts @@ -391,7 +391,6 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { }, ], latestVersion: '0.3.0', - removable: true, notice: undefined, status: 'not_installed', assets: { 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 5a6b20550f224..35eb9de6d4060 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -1764,7 +1764,6 @@ export class EndpointDocGenerator extends BaseDataGenerator { name: 'endpoint', version: '0.5.0', internal: false, - removable: false, install_version: '0.5.0', install_status: 'installed', install_started_at: '2020-06-24T14:41:23.098Z', diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 8bd7308a27a70..85ea8a0ffc348 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -12839,7 +12839,6 @@ "xpack.fleet.integrations.settings.packageUninstallDescription": "Supprimez les ressources Kibana et Elasticsearch installées par cette intégration.", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteDetail": "{strongNote} Impossible d'installer {title}, car des agents actifs utilisent cette intégration. Pour procéder à la désinstallation, supprimez toutes les intégrations {title} de vos stratégies d'agent.", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteLabel": "Remarque :", - "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallUninstallableNoteDetail": "{strongNote} L'intégration de {title} est une intégration système. Vous ne pouvez pas la supprimer.", "xpack.fleet.integrations.settings.packageUninstallTitle": "Désinstaller", "xpack.fleet.integrations.settings.packageVersionTitle": "Version de {title}", "xpack.fleet.integrations.settings.versionInfo.installedVersion": "Version installée", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 12300057ca7ff..cf84dbd2d6305 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12946,7 +12946,6 @@ "xpack.fleet.integrations.settings.packageUninstallDescription": "この統合によってインストールされたKibanaおよびElasticsearchアセットを削除します。", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteDetail": "{strongNote} {title}をアンインストールできません。この統合を使用しているアクティブなエージェントがあります。アンインストールするには、エージェントポリシーからすべての{title}統合を削除します。", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteLabel": "注:", - "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallUninstallableNoteDetail": "{strongNote} {title}統合はシステム統合であるため、削除できません。", "xpack.fleet.integrations.settings.packageUninstallTitle": "アンインストール", "xpack.fleet.integrations.settings.packageVersionTitle": "{title}バージョン", "xpack.fleet.integrations.settings.versionInfo.installedVersion": "インストールされているバージョン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5953802b0a0a5..b15cacd8dc8ab 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12970,7 +12970,6 @@ "xpack.fleet.integrations.settings.packageUninstallDescription": "移除此集成安装的 Kibana 和 Elasticsearch 资产。", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteDetail": "{strongNote}{title} 无法卸载,因为存在使用此集成的活动代理。要卸载,请从您的代理策略中移除所有 {title} 集成。", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteLabel": "注意:", - "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallUninstallableNoteDetail": "{strongNote}{title} 集成是系统集成,无法移除。", "xpack.fleet.integrations.settings.packageUninstallTitle": "卸载", "xpack.fleet.integrations.settings.packageVersionTitle": "{title} 版本", "xpack.fleet.integrations.settings.versionInfo.installedVersion": "已安装版本", diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index ddb9317789069..0d06a1ca9e0f7 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -738,7 +738,6 @@ const expectAssetsInstalled = ({ }, name: 'all_assets', version: '0.1.0', - removable: true, install_version: '0.1.0', install_status: 'installed', install_started_at: res.attributes.install_started_at, diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index 6cbedf68da567..e367e76049b72 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -498,7 +498,6 @@ export default function (providerContext: FtrProviderContext) { ], name: 'all_assets', version: '0.2.0', - removable: true, install_version: '0.2.0', install_status: 'installed', install_started_at: res.attributes.install_started_at, diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml index ec3586689becf..c4fb3f967913d 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml @@ -10,8 +10,6 @@ release: beta # The default type is integration and will be set if empty. type: integration license: basic -# This package can be removed -removable: true requirement: elasticsearch: diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml index f1ed5a8a5a78b..472888818e717 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml @@ -10,8 +10,6 @@ release: beta # The default type is integration and will be set if empty. type: integration license: basic -# This package can be removed -removable: true requirement: elasticsearch: From 1b4ac7d2719b64ec22c5c50a7e245e37d9e148fe Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Fri, 20 May 2022 17:54:13 +0300 Subject: [PATCH 115/150] [XY] Reference lines overlay fix. (#132607) --- .../reference_line.test.ts | 4 + .../common/types/expression_functions.ts | 3 +- .../reference_lines/reference_line.tsx | 4 +- .../reference_lines/reference_lines.test.tsx | 18 ++--- .../reference_lines/reference_lines.tsx | 53 ++++---------- .../components/reference_lines/utils.tsx | 73 ++++++++++++++++++- 6 files changed, 103 insertions(+), 52 deletions(-) diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts index b96f39923fab2..4c7c2e3dc628f 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts @@ -14,6 +14,7 @@ describe('referenceLine', () => { test('produces the correct arguments for minimum arguments', async () => { const args: ReferenceLineArgs = { value: 100, + fill: 'above', }; const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); @@ -67,6 +68,7 @@ describe('referenceLine', () => { const args: ReferenceLineArgs = { name: 'some name', value: 100, + fill: 'none', }; const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); @@ -90,6 +92,7 @@ describe('referenceLine', () => { const args: ReferenceLineArgs = { value: 100, textVisibility: true, + fill: 'none', }; const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); @@ -115,6 +118,7 @@ describe('referenceLine', () => { value: 100, name: 'some text', textVisibility, + fill: 'none', }; const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index 05447607bc194..502bb39cda894 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -297,9 +297,10 @@ export type ExtendedAnnotationLayerConfigResult = ExtendedAnnotationLayerArgs & layerType: typeof LayerTypes.ANNOTATIONS; }; -export interface ReferenceLineArgs extends Omit { +export interface ReferenceLineArgs extends Omit { name?: string; value: number; + fill: FillStyle; } export interface ReferenceLineLayerArgs { diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx index 74bb18597f2f2..30f4a97986ec3 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx @@ -19,6 +19,7 @@ interface ReferenceLineProps { formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; axesMap: Record<'left' | 'right', boolean>; isHorizontal: boolean; + nextValue?: number; } export const ReferenceLine: FC = ({ @@ -27,6 +28,7 @@ export const ReferenceLine: FC = ({ formatters, paddingMap, isHorizontal, + nextValue, }) => { const { yConfig: [yConfig], @@ -46,7 +48,7 @@ export const ReferenceLine: FC = ({ return ( { ['yAccessorLeft', 'below'], ['yAccessorRight', 'above'], ['yAccessorRight', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( + ] as Array<[string, Exclude]>)( 'should render a RectAnnotation for a reference line with fill set: %s %s', (layerPrefix, fill) => { const axisMode = getAxisFromId(layerPrefix); @@ -154,7 +154,7 @@ describe('ReferenceLines', () => { it.each([ ['xAccessor', 'above'], ['xAccessor', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( + ] as Array<[string, Exclude]>)( 'should render a RectAnnotation for a reference line with fill set: %s %s', (layerPrefix, fill) => { const wrapper = shallow( @@ -196,7 +196,7 @@ describe('ReferenceLines', () => { ['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], ['yAccessorRight', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], ['yAccessorRight', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], - ] as Array<[string, ExtendedYConfig['fill'], YCoords, YCoords]>)( + ] as Array<[string, Exclude, YCoords, YCoords]>)( 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', (layerPrefix, fill, coordsA, coordsB) => { const axisMode = getAxisFromId(layerPrefix); @@ -252,7 +252,7 @@ describe('ReferenceLines', () => { it.each([ ['xAccessor', 'above', { x0: 1, x1: 2 }, { x0: 2, x1: undefined }], ['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: 1, x1: 2 }], - ] as Array<[string, ExtendedYConfig['fill'], XCoords, XCoords]>)( + ] as Array<[string, Exclude, XCoords, XCoords]>)( 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', (layerPrefix, fill, coordsA, coordsB) => { const wrapper = shallow( @@ -361,7 +361,7 @@ describe('ReferenceLines', () => { it.each([ ['above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], ['below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], - ] as Array<[ExtendedYConfig['fill'], YCoords, YCoords]>)( + ] as Array<[Exclude, YCoords, YCoords]>)( 'should be robust and works also for different axes when on same direction: 1x Left + 1x Right both %s', (fill, coordsA, coordsB) => { const wrapper = shallow( @@ -438,7 +438,7 @@ describe('ReferenceLines', () => { ['yAccessorLeft', 'below'], ['yAccessorRight', 'above'], ['yAccessorRight', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( + ] as Array<[string, Exclude]>)( 'should render a RectAnnotation for a reference line with fill set: %s %s', (layerPrefix, fill) => { const axisMode = getAxisFromId(layerPrefix); @@ -479,7 +479,7 @@ describe('ReferenceLines', () => { it.each([ ['xAccessor', 'above'], ['xAccessor', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( + ] as Array<[string, Exclude]>)( 'should render a RectAnnotation for a reference line with fill set: %s %s', (layerPrefix, fill) => { const value = 1; @@ -519,7 +519,7 @@ describe('ReferenceLines', () => { it.each([ ['yAccessorLeft', 'above', { y0: 10, y1: undefined }, { y0: 10, y1: undefined }], ['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: undefined, y1: 5 }], - ] as Array<[string, ExtendedYConfig['fill'], YCoords, YCoords]>)( + ] as Array<[string, Exclude, YCoords, YCoords]>)( 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', (layerPrefix, fill, coordsA, coordsB) => { const axisMode = getAxisFromId(layerPrefix); @@ -570,7 +570,7 @@ describe('ReferenceLines', () => { it.each([ ['xAccessor', 'above', { x0: 1, x1: undefined }, { x0: 1, x1: undefined }], ['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: undefined, x1: 1 }], - ] as Array<[string, ExtendedYConfig['fill'], XCoords, XCoords]>)( + ] as Array<[string, Exclude, XCoords, XCoords]>)( 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', (layerPrefix, fill, coordsA, coordsB) => { const value = coordsA.x0 ?? coordsA.x1!; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx index 9dca7b6107072..5d48c3c05166d 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx @@ -11,44 +11,11 @@ import './reference_lines.scss'; import React from 'react'; import { Position } from '@elastic/charts'; import type { FieldFormat } from '@kbn/field-formats-plugin/common'; -import type { CommonXYReferenceLineLayerConfig } from '../../../common/types'; -import { isReferenceLine, mapVerticalToHorizontalPlacement } from '../../helpers'; +import type { CommonXYReferenceLineLayerConfig, ReferenceLineConfig } from '../../../common/types'; +import { isReferenceLine } from '../../helpers'; import { ReferenceLineLayer } from './reference_line_layer'; import { ReferenceLine } from './reference_line'; - -export const computeChartMargins = ( - referenceLinePaddings: Partial>, - labelVisibility: Partial>, - titleVisibility: Partial>, - axesMap: Record<'left' | 'right', unknown>, - isHorizontal: boolean -) => { - const result: Partial> = {}; - if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; - result[placement] = referenceLinePaddings.bottom; - } - if ( - referenceLinePaddings.left && - (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; - result[placement] = referenceLinePaddings.left; - } - if ( - referenceLinePaddings.right && - (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; - result[placement] = referenceLinePaddings.right; - } - // there's no top axis, so just check if a margin has been computed - if (referenceLinePaddings.top) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; - result[placement] = referenceLinePaddings.top; - } - return result; -}; +import { getNextValuesForReferenceLines } from './utils'; export interface ReferenceLinesProps { layers: CommonXYReferenceLineLayerConfig[]; @@ -59,6 +26,12 @@ export interface ReferenceLinesProps { } export const ReferenceLines = ({ layers, ...rest }: ReferenceLinesProps) => { + const referenceLines = layers.filter((layer): layer is ReferenceLineConfig => + isReferenceLine(layer) + ); + + const referenceLinesNextValues = getNextValuesForReferenceLines(referenceLines); + return ( <> {layers.flatMap((layer) => { @@ -66,13 +39,13 @@ export const ReferenceLines = ({ layers, ...rest }: ReferenceLinesProps) => { return null; } + const key = `referenceLine-${layer.layerId}`; if (isReferenceLine(layer)) { - return ; + const nextValue = referenceLinesNextValues[layer.yConfig[0].fill][layer.layerId]; + return ; } - return ( - - ); + return ; })} ); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx index 1a6eae6a490e6..85d96c573f314 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx @@ -10,7 +10,9 @@ import React from 'react'; import { Position } from '@elastic/charts'; import { euiLightVars } from '@kbn/ui-theme'; import { FieldFormat } from '@kbn/field-formats-plugin/common'; -import { IconPosition, YAxisMode } from '../../../common/types'; +import { groupBy, orderBy } from 'lodash'; +import { IconPosition, ReferenceLineConfig, YAxisMode, FillStyle } from '../../../common/types'; +import { FillStyles } from '../../../common/constants'; import { LINES_MARKER_SIZE, mapVerticalToHorizontalPlacement, @@ -141,3 +143,72 @@ export const getHorizontalRect = ( header: headerLabel, details: formatter?.convert(currentValue) || currentValue.toString(), }); + +const sortReferenceLinesByGroup = (referenceLines: ReferenceLineConfig[], group: FillStyle) => { + if (group === FillStyles.ABOVE || group === FillStyles.BELOW) { + const order = group === FillStyles.ABOVE ? 'asc' : 'desc'; + return orderBy(referenceLines, ({ yConfig: [{ value }] }) => value, [order]); + } + return referenceLines; +}; + +export const getNextValuesForReferenceLines = (referenceLines: ReferenceLineConfig[]) => { + const grouppedReferenceLines = groupBy(referenceLines, ({ yConfig: [yConfig] }) => yConfig.fill); + const groups = Object.keys(grouppedReferenceLines) as FillStyle[]; + + return groups.reduce>>( + (nextValueByDirection, group) => { + const sordedReferenceLines = sortReferenceLinesByGroup(grouppedReferenceLines[group], group); + + const nv = sordedReferenceLines.reduce>( + (nextValues, referenceLine, index, lines) => { + let nextValue: number | undefined; + if (index < lines.length - 1) { + const [yConfig] = lines[index + 1].yConfig; + nextValue = yConfig.value; + } + + return { ...nextValues, [referenceLine.layerId]: nextValue }; + }, + {} + ); + + return { ...nextValueByDirection, [group]: nv }; + }, + {} as Record> + ); +}; + +export const computeChartMargins = ( + referenceLinePaddings: Partial>, + labelVisibility: Partial>, + titleVisibility: Partial>, + axesMap: Record<'left' | 'right', unknown>, + isHorizontal: boolean +) => { + const result: Partial> = {}; + if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; + result[placement] = referenceLinePaddings.bottom; + } + if ( + referenceLinePaddings.left && + (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; + result[placement] = referenceLinePaddings.left; + } + if ( + referenceLinePaddings.right && + (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; + result[placement] = referenceLinePaddings.right; + } + // there's no top axis, so just check if a margin has been computed + if (referenceLinePaddings.top) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; + result[placement] = referenceLinePaddings.top; + } + return result; +}; From d9f141a3e1bd4c9918b75e3b08d8a0ceac2cbf2c Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Fri, 20 May 2022 11:37:35 -0400 Subject: [PATCH 116/150] [Security Solution] Telemetry for Event Filters counts on both user and global entries (#132542) --- .../security_solution/server/lib/telemetry/tasks/endpoint.ts | 2 ++ .../plugins/security_solution/server/lib/telemetry/types.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts index 59bc07f8ca2eb..f6e3ca6e9d8ef 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts @@ -256,6 +256,7 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { malicious_behavior_rules: maliciousBehaviorRules, system_impact: systemImpact, threads, + event_filter: eventFilter, } = endpoint.endpoint_metrics.Endpoint.metrics; const endpointPolicyDetail = extractEndpointPolicyConfig(policyConfig); @@ -275,6 +276,7 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { maliciousBehaviorRules, systemImpact, threads, + eventFilter, }, endpoint_meta: { os: endpoint.endpoint_metrics.host.os, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts index 15c92740e3a71..d70a011ea85aa 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts @@ -233,6 +233,10 @@ export interface EndpointMetrics { library_load_events?: SystemImpactEventsMetrics; }>; threads: Array<{ name: string; cpu: { mean: number } }>; + event_filter: { + active_global_count: number; + active_user_count: number; + }; } interface EndpointMetricOS { From f70b4af7f2a5119bab5d56fb7a79d08f268570aa Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Fri, 20 May 2022 12:22:08 -0400 Subject: [PATCH 117/150] [Fleet] Fix rolling upgrade CANCEL and UI fixes (#132625) --- .../hooks/use_current_upgrades.tsx | 8 ++--- .../sections/agents/agent_list_page/index.tsx | 4 +-- .../server/services/agents/actions.test.ts | 30 +++++++++++++++++++ .../fleet/server/services/agents/actions.ts | 14 +++++++++ .../fleet/server/services/agents/upgrade.ts | 2 ++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx index 02463025c86db..cdec2ad667be4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx @@ -12,9 +12,9 @@ import { sendGetCurrentUpgrades, sendPostCancelAction, useStartServices } from ' import type { CurrentUpgrade } from '../../../../types'; -const POLL_INTERVAL = 30 * 1000; +const POLL_INTERVAL = 2 * 60 * 1000; // 2 minutes -export function useCurrentUpgrades() { +export function useCurrentUpgrades(onAbortSuccess: () => void) { const [currentUpgrades, setCurrentUpgrades] = useState([]); const currentTimeoutRef = useRef(); const isCancelledRef = useRef(false); @@ -65,7 +65,7 @@ export function useCurrentUpgrades() { return; } await sendPostCancelAction(currentUpgrade.actionId); - await refreshUpgrades(); + await Promise.all([refreshUpgrades(), onAbortSuccess()]); } catch (err) { notifications.toasts.addError(err, { title: i18n.translate('xpack.fleet.currentUpgrade.abortRequestError', { @@ -74,7 +74,7 @@ export function useCurrentUpgrades() { }); } }, - [refreshUpgrades, notifications.toasts, overlays] + [refreshUpgrades, notifications.toasts, overlays, onAbortSuccess] ); // Poll for upgrades diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 7ddf9b0f332f8..bbea3284f72b8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -338,7 +338,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, [flyoutContext]); // Current upgrades - const { abortUpgrade, currentUpgrades, refreshUpgrades } = useCurrentUpgrades(); + const { abortUpgrade, currentUpgrades, refreshUpgrades } = useCurrentUpgrades(fetchData); const columns = [ { @@ -545,7 +545,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { selectionMode={selectionMode} currentQuery={kuery} selectedAgents={selectedAgents} - refreshAgents={() => fetchData()} + refreshAgents={() => Promise.all([fetchData(), refreshUpgrades()])} /> {/* Agent total, bulk actions and status bar */} diff --git a/x-pack/plugins/fleet/server/services/agents/actions.test.ts b/x-pack/plugins/fleet/server/services/agents/actions.test.ts index 2838f2204ad96..97d7c73035e6d 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.test.ts @@ -8,6 +8,11 @@ import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; import { cancelAgentAction } from './actions'; +import { bulkUpdateAgents } from './crud'; + +jest.mock('./crud'); + +const mockedBulkUpdateAgents = bulkUpdateAgents as jest.Mock; describe('Agent actions', () => { describe('cancelAgentAction', () => { @@ -67,5 +72,30 @@ describe('Agent actions', () => { }) ); }); + + it('should cancel UPGRADE action', async () => { + const esClient = elasticsearchServiceMock.createInternalClient(); + esClient.search.mockResolvedValue({ + hits: { + hits: [ + { + _source: { + type: 'UPGRADE', + action_id: 'action1', + agents: ['agent1', 'agent2'], + expiration: '2022-05-12T18:16:18.019Z', + }, + }, + ], + }, + } as any); + await cancelAgentAction(esClient, 'action1'); + + expect(mockedBulkUpdateAgents).toBeCalled(); + expect(mockedBulkUpdateAgents).toBeCalledWith(expect.anything(), [ + expect.objectContaining({ agentId: 'agent1' }), + expect.objectContaining({ agentId: 'agent2' }), + ]); + }); }); }); diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index afa65bfe91fb3..c4f3530892543 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -17,6 +17,8 @@ import type { import { AGENT_ACTIONS_INDEX, SO_SEARCH_LIMIT } from '../../../common/constants'; import { AgentActionNotFoundError } from '../../errors'; +import { bulkUpdateAgents } from './crud'; + const ONE_MONTH_IN_MS = 2592000000; export async function createAgentAction( @@ -131,6 +133,18 @@ export async function cancelAgentAction(esClient: ElasticsearchClient, actionId: created_at: now, expiration: hit._source.expiration, }); + if (hit._source.type === 'UPGRADE') { + await bulkUpdateAgents( + esClient, + hit._source.agents.map((agentId) => ({ + agentId, + data: { + upgraded_at: null, + upgrade_started_at: null, + }, + })) + ); + } } return { diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 6d0174e064184..d7f2735e2d284 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -267,6 +267,7 @@ async function _getCancelledActionId( ) { const res = await esClient.search({ index: AGENT_ACTIONS_INDEX, + ignore_unavailable: true, query: { bool: { must: [ @@ -296,6 +297,7 @@ async function _getCancelledActionId( async function _getUpgradeActions(esClient: ElasticsearchClient, now = new Date().toISOString()) { const res = await esClient.search({ index: AGENT_ACTIONS_INDEX, + ignore_unavailable: true, query: { bool: { must: [ From d70ae0fa8a02fc927b932b2a17e539272f4ed5fc Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Fri, 20 May 2022 11:34:35 -0500 Subject: [PATCH 118/150] [ILM] Add warnings for managed system policies (#132269) * Add warnings to system/managed policies * Fix translations, policies * Add jest tests * Add jest tests to assert new toggle behavior * Add jest tests for edit policy callout * Fix snapshot * [ML] Update jest tests with helper, rename helper for clarity * [ML] Add hook for local storage to remember toggle setting * [ML] Fix naming Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__snapshots__/policy_table.test.tsx.snap | 64 +++++----- .../edit_policy/constants.ts | 23 ++++ .../edit_policy/features/edit_warning.test.ts | 15 ++- .../__jest__/policy_table.test.tsx | 112 +++++++++++++++++- .../application/lib/settings_local_storage.ts | 31 +++++ .../edit_policy/components/edit_warning.tsx | 29 ++++- .../policy_list/components/confirm_delete.tsx | 62 ++++++++-- .../policy_list/components/policy_table.tsx | 101 +++++++++++++--- 8 files changed, 375 insertions(+), 62 deletions(-) create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/lib/settings_local_storage.ts diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap index 8cbb4aa450c7c..32d2b96675594 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap @@ -7,26 +7,26 @@ Array [ "testy10", "testy100", "testy101", - "testy102", "testy103", "testy104", "testy11", - "testy12", + "testy13", + "testy14", ] `; exports[`policy table changes pages when a pagination link is clicked on 2`] = ` Array [ - "testy13", - "testy14", - "testy15", "testy16", "testy17", - "testy18", "testy19", "testy2", "testy20", - "testy21", + "testy22", + "testy23", + "testy25", + "testy26", + "testy28", ] `; @@ -113,15 +113,15 @@ exports[`policy table shows empty state when there are no policies 1`] = ` exports[`policy table sorts when linked index templates header is clicked 1`] = ` Array [ "testy1", - "testy3", "testy5", "testy7", - "testy9", "testy11", "testy13", - "testy15", "testy17", "testy19", + "testy23", + "testy25", + "testy29", ] `; @@ -130,28 +130,28 @@ Array [ "testy0", "testy2", "testy4", - "testy6", "testy8", "testy10", - "testy12", "testy14", "testy16", - "testy18", + "testy20", + "testy22", + "testy26", ] `; exports[`policy table sorts when linked indices header is clicked 1`] = ` Array [ "testy1", - "testy3", "testy5", "testy7", - "testy9", "testy11", "testy13", - "testy15", "testy17", "testy19", + "testy23", + "testy25", + "testy29", ] `; @@ -160,13 +160,13 @@ Array [ "testy0", "testy2", "testy4", - "testy6", "testy8", "testy10", - "testy12", "testy14", "testy16", - "testy18", + "testy20", + "testy22", + "testy26", ] `; @@ -175,13 +175,13 @@ Array [ "testy0", "testy104", "testy103", - "testy102", "testy101", "testy100", - "testy99", "testy98", "testy97", - "testy96", + "testy95", + "testy94", + "testy92", ] `; @@ -189,29 +189,29 @@ exports[`policy table sorts when modified date header is clicked 2`] = ` Array [ "testy1", "testy2", - "testy3", "testy4", "testy5", - "testy6", "testy7", "testy8", - "testy9", "testy10", + "testy11", + "testy13", + "testy14", ] `; exports[`policy table sorts when name header is clicked 1`] = ` Array [ - "testy99", "testy98", "testy97", - "testy96", "testy95", "testy94", - "testy93", "testy92", "testy91", - "testy90", + "testy89", + "testy88", + "testy86", + "testy85", ] `; @@ -220,12 +220,12 @@ Array [ "testy0", "testy1", "testy2", - "testy3", "testy4", "testy5", - "testy6", "testy7", "testy8", - "testy9", + "testy10", + "testy11", + "testy13", ] `; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts index f57f351ae0831..620cb9d6f8dde 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts @@ -221,6 +221,29 @@ export const POLICY_WITH_KNOWN_AND_UNKNOWN_FIELDS = { name: POLICY_NAME, } as any as PolicyFromES; +export const POLICY_MANAGED_BY_ES: PolicyFromES = { + version: 1, + modifiedDate: Date.now().toString(), + policy: { + name: POLICY_NAME, + phases: { + hot: { + min_age: '0ms', + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + }, + }, + }, + _meta: { + managed: true, + }, + }, + name: POLICY_NAME, +}; + export const getGeneratedPolicies = (): PolicyFromES[] => { const policy = { phases: { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts index 0cf57f4140aa4..98d6078da031c 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts @@ -9,7 +9,7 @@ import { act } from 'react-dom/test-utils'; import { TestBed } from '@kbn/test-jest-helpers'; import { setupEnvironment } from '../../helpers'; import { initTestBed } from '../init_test_bed'; -import { getDefaultHotPhasePolicy, POLICY_NAME } from '../constants'; +import { getDefaultHotPhasePolicy, POLICY_NAME, POLICY_MANAGED_BY_ES } from '../constants'; describe(' edit warning', () => { let testBed: TestBed; @@ -54,6 +54,19 @@ describe(' edit warning', () => { expect(exists('editWarning')).toBe(true); }); + test('an edit warning callout is shown for an existing, managed policy', async () => { + httpRequestsMockHelpers.setLoadPolicies([POLICY_MANAGED_BY_ES]); + + await act(async () => { + testBed = await initTestBed(httpSetup); + }); + const { exists, component } = testBed; + component.update(); + + expect(exists('editWarning')).toBe(true); + expect(exists('editManagedPolicyCallOut')).toBe(true); + }); + test('no indices link if no indices', async () => { httpRequestsMockHelpers.setLoadPolicies([ { ...getDefaultHotPhasePolicy(POLICY_NAME), indices: [] }, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx index 771cf70e3daea..0e8ac17ff86c2 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx @@ -52,17 +52,27 @@ const testPolicy = { }, }; +const isUsedByAnIndex = (i: number) => i % 2 === 0; +const isDesignatedManagedPolicy = (i: number) => i > 0 && i % 3 === 0; + const policies: PolicyFromES[] = [testPolicy]; for (let i = 1; i < 105; i++) { policies.push({ version: i, modifiedDate: moment().subtract(i, 'days').toISOString(), - indices: i % 2 === 0 ? [`index${i}`] : [], + indices: isUsedByAnIndex(i) ? [`index${i}`] : [], indexTemplates: i % 2 === 0 ? [`indexTemplate${i}`] : [], name: `testy${i}`, policy: { name: `testy${i}`, phases: {}, + ...(isDesignatedManagedPolicy(i) + ? { + _meta: { + managed: true, + }, + } + : {}), }, }); } @@ -89,6 +99,20 @@ const getPolicyNames = (rendered: ReactWrapper): string[] => { return (getPolicyLinks(rendered) as ReactWrapper).map((button) => button.text()); }; +const getPolicies = (rendered: ReactWrapper) => { + const visiblePolicyNames = getPolicyNames(rendered); + const visiblePolicies = visiblePolicyNames.map((name) => { + const version = parseInt(name.replace('testy', ''), 10); + return { + version, + name, + isManagedPolicy: isDesignatedManagedPolicy(version), + isUsedByAnIndex: isUsedByAnIndex(version), + }; + }); + return visiblePolicies; +}; + const testSort = (headerName: string) => { const rendered = mountWithIntl(component); const nameHeader = findTestSubject(rendered, `tableHeaderCell_${headerName}`).find('button'); @@ -114,6 +138,7 @@ const TestComponent = ({ testPolicies }: { testPolicies: PolicyFromES[] }) => { describe('policy table', () => { beforeEach(() => { component = ; + window.localStorage.removeItem('ILM_SHOW_MANAGED_POLICIES_BY_DEFAULT'); }); test('shows empty state when there are no policies', () => { @@ -129,8 +154,23 @@ describe('policy table', () => { rendered.update(); snapshot(getPolicyNames(rendered)); }); + + test('does not show any hidden policies by default', () => { + const rendered = mountWithIntl(component); + const includeHiddenPoliciesSwitch = findTestSubject(rendered, `includeHiddenPoliciesSwitch`); + expect(includeHiddenPoliciesSwitch.prop('aria-checked')).toEqual(false); + const visiblePolicies = getPolicies(rendered); + const hasManagedPolicies = visiblePolicies.some((p) => { + const policyRow = findTestSubject(rendered, `policyTableRow-${p.name}`); + const warningBadge = findTestSubject(policyRow, 'managedPolicyBadge'); + return warningBadge.exists(); + }); + expect(hasManagedPolicies).toEqual(false); + }); + test('shows more policies when "Rows per page" value is increased', () => { const rendered = mountWithIntl(component); + const perPageButton = rendered.find('EuiTablePagination EuiPopover').find('button'); perPageButton.simulate('click'); rendered.update(); @@ -139,6 +179,36 @@ describe('policy table', () => { rendered.update(); expect(getPolicyNames(rendered).length).toBe(25); }); + + test('shows hidden policies with Managed badges when setting is switched on', () => { + const rendered = mountWithIntl(component); + const includeHiddenPoliciesSwitch = findTestSubject(rendered, `includeHiddenPoliciesSwitch`); + includeHiddenPoliciesSwitch.find('button').simulate('click'); + rendered.update(); + + // Increase page size for better sample set that contains managed indices + // Since table is ordered alphabetically and not numerically + const perPageButton = rendered.find('EuiTablePagination EuiPopover').find('button'); + perPageButton.simulate('click'); + rendered.update(); + const numberOfRowsButton = rendered.find('.euiContextMenuItem').at(2); + numberOfRowsButton.simulate('click'); + rendered.update(); + + const visiblePolicies = getPolicies(rendered); + expect(visiblePolicies.filter((p) => p.isManagedPolicy).length).toBeGreaterThan(0); + + visiblePolicies.forEach((p) => { + const policyRow = findTestSubject(rendered, `policyTableRow-${p.name}`); + const warningBadge = findTestSubject(policyRow, 'managedPolicyBadge'); + if (p.isManagedPolicy) { + expect(warningBadge.exists()).toBeTruthy(); + } else { + expect(warningBadge.exists()).toBeFalsy(); + } + }); + }); + test('filters based on content of search input', () => { const rendered = mountWithIntl(component); const searchInput = rendered.find('.euiFieldSearch').first(); @@ -167,7 +237,11 @@ describe('policy table', () => { }); test('delete policy button is enabled when there are no linked indices', () => { const rendered = mountWithIntl(component); - const policyRow = findTestSubject(rendered, `policyTableRow-testy1`); + const visiblePolicies = getPolicies(rendered); + const unusedPolicy = visiblePolicies.find((p) => !p.isUsedByAnIndex); + expect(unusedPolicy).toBeDefined(); + + const policyRow = findTestSubject(rendered, `policyTableRow-${unusedPolicy!.name}`); const deleteButton = findTestSubject(policyRow, 'deletePolicy'); expect(deleteButton.props().disabled).toBeFalsy(); }); @@ -179,6 +253,36 @@ describe('policy table', () => { rendered.update(); expect(findTestSubject(rendered, 'deletePolicyModal').exists()).toBeTruthy(); }); + + test('confirmation modal shows warning when delete button is pressed for a hidden policy', () => { + const rendered = mountWithIntl(component); + + // Toggles switch to show managed policies + const includeHiddenPoliciesSwitch = findTestSubject(rendered, `includeHiddenPoliciesSwitch`); + includeHiddenPoliciesSwitch.find('button').simulate('click'); + rendered.update(); + + // Increase page size for better sample set that contains managed indices + // Since table is ordered alphabetically and not numerically + const perPageButton = rendered.find('EuiTablePagination EuiPopover').find('button'); + perPageButton.simulate('click'); + rendered.update(); + const numberOfRowsButton = rendered.find('.euiContextMenuItem').at(2); + numberOfRowsButton.simulate('click'); + rendered.update(); + + const visiblePolicies = getPolicies(rendered); + const managedPolicy = visiblePolicies.find((p) => p.isManagedPolicy && !p.isUsedByAnIndex); + expect(managedPolicy).toBeDefined(); + + const policyRow = findTestSubject(rendered, `policyTableRow-${managedPolicy!.name}`); + const addPolicyToTemplateButton = findTestSubject(policyRow, 'deletePolicy'); + addPolicyToTemplateButton.simulate('click'); + rendered.update(); + expect(findTestSubject(rendered, 'deletePolicyModal').exists()).toBeTruthy(); + expect(findTestSubject(rendered, 'deleteManagedPolicyCallOut').exists()).toBeTruthy(); + }); + test('add index template modal shows when add policy to index template button is pressed', () => { const rendered = mountWithIntl(component); const policyRow = findTestSubject(rendered, `policyTableRow-${testPolicy.name}`); @@ -190,8 +294,8 @@ describe('policy table', () => { test('displays policy properties', () => { const rendered = mountWithIntl(component); const firstRow = findTestSubject(rendered, 'policyTableRow-testy0'); - const policyName = findTestSubject(firstRow, 'policy-name').text(); - expect(policyName).toBe(`Name${testPolicy.name}`); + const policyName = findTestSubject(firstRow, 'policyTablePolicyNameLink').text(); + expect(policyName).toBe(`${testPolicy.name}`); const policyIndexTemplates = findTestSubject(firstRow, 'policy-indexTemplates').text(); expect(policyIndexTemplates).toBe(`Linked index templates${testPolicy.indexTemplates.length}`); const policyIndices = findTestSubject(firstRow, 'policy-indices').text(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/settings_local_storage.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/settings_local_storage.ts new file mode 100644 index 0000000000000..0eb5ae22fd01c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/settings_local_storage.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; + +function parseJsonOrDefault(value: string | null, defaultValue: Obj): Obj { + if (!value) { + return defaultValue; + } + try { + return JSON.parse(value) as Obj; + } catch (e) { + return defaultValue; + } +} + +export function useStateWithLocalStorage( + key: string, + defaultState: State +): [State, Dispatch>] { + const storageState = localStorage.getItem(key); + const [state, setState] = useState(parseJsonOrDefault(storageState, defaultState)); + useEffect(() => { + localStorage.setItem(key, JSON.stringify(state)); + }, [key, state]); + return [state, setState]; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx index 8b0c21e9999c0..c2acc89fe34d1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx @@ -6,7 +6,7 @@ */ import React, { FunctionComponent, useState } from 'react'; -import { EuiLink, EuiText } from '@elastic/eui'; +import { EuiCallOut, EuiLink, EuiText, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { useEditPolicyContext } from '../edit_policy_context'; import { getIndicesListPath } from '../../../services/navigation'; @@ -14,7 +14,7 @@ import { useKibana } from '../../../../shared_imports'; import { IndexTemplatesFlyout } from '../../../components/index_templates_flyout'; export const EditWarning: FunctionComponent = () => { - const { isNewPolicy, indices, indexTemplates, policyName } = useEditPolicyContext(); + const { isNewPolicy, indices, indexTemplates, policyName, policy } = useEditPolicyContext(); const { services: { getUrlForApp }, } = useKibana(); @@ -67,6 +67,8 @@ export const EditWarning: FunctionComponent = () => { ) : ( indexTemplatesLink ); + const isManagedPolicy = policy?._meta?.managed; + return ( <> {isIndexTemplatesFlyoutShown && ( @@ -77,6 +79,29 @@ export const EditWarning: FunctionComponent = () => { /> )} + {isManagedPolicy && ( + <> + + } + color="danger" + iconType="alert" + data-test-subj="editManagedPolicyCallOut" + > +

+ +

+
+ + + )}

void; } export class ConfirmDelete extends Component { + public state = { + isDeleteConfirmed: false, + }; + + setIsDeleteConfirmed = (confirmed: boolean) => { + this.setState({ + isDeleteConfirmed: confirmed, + }); + }; + deletePolicy = async () => { const { policyToDelete, callback } = this.props; const policyName = policyToDelete.name; @@ -43,8 +53,12 @@ export class ConfirmDelete extends Component { callback(); } }; + isPolicyPolicy = true; render() { const { policyToDelete, onCancel } = this.props; + const { isDeleteConfirmed } = this.state; + const isManagedPolicy = policyToDelete.policy?._meta?.managed; + const title = i18n.translate('xpack.indexLifecycleMgmt.confirmDelete.title', { defaultMessage: 'Delete policy "{name}"', values: { name: policyToDelete.name }, @@ -68,13 +82,47 @@ export class ConfirmDelete extends Component { /> } buttonColor="danger" + confirmButtonDisabled={isManagedPolicy ? !isDeleteConfirmed : false} > -

- -
+ {isManagedPolicy ? ( + + } + color="danger" + iconType="alert" + data-test-subj="deleteManagedPolicyCallOut" + > +

+ +

+ + } + checked={isDeleteConfirmed} + onChange={(e) => this.setIsDeleteConfirmed(e.target.checked)} + /> +
+ ) : ( +
+ +
+ )} ); } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx index 8a89759a4225e..2d79737baf2bc 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx @@ -5,8 +5,17 @@ * 2.0. */ -import React from 'react'; -import { EuiButtonEmpty, EuiLink, EuiInMemoryTable, EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { + EuiButtonEmpty, + EuiLink, + EuiInMemoryTable, + EuiToolTip, + EuiButtonIcon, + EuiBadge, + EuiFlexItem, + EuiSwitch, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -15,6 +24,8 @@ import { METRIC_TYPE } from '@kbn/analytics'; import { useHistory } from 'react-router-dom'; import { EuiBasicTableColumn } from '@elastic/eui/src/components/basic_table/basic_table'; import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useStateWithLocalStorage } from '../../../lib/settings_local_storage'; import { PolicyFromES } from '../../../../../common/types'; import { useKibana } from '../../../../shared_imports'; import { getIndicesListPath, getPolicyEditPath } from '../../../services/navigation'; @@ -45,17 +56,63 @@ const actionTooltips = { ), }; +const managedPolicyTooltips = { + badge: i18n.translate('xpack.indexLifecycleMgmt.policyTable.templateBadgeType.managedLabel', { + defaultMessage: 'Managed', + }), + badgeTooltip: i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.templateBadgeType.managedDescription', + { + defaultMessage: + 'This policy is preconfigured and managed by Elastic; editing or deleting this policy might break Kibana.', + } + ), +}; + interface Props { policies: PolicyFromES[]; } +const SHOW_MANAGED_POLICIES_BY_DEFAULT = 'ILM_SHOW_MANAGED_POLICIES_BY_DEFAULT'; + export const PolicyTable: React.FunctionComponent = ({ policies }) => { const history = useHistory(); const { services: { getUrlForApp }, } = useKibana(); - + const [managedPoliciesVisible, setManagedPoliciesVisible] = useStateWithLocalStorage( + SHOW_MANAGED_POLICIES_BY_DEFAULT, + false + ); const { setListAction } = usePolicyListContext(); + const searchOptions = useMemo( + () => ({ + box: { incremental: true, 'data-test-subj': 'ilmSearchBar' }, + toolsRight: ( + + setManagedPoliciesVisible(event.target.checked)} + label={ + + } + /> + + ), + }), + [managedPoliciesVisible, setManagedPoliciesVisible] + ); + + const filteredPolicies = useMemo(() => { + return managedPoliciesVisible + ? policies + : policies.filter((item) => !item.policy?._meta?.managed); + }, [policies, managedPoliciesVisible]); const columns: Array> = [ { @@ -65,17 +122,31 @@ export const PolicyTable: React.FunctionComponent = ({ policies }) => { defaultMessage: 'Name', }), sortable: true, - render: (value: string) => { + render: (value: string, item) => { + const isManaged = item.policy?._meta?.managed; return ( - - trackUiMetric(METRIC_TYPE.CLICK, UIM_EDIT_CLICK) + <> + + trackUiMetric(METRIC_TYPE.CLICK, UIM_EDIT_CLICK) + )} + > + {value} + + + {isManaged && ( + <> +   + + + {managedPolicyTooltips.badge} + + + )} - > - {value} - + ); }, }, @@ -191,11 +262,9 @@ export const PolicyTable: React.FunctionComponent = ({ policies }) => { direction: 'asc', }, }} - search={{ - box: { incremental: true, 'data-test-subj': 'ilmSearchBar' }, - }} + search={searchOptions} tableLayout="auto" - items={policies} + items={filteredPolicies} columns={columns} rowProps={(policy: PolicyFromES) => ({ 'data-test-subj': `policyTableRow-${policy.name}` })} /> From c24488361a0f53b9d71d4248dd1a57d00520adad Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 20 May 2022 10:35:00 -0600 Subject: [PATCH 119/150] [maps] show marker size in legend (#132549) * [Maps] size legend * clean-up * refine spacing * clean up * more cleanup * use euiTheme for colors * fix jest test * do not show marker sizes for icons * remove lodash Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/legend/marker_size_legend.tsx | 164 ++++++++++++++++ .../dynamic_size_property.test.tsx.snap | 176 +++++++++++++++++- .../dynamic_size_property.test.tsx | 48 ++++- .../dynamic_size_property.tsx | 7 +- 4 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx new file mode 100644 index 0000000000000..295e7c57b7a22 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx @@ -0,0 +1,164 @@ +/* + * 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, { Component } from 'react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; +import { DynamicSizeProperty } from '../../properties/dynamic_size_property'; + +const FONT_SIZE = 10; +const HALF_FONT_SIZE = FONT_SIZE / 2; +const MIN_MARKER_DISTANCE = (FONT_SIZE + 2) / 2; + +const EMPTY_VALUE = ''; + +interface Props { + style: DynamicSizeProperty; +} + +interface State { + label: string; +} + +export class MarkerSizeLegend extends Component { + private _isMounted: boolean = false; + + state: State = { + label: EMPTY_VALUE, + }; + + componentDidMount() { + this._isMounted = true; + this._loadLabel(); + } + + componentDidUpdate() { + this._loadLabel(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadLabel() { + const field = this.props.style.getField(); + if (!field) { + return; + } + const label = await field.getLabel(); + if (this._isMounted && this.state.label !== label) { + this.setState({ label }); + } + } + + _formatValue(value: string | number) { + return value === EMPTY_VALUE ? value : this.props.style.formatField(value); + } + + _renderMarkers() { + const fieldMeta = this.props.style.getRangeFieldMeta(); + const options = this.props.style.getOptions(); + if (!fieldMeta || !options) { + return null; + } + + const circleStyle = { + fillOpacity: 0, + stroke: euiThemeVars.euiTextColor, + strokeWidth: 1, + }; + + const svgHeight = options.maxSize * 2 + HALF_FONT_SIZE + circleStyle.strokeWidth * 2; + const circleCenterX = options.maxSize + circleStyle.strokeWidth; + const circleBottomY = svgHeight - circleStyle.strokeWidth; + + function makeMarker(radius: number, formattedValue: string | number) { + const circleCenterY = circleBottomY - radius; + const circleTopY = circleCenterY - radius; + return ( + + + + {formattedValue} + + + + ); + } + + function getMarkerRadius(percentage: number) { + const delta = options.maxSize - options.minSize; + return percentage * delta + options.minSize; + } + + function getValue(percentage: number) { + // Markers interpolated by area instead of radius to be more consistent with how the human eye+brain perceive shapes + // and their visual relevance + // This function mirrors output of maplibre expression created from DynamicSizeProperty.getMbSizeExpression + const value = Math.pow(percentage * Math.sqrt(fieldMeta!.delta), 2) + fieldMeta!.min; + return fieldMeta!.delta > 3 ? Math.round(value) : value; + } + + const markers = []; + + if (fieldMeta.delta > 0) { + const smallestMarker = makeMarker(options.minSize, this._formatValue(fieldMeta.min)); + markers.push(smallestMarker); + + const markerDelta = options.maxSize - options.minSize; + if (markerDelta > MIN_MARKER_DISTANCE * 3) { + markers.push(makeMarker(getMarkerRadius(0.25), this._formatValue(getValue(0.25)))); + markers.push(makeMarker(getMarkerRadius(0.5), this._formatValue(getValue(0.5)))); + markers.push(makeMarker(getMarkerRadius(0.75), this._formatValue(getValue(0.75)))); + } else if (markerDelta > MIN_MARKER_DISTANCE) { + markers.push(makeMarker(getMarkerRadius(0.5), this._formatValue(getValue(0.5)))); + } + } + + const largestMarker = makeMarker(options.maxSize, this._formatValue(fieldMeta.max)); + markers.push(largestMarker); + + return ( + + {markers} + + ); + } + + render() { + return ( +
+ + + + + + {this.state.label} + + + + + + {this._renderMarkers()} +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap index 9dc0e99669c79..bf239aa40e33a 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap @@ -1,6 +1,165 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renderLegendDetailRow Should render as range 1`] = ` +exports[`renderLegendDetailRow Should render icon size scale 1`] = ` +
+ + + + + + + foobar_label + + + + + + + + + + + 0_format + + + + + + + 25_format + + + + + + + 100_format + + + + +
+`; + +exports[`renderLegendDetailRow Should render line width simple range 1`] = ` @@ -36,9 +196,10 @@ exports[`renderLegendDetailRow Should render as range 1`] = ` @@ -56,8 +217,9 @@ exports[`renderLegendDetailRow Should render as range 1`] = ` `; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx index 0446b9e30f47b..9f92d81313da7 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx @@ -20,7 +20,53 @@ import { IField } from '../../../../fields/field'; import { IVectorLayer } from '../../../../layers/vector_layer'; describe('renderLegendDetailRow', () => { - test('Should render as range', async () => { + test('Should render line width simple range', async () => { + const field = { + getLabel: async () => { + return 'foobar_label'; + }, + getName: () => { + return 'foodbar'; + }, + getOrigin: () => { + return FIELD_ORIGIN.SOURCE; + }, + supportsFieldMetaFromEs: () => { + return true; + }, + supportsFieldMetaFromLocalData: () => { + return true; + }, + } as unknown as IField; + const sizeProp = new DynamicSizeProperty( + { minSize: 0, maxSize: 10, fieldMetaOptions: { isEnabled: true } }, + VECTOR_STYLES.LINE_WIDTH, + field, + {} as unknown as IVectorLayer, + () => { + return (value: RawValue) => value + '_format'; + }, + false + ); + sizeProp.getRangeFieldMeta = () => { + return { + min: 0, + max: 100, + delta: 100, + }; + }; + + const legendRow = sizeProp.renderLegendDetailRow(); + const component = shallow(legendRow); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); + + test('Should render icon size scale', async () => { const field = { getLabel: async () => { return 'foobar_label'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx index d8fe8463edba8..83ac50c7b4eaa 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx @@ -9,6 +9,7 @@ import React from 'react'; import type { Map as MbMap } from '@kbn/mapbox-gl'; import { DynamicStyleProperty } from '../dynamic_style_property'; import { OrdinalLegend } from '../../components/legend/ordinal_legend'; +import { MarkerSizeLegend } from '../../components/legend/marker_size_legend'; import { makeMbClampedNumberExpression } from '../../style_util'; import { FieldFormatter, @@ -141,6 +142,10 @@ export class DynamicSizeProperty extends DynamicStyleProperty; + return this.getStyleName() === VECTOR_STYLES.ICON_SIZE && !this._isSymbolizedAsIcon ? ( + + ) : ( + + ); } } From 583d2b78e085ec7e51f6d7608b0d1fe75f1bfcc4 Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Fri, 20 May 2022 13:12:32 -0400 Subject: [PATCH 120/150] [Workplace Search] Add documentation links for v8.3.0 connectors (#132547) --- packages/kbn-doc-links/src/get_doc_links.ts | 6 ++++++ packages/kbn-doc-links/src/types.ts | 6 ++++++ .../shared/doc_links/doc_links.ts | 20 +++++++++++++++++++ .../views/content_sources/source_data.tsx | 12 +++++------ 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 55909e360b0e5..53f69411c43dd 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -125,7 +125,9 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { apiKeys: `${WORKPLACE_SEARCH_DOCS}workplace-search-api-authentication.html`, box: `${WORKPLACE_SEARCH_DOCS}workplace-search-box-connector.html`, confluenceCloud: `${WORKPLACE_SEARCH_DOCS}workplace-search-confluence-cloud-connector.html`, + confluenceCloudConnectorPackage: `${WORKPLACE_SEARCH_DOCS}confluence-cloud.html`, confluenceServer: `${WORKPLACE_SEARCH_DOCS}workplace-search-confluence-server-connector.html`, + customConnectorPackage: `${WORKPLACE_SEARCH_DOCS}custom-connector-package.html`, customSources: `${WORKPLACE_SEARCH_DOCS}workplace-search-custom-api-sources.html`, customSourcePermissions: `${WORKPLACE_SEARCH_DOCS}workplace-search-custom-api-sources.html#custom-api-source-document-level-access-control`, documentPermissions: `${WORKPLACE_SEARCH_DOCS}workplace-search-sources-document-permissions.html`, @@ -139,7 +141,9 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { indexingSchedule: `${WORKPLACE_SEARCH_DOCS}workplace-search-customizing-indexing-rules.html#_indexing_schedule`, jiraCloud: `${WORKPLACE_SEARCH_DOCS}workplace-search-jira-cloud-connector.html`, jiraServer: `${WORKPLACE_SEARCH_DOCS}workplace-search-jira-server-connector.html`, + networkDrive: `${WORKPLACE_SEARCH_DOCS}network-drives.html`, oneDrive: `${WORKPLACE_SEARCH_DOCS}workplace-search-onedrive-connector.html`, + outlook: `${WORKPLACE_SEARCH_DOCS}microsoft-outlook.html`, permissions: `${WORKPLACE_SEARCH_DOCS}workplace-search-permissions.html#organizational-sources-private-sources`, salesforce: `${WORKPLACE_SEARCH_DOCS}workplace-search-salesforce-connector.html`, security: `${WORKPLACE_SEARCH_DOCS}workplace-search-security.html`, @@ -148,7 +152,9 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { sharePointServer: `${WORKPLACE_SEARCH_DOCS}sharepoint-server.html`, slack: `${WORKPLACE_SEARCH_DOCS}workplace-search-slack-connector.html`, synch: `${WORKPLACE_SEARCH_DOCS}workplace-search-customizing-indexing-rules.html`, + teams: `${WORKPLACE_SEARCH_DOCS}microsoft-teams.html`, zendesk: `${WORKPLACE_SEARCH_DOCS}workplace-search-zendesk-connector.html`, + zoom: `${WORKPLACE_SEARCH_DOCS}zoom.html`, }, metricbeat: { base: `${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index c492509e80511..6dc3ad0f5fdda 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -111,7 +111,9 @@ export interface DocLinks { readonly apiKeys: string; readonly box: string; readonly confluenceCloud: string; + readonly confluenceCloudConnectorPackage: string; readonly confluenceServer: string; + readonly customConnectorPackage: string; readonly customSources: string; readonly customSourcePermissions: string; readonly documentPermissions: string; @@ -125,7 +127,9 @@ export interface DocLinks { readonly indexingSchedule: string; readonly jiraCloud: string; readonly jiraServer: string; + readonly networkDrive: string; readonly oneDrive: string; + readonly outlook: string; readonly permissions: string; readonly salesforce: string; readonly security: string; @@ -134,7 +138,9 @@ export interface DocLinks { readonly sharePointServer: string; readonly slack: string; readonly synch: string; + readonly teams: string; readonly zendesk: string; + readonly zoom: string; }; readonly heartbeat: { readonly base: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts index b037a5aed6217..1d38cb584fa43 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts @@ -64,7 +64,9 @@ class DocLinks { public workplaceSearchApiKeys: string; public workplaceSearchBox: string; public workplaceSearchConfluenceCloud: string; + public workplaceSearchConfluenceCloudConnectorPackage: string; public workplaceSearchConfluenceServer: string; + public workplaceSearchCustomConnectorPackage: string; public workplaceSearchCustomSources: string; public workplaceSearchCustomSourcePermissions: string; public workplaceSearchDocumentPermissions: string; @@ -78,7 +80,9 @@ class DocLinks { public workplaceSearchIndexingSchedule: string; public workplaceSearchJiraCloud: string; public workplaceSearchJiraServer: string; + public workplaceSearchNetworkDrive: string; public workplaceSearchOneDrive: string; + public workplaceSearchOutlook: string; public workplaceSearchPermissions: string; public workplaceSearchSalesforce: string; public workplaceSearchSecurity: string; @@ -87,7 +91,9 @@ class DocLinks { public workplaceSearchSharePointServer: string; public workplaceSearchSlack: string; public workplaceSearchSynch: string; + public workplaceSearchTeams: string; public workplaceSearchZendesk: string; + public workplaceSearchZoom: string; constructor() { this.appSearchApis = ''; @@ -146,7 +152,9 @@ class DocLinks { this.workplaceSearchApiKeys = ''; this.workplaceSearchBox = ''; this.workplaceSearchConfluenceCloud = ''; + this.workplaceSearchConfluenceCloudConnectorPackage = ''; this.workplaceSearchConfluenceServer = ''; + this.workplaceSearchCustomConnectorPackage = ''; this.workplaceSearchCustomSources = ''; this.workplaceSearchCustomSourcePermissions = ''; this.workplaceSearchDocumentPermissions = ''; @@ -160,7 +168,9 @@ class DocLinks { this.workplaceSearchIndexingSchedule = ''; this.workplaceSearchJiraCloud = ''; this.workplaceSearchJiraServer = ''; + this.workplaceSearchNetworkDrive = ''; this.workplaceSearchOneDrive = ''; + this.workplaceSearchOutlook = ''; this.workplaceSearchPermissions = ''; this.workplaceSearchSalesforce = ''; this.workplaceSearchSecurity = ''; @@ -169,7 +179,9 @@ class DocLinks { this.workplaceSearchSharePointServer = ''; this.workplaceSearchSlack = ''; this.workplaceSearchSynch = ''; + this.workplaceSearchTeams = ''; this.workplaceSearchZendesk = ''; + this.workplaceSearchZoom = ''; } public setDocLinks(docLinks: DocLinksStart): void { @@ -230,7 +242,11 @@ class DocLinks { this.workplaceSearchApiKeys = docLinks.links.workplaceSearch.apiKeys; this.workplaceSearchBox = docLinks.links.workplaceSearch.box; this.workplaceSearchConfluenceCloud = docLinks.links.workplaceSearch.confluenceCloud; + this.workplaceSearchConfluenceCloudConnectorPackage = + docLinks.links.workplaceSearch.confluenceCloudConnectorPackage; this.workplaceSearchConfluenceServer = docLinks.links.workplaceSearch.confluenceServer; + this.workplaceSearchCustomConnectorPackage = + docLinks.links.workplaceSearch.customConnectorPackage; this.workplaceSearchCustomSources = docLinks.links.workplaceSearch.customSources; this.workplaceSearchCustomSourcePermissions = docLinks.links.workplaceSearch.customSourcePermissions; @@ -246,7 +262,9 @@ class DocLinks { this.workplaceSearchIndexingSchedule = docLinks.links.workplaceSearch.indexingSchedule; this.workplaceSearchJiraCloud = docLinks.links.workplaceSearch.jiraCloud; this.workplaceSearchJiraServer = docLinks.links.workplaceSearch.jiraServer; + this.workplaceSearchNetworkDrive = docLinks.links.workplaceSearch.networkDrive; this.workplaceSearchOneDrive = docLinks.links.workplaceSearch.oneDrive; + this.workplaceSearchOutlook = docLinks.links.workplaceSearch.outlook; this.workplaceSearchPermissions = docLinks.links.workplaceSearch.permissions; this.workplaceSearchSalesforce = docLinks.links.workplaceSearch.salesforce; this.workplaceSearchSecurity = docLinks.links.workplaceSearch.security; @@ -255,7 +273,9 @@ class DocLinks { this.workplaceSearchSharePointServer = docLinks.links.workplaceSearch.sharePointServer; this.workplaceSearchSlack = docLinks.links.workplaceSearch.slack; this.workplaceSearchSynch = docLinks.links.workplaceSearch.synch; + this.workplaceSearchTeams = docLinks.links.workplaceSearch.teams; this.workplaceSearchZendesk = docLinks.links.workplaceSearch.zendesk; + this.workplaceSearchZoom = docLinks.links.workplaceSearch.zoom; } } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index db3da678e1e00..181cd8b7c9a73 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -25,7 +25,7 @@ export const staticGenericExternalSourceData: SourceDataItem = { isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchCustomConnectorPackage, applicationPortalUrl: '', }, objTypes: [], @@ -107,7 +107,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: true, - documentationUrl: docLinks.workplaceSearchConfluenceCloud, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchConfluenceCloudConnectorPackage, applicationPortalUrl: 'https://developer.atlassian.com/console/myapps/', }, objTypes: [ @@ -387,7 +387,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchNetworkDrive, applicationPortalUrl: '', githubRepository: 'elastic/enterprise-search-network-drive-connector', }, @@ -433,7 +433,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchOutlook, applicationPortalUrl: '', githubRepository: 'elastic/enterprise-search-outlook-connector', }, @@ -649,7 +649,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchTeams, applicationPortalUrl: '', githubRepository: 'elastic/enterprise-search-teams-connector', }, @@ -691,7 +691,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchZoom, applicationPortalUrl: '', githubRepository: 'elastic/enterprise-search-zoom-connector', }, From 065ea3e772f50cd0e5357ba98ba5bb04ccd4323f Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Fri, 20 May 2022 13:12:49 -0400 Subject: [PATCH 121/150] [Workplace Search] Remove Custom API Source Integration tile (#132538) --- .../apis/custom_integration/integrations.ts | 2 +- .../assets/source_icons/custom_api_source.svg | 1 - .../enterprise_search/server/integrations.ts | 18 ------------------ .../translations/translations/fr-FR.json | 2 -- .../translations/translations/ja-JP.json | 2 -- .../translations/translations/zh-CN.json | 2 -- 6 files changed, 1 insertion(+), 26 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/public/assets/source_icons/custom_api_source.svg diff --git a/test/api_integration/apis/custom_integration/integrations.ts b/test/api_integration/apis/custom_integration/integrations.ts index c1b6518f6684a..c4fda918328f8 100644 --- a/test/api_integration/apis/custom_integration/integrations.ts +++ b/test/api_integration/apis/custom_integration/integrations.ts @@ -22,7 +22,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.be.an('array'); - expect(resp.body.length).to.be(43); + expect(resp.body.length).to.be(42); // Test for sample data card expect(resp.body.findIndex((c: { id: string }) => c.id === 'sample_data_all')).to.be.above( diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/custom_api_source.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/custom_api_source.svg deleted file mode 100644 index cc07fbbc50877..0000000000000 --- a/x-pack/plugins/enterprise_search/public/assets/source_icons/custom_api_source.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/x-pack/plugins/enterprise_search/server/integrations.ts b/x-pack/plugins/enterprise_search/server/integrations.ts index d2d3b5d4d6829..140e36ba15555 100644 --- a/x-pack/plugins/enterprise_search/server/integrations.ts +++ b/x-pack/plugins/enterprise_search/server/integrations.ts @@ -338,24 +338,6 @@ const workplaceSearchIntegrations: WorkplaceSearchIntegration[] = [ categories: ['enterprise_search', 'communications', 'productivity'], uiInternalPath: '/app/enterprise_search/workplace_search/sources/add/zoom', }, - { - id: 'custom_api_source', - title: i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceName', - { - defaultMessage: 'Custom API Source', - } - ), - description: i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceDescription', - { - defaultMessage: - 'Search over anything by building your own integration with Workplace Search.', - } - ), - categories: ['custom'], - uiInternalPath: '/app/enterprise_search/workplace_search/sources/add/custom', - }, ]; export const registerEnterpriseSearchIntegrations = ( diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 85ea8a0ffc348..b62a957cfa927 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -11801,8 +11801,6 @@ "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceCloudName": "Cloud Confluence", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerDescription": "Effectuez des recherches sur le contenu de votre organisation sur le serveur Confluence avec Workplace Search.", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerName": "Serveur Confluence", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceDescription": "Effectuez n'importe quelle recherche en créant votre propre intégration avec Workplace Search.", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceName": "Source d'API personnalisée", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxDescription": "Effectuez des recherches dans vos fichiers et dossiers stockés sur Dropbox avec Workplace Search.", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxName": "Dropbox", "xpack.enterpriseSearch.workplaceSearch.integrations.githubDescription": "Effectuez des recherches sur vos projets et référentiels sur GitHub avec Workplace Search.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index cf84dbd2d6305..fe7056a5e3ec1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11900,8 +11900,6 @@ "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceCloudName": "Confluence Cloud", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerDescription": "Workplace Searchを使用して、Confluence Serverの組織コンテンツを検索します。", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerName": "Confluence Server", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceDescription": "Workplace Searchを使用して、独自の統合を構築し、項目を検索します。", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceName": "カスタムAPIソース", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxDescription": "Workplace Searchを使用して、Dropboxに保存されたファイルとフォルダーを検索します。", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxName": "Dropbox", "xpack.enterpriseSearch.workplaceSearch.integrations.githubDescription": "Workplace Searchを使用して、GitHubのプロジェクトとリポジトリを検索します。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b15cacd8dc8ab..990a113fcd9d6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11922,8 +11922,6 @@ "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceCloudName": "Confluence Cloud", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerDescription": "通过 Workplace Search 搜索 Confluence Server 上的组织内容。", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerName": "Confluence Server", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceDescription": "通过使用 Workplace Search 构建自己的集成来搜索任何内容。", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceName": "定制 API 源", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxDescription": "通过 Workplace Search 搜索存储在 Dropbox 上的文件和文件夹。", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxName": "Dropbox", "xpack.enterpriseSearch.workplaceSearch.integrations.githubDescription": "通过 Workplace Search 搜索 GitHub 上的项目和存储库。", From ecca23166e0a619c4a529d463aefecf31da39830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Fri, 20 May 2022 19:37:03 +0200 Subject: [PATCH 122/150] [Stack Monitoring] Convert setup routes to TypeScript (#131265) --- x-pack/plugins/infra/server/mocks.ts | 34 ++++++ .../log_views/log_views_service.mock.ts | 6 +- .../http_api/cluster/index.ts} | 5 +- .../common/http_api/cluster/post_cluster.ts | 29 +++++ .../common/http_api/cluster/post_clusters.ts | 20 ++++ .../monitoring/common/http_api/setup/index.ts | 10 ++ .../setup/post_cluster_setup_status.ts | 44 +++++++ .../setup/post_disable_internal_collection.ts | 14 +++ .../http_api/setup/post_node_setup_status.ts | 43 +++++++ .../http_api/shared/literal_value.test.ts | 30 +++++ .../shared/query_string_boolean.test.ts | 23 ++++ .../plugins/monitoring/server/debug_logger.ts | 19 +-- .../lib/cluster/flag_supported_clusters.ts | 18 ++- .../server/lib/cluster/get_index_patterns.ts | 6 +- .../elasticsearch/verify_monitoring_auth.ts | 4 +- ....test.js => get_collection_status.test.ts} | 108 ++++++++++++------ .../setup/collection/get_collection_status.ts | 23 ++-- x-pack/plugins/monitoring/server/mocks.ts | 25 ++++ .../server/routes/api/v1/alerts/enable.ts | 8 +- .../server/routes/api/v1/alerts/index.ts | 10 +- .../server/routes/api/v1/alerts/status.ts | 7 +- .../server/routes/api/v1/apm/index.ts | 13 ++- .../server/routes/api/v1/beats/index.ts | 13 ++- .../api/v1/check_access/check_access.ts | 9 +- .../routes/api/v1/check_access/index.ts | 7 +- .../server/routes/api/v1/cluster/cluster.ts | 46 ++++---- .../server/routes/api/v1/cluster/clusters.ts | 40 +++---- .../server/routes/api/v1/cluster/index.ts | 10 +- .../routes/api/v1/elasticsearch/index.ts | 28 +++-- .../check/internal_monitoring.ts | 4 +- .../api/v1/elasticsearch_settings/index.ts | 22 +++- .../monitoring/server/routes/api/v1/index.ts | 16 +++ .../server/routes/api/v1/logstash/index.ts | 25 ++-- .../api/v1/setup/cluster_setup_status.js | 72 ------------ .../api/v1/setup/cluster_setup_status.ts | 62 ++++++++++ ...able_elasticsearch_internal_collection.ts} | 18 ++- .../server/routes/api/v1/setup/index.ts | 17 +++ .../routes/api/v1/setup/node_setup_status.js | 74 ------------ .../routes/api/v1/setup/node_setup_status.ts | 64 +++++++++++ .../monitoring/server/routes/api/v1/ui.js | 42 ------- .../monitoring/server/routes/api/v1/ui.ts | 14 +++ .../plugins/monitoring/server/routes/index.ts | 35 ++++-- x-pack/plugins/monitoring/server/types.ts | 3 +- 43 files changed, 753 insertions(+), 367 deletions(-) create mode 100644 x-pack/plugins/infra/server/mocks.ts rename x-pack/plugins/monitoring/{server/routes/api/v1/setup/index.js => common/http_api/cluster/index.ts} (52%) create mode 100644 x-pack/plugins/monitoring/common/http_api/cluster/post_cluster.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/cluster/post_clusters.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/setup/index.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/setup/post_cluster_setup_status.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/setup/post_disable_internal_collection.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/setup/post_node_setup_status.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/shared/literal_value.test.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/shared/query_string_boolean.test.ts rename x-pack/plugins/monitoring/server/lib/setup/collection/{get_collection_status.test.js => get_collection_status.test.ts} (79%) create mode 100644 x-pack/plugins/monitoring/server/mocks.ts create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/index.ts delete mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.ts rename x-pack/plugins/monitoring/server/routes/api/v1/setup/{disable_elasticsearch_internal_collection.js => disable_elasticsearch_internal_collection.ts} (74%) create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/setup/index.ts delete mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.ts delete mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/ui.js create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/ui.ts diff --git a/x-pack/plugins/infra/server/mocks.ts b/x-pack/plugins/infra/server/mocks.ts new file mode 100644 index 0000000000000..5b587a1fe80d5 --- /dev/null +++ b/x-pack/plugins/infra/server/mocks.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + createLogViewsServiceSetupMock, + createLogViewsServiceStartMock, +} from './services/log_views/log_views_service.mock'; +import { InfraPluginSetup, InfraPluginStart } from './types'; + +const createInfraSetupMock = () => { + const infraSetupMock: jest.Mocked = { + defineInternalSourceConfiguration: jest.fn(), + logViews: createLogViewsServiceSetupMock(), + }; + + return infraSetupMock; +}; + +const createInfraStartMock = () => { + const infraStartMock: jest.Mocked = { + getMetricIndices: jest.fn(), + logViews: createLogViewsServiceStartMock(), + }; + return infraStartMock; +}; + +export const infraPluginMock = { + createSetupContract: createInfraSetupMock, + createStartContract: createInfraStartMock, +}; diff --git a/x-pack/plugins/infra/server/services/log_views/log_views_service.mock.ts b/x-pack/plugins/infra/server/services/log_views/log_views_service.mock.ts index becd5a015b2ec..e472e30fae2b4 100644 --- a/x-pack/plugins/infra/server/services/log_views/log_views_service.mock.ts +++ b/x-pack/plugins/infra/server/services/log_views/log_views_service.mock.ts @@ -6,7 +6,11 @@ */ import { createLogViewsClientMock } from './log_views_client.mock'; -import { LogViewsServiceStart } from './types'; +import { LogViewsServiceSetup, LogViewsServiceStart } from './types'; + +export const createLogViewsServiceSetupMock = (): jest.Mocked => ({ + defineInternalLogView: jest.fn(), +}); export const createLogViewsServiceStartMock = (): jest.Mocked => ({ getClient: jest.fn((_savedObjectsClient: any, _elasticsearchClient: any) => diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.js b/x-pack/plugins/monitoring/common/http_api/cluster/index.ts similarity index 52% rename from x-pack/plugins/monitoring/server/routes/api/v1/setup/index.js rename to x-pack/plugins/monitoring/common/http_api/cluster/index.ts index f450fc906d076..af53ade67f610 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.js +++ b/x-pack/plugins/monitoring/common/http_api/cluster/index.ts @@ -5,6 +5,5 @@ * 2.0. */ -export { clusterSetupStatusRoute } from './cluster_setup_status'; -export { nodeSetupStatusRoute } from './node_setup_status'; -export { disableElasticsearchInternalCollectionRoute } from './disable_elasticsearch_internal_collection'; +export * from './post_cluster'; +export * from './post_clusters'; diff --git a/x-pack/plugins/monitoring/common/http_api/cluster/post_cluster.ts b/x-pack/plugins/monitoring/common/http_api/cluster/post_cluster.ts new file mode 100644 index 0000000000000..faa26989fec37 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/cluster/post_cluster.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { ccsRT, clusterUuidRT, timeRangeRT } from '../shared'; + +export const postClusterRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, +}); + +export const postClusterRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + }), + rt.type({ + timeRange: timeRangeRT, + codePaths: rt.array(rt.string), + }), +]); + +export type PostClusterRequestPayload = rt.TypeOf; + +export const postClusterResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/cluster/post_clusters.ts b/x-pack/plugins/monitoring/common/http_api/cluster/post_clusters.ts new file mode 100644 index 0000000000000..ad3214c354bc5 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/cluster/post_clusters.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { timeRangeRT } from '../shared'; + +export const postClustersRequestPayloadRT = rt.type({ + timeRange: timeRangeRT, + codePaths: rt.array(rt.string), +}); + +export type PostClustersRequestPayload = rt.TypeOf; + +export const postClustersResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/setup/index.ts b/x-pack/plugins/monitoring/common/http_api/setup/index.ts new file mode 100644 index 0000000000000..33cce5833c3c5 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/setup/index.ts @@ -0,0 +1,10 @@ +/* + * 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 * from './post_cluster_setup_status'; +export * from './post_node_setup_status'; +export * from './post_disable_internal_collection'; diff --git a/x-pack/plugins/monitoring/common/http_api/setup/post_cluster_setup_status.ts b/x-pack/plugins/monitoring/common/http_api/setup/post_cluster_setup_status.ts new file mode 100644 index 0000000000000..2c4f1293fb89e --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/setup/post_cluster_setup_status.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { + booleanFromStringRT, + ccsRT, + clusterUuidRT, + createLiteralValueFromUndefinedRT, + timeRangeRT, +} from '../shared'; + +export const postClusterSetupStatusRequestParamsRT = rt.partial({ + clusterUuid: clusterUuidRT, +}); + +export const postClusterSetupStatusRequestQueryRT = rt.partial({ + // This flag is not intended to be used in production. It was introduced + // as a way to ensure consistent API testing - the typical data source + // for API tests are archived data, where the cluster configuration and data + // are consistent from environment to environment. However, this endpoint + // also attempts to retrieve data from the running stack products (ES and Kibana) + // which will vary from environment to environment making it difficult + // to write tests against. Therefore, this flag exists and should only be used + // in our testing environment. + skipLiveData: rt.union([booleanFromStringRT, createLiteralValueFromUndefinedRT(false)]), +}); + +export const postClusterSetupStatusRequestPayloadRT = rt.partial({ + ccs: ccsRT, + timeRange: timeRangeRT, +}); + +export type PostClusterSetupStatusRequestPayload = rt.TypeOf< + typeof postClusterSetupStatusRequestPayloadRT +>; + +export const postClusterSetupStatusResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/setup/post_disable_internal_collection.ts b/x-pack/plugins/monitoring/common/http_api/setup/post_disable_internal_collection.ts new file mode 100644 index 0000000000000..d44794d7e1829 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/setup/post_disable_internal_collection.ts @@ -0,0 +1,14 @@ +/* + * 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 * as rt from 'io-ts'; +import { clusterUuidRT } from '../shared'; + +export const postDisableInternalCollectionRequestParamsRT = rt.partial({ + // the cluster uuid seems to be required but never used + clusterUuid: clusterUuidRT, +}); diff --git a/x-pack/plugins/monitoring/common/http_api/setup/post_node_setup_status.ts b/x-pack/plugins/monitoring/common/http_api/setup/post_node_setup_status.ts new file mode 100644 index 0000000000000..1d51d36ae4477 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/setup/post_node_setup_status.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { + booleanFromStringRT, + ccsRT, + createLiteralValueFromUndefinedRT, + timeRangeRT, +} from '../shared'; + +export const postNodeSetupStatusRequestParamsRT = rt.type({ + nodeUuid: rt.string, +}); + +export const postNodeSetupStatusRequestQueryRT = rt.partial({ + // This flag is not intended to be used in production. It was introduced + // as a way to ensure consistent API testing - the typical data source + // for API tests are archived data, where the cluster configuration and data + // are consistent from environment to environment. However, this endpoint + // also attempts to retrieve data from the running stack products (ES and Kibana) + // which will vary from environment to environment making it difficult + // to write tests against. Therefore, this flag exists and should only be used + // in our testing environment. + skipLiveData: rt.union([booleanFromStringRT, createLiteralValueFromUndefinedRT(false)]), +}); + +export const postNodeSetupStatusRequestPayloadRT = rt.partial({ + ccs: ccsRT, + timeRange: timeRangeRT, +}); + +export type PostNodeSetupStatusRequestPayload = rt.TypeOf< + typeof postNodeSetupStatusRequestPayloadRT +>; + +export const postNodeSetupStatusResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/shared/literal_value.test.ts b/x-pack/plugins/monitoring/common/http_api/shared/literal_value.test.ts new file mode 100644 index 0000000000000..3d70e86620602 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/shared/literal_value.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { either } from 'fp-ts'; +import * as rt from 'io-ts'; +import { createLiteralValueFromUndefinedRT } from './literal_value'; + +describe('LiteralValueFromUndefined runtime type', () => { + it('decodes undefined to a given literal value', () => { + expect(createLiteralValueFromUndefinedRT('SOME_VALUE').decode(undefined)).toEqual( + either.right('SOME_VALUE') + ); + }); + + it('can be used to define default values when decoding', () => { + expect( + rt.union([rt.boolean, createLiteralValueFromUndefinedRT(true)]).decode(undefined) + ).toEqual(either.right(true)); + }); + + it('rejects other values', () => { + expect( + either.isLeft(createLiteralValueFromUndefinedRT('SOME_VALUE').decode('DEFINED')) + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/monitoring/common/http_api/shared/query_string_boolean.test.ts b/x-pack/plugins/monitoring/common/http_api/shared/query_string_boolean.test.ts new file mode 100644 index 0000000000000..1801c6746feb2 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/shared/query_string_boolean.test.ts @@ -0,0 +1,23 @@ +/* + * 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 { either } from 'fp-ts'; +import { booleanFromStringRT } from './query_string_boolean'; + +describe('BooleanFromString runtime type', () => { + it('decodes string "true" to a boolean', () => { + expect(booleanFromStringRT.decode('true')).toEqual(either.right(true)); + }); + + it('decodes string "false" to a boolean', () => { + expect(booleanFromStringRT.decode('false')).toEqual(either.right(false)); + }); + + it('rejects other strings', () => { + expect(either.isLeft(booleanFromStringRT.decode('maybe'))).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/monitoring/server/debug_logger.ts b/x-pack/plugins/monitoring/server/debug_logger.ts index 0add1f12f0304..cce00f834cbb2 100644 --- a/x-pack/plugins/monitoring/server/debug_logger.ts +++ b/x-pack/plugins/monitoring/server/debug_logger.ts @@ -4,18 +4,19 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { RouteMethod } from '@kbn/core/server'; import fs from 'fs'; import { MonitoringConfig } from './config'; -import { RouteDependencies } from './types'; +import { LegacyRequest, MonitoringCore, MonitoringRouteConfig, RouteDependencies } from './types'; export function decorateDebugServer( - _server: any, + server: MonitoringCore, config: MonitoringConfig, logger: RouteDependencies['logger'] -) { +): MonitoringCore { // bail if the proper config value is not set (extra protection) if (!config.ui.debug_mode) { - return _server; + return server; } // create a debug logger that will either write to file (if debug_log_path exists) or log out via logger @@ -23,14 +24,16 @@ export function decorateDebugServer( return { // maintain the rest of _server untouched - ..._server, + ...server, // TODO: replace any - route: (options: any) => { + route: ( + options: MonitoringRouteConfig + ) => { const apiPath = options.path; - return _server.route({ + return server.route({ ...options, // TODO: replace any - handler: async (req: any) => { + handler: async (req: LegacyRequest): Promise => { const { elasticsearch: cached } = req.server.plugins; const apiRequestHeaders = req.headers; req.server.plugins.elasticsearch = { diff --git a/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts b/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts index 80d17a8ad0627..f93c3f8ad7590 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts @@ -6,13 +6,18 @@ */ import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../common/constants'; +import { TimeRange } from '../../../common/http_api/shared'; import { ElasticsearchResponse } from '../../../common/types/es'; -import { LegacyRequest, Cluster } from '../../types'; -import { getNewIndexPatterns } from './get_index_patterns'; import { Globals } from '../../static_globals'; +import { Cluster, LegacyRequest } from '../../types'; +import { getNewIndexPatterns } from './get_index_patterns'; + +export interface FindSupportClusterRequestPayload { + timeRange: TimeRange; +} async function findSupportedBasicLicenseCluster( - req: LegacyRequest, + req: LegacyRequest, clusters: Cluster[], ccs: string, kibanaUuid: string, @@ -53,7 +58,7 @@ async function findSupportedBasicLicenseCluster( }, }, { term: { 'kibana_stats.kibana.uuid': kibanaUuid } }, - { range: { timestamp: { gte, lte, format: 'strict_date_optional_time' } } }, + { range: { timestamp: { gte, lte, format: 'epoch_millis' } } }, ], }, }, @@ -86,7 +91,10 @@ async function findSupportedBasicLicenseCluster( * Non-Basic license clusters and any cluster in a single-cluster environment * are also flagged as supported in this method. */ -export function flagSupportedClusters(req: LegacyRequest, ccs: string) { +export function flagSupportedClusters( + req: LegacyRequest, + ccs: string +) { const serverLog = (message: string) => req.getLogger('supported-clusters').debug(message); const flagAllSupported = (clusters: Cluster[]) => { clusters.forEach((cluster) => { diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.ts b/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.ts index 7d470857dfe5a..2ebf4fe6b480e 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { LegacyServer } from '../../types'; import { prefixIndexPatternWithCcs } from '../../../common/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH, @@ -20,14 +19,13 @@ import { INDEX_PATTERN_ENTERPRISE_SEARCH, CCS_REMOTE_PATTERN, } from '../../../common/constants'; -import { MonitoringConfig } from '../..'; +import { MonitoringConfig } from '../../config'; export function getIndexPatterns( - server: LegacyServer, + config: MonitoringConfig, additionalPatterns: Record = {}, ccs: string = CCS_REMOTE_PATTERN ) { - const config = server.config; const esIndexPattern = prefixIndexPatternWithCcs(config, INDEX_PATTERN_ELASTICSEARCH, ccs); const kbnIndexPattern = prefixIndexPatternWithCcs(config, INDEX_PATTERN_KIBANA, ccs); const lsIndexPattern = prefixIndexPatternWithCcs(config, INDEX_PATTERN_LOGSTASH, ccs); diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts index 3bd9f6d2265dc..a5ee876012c1d 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts @@ -19,7 +19,7 @@ import { LegacyRequest } from '../../types'; */ // TODO: replace LegacyRequest with current request object + plugin retrieval -export async function verifyMonitoringAuth(req: LegacyRequest) { +export async function verifyMonitoringAuth(req: LegacyRequest) { const xpackInfo = get(req.server.plugins.monitoring, 'info'); if (xpackInfo) { @@ -42,7 +42,7 @@ export async function verifyMonitoringAuth(req: LegacyRequest) { */ // TODO: replace LegacyRequest with current request object + plugin retrieval -async function verifyHasPrivileges(req: LegacyRequest) { +async function verifyHasPrivileges(req: LegacyRequest): Promise { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); let response; diff --git a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.js b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.ts similarity index 79% rename from x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.js rename to x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.ts index 214e8d5907443..ed92948be8e3b 100644 --- a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.js +++ b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.ts @@ -5,49 +5,50 @@ * 2.0. */ -import { getCollectionStatus } from '.'; +import { featuresPluginMock } from '@kbn/features-plugin/server/mocks'; +import { infraPluginMock } from '@kbn/infra-plugin/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks'; +import { configSchema, createConfig } from '../../../config'; +import { monitoringPluginMock } from '../../../mocks'; +import { LegacyRequest } from '../../../types'; import { getIndexPatterns } from '../../cluster/get_index_patterns'; +import { getCollectionStatus } from './get_collection_status'; const liveClusterUuid = 'a12'; const mockReq = ( - searchResult = {}, - securityEnabled = true, - userHasPermissions = true, - securityErrorMessage = null -) => { + searchResult: object = {}, + securityEnabled: boolean = true, + userHasPermissions: boolean = true, + securityErrorMessage: string | null = null +): LegacyRequest => { + const usageCollectionSetup = usageCollectionPluginMock.createSetupContract(); + const licenseService = monitoringPluginMock.createLicenseServiceMock(); + licenseService.getSecurityFeature.mockReturnValue({ + isAvailable: securityEnabled, + isEnabled: securityEnabled, + }); + const logger = loggerMock.create(); + return { server: { instanceUuid: 'kibana-1234', newPlatform: { setup: { plugins: { - usageCollection: { - getCollectorByType: () => ({ - isReady: () => false, - }), - }, + usageCollection: usageCollectionSetup, + features: featuresPluginMock.createSetup(), + infra: infraPluginMock.createSetupContract(), }, }, }, - config: { ui: { ccs: { enabled: false } } }, - usage: { - collectorSet: { - getCollectorByType: () => ({ - isReady: () => false, - }), - }, - }, + config: createConfig(configSchema.validate({ ui: { ccs: { enabled: false } } })), + log: logger, + route: jest.fn(), plugins: { monitoring: { info: { - getLicenseService: () => ({ - getSecurityFeature: () => { - return { - isAvailable: securityEnabled, - isEnabled: securityEnabled, - }; - }, - }), + getLicenseService: () => licenseService, }, }, elasticsearch: { @@ -86,6 +87,17 @@ const mockReq = ( }, }, }, + logger, + getLogger: () => logger, + params: {}, + payload: {}, + query: {}, + headers: {}, + getKibanaStatsCollector: () => null, + getUiSettingsService: () => null, + getActionTypeRegistry: () => null, + getRulesClient: () => null, + getActionsClient: () => null, }; }; @@ -124,7 +136,7 @@ describe('getCollectionStatus', () => { }, }); - const result = await getCollectionStatus(req, getIndexPatterns(req.server)); + const result = await getCollectionStatus(req, getIndexPatterns(req.server.config)); expect(result.kibana.totalUniqueInstanceCount).toBe(1); expect(result.kibana.totalUniqueFullyMigratedCount).toBe(0); @@ -173,7 +185,7 @@ describe('getCollectionStatus', () => { }, }); - const result = await getCollectionStatus(req, getIndexPatterns(req.server)); + const result = await getCollectionStatus(req, getIndexPatterns(req.server.config)); expect(result.kibana.totalUniqueInstanceCount).toBe(1); expect(result.kibana.totalUniqueFullyMigratedCount).toBe(1); @@ -229,7 +241,7 @@ describe('getCollectionStatus', () => { }, }); - const result = await getCollectionStatus(req, getIndexPatterns(req.server)); + const result = await getCollectionStatus(req, getIndexPatterns(req.server.config)); expect(result.kibana.totalUniqueInstanceCount).toBe(2); expect(result.kibana.totalUniqueFullyMigratedCount).toBe(1); @@ -251,7 +263,11 @@ describe('getCollectionStatus', () => { it('should detect products based on other indices', async () => { const req = mockReq({ hits: { total: { value: 1 } } }); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result.kibana.detected.doesExist).toBe(true); expect(result.elasticsearch.detected.doesExist).toBe(true); @@ -261,13 +277,21 @@ describe('getCollectionStatus', () => { it('should work properly when security is disabled', async () => { const req = mockReq({ hits: { total: { value: 1 } } }, false); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result.kibana.detected.doesExist).toBe(true); }); it('should work properly with an unknown security message', async () => { const req = mockReq({ hits: { total: { value: 1 } } }, true, true, 'foobar'); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result._meta.hasPermissions).toBe(false); }); @@ -278,7 +302,11 @@ describe('getCollectionStatus', () => { true, 'no handler found for uri [/_security/user/_has_privileges] and method [POST]' ); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result.kibana.detected.doesExist).toBe(true); }); @@ -289,13 +317,21 @@ describe('getCollectionStatus', () => { true, 'Invalid index name [_security]' ); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result.kibana.detected.doesExist).toBe(true); }); it('should not work if the user does not have the necessary permissions', async () => { const req = mockReq({ hits: { total: { value: 1 } } }, true, false); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result._meta.hasPermissions).toBe(false); }); }); diff --git a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.ts b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.ts index b06b74fd255f4..568b8bbaef567 100644 --- a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.ts +++ b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.ts @@ -5,17 +5,18 @@ * 2.0. */ -import { get, uniq } from 'lodash'; import { CollectorFetchContext, UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import { get, uniq } from 'lodash'; import { - METRICBEAT_INDEX_NAME_UNIQUE_TOKEN, - ELASTICSEARCH_SYSTEM_ID, APM_SYSTEM_ID, - KIBANA_SYSTEM_ID, BEATS_SYSTEM_ID, - LOGSTASH_SYSTEM_ID, + ELASTICSEARCH_SYSTEM_ID, KIBANA_STATS_TYPE_MONITORING, + KIBANA_SYSTEM_ID, + LOGSTASH_SYSTEM_ID, + METRICBEAT_INDEX_NAME_UNIQUE_TOKEN, } from '../../../../common/constants'; +import { TimeRange } from '../../../../common/http_api/shared'; import { LegacyRequest } from '../../../types'; import { getLivesNodes } from '../../elasticsearch/nodes/get_nodes/get_live_nodes'; @@ -31,7 +32,7 @@ interface Bucket { const NUMBER_OF_SECONDS_AGO_TO_LOOK = 30; const getRecentMonitoringDocuments = async ( - req: LegacyRequest, + req: LegacyRequest, indexPatterns: Record, clusterUuid?: string, nodeUuid?: string, @@ -300,7 +301,7 @@ function isBeatFromAPM(bucket: Bucket) { return get(beatType, 'buckets[0].key') === 'apm-server'; } -async function hasNecessaryPermissions(req: LegacyRequest) { +async function hasNecessaryPermissions(req: LegacyRequest) { const licenseService = await req.server.plugins.monitoring.info.getLicenseService(); const securityFeature = licenseService.getSecurityFeature(); if (!securityFeature.isAvailable || !securityFeature.isEnabled) { @@ -366,7 +367,7 @@ async function getLiveKibanaInstance(usageCollection?: UsageCollectionSetup) { ); } -async function getLiveElasticsearchClusterUuid(req: LegacyRequest) { +async function getLiveElasticsearchClusterUuid(req: LegacyRequest) { const params = { path: '/_cluster/state/cluster_uuid', method: 'GET', @@ -377,7 +378,9 @@ async function getLiveElasticsearchClusterUuid(req: LegacyRequest) { return clusterUuid; } -async function getLiveElasticsearchCollectionEnabled(req: LegacyRequest) { +async function getLiveElasticsearchCollectionEnabled( + req: LegacyRequest +) { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); const response = await callWithRequest(req, 'transport.request', { method: 'GET', @@ -425,7 +428,7 @@ async function getLiveElasticsearchCollectionEnabled(req: LegacyRequest) { * @param {*} skipLiveData Optional and will not make any live api calls if set to true */ export const getCollectionStatus = async ( - req: LegacyRequest, + req: LegacyRequest, indexPatterns: Record, clusterUuid?: string, nodeUuid?: string, diff --git a/x-pack/plugins/monitoring/server/mocks.ts b/x-pack/plugins/monitoring/server/mocks.ts new file mode 100644 index 0000000000000..5adeae22acfc0 --- /dev/null +++ b/x-pack/plugins/monitoring/server/mocks.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ILicense } from '@kbn/licensing-plugin/server'; +import { Subject } from 'rxjs'; +import { MonitoringLicenseService } from './types'; + +const createLicenseServiceMock = (): jest.Mocked => ({ + refresh: jest.fn(), + license$: new Subject(), + getMessage: jest.fn(), + getWatcherFeature: jest.fn(), + getMonitoringFeature: jest.fn(), + getSecurityFeature: jest.fn(), + stop: jest.fn(), +}); + +// this might be incomplete and is added to as needed +export const monitoringPluginMock = { + createLicenseServiceMock, +}; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts index 9188215137565..b773e25b81152 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts @@ -8,15 +8,15 @@ // @ts-ignore import { ActionResult } from '@kbn/actions-plugin/common'; import { RuleTypeParams, SanitizedRule } from '@kbn/alerting-plugin/common'; -import { handleError } from '../../../../lib/errors'; -import { AlertsFactory } from '../../../../alerts'; -import { LegacyServer, RouteDependencies } from '../../../../types'; import { ALERT_ACTION_TYPE_LOG } from '../../../../../common/constants'; +import { AlertsFactory } from '../../../../alerts'; import { disableWatcherClusterAlerts } from '../../../../lib/alerts/disable_watcher_cluster_alerts'; +import { handleError } from '../../../../lib/errors'; +import { MonitoringCore, RouteDependencies } from '../../../../types'; const DEFAULT_SERVER_LOG_NAME = 'Monitoring: Write to Kibana log'; -export function enableAlertsRoute(server: LegacyServer, npRoute: RouteDependencies) { +export function enableAlertsRoute(server: MonitoringCore, npRoute: RouteDependencies) { npRoute.router.post( { path: '/api/monitoring/v1/alerts/enable', diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts index 11782c73d9b55..c2511e1d24c0a 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts @@ -5,5 +5,11 @@ * 2.0. */ -export { enableAlertsRoute } from './enable'; -export { alertStatusRoute } from './status'; +import { MonitoringCore, RouteDependencies } from '../../../../types'; +import { enableAlertsRoute } from './enable'; +import { alertStatusRoute } from './status'; + +export function registerV1AlertRoutes(server: MonitoringCore, npRoute: RouteDependencies) { + alertStatusRoute(npRoute); + enableAlertsRoute(server, npRoute); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/status.ts b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/status.ts index a145d92921634..a9efc14c8c458 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/status.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/status.ts @@ -6,13 +6,12 @@ */ import { schema } from '@kbn/config-schema'; -// @ts-ignore +import { CommonAlertFilter } from '../../../../../common/types/alerts'; +import { fetchStatus } from '../../../../lib/alerts/fetch_status'; import { handleError } from '../../../../lib/errors'; import { RouteDependencies } from '../../../../types'; -import { fetchStatus } from '../../../../lib/alerts/fetch_status'; -import { CommonAlertFilter } from '../../../../../common/types/alerts'; -export function alertStatusRoute(server: any, npRoute: RouteDependencies) { +export function alertStatusRoute(npRoute: RouteDependencies) { npRoute.router.post( { path: '/api/monitoring/v1/alert/{clusterUuid}/status', diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/apm/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/apm/index.ts index 0fb4dd78c9be6..97d9a2f9789d7 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/apm/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/apm/index.ts @@ -5,6 +5,13 @@ * 2.0. */ -export { apmInstanceRoute } from './instance'; -export { apmInstancesRoute } from './instances'; -export { apmOverviewRoute } from './overview'; +import { MonitoringCore } from '../../../../types'; +import { apmInstanceRoute } from './instance'; +import { apmInstancesRoute } from './instances'; +import { apmOverviewRoute } from './overview'; + +export function registerV1ApmRoutes(server: MonitoringCore) { + apmInstanceRoute(server); + apmInstancesRoute(server); + apmOverviewRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/beats/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/beats/index.ts index 57423052760bf..935ca35c3a384 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/beats/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/beats/index.ts @@ -5,6 +5,13 @@ * 2.0. */ -export { beatsOverviewRoute } from './overview'; -export { beatsListingRoute } from './beats'; -export { beatsDetailRoute } from './beat_detail'; +import { MonitoringCore } from '../../../../types'; +import { beatsListingRoute } from './beats'; +import { beatsDetailRoute } from './beat_detail'; +import { beatsOverviewRoute } from './overview'; + +export function registerV1BeatsRoutes(server: MonitoringCore) { + beatsDetailRoute(server); + beatsListingRoute(server); + beatsOverviewRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts index 450872049a3de..2db7481882b89 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts @@ -7,18 +7,19 @@ import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; import { handleError } from '../../../../lib/errors'; -import { LegacyRequest, LegacyServer } from '../../../../types'; +import { LegacyRequest, MonitoringCore } from '../../../../types'; /* * API for checking read privilege on Monitoring Data * Used for the "Access Denied" page as something to auto-retry with. */ -// TODO: Replace this LegacyServer call with the "new platform" core Kibana route method -export function checkAccessRoute(server: LegacyServer) { +// TODO: Replace this legacy route registration with the "new platform" core Kibana route method +export function checkAccessRoute(server: MonitoringCore) { server.route({ - method: 'GET', + method: 'get', path: '/api/monitoring/v1/check_access', + validate: {}, handler: async (req: LegacyRequest) => { const response: { has_access?: boolean } = {}; try { diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts index 0fb8228f82442..5209ec8b92e9a 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts @@ -5,4 +5,9 @@ * 2.0. */ -export { checkAccessRoute } from './check_access'; +import { MonitoringCore } from '../../../../types'; +import { checkAccessRoute } from './check_access'; + +export function registerV1CheckAccessRoutes(server: MonitoringCore) { + checkAccessRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.ts b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.ts index 30749f2e95c9f..6bd0a19d79c5f 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.ts @@ -5,39 +5,36 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; +import { + postClusterRequestParamsRT, + postClusterRequestPayloadRT, + postClusterResponsePayloadRT, +} from '../../../../../common/http_api/cluster'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; import { getClustersFromRequest } from '../../../../lib/cluster/get_clusters_from_request'; -// @ts-ignore -import { handleError } from '../../../../lib/errors'; import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; -import { LegacyRequest, LegacyServer } from '../../../../types'; +import { handleError } from '../../../../lib/errors'; +import { MonitoringCore } from '../../../../types'; -export function clusterRoute(server: LegacyServer) { +export function clusterRoute(server: MonitoringCore) { /* * Cluster Overview */ + + const validateParams = createValidationFunction(postClusterRequestParamsRT); + const validateBody = createValidationFunction(postClusterRequestPayloadRT); + server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters/{clusterUuid}', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - codePaths: schema.arrayOf(schema.string()), - }), - }, + validate: { + params: validateParams, + body: validateBody, }, - handler: async (req: LegacyRequest) => { + handler: async (req) => { const config = server.config; - const indexPatterns = getIndexPatterns(server, { + const indexPatterns = getIndexPatterns(config, { filebeatIndexPattern: config.ui.logs.index, }); const options = { @@ -47,13 +44,12 @@ export function clusterRoute(server: LegacyServer) { codePaths: req.payload.codePaths, }; - let clusters = []; try { - clusters = await getClustersFromRequest(req, indexPatterns, options); + const clusters = await getClustersFromRequest(req, indexPatterns, options); + return postClusterResponsePayloadRT.encode(clusters); } catch (err) { throw handleError(err, req); } - return clusters; }, }); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts index 81acd0e53f319..9591dda205487 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts @@ -5,36 +5,33 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; -import { LegacyRequest, LegacyServer } from '../../../../types'; +import { + postClustersRequestPayloadRT, + postClustersResponsePayloadRT, +} from '../../../../../common/http_api/cluster'; import { getClustersFromRequest } from '../../../../lib/cluster/get_clusters_from_request'; +import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; import { handleError } from '../../../../lib/errors'; -import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; +import { MonitoringCore } from '../../../../types'; -export function clustersRoute(server: LegacyServer) { +export function clustersRoute(server: MonitoringCore) { /* * Monitoring Home * Route Init (for checking license and compatibility for multi-cluster monitoring */ + const validateBody = createValidationFunction(postClustersRequestPayloadRT); + // TODO switch from the LegacyServer route() method to the "new platform" route methods server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters', - config: { - validate: { - body: schema.object({ - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - codePaths: schema.arrayOf(schema.string()), - }), - }, + validate: { + body: validateBody, }, - handler: async (req: LegacyRequest) => { - let clusters = []; + handler: async (req) => { const config = server.config; // NOTE using try/catch because checkMonitoringAuth is expected to throw @@ -42,17 +39,16 @@ export function clustersRoute(server: LegacyServer) { // the monitoring data. `try/catch` makes it a little more explicit. try { await verifyMonitoringAuth(req); - const indexPatterns = getIndexPatterns(server, { + const indexPatterns = getIndexPatterns(config, { filebeatIndexPattern: config.ui.logs.index, }); - clusters = await getClustersFromRequest(req, indexPatterns, { - codePaths: req.payload.codePaths as string[], // TODO remove this cast when we can properly type req by using the right route handler + const clusters = await getClustersFromRequest(req, indexPatterns, { + codePaths: req.payload.codePaths, }); + return postClustersResponsePayloadRT.encode(clusters); } catch (err) { throw handleError(err, req); } - - return clusters; }, }); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts index 769f315480d9c..9534398db52c1 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts @@ -5,5 +5,11 @@ * 2.0. */ -export { clusterRoute } from './cluster'; -export { clustersRoute } from './clusters'; +import { clusterRoute } from './cluster'; +import { clustersRoute } from './clusters'; +import { MonitoringCore } from '../../../../types'; + +export function registerV1ClusterRoutes(server: MonitoringCore) { + clusterRoute(server); + clustersRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index.ts index b2d432a5e35b5..e706dc61c0a41 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index.ts @@ -5,11 +5,23 @@ * 2.0. */ -export { esIndexRoute } from './index_detail'; -export { esIndicesRoute } from './indices'; -export { esNodeRoute } from './node_detail'; -export { esNodesRoute } from './nodes'; -export { esOverviewRoute } from './overview'; -export { mlJobRoute } from './ml_jobs'; -export { ccrRoute } from './ccr'; -export { ccrShardRoute } from './ccr_shard'; +import { MonitoringCore } from '../../../../types'; +import { ccrRoute } from './ccr'; +import { ccrShardRoute } from './ccr_shard'; +import { esIndexRoute } from './index_detail'; +import { esIndicesRoute } from './indices'; +import { mlJobRoute } from './ml_jobs'; +import { esNodesRoute } from './nodes'; +import { esNodeRoute } from './node_detail'; +import { esOverviewRoute } from './overview'; + +export function registerV1ElasticsearchRoutes(server: MonitoringCore) { + esIndexRoute(server); + esIndicesRoute(server); + esNodeRoute(server); + esNodesRoute(server); + esOverviewRoute(server); + mlJobRoute(server); + ccrRoute(server); + ccrShardRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts index 11e0eec3f08f0..f8742144b28f8 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts @@ -19,7 +19,7 @@ import { } from '../../../../../../common/http_api/elasticsearch_settings'; import { createValidationFunction } from '../../../../../lib/create_route_validation_function'; import { handleError } from '../../../../../lib/errors'; -import { LegacyServer, RouteDependencies } from '../../../../../types'; +import { MonitoringCore, RouteDependencies } from '../../../../../types'; const queryBody = { size: 0, @@ -72,7 +72,7 @@ const checkLatestMonitoringIsLegacy = async (context: RequestHandlerContext, ind return counts; }; -export function internalMonitoringCheckRoute(server: LegacyServer, npRoute: RouteDependencies) { +export function internalMonitoringCheckRoute(server: MonitoringCore, npRoute: RouteDependencies) { const validateBody = createValidationFunction( postElasticsearchSettingsInternalMonitoringRequestPayloadRT ); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts index 61bb1ba804a5a..dfc68068bf80d 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts @@ -5,8 +5,20 @@ * 2.0. */ -export { clusterSettingsCheckRoute } from './check/cluster'; -export { internalMonitoringCheckRoute } from './check/internal_monitoring'; -export { nodesSettingsCheckRoute } from './check/nodes'; -export { setCollectionEnabledRoute } from './set/collection_enabled'; -export { setCollectionIntervalRoute } from './set/collection_interval'; +import { MonitoringCore, RouteDependencies } from '../../../../types'; +import { clusterSettingsCheckRoute } from './check/cluster'; +import { internalMonitoringCheckRoute } from './check/internal_monitoring'; +import { nodesSettingsCheckRoute } from './check/nodes'; +import { setCollectionEnabledRoute } from './set/collection_enabled'; +import { setCollectionIntervalRoute } from './set/collection_interval'; + +export function registerV1ElasticsearchSettingsRoutes( + server: MonitoringCore, + npRoute: RouteDependencies +) { + clusterSettingsCheckRoute(server); + internalMonitoringCheckRoute(server, npRoute); + nodesSettingsCheckRoute(server); + setCollectionEnabledRoute(server); + setCollectionIntervalRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/index.ts new file mode 100644 index 0000000000000..e0f5e55c6c128 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { registerV1AlertRoutes } from './alerts'; +export { registerV1ApmRoutes } from './apm'; +export { registerV1BeatsRoutes } from './beats'; +export { registerV1CheckAccessRoutes } from './check_access'; +export { registerV1ClusterRoutes } from './cluster'; +export { registerV1ElasticsearchRoutes } from './elasticsearch'; +export { registerV1ElasticsearchSettingsRoutes } from './elasticsearch_settings'; +export { registerV1LogstashRoutes } from './logstash'; +export { registerV1SetupRoutes } from './setup'; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts index b267c17fc3346..a4975726cf0a1 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts @@ -5,10 +5,21 @@ * 2.0. */ -export { logstashNodesRoute } from './nodes'; -export { logstashNodeRoute } from './node'; -export { logstashOverviewRoute } from './overview'; -export { logstashPipelineRoute } from './pipeline'; -export { logstashNodePipelinesRoute } from './pipelines/node_pipelines'; -export { logstashClusterPipelinesRoute } from './pipelines/cluster_pipelines'; -export { logstashClusterPipelineIdsRoute } from './pipelines/cluster_pipeline_ids'; +import { MonitoringCore } from '../../../../types'; +import { logstashNodeRoute } from './node'; +import { logstashNodesRoute } from './nodes'; +import { logstashOverviewRoute } from './overview'; +import { logstashPipelineRoute } from './pipeline'; +import { logstashClusterPipelinesRoute } from './pipelines/cluster_pipelines'; +import { logstashClusterPipelineIdsRoute } from './pipelines/cluster_pipeline_ids'; +import { logstashNodePipelinesRoute } from './pipelines/node_pipelines'; + +export function registerV1LogstashRoutes(server: MonitoringCore) { + logstashClusterPipelineIdsRoute(server); + logstashClusterPipelinesRoute(server); + logstashNodePipelinesRoute(server); + logstashNodeRoute(server); + logstashNodesRoute(server); + logstashOverviewRoute(server); + logstashPipelineRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js b/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js deleted file mode 100644 index bc8b722d22214..0000000000000 --- a/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; -import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; -import { handleError } from '../../../../lib/errors'; -import { getCollectionStatus } from '../../../../lib/setup/collection'; -import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; - -export function clusterSetupStatusRoute(server) { - /* - * Monitoring Home - * Route Init (for checking license and compatibility for multi-cluster monitoring - */ - server.route({ - method: 'POST', - path: '/api/monitoring/v1/setup/collection/cluster/{clusterUuid?}', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.maybe(schema.string()), - }), - query: schema.object({ - // This flag is not intended to be used in production. It was introduced - // as a way to ensure consistent API testing - the typical data source - // for API tests are archived data, where the cluster configuration and data - // are consistent from environment to environment. However, this endpoint - // also attempts to retrieve data from the running stack products (ES and Kibana) - // which will vary from environment to environment making it difficult - // to write tests against. Therefore, this flag exists and should only be used - // in our testing environment. - skipLiveData: schema.boolean({ defaultValue: false }), - }), - body: schema.nullable( - schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string({ defaultValue: '' }), - max: schema.string({ defaultValue: '' }), - }), - }) - ), - }, - }, - handler: async (req) => { - let status = null; - - // NOTE using try/catch because checkMonitoringAuth is expected to throw - // an error when current logged-in user doesn't have permission to read - // the monitoring data. `try/catch` makes it a little more explicit. - try { - await verifyMonitoringAuth(req); - const indexPatterns = getIndexPatterns(server, {}, req.payload.ccs); - status = await getCollectionStatus( - req, - indexPatterns, - req.params.clusterUuid, - null, - req.query.skipLiveData - ); - } catch (err) { - throw handleError(err, req); - } - - return status; - }, - }); -} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.ts b/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.ts new file mode 100644 index 0000000000000..370947df46b42 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + postClusterSetupStatusRequestParamsRT, + postClusterSetupStatusRequestPayloadRT, + postClusterSetupStatusRequestQueryRT, + postClusterSetupStatusResponsePayloadRT, +} from '../../../../../common/http_api/setup'; +import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; +import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; +import { handleError } from '../../../../lib/errors'; +import { getCollectionStatus } from '../../../../lib/setup/collection'; +import { MonitoringCore } from '../../../../types'; + +export function clusterSetupStatusRoute(server: MonitoringCore) { + /* + * Monitoring Home + * Route Init (for checking license and compatibility for multi-cluster monitoring + */ + + const validateParams = createValidationFunction(postClusterSetupStatusRequestParamsRT); + const validateQuery = createValidationFunction(postClusterSetupStatusRequestQueryRT); + const validateBody = createValidationFunction(postClusterSetupStatusRequestPayloadRT); + + server.route({ + method: 'post', + path: '/api/monitoring/v1/setup/collection/cluster/{clusterUuid?}', + validate: { + params: validateParams, + query: validateQuery, + body: validateBody, + }, + handler: async (req) => { + const clusterUuid = req.params.clusterUuid; + const skipLiveData = req.query.skipLiveData; + + // NOTE using try/catch because checkMonitoringAuth is expected to throw + // an error when current logged-in user doesn't have permission to read + // the monitoring data. `try/catch` makes it a little more explicit. + try { + await verifyMonitoringAuth(req); + const indexPatterns = getIndexPatterns(server.config, {}, req.payload.ccs); + const status = await getCollectionStatus( + req, + indexPatterns, + clusterUuid, + undefined, + skipLiveData + ); + return postClusterSetupStatusResponsePayloadRT.encode(status); + } catch (err) { + throw handleError(err, req); + } + }, + }); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.js b/x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.ts similarity index 74% rename from x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.js rename to x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.ts index 9590d91c357ee..cdecf346bae9d 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.ts @@ -5,21 +5,19 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; +import { postDisableInternalCollectionRequestParamsRT } from '../../../../../common/http_api/setup'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; -import { handleError } from '../../../../lib/errors'; import { setCollectionDisabled } from '../../../../lib/elasticsearch_settings/set/collection_disabled'; +import { handleError } from '../../../../lib/errors'; +import { MonitoringCore } from '../../../../types'; -export function disableElasticsearchInternalCollectionRoute(server) { +export function disableElasticsearchInternalCollectionRoute(server: MonitoringCore) { server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/setup/collection/{clusterUuid}/disable_internal_collection', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - }, + validate: { + params: createValidationFunction(postDisableInternalCollectionRequestParamsRT), }, handler: async (req) => { // NOTE using try/catch because checkMonitoringAuth is expected to throw diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.ts new file mode 100644 index 0000000000000..6a8ecac8597a8 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MonitoringCore } from '../../../../types'; +import { clusterSetupStatusRoute } from './cluster_setup_status'; +import { disableElasticsearchInternalCollectionRoute } from './disable_elasticsearch_internal_collection'; +import { nodeSetupStatusRoute } from './node_setup_status'; + +export function registerV1SetupRoutes(server: MonitoringCore) { + clusterSetupStatusRoute(server); + disableElasticsearchInternalCollectionRoute(server); + nodeSetupStatusRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js b/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js deleted file mode 100644 index 1f93e92843ea8..0000000000000 --- a/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; -import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; -import { handleError } from '../../../../lib/errors'; -import { getCollectionStatus } from '../../../../lib/setup/collection'; -import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; - -export function nodeSetupStatusRoute(server) { - /* - * Monitoring Home - * Route Init (for checking license and compatibility for multi-cluster monitoring - */ - server.route({ - method: 'POST', - path: '/api/monitoring/v1/setup/collection/node/{nodeUuid}', - config: { - validate: { - params: schema.object({ - nodeUuid: schema.string(), - }), - query: schema.object({ - // This flag is not intended to be used in production. It was introduced - // as a way to ensure consistent API testing - the typical data source - // for API tests are archived data, where the cluster configuration and data - // are consistent from environment to environment. However, this endpoint - // also attempts to retrieve data from the running stack products (ES and Kibana) - // which will vary from environment to environment making it difficult - // to write tests against. Therefore, this flag exists and should only be used - // in our testing environment. - skipLiveData: schema.boolean({ defaultValue: false }), - }), - body: schema.nullable( - schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.maybe( - schema.object({ - min: schema.string(), - max: schema.string(), - }) - ), - }) - ), - }, - }, - handler: async (req) => { - let status = null; - - // NOTE using try/catch because checkMonitoringAuth is expected to throw - // an error when current logged-in user doesn't have permission to read - // the monitoring data. `try/catch` makes it a little more explicit. - try { - await verifyMonitoringAuth(req); - const indexPatterns = getIndexPatterns(server, {}, req.payload.ccs); - status = await getCollectionStatus( - req, - indexPatterns, - null, - req.params.nodeUuid, - req.query.skipLiveData - ); - } catch (err) { - throw handleError(err, req); - } - - return status; - }, - }); -} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.ts b/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.ts new file mode 100644 index 0000000000000..327b741a0e64a --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.ts @@ -0,0 +1,64 @@ +/* + * 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 { + postNodeSetupStatusRequestParamsRT, + postNodeSetupStatusRequestPayloadRT, + postNodeSetupStatusRequestQueryRT, + postNodeSetupStatusResponsePayloadRT, +} from '../../../../../common/http_api/setup'; +import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; +import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; +import { handleError } from '../../../../lib/errors'; +import { getCollectionStatus } from '../../../../lib/setup/collection'; +import { MonitoringCore } from '../../../../types'; + +export function nodeSetupStatusRoute(server: MonitoringCore) { + /* + * Monitoring Home + * Route Init (for checking license and compatibility for multi-cluster monitoring + */ + + const validateParams = createValidationFunction(postNodeSetupStatusRequestParamsRT); + const validateQuery = createValidationFunction(postNodeSetupStatusRequestQueryRT); + const validateBody = createValidationFunction(postNodeSetupStatusRequestPayloadRT); + + server.route({ + method: 'post', + path: '/api/monitoring/v1/setup/collection/node/{nodeUuid}', + validate: { + params: validateParams, + query: validateQuery, + body: validateBody, + }, + handler: async (req) => { + const nodeUuid = req.params.nodeUuid; + const skipLiveData = req.query.skipLiveData; + const ccs = req.payload.ccs; + + // NOTE using try/catch because checkMonitoringAuth is expected to throw + // an error when current logged-in user doesn't have permission to read + // the monitoring data. `try/catch` makes it a little more explicit. + try { + await verifyMonitoringAuth(req); + const indexPatterns = getIndexPatterns(server.config, {}, ccs); + const status = await getCollectionStatus( + req, + indexPatterns, + undefined, + nodeUuid, + skipLiveData + ); + + return postNodeSetupStatusResponsePayloadRT.encode(status); + } catch (err) { + throw handleError(err, req); + } + }, + }); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/ui.js b/x-pack/plugins/monitoring/server/routes/api/v1/ui.js deleted file mode 100644 index 618d12afedef7..0000000000000 --- a/x-pack/plugins/monitoring/server/routes/api/v1/ui.js +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// all routes for the app -export { checkAccessRoute } from './check_access'; -export * from './alerts'; -export { beatsDetailRoute, beatsListingRoute, beatsOverviewRoute } from './beats'; -export { clusterRoute, clustersRoute } from './cluster'; -export { - esIndexRoute, - esIndicesRoute, - esNodeRoute, - esNodesRoute, - esOverviewRoute, - mlJobRoute, - ccrRoute, - ccrShardRoute, -} from './elasticsearch'; -export { - internalMonitoringCheckRoute, - clusterSettingsCheckRoute, - nodesSettingsCheckRoute, - setCollectionEnabledRoute, - setCollectionIntervalRoute, -} from './elasticsearch_settings'; -export { kibanaInstanceRoute, kibanaInstancesRoute, kibanaOverviewRoute } from './kibana'; -export { apmInstanceRoute, apmInstancesRoute, apmOverviewRoute } from './apm'; -export { - logstashClusterPipelinesRoute, - logstashNodePipelinesRoute, - logstashNodeRoute, - logstashNodesRoute, - logstashOverviewRoute, - logstashPipelineRoute, - logstashClusterPipelineIdsRoute, -} from './logstash'; -export { entSearchOverviewRoute } from './enterprise_search'; -export * from './setup'; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/ui.ts b/x-pack/plugins/monitoring/server/routes/api/v1/ui.ts new file mode 100644 index 0000000000000..7aaa6591e868e --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/ui.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +// these are the remaining routes not yet converted to TypeScript +// all others are registered through index.ts + +// @ts-expect-error +export { kibanaInstanceRoute, kibanaInstancesRoute, kibanaOverviewRoute } from './kibana'; +// @ts-expect-error +export { entSearchOverviewRoute } from './enterprise_search'; diff --git a/x-pack/plugins/monitoring/server/routes/index.ts b/x-pack/plugins/monitoring/server/routes/index.ts index 05a8de96b4c07..f38612d5a42da 100644 --- a/x-pack/plugins/monitoring/server/routes/index.ts +++ b/x-pack/plugins/monitoring/server/routes/index.ts @@ -8,22 +8,43 @@ import { MonitoringConfig } from '../config'; import { decorateDebugServer } from '../debug_logger'; -import { RouteDependencies } from '../types'; -// @ts-ignore -import * as uiRoutes from './api/v1/ui'; // namespace import +import { MonitoringCore, RouteDependencies } from '../types'; +import { + registerV1AlertRoutes, + registerV1ApmRoutes, + registerV1BeatsRoutes, + registerV1CheckAccessRoutes, + registerV1ClusterRoutes, + registerV1ElasticsearchRoutes, + registerV1ElasticsearchSettingsRoutes, + registerV1LogstashRoutes, + registerV1SetupRoutes, +} from './api/v1'; +import * as uiRoutes from './api/v1/ui'; export function requireUIRoutes( - _server: any, + server: MonitoringCore, config: MonitoringConfig, npRoute: RouteDependencies ) { const routes = Object.keys(uiRoutes); - const server = config.ui.debug_mode - ? decorateDebugServer(_server, config, npRoute.logger) - : _server; + const decoratedServer = config.ui.debug_mode + ? decorateDebugServer(server, config, npRoute.logger) + : server; routes.forEach((route) => { + // @ts-expect-error const registerRoute = uiRoutes[route]; // computed reference to module objects imported via namespace registerRoute(server, npRoute); }); + + registerV1AlertRoutes(decoratedServer, npRoute); + registerV1ApmRoutes(server); + registerV1BeatsRoutes(server); + registerV1CheckAccessRoutes(server); + registerV1ClusterRoutes(server); + registerV1ElasticsearchRoutes(server); + registerV1ElasticsearchSettingsRoutes(server, npRoute); + registerV1LogstashRoutes(server); + registerV1SetupRoutes(server); } diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index 86447a24fdf04..64931f5888514 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -34,7 +34,7 @@ import { LicensingPluginStart } from '@kbn/licensing-plugin/server'; import { PluginSetupContract as FeaturesPluginSetupContract } from '@kbn/features-plugin/server'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import { CloudSetup } from '@kbn/cloud-plugin/server'; -import { RouteConfig, RouteMethod } from '@kbn/core/server'; +import { RouteConfig, RouteMethod, Headers } from '@kbn/core/server'; import { ElasticsearchModifiedSource } from '../common/types/es'; import { RulesByType } from '../common/types/alerts'; import { configSchema, MonitoringConfig } from './config'; @@ -124,6 +124,7 @@ export interface LegacyRequest { payload: Body; params: Params; query: Query; + headers: Headers; getKibanaStatsCollector: () => any; getUiSettingsService: () => any; getActionTypeRegistry: () => any; From 6fc2fff3f2dfc263f767bbc54f46eb4946438e4c Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Fri, 20 May 2022 10:48:15 -0700 Subject: [PATCH 123/150] [ML] Minor edits in prebuilt job descriptions (#132633) --- .../modules/security_auth/ml/auth_high_count_logon_events.json | 2 +- .../ml/auth_high_count_logon_events_for_a_source_ip.json | 2 +- .../modules/security_auth/ml/auth_high_count_logon_fails.json | 2 +- .../models/data_recognizer/modules/security_linux/manifest.json | 2 +- .../security_network/ml/high_count_by_destination_country.json | 2 +- .../modules/security_network/ml/high_count_network_denies.json | 2 +- .../modules/security_network/ml/high_count_network_events.json | 2 +- .../data_recognizer/modules/security_windows/manifest.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json index 35fc14e23624f..fa87299dfb464 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Authentication - looks for an unusually large spike in successful authentication events. This can be due to password spraying, user enumeration or brute force activity.", + "description": "Security: Authentication - Looks for an unusually large spike in successful authentication events. This can be due to password spraying, user enumeration, or brute force activity.", "groups": [ "security", "authentication" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json index cdf219152c7fd..9f2f10973a35b 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Authentication - looks for an unusually large spike in successful authentication events from a particular source IP address. This can be due to password spraying, user enumeration or brute force activity.", + "description": "Security: Authentication - Looks for an unusually large spike in successful authentication events from a particular source IP address. This can be due to password spraying, user enumeration, or brute force activity.", "groups": [ "security", "authentication" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json index cde52bf7d33cc..c74dff5257864 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Authentication - looks for an unusually large spike in authentication failure events. This can be due to password spraying, user enumeration or brute force activity and may be a precursor to account takeover or credentialed access.", + "description": "Security: Authentication - Looks for an unusually large spike in authentication failure events. This can be due to password spraying, user enumeration, or brute force activity and may be a precursor to account takeover or credentialed access.", "groups": [ "security", "authentication" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json index efed4a3c9e9b1..cfa9f45c5d1ac 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json @@ -1,7 +1,7 @@ { "id": "security_linux_v3", "title": "Security: Linux", - "description": "Anomaly detection jobs for Linux host based threat hunting and detection.", + "description": "Anomaly detection jobs for Linux host-based threat hunting and detection.", "type": "linux data", "logoFile": "logo.json", "defaultIndexPattern": "auditbeat-*,logs-*", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json index 2360233937c2b..45375ad939f36 100755 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Network - looks for an unusually large spike in network activity to one destination country in the network logs. This could be due to unusually large amounts of reconnaissance or enumeration traffic. Data exfiltration activity may also produce such a surge in traffic to a destination country which does not normally appear in network traffic or business work-flows. Malware instances and persistence mechanisms may communicate with command-and-control (C2) infrastructure in their country of origin, which may be an unusual destination country for the source network.", + "description": "Security: Network - Looks for an unusually large spike in network activity to one destination country in the network logs. This could be due to unusually large amounts of reconnaissance or enumeration traffic. Data exfiltration activity may also produce such a surge in traffic to a destination country which does not normally appear in network traffic or business work-flows. Malware instances and persistence mechanisms may communicate with command-and-control (C2) infrastructure in their country of origin, which may be an unusual destination country for the source network.", "groups": [ "security", "network" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json index 2a3b4b0100183..45c22599f37d2 100755 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Network - looks for an unusually large spike in network traffic that was denied by network ACLs or firewall rules. Such a burst of denied traffic is usually either 1) a misconfigured application or firewall or 2) suspicious or malicious activity. Unsuccessful attempts at network transit, in order to connect to command-and-control (C2), or engage in data exfiltration, may produce a burst of failed connections. This could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", + "description": "Security: Network - Looks for an unusually large spike in network traffic that was denied by network ACLs or firewall rules. Such a burst of denied traffic is usually either 1) a misconfigured application or firewall or 2) suspicious or malicious activity. Unsuccessful attempts at network transit, in order to connect to command-and-control (C2), or engage in data exfiltration, may produce a burst of failed connections. This could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", "groups": [ "security", "network" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json index 792d7f2513985..a3bb734ad9bdc 100755 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Network - looks for an unusually large spike in network traffic. Such a burst of traffic, if not caused by a surge in business activity, can be due to suspicious or malicious activity. Large-scale data exfiltration may produce a burst of network traffic; this could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", + "description": "Security: Network - Looks for an unusually large spike in network traffic. Such a burst of traffic, if not caused by a surge in business activity, can be due to suspicious or malicious activity. Large-scale data exfiltration may produce a burst of network traffic; this could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", "groups": [ "security", "network" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json index bf39cd7ec7902..8d01d0d91e0c2 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json @@ -1,7 +1,7 @@ { "id": "security_windows_v3", "title": "Security: Windows", - "description": "Anomaly detection jobs for Windows host based threat hunting and detection.", + "description": "Anomaly detection jobs for Windows host-based threat hunting and detection.", "type": "windows data", "logoFile": "logo.json", "defaultIndexPattern": "winlogbeat-*,logs-*", From e857b30f8a9b4b1ccaa9527b3809875b892dec01 Mon Sep 17 00:00:00 2001 From: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Date: Fri, 20 May 2022 20:36:59 +0200 Subject: [PATCH 124/150] remove human-readable automatic slug generation (#132593) * remove human-readable automatic slug generation * make change non-breaking * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * remove test Co-authored-by: streamich Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/share/README.mdx | 13 ------------- .../share/common/url_service/short_urls/types.ts | 6 ------ .../short_urls/short_url_client.test.ts | 2 -- .../url_service/short_urls/short_url_client.ts | 3 --- .../http/short_urls/register_create_route.ts | 12 ++++++++++-- .../short_urls/short_url_client.test.ts | 13 ------------- .../url_service/short_urls/short_url_client.ts | 4 +--- .../apis/short_url/create_short_url/main.ts | 16 ---------------- 8 files changed, 11 insertions(+), 58 deletions(-) diff --git a/src/plugins/share/README.mdx b/src/plugins/share/README.mdx index 1a1e2e721c2ab..1a1fef0587812 100644 --- a/src/plugins/share/README.mdx +++ b/src/plugins/share/README.mdx @@ -215,19 +215,6 @@ const url = await shortUrls.create({ }); ``` -You can make the short URL slug human-readable by specifying the -`humanReadableSlug` flag: - -```ts -const url = await shortUrls.create({ - locator, - params: { - dashboardId: '123', - }, - humanReadableSlug: true, -}); -``` - Or you can manually specify the slug for the short URL using the `slug` option: ```ts diff --git a/src/plugins/share/common/url_service/short_urls/types.ts b/src/plugins/share/common/url_service/short_urls/types.ts index 44e11a0610a66..a7c4d106873f9 100644 --- a/src/plugins/share/common/url_service/short_urls/types.ts +++ b/src/plugins/share/common/url_service/short_urls/types.ts @@ -79,12 +79,6 @@ export interface ShortUrlCreateParams

{ * URL. This part will be visible to the user, it can have user-friendly text. */ slug?: string; - - /** - * Whether to generate a slug automatically. If `true`, the slug will be - * a human-readable text consisting of three worlds: "--". - */ - humanReadableSlug?: boolean; } /** diff --git a/src/plugins/share/public/url_service/short_urls/short_url_client.test.ts b/src/plugins/share/public/url_service/short_urls/short_url_client.test.ts index 8a125206d1c80..693d06538e63e 100644 --- a/src/plugins/share/public/url_service/short_urls/short_url_client.test.ts +++ b/src/plugins/share/public/url_service/short_urls/short_url_client.test.ts @@ -88,7 +88,6 @@ describe('create()', () => { body: expect.any(String), }); expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toStrictEqual({ - humanReadableSlug: false, locatorId: LEGACY_SHORT_URL_LOCATOR_ID, params: { url: 'https://example.com/foo/bar', @@ -173,7 +172,6 @@ describe('createFromLongUrl()', () => { body: expect.any(String), }); expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toStrictEqual({ - humanReadableSlug: true, locatorId: LEGACY_SHORT_URL_LOCATOR_ID, params: { url: '/a/b/c', diff --git a/src/plugins/share/public/url_service/short_urls/short_url_client.ts b/src/plugins/share/public/url_service/short_urls/short_url_client.ts index 63dcdc0b78718..4a9dbf3909288 100644 --- a/src/plugins/share/public/url_service/short_urls/short_url_client.ts +++ b/src/plugins/share/public/url_service/short_urls/short_url_client.ts @@ -59,7 +59,6 @@ export class BrowserShortUrlClient implements IShortUrlClient { locator, params, slug = undefined, - humanReadableSlug = false, }: ShortUrlCreateParams

): Promise> { const { http } = this.dependencies; const data = await http.fetch>('/api/short_url', { @@ -67,7 +66,6 @@ export class BrowserShortUrlClient implements IShortUrlClient { body: JSON.stringify({ locatorId: locator.id, slug, - humanReadableSlug, params, }), }); @@ -113,7 +111,6 @@ export class BrowserShortUrlClient implements IShortUrlClient { const result = await this.createWithLocator({ locator, - humanReadableSlug: true, params: { url: relativeUrl, }, diff --git a/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts b/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts index 1208f6fda4d1e..97594837f0720 100644 --- a/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts +++ b/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts @@ -26,6 +26,15 @@ export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => { minLength: 3, maxLength: 255, }), + /** + * @deprecated + * + * This field is deprecated as the API does not support automatic + * human-readable slug generation. + * + * @todo This field will be removed in a future version. It is left + * here for backwards compatibility. + */ humanReadableSlug: schema.boolean({ defaultValue: false, }), @@ -36,7 +45,7 @@ export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => { router.handleLegacyErrors(async (ctx, req, res) => { const savedObjects = (await ctx.core).savedObjects.client; const shortUrls = url.shortUrls.get({ savedObjects }); - const { locatorId, params, slug, humanReadableSlug } = req.body; + const { locatorId, params, slug } = req.body; const locator = url.locators.get(locatorId); if (!locator) { @@ -51,7 +60,6 @@ export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => { locator, params, slug, - humanReadableSlug, }); return res.ok({ diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts index 5fc108cdbf56c..fe6365d498628 100644 --- a/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts +++ b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts @@ -128,19 +128,6 @@ describe('ServerShortUrlClient', () => { }) ).rejects.toThrowError(new UrlServiceError(`Slug "lala" already exists.`, 'SLUG_EXISTS')); }); - - test('can automatically generate human-readable slug', async () => { - const { client, locator } = setup(); - const shortUrl = await client.create({ - locator, - humanReadableSlug: true, - params: { - url: '/app/test#foo/bar/baz', - }, - }); - - expect(shortUrl.data.slug.split('-').length).toBe(3); - }); }); describe('.get()', () => { diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.ts index 762ded11bf8ee..cecc4c3127135 100644 --- a/src/plugins/share/server/url_service/short_urls/short_url_client.ts +++ b/src/plugins/share/server/url_service/short_urls/short_url_client.ts @@ -8,7 +8,6 @@ import type { SerializableRecord } from '@kbn/utility-types'; import { SavedObjectReference } from '@kbn/core/server'; -import { generateSlug } from 'random-word-slugs'; import { ShortUrlRecord } from '.'; import type { IShortUrlClient, @@ -60,14 +59,13 @@ export class ServerShortUrlClient implements IShortUrlClient { locator, params, slug = '', - humanReadableSlug = false, }: ShortUrlCreateParams

): Promise> { if (slug) { validateSlug(slug); } if (!slug) { - slug = humanReadableSlug ? generateSlug() : randomStr(4); + slug = randomStr(5); } const { storage, currentVersion } = this.dependencies; diff --git a/test/api_integration/apis/short_url/create_short_url/main.ts b/test/api_integration/apis/short_url/create_short_url/main.ts index 4eb6fa489b725..d0b57a9873135 100644 --- a/test/api_integration/apis/short_url/create_short_url/main.ts +++ b/test/api_integration/apis/short_url/create_short_url/main.ts @@ -70,22 +70,6 @@ export default function ({ getService }: FtrProviderContext) { expect(response.body.url).to.be(''); }); - it('can generate a human-readable slug, composed of three words', async () => { - const response = await supertest.post('/api/short_url').send({ - locatorId: 'LEGACY_SHORT_URL_LOCATOR', - params: {}, - humanReadableSlug: true, - }); - - expect(response.status).to.be(200); - expect(typeof response.body.slug).to.be('string'); - const words = response.body.slug.split('-'); - expect(words.length).to.be(3); - for (const word of words) { - expect(word.length > 0).to.be(true); - } - }); - it('can create a short URL with custom slug', async () => { const rnd = Math.round(Math.random() * 1e6) + 1; const slug = 'test-slug-' + Date.now() + '-' + rnd; From 46cd72911c5e96b8dc8df6e12d47df004efba381 Mon Sep 17 00:00:00 2001 From: Jan Monschke Date: Fri, 20 May 2022 22:02:00 +0200 Subject: [PATCH 125/150] [SecuritySolution] Disable agent status filters and timeline interaction (#132586) * fix: disable drag-ability and hover actions for agent statuses The agent fields cannot be queried with ECS and therefore should not provide Filter In/Out functionality nor should users be able to add their representative fields to timeline investigations. Therefore users should not be able to add them to a timeline query by dragging them. * chore: make code more readable --- .../table/summary_value_cell.tsx | 46 +++++++++++-------- .../body/renderers/agent_statuses.tsx | 38 +++------------ 2 files changed, 32 insertions(+), 52 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.tsx index 1c9c0292ed912..d4677d22485b4 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.tsx @@ -16,6 +16,8 @@ import { AGENT_STATUS_FIELD_NAME } from '../../../../timelines/components/timeli const FIELDS_WITHOUT_ACTIONS: { [field: string]: boolean } = { [AGENT_STATUS_FIELD_NAME]: true }; +const style = { flexGrow: 0 }; + export const SummaryValueCell: React.FC = ({ data, eventId, @@ -25,32 +27,36 @@ export const SummaryValueCell: React.FC = ({ timelineId, values, isReadOnly, -}) => ( - <> - - {timelineId !== TimelineId.active && !isReadOnly && !FIELDS_WITHOUT_ACTIONS[data.field] && ( - { + const hoverActionsEnabled = !FIELDS_WITHOUT_ACTIONS[data.field]; + + return ( + <> + - )} - -); + {timelineId !== TimelineId.active && !isReadOnly && hoverActionsEnabled && ( + + )} + + ); +}; SummaryValueCell.displayName = 'SummaryValueCell'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx index edc8faff1b5fc..c459a9f05a678 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx @@ -7,7 +7,6 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { DefaultDraggable } from '../../../../../common/components/draggables'; import { EndpointHostIsolationStatus } from '../../../../../common/components/endpoint/host_isolation'; import { useHostIsolationStatus } from '../../../../../detections/containers/detection_engine/alerts/use_host_isolation_status'; import { AgentStatus } from '../../../../../common/components/endpoint/agent_status'; @@ -33,26 +32,11 @@ export const AgentStatuses = React.memo( }) => { const { isIsolated, agentStatus, pendingIsolation, pendingUnisolation } = useHostIsolationStatus({ agentId: value }); - const isolationFieldName = 'host.isolation'; return ( {agentStatus !== undefined ? ( - {isDraggable ? ( - - - - ) : ( - - )} + ) : ( @@ -60,21 +44,11 @@ export const AgentStatuses = React.memo( )} - - - + ); From e55bf409976a22c429d3f48f4a3c198e2591cbe3 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 20 May 2022 14:15:00 -0600 Subject: [PATCH 126/150] [Maps] create MVT_VECTOR when using choropleth wizard (#132648) --- .../create_choropleth_layer_descriptor.ts | 61 +++++++++++-------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts index 92045f5911176..36e07d7383d18 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts @@ -10,6 +10,7 @@ import { AGG_TYPE, COLOR_MAP_TYPE, FIELD_ORIGIN, + LAYER_TYPE, SCALING_TYPES, SOURCE_TYPES, STYLE_TYPE, @@ -21,10 +22,11 @@ import { CountAggDescriptor, EMSFileSourceDescriptor, ESSearchSourceDescriptor, + JoinDescriptor, VectorStylePropertiesDescriptor, } from '../../../../../common/descriptor_types'; import { VectorStyle } from '../../../styles/vector/vector_style'; -import { GeoJsonVectorLayer } from '../../vector_layer'; +import { GeoJsonVectorLayer, MvtVectorLayer } from '../../vector_layer'; import { EMSFileSource } from '../../../sources/ems_file_source'; // @ts-ignore import { ESSearchSource } from '../../../sources/es_search_source'; @@ -38,14 +40,14 @@ function createChoroplethLayerDescriptor({ rightIndexPatternId, rightIndexPatternTitle, rightTermField, - setLabelStyle, + layerType, }: { sourceDescriptor: EMSFileSourceDescriptor | ESSearchSourceDescriptor; leftField: string; rightIndexPatternId: string; rightIndexPatternTitle: string; rightTermField: string; - setLabelStyle: boolean; + layerType: LAYER_TYPE.GEOJSON_VECTOR | LAYER_TYPE.MVT_VECTOR; }) { const metricsDescriptor: CountAggDescriptor = { type: AGG_TYPE.COUNT }; const joinId = uuid(); @@ -75,7 +77,8 @@ function createChoroplethLayerDescriptor({ }, }, }; - if (setLabelStyle) { + // Styling label by join metric with MVT is not supported + if (layerType === LAYER_TYPE.GEOJSON_VECTOR) { styleProperties[VECTOR_STYLES.LABEL_TEXT] = { type: STYLE_TYPE.DYNAMIC, options: { @@ -88,26 +91,34 @@ function createChoroplethLayerDescriptor({ }; } - return GeoJsonVectorLayer.createDescriptor({ - joins: [ - { - leftField, - right: { - type: SOURCE_TYPES.ES_TERM_SOURCE, - id: joinId, - indexPatternId: rightIndexPatternId, - indexPatternTitle: rightIndexPatternTitle, - term: rightTermField, - metrics: [metricsDescriptor], - applyGlobalQuery: true, - applyGlobalTime: true, - applyForceRefresh: true, - }, + const joins = [ + { + leftField, + right: { + type: SOURCE_TYPES.ES_TERM_SOURCE, + id: joinId, + indexPatternId: rightIndexPatternId, + indexPatternTitle: rightIndexPatternTitle, + term: rightTermField, + metrics: [metricsDescriptor], + applyGlobalQuery: true, + applyGlobalTime: true, + applyForceRefresh: true, }, - ], - sourceDescriptor, - style: VectorStyle.createDescriptor(styleProperties), - }); + } as JoinDescriptor, + ]; + + return layerType === LAYER_TYPE.MVT_VECTOR + ? MvtVectorLayer.createDescriptor({ + joins, + sourceDescriptor, + style: VectorStyle.createDescriptor(styleProperties), + }) + : GeoJsonVectorLayer.createDescriptor({ + joins, + sourceDescriptor, + style: VectorStyle.createDescriptor(styleProperties), + }); } export function createEmsChoroplethLayerDescriptor({ @@ -132,7 +143,7 @@ export function createEmsChoroplethLayerDescriptor({ rightIndexPatternId, rightIndexPatternTitle, rightTermField, - setLabelStyle: true, + layerType: LAYER_TYPE.GEOJSON_VECTOR, }); } @@ -165,6 +176,6 @@ export function createEsChoroplethLayerDescriptor({ rightIndexPatternId, rightIndexPatternTitle, rightTermField, - setLabelStyle: false, // Styling label by join metric with MVT is not supported + layerType: LAYER_TYPE.MVT_VECTOR, }); } From 791ebfad8c589b91555a7e252d99ea1841f75906 Mon Sep 17 00:00:00 2001 From: debadair Date: Fri, 20 May 2022 13:34:04 -0700 Subject: [PATCH 127/150] [DOCS] Remove obsolete license expiration info (#131474) * [DOCS] Remove obsolete license expiration info As of https://github.com/elastic/elasticsearch/pull/79671, Elasticsearch does a more stringent license check rather than operating in a semi-degraded mode. Closes #127845 Closes #125702 * Update docs/management/managing-licenses.asciidoc Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/management/managing-licenses.asciidoc | 192 +++------------------ 1 file changed, 22 insertions(+), 170 deletions(-) diff --git a/docs/management/managing-licenses.asciidoc b/docs/management/managing-licenses.asciidoc index cf501518ea534..837a83f0aae38 100644 --- a/docs/management/managing-licenses.asciidoc +++ b/docs/management/managing-licenses.asciidoc @@ -1,191 +1,43 @@ [[managing-licenses]] == License Management -When you install the default distribution of {kib}, you receive free features -with no expiration date. For the full list of features, refer to -{subscriptions}. +By default, new installations have a Basic license that never expires. +For the full list of features available at the Free and Open Basic subscription level, +refer to {subscriptions}. -If you want to try out the full set of features, you can activate a free 30-day -trial. To view the status of your license, start a trial, or install a new -license, open the main menu, then click *Stack Management > License Management*. - -NOTE: You can start a trial only if your cluster has not already activated a -trial license for the current major product version. For example, if you have -already activated a trial for 6.0, you cannot start a new trial until -7.0. You can, however, request an extended trial at {extendtrial}. - -When you activate a new license level, new features appear in *Stack Management*. - -[role="screenshot"] -image::images/management-license.png[] +To explore all of the available solutions and features, start a 30-day free trial. +You can activate a trial subscription once per major product version. +If you need more than 30 days to complete your evaluation, +request an extended trial at {extendtrial}. -At the end of the trial period, some features operate in a -<>. You can revert to Basic, extend the trial, -or purchase a subscription. - -TIP: If {security-features} are enabled, unless you have a trial license, -you must configure Transport Layer Security (TLS) in {es}. -See {ref}/encrypting-communications.html[Encrypting communications]. -{kib} and the {ref}/start-basic.html[start basic API] provide a list of all of -the features that will no longer be supported if you revert to a basic license. +To view the status of your license, start a trial, or install a new +license, open the main menu, then click *Stack Management > License Management*. -[float] +[discrete] === Required permissions The `manage` cluster privilege is required to access *License Management*. To add the privilege, open the main menu, then click *Stack Management > Roles*. -[discrete] -[[update-license]] -=== Update your license - -You can update your license at runtime without shutting down your {es} nodes. -License updates take effect immediately. The license is provided as a _JSON_ -file that you install in {kib} or by using the -{ref}/update-license.html[update license API]. - -TIP: If you are using a basic or trial license, {security-features} are disabled -by default. In all other licenses, {security-features} are enabled by default; -you must secure the {stack} or disable the {security-features}. - [discrete] [[license-expiration]] === License expiration -Your license is time based and expires at a future date. If you're using -{monitor-features} and your license will expire within 30 days, a license -expiration warning is displayed prominently. Warnings are also displayed on -startup and written to the {es} log starting 30 days from the expiration date. -These error messages tell you when the license expires and what features will be -disabled if you do not update the license. - -IMPORTANT: You should update your license as soon as possible. You are -essentially flying blind when running with an expired license. Access to the -cluster health and stats APIs is critical for monitoring and managing an {es} -cluster. - -[discrete] -[[expiration-beats]] -==== Beats - -* Beats will continue to poll centrally-managed configuration. - -[discrete] -[[expiration-elasticsearch]] -==== {es} - -// Upgrade API is disabled -* The deprecation API is disabled. -* SQL support is disabled. -* Aggregations provided by the analytics plugin are no longer usable. -* All searchable snapshots indices are unassigned and cannot be searched. - -[discrete] -[[expiration-watcher]] -==== {stack} {alert-features} - -* The PUT and GET watch APIs are disabled. The DELETE watch API continues to work. -* Watches execute and write to the history. -* The actions of the watches do not execute. - -[discrete] -[[expiration-graph]] -==== {stack} {graph-features} - -* Graph explore APIs are disabled. - -[discrete] -[[expiration-ml]] -==== {stack} {ml-features} +Licenses are valid for a specific time period. +30 days before the license expiration date, {es} starts logging expiration warnings. +If monitoring is enabled, expiration warnings are displayed prominently in {kib}. -* APIs to create {anomaly-jobs}, open jobs, send data to jobs, create {dfeeds}, -and start {dfeeds} are disabled. -* All started {dfeeds} are stopped. -* All open {anomaly-jobs} are closed. -* APIs to create and start {dfanalytics-jobs} are disabled. -* Existing {anomaly-job} and {dfanalytics-job} results continue to be available -by using {kib} or APIs. +If your license expires, your subscription level reverts to Basic and +you will no longer be able to use https://www.elastic.co/subscriptions[Platinum or Enterprise features]. [discrete] -[[expiration-monitoring]] -==== {stack} {monitor-features} - -* The agent stops collecting cluster and indices metrics. -* The agent stops automatically cleaning indices older than -`xpack.monitoring.history.duration`. - -[discrete] -[[expiration-security]] -==== {stack} {security-features} - -* Cluster health, cluster stats, and indices stats operations are blocked. -* All data operations (read and write) continue to work. - -Once the license expires, calls to the cluster health, cluster stats, and index -stats APIs fail with a `security_exception` and return a 403 HTTP status code. - -[source,sh] ------------------------------------------------------ -{ - "error": { - "root_cause": [ - { - "type": "security_exception", - "reason": "current license is non-compliant for [security]", - "license.expired.feature": "security" - } - ], - "type": "security_exception", - "reason": "current license is non-compliant for [security]", - "license.expired.feature": "security" - }, - "status": 403 -} ------------------------------------------------------ - -This message enables automatic monitoring systems to easily detect the license -failure without immediately impacting other users. - -[discrete] -[[expiration-logstash]] -==== {ls} pipeline management - -* Cannot create new pipelines or edit or delete existing pipelines from the UI. -* Cannot list or view existing pipelines from the UI. -* Cannot run Logstash instances which are registered to listen to existing pipelines. -//TBD: * Logstash will continue to poll centrally-managed pipelines - -[discrete] -[[expiration-kibana]] -==== {kib} - -* Users can still log into {kib}. -* {kib} works for data exploration and visualization, but some features -are disabled. -* The license management UI is available to easily upgrade your license. See -<> and <>. - -[discrete] -[[expiration-reporting]] -==== {kib} {report-features} - -* Reporting is no longer available in {kib}. -* Report generation URLs stop working. -* Existing reports are no longer accessible. - -[discrete] -[[expiration-rollups]] -==== {rollups-cap} - -* {rollup-jobs-cap} cannot be created or started. -* Existing {rollup-jobs} can be stopped and deleted. -* The get rollup caps and rollup search APIs continue to function. +[[update-license]] +=== Update your license -[discrete] -[[expiration-transforms]] -==== {transforms-cap} +Licenses are provided as a _JSON_ file and have an effective date and an expiration date. +You cannot install a new license before its effective date. +License updates take effect immediately and do not require restarting {es}. -* {transforms-cap} cannot be created, previewed, started, or updated. -* Existing {transforms} can be stopped and deleted. -* Existing {transform} results continue to be available. +You can update your license from *Stack Management > License Management* or through the +{ref}/update-license.html[update license API]. From 41635e288f790a8e79e8294f65d43212fa479366 Mon Sep 17 00:00:00 2001 From: Karl Godard Date: Fri, 20 May 2022 13:35:30 -0700 Subject: [PATCH 128/150] fixed search highlighting. was only showing highlighted text w/o context (#132650) Co-authored-by: mitodrummer --- .../public/components/process_tree_node/index.test.tsx | 10 ++++++++-- .../public/components/process_tree_node/index.tsx | 2 +- .../public/components/process_tree_node/styles.ts | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx index 1316313427c5e..cff05c5c1003b 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx @@ -295,13 +295,19 @@ describe('ProcessTreeNode component', () => { describe('Search', () => { it('highlights text within the process node line item if it matches the searchQuery', () => { // set a mock search matched indicator for the process (typically done by ProcessTree/helpers.ts) - processMock.searchMatched = '/vagrant'; + processMock.searchMatched = '/vagr'; renderResult = mockedContext.render(); expect( renderResult.getByTestId('sessionView:processNodeSearchHighlight').textContent - ).toEqual('/vagrant'); + ).toEqual('/vagr'); + + // ensures we are showing the rest of the info, and not replacing it with just the match. + const { process } = props.process.getDetails(); + expect(renderResult.container.textContent).toContain( + process?.working_directory + '\xA0' + (process?.args && process.args.join(' ')) + ); }); }); }); diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx index 4d6074497af5a..f65cb0f25530a 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx @@ -146,7 +146,7 @@ export function ProcessTreeNode({ }); // eslint-disable-next-line no-unsanitized/property - textRef.current.innerHTML = html; + textRef.current.innerHTML = '' + html + ''; } } }, [searchMatched, styles.searchHighlight]); diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts index b68df480064b3..54dbdb1bc4565 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts +++ b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts @@ -117,7 +117,6 @@ export const useStyles = ({ fontSize: FONT_SIZE, lineHeight: LINE_HEIGHT, verticalAlign: 'middle', - display: 'inline-block', }, }; @@ -165,6 +164,7 @@ export const useStyles = ({ paddingLeft: size.xxl, position: 'relative', lineHeight: LINE_HEIGHT, + marginTop: '1px', }; const alertDetails: CSSObject = { From e0ea600d54ec68159fc6fa89eb761b36988cc1a6 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Fri, 20 May 2022 14:55:31 -0600 Subject: [PATCH 129/150] Add group 6 to FTR config (#132655) --- .buildkite/ftr_configs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index e070baa844ea9..4a59641e29af2 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -68,6 +68,7 @@ enabled: - test/functional/apps/dashboard/group3/config.ts - test/functional/apps/dashboard/group4/config.ts - test/functional/apps/dashboard/group5/config.ts + - test/functional/apps/dashboard/group6/config.ts - test/functional/apps/discover/config.ts - test/functional/apps/getting_started/config.ts - test/functional/apps/home/config.ts From eb6a061a930a0c48fa4a28b66197b7681d3fd5cf Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Fri, 20 May 2022 16:57:49 -0400 Subject: [PATCH 130/150] [docs] Add 'yarn dev-docs' for managing and starting dev docs (#132647) --- package.json | 1 + scripts/dev_docs.sh | 103 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100755 scripts/dev_docs.sh diff --git a/package.json b/package.json index 9b01ec9decdcb..36a1cd9a5ffad 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "cover:report": "nyc report --temp-dir target/kibana-coverage/functional --report-dir target/coverage/report --reporter=lcov && open ./target/coverage/report/lcov-report/index.html", "debug": "node --nolazy --inspect scripts/kibana --dev", "debug-break": "node --nolazy --inspect-brk scripts/kibana --dev", + "dev-docs": "scripts/dev_docs.sh", "docs:acceptApiChanges": "node --max-old-space-size=6144 scripts/check_published_api_changes.js --accept", "es": "node scripts/es", "preinstall": "node ./preinstall_check", diff --git a/scripts/dev_docs.sh b/scripts/dev_docs.sh new file mode 100755 index 0000000000000..55d8f4d51e8dc --- /dev/null +++ b/scripts/dev_docs.sh @@ -0,0 +1,103 @@ +#!/bin/bash +set -euo pipefail + +KIBANA_DIR=$(cd "$(dirname "$0")"/.. && pwd) +WORKSPACE=$(cd "$KIBANA_DIR/.." && pwd)/kibana-docs +export NVM_DIR="$WORKSPACE/.nvm" + +DOCS_DIR="$WORKSPACE/docs.elastic.dev" + +# These are the other repos with docs currently required to build the docs in this repo and not get errors +# For example, kibana docs link to docs in these repos, and if they aren't built, you'll get errors +DEV_DIR="$WORKSPACE/dev" +TEAM_DIR="$WORKSPACE/kibana-team" + +cd "$KIBANA_DIR" +origin=$(git remote get-url origin || true) +GIT_PREFIX="git@github.com:" +if [[ "$origin" == "https"* ]]; then + GIT_PREFIX="https://github.com/" +fi + +mkdir -p "$WORKSPACE" +cd "$WORKSPACE" + +if [[ ! -d "$NVM_DIR" ]]; then + echo "Installing a separate copy of nvm" + git clone https://github.com/nvm-sh/nvm.git "$NVM_DIR" + cd "$NVM_DIR" + git checkout "$(git describe --abbrev=0 --tags --match "v[0-9]*" "$(git rev-list --tags --max-count=1)")" + cd "$WORKSPACE" +fi +source "$NVM_DIR/nvm.sh" + +if [[ ! -d "$DOCS_DIR" ]]; then + echo "Cloning docs.elastic.dev repo..." + git clone --depth 1 "${GIT_PREFIX}elastic/docs.elastic.dev.git" +else + cd "$DOCS_DIR" + git pull + cd "$WORKSPACE" +fi + +if [[ ! -d "$DEV_DIR" ]]; then + echo "Cloning dev repo..." + git clone --depth 1 "${GIT_PREFIX}elastic/dev.git" +else + cd "$DEV_DIR" + git pull + cd "$WORKSPACE" +fi + +if [[ ! -d "$TEAM_DIR" ]]; then + echo "Cloning kibana-team repo..." + git clone --depth 1 "${GIT_PREFIX}elastic/kibana-team.git" +else + cd "$TEAM_DIR" + git pull + cd "$WORKSPACE" +fi + +# The minimum sources required to build kibana docs +cat << EOF > "$DOCS_DIR/sources-dev.json" +{ + "sources": [ + { + "type": "file", + "location": "$KIBANA_DIR" + }, + { + "type": "file", + "location": "$DEV_DIR" + }, + { + "type": "file", + "location": "$TEAM_DIR" + } + ] +} +EOF + +cd "$DOCS_DIR" +nvm install + +if ! which yarn; then + npm install -g yarn +fi + +yarn + +if [[ ! -d .docsmobile ]]; then + yarn init-docs +fi + +echo "" +echo "The docs.elastic.dev project is located at:" +echo "$DOCS_DIR" +echo "" + +if [[ "${1:-}" ]]; then + yarn "$@" +else + yarn dev +fi From 642290b0f11a6647aef0170648b1a24da712ba56 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 20 May 2022 15:11:15 -0600 Subject: [PATCH 131/150] [maps] convert ESPewPewSource to typescript (#132656) * [maps] convert ESPewPewSource to typescript * move @ts-expect-error moved by fix --- .../security/create_layer_descriptors.ts | 2 - ...ew_pew_source.js => es_pew_pew_source.tsx} | 102 ++++++++++++------ .../es_pew_pew_source/{index.js => index.ts} | 0 .../point_2_point_layer_wizard.tsx | 9 +- 4 files changed, 73 insertions(+), 40 deletions(-) rename x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/{es_pew_pew_source.js => es_pew_pew_source.tsx} (67%) rename x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/{index.js => index.ts} (100%) diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts index 5792d861f6f5c..f295464126c96 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts +++ b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts @@ -24,9 +24,7 @@ import { } from '../../../../../../common/constants'; import { GeoJsonVectorLayer } from '../../../vector_layer'; import { VectorStyle } from '../../../../styles/vector/vector_style'; -// @ts-ignore import { ESSearchSource } from '../../../../sources/es_search_source'; -// @ts-ignore import { ESPewPewSource } from '../../../../sources/es_pew_pew_source'; import { getDefaultDynamicProperties } from '../../../../styles/vector/vector_style_defaults'; import { APM_INDEX_PATTERN_TITLE } from '../observability'; diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.tsx similarity index 67% rename from x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js rename to x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.tsx index a38c769205304..910181d6a2868 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.tsx @@ -8,17 +8,35 @@ import React from 'react'; import turfBbox from '@turf/bbox'; import { multiPoint } from '@turf/helpers'; +import { Adapters } from '@kbn/inspector-plugin/common/adapters'; +import { type Filter, buildExistsFilter } from '@kbn/es-query'; +import { lastValueFrom } from 'rxjs'; +import type { + AggregationsGeoBoundsAggregate, + LatLonGeoLocation, + TopLeftBottomRightGeoBounds, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { UpdateSourceEditor } from './update_source_editor'; import { i18n } from '@kbn/i18n'; +// @ts-expect-error +import { UpdateSourceEditor } from './update_source_editor'; import { SOURCE_TYPES, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; import { getDataSourceLabel, getDataViewLabel } from '../../../../common/i18n_getters'; +// @ts-expect-error import { convertToLines } from './convert_to_lines'; import { AbstractESAggSource } from '../es_agg_source'; import { registerSource } from '../source_registry'; import { turfBboxToBounds } from '../../../../common/elasticsearch_util'; import { DataRequestAbortError } from '../../util/data_request'; import { makePublicExecutionContext } from '../../../util'; +import { SourceEditorArgs } from '../source'; +import { + ESPewPewSourceDescriptor, + MapExtent, + VectorSourceRequestMeta, +} from '../../../../common/descriptor_types'; +import { isValidStringConfig } from '../../util/valid_string_config'; +import { BoundsRequestMeta, GeoJsonWithMeta } from '../vector_source'; const MAX_GEOTILE_LEVEL = 29; @@ -27,20 +45,30 @@ export const sourceTitle = i18n.translate('xpack.maps.source.pewPewTitle', { }); export class ESPewPewSource extends AbstractESAggSource { - static type = SOURCE_TYPES.ES_PEW_PEW; + readonly _descriptor: ESPewPewSourceDescriptor; - static createDescriptor(descriptor) { + static createDescriptor(descriptor: Partial): ESPewPewSourceDescriptor { const normalizedDescriptor = AbstractESAggSource.createDescriptor(descriptor); + if (!isValidStringConfig(descriptor.sourceGeoField)) { + throw new Error('Cannot create ESPewPewSourceDescriptor, sourceGeoField is not provided'); + } + if (!isValidStringConfig(descriptor.destGeoField)) { + throw new Error('Cannot create ESPewPewSourceDescriptor, destGeoField is not provided'); + } return { ...normalizedDescriptor, - type: ESPewPewSource.type, - indexPatternId: descriptor.indexPatternId, - sourceGeoField: descriptor.sourceGeoField, - destGeoField: descriptor.destGeoField, + type: SOURCE_TYPES.ES_PEW_PEW, + sourceGeoField: descriptor.sourceGeoField!, + destGeoField: descriptor.destGeoField!, }; } - renderSourceSettingsEditor({ onChange }) { + constructor(descriptor: ESPewPewSourceDescriptor) { + super(descriptor); + this._descriptor = descriptor; + } + + renderSourceSettingsEditor({ onChange }: SourceEditorArgs) { return ( void) => void, + isRequestStillActive: () => boolean, + inspectorAdapters: Adapters + ): Promise { const indexPattern = await this.getIndexPattern(); const searchSource = await this.makeSearchSource(searchFilters, 0); searchSource.setField('trackTotalHits', false); @@ -151,14 +179,10 @@ export class ESPewPewSource extends AbstractESAggSource { // Some underlying indices may not contain geo fields // Filter out documents without geo fields to avoid shard failures for those indices searchSource.setField('filter', [ - ...searchSource.getField('filter'), + ...(searchSource.getField('filter') as Filter[]), // destGeoField exists ensured by buffer filter // so only need additional check for sourceGeoField - { - exists: { - field: this._descriptor.sourceGeoField, - }, - }, + buildExistsFilter({ name: this._descriptor.sourceGeoField, type: 'geo_point' }, indexPattern), ]); const esResponse = await this._runEsQuery({ @@ -188,7 +212,10 @@ export class ESPewPewSource extends AbstractESAggSource { return this._descriptor.destGeoField; } - async getBoundsForFilters(boundsFilters, registerCancelCallback) { + async getBoundsForFilters( + boundsFilters: BoundsRequestMeta, + registerCancelCallback: (callback: () => void) => void + ): Promise { const searchSource = await this.makeSearchSource(boundsFilters, 0); searchSource.setField('trackTotalHits', false); searchSource.setField('aggs', { @@ -208,31 +235,36 @@ export class ESPewPewSource extends AbstractESAggSource { try { const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); - const { rawResponse: esResp } = await searchSource - .fetch$({ + const { rawResponse: esResp } = await lastValueFrom( + searchSource.fetch$({ abortSignal: abortController.signal, legacyHitsTotal: false, executionContext: makePublicExecutionContext('es_pew_pew_source:bounds'), }) - .toPromise(); - if (esResp.aggregations.destFitToBounds.bounds) { + ); + const destBounds = (esResp.aggregations?.destFitToBounds as AggregationsGeoBoundsAggregate) + .bounds as TopLeftBottomRightGeoBounds; + if (destBounds) { corners.push([ - esResp.aggregations.destFitToBounds.bounds.top_left.lon, - esResp.aggregations.destFitToBounds.bounds.top_left.lat, + (destBounds.top_left as LatLonGeoLocation).lon, + (destBounds.top_left as LatLonGeoLocation).lat, ]); corners.push([ - esResp.aggregations.destFitToBounds.bounds.bottom_right.lon, - esResp.aggregations.destFitToBounds.bounds.bottom_right.lat, + (destBounds.bottom_right as LatLonGeoLocation).lon, + (destBounds.bottom_right as LatLonGeoLocation).lat, ]); } - if (esResp.aggregations.sourceFitToBounds.bounds) { + const sourceBounds = ( + esResp.aggregations?.sourceFitToBounds as AggregationsGeoBoundsAggregate + ).bounds as TopLeftBottomRightGeoBounds; + if (sourceBounds) { corners.push([ - esResp.aggregations.sourceFitToBounds.bounds.top_left.lon, - esResp.aggregations.sourceFitToBounds.bounds.top_left.lat, + (sourceBounds.top_left as LatLonGeoLocation).lon, + (sourceBounds.top_left as LatLonGeoLocation).lat, ]); corners.push([ - esResp.aggregations.sourceFitToBounds.bounds.bottom_right.lon, - esResp.aggregations.sourceFitToBounds.bounds.bottom_right.lat, + (sourceBounds.bottom_right as LatLonGeoLocation).lon, + (sourceBounds.bottom_right as LatLonGeoLocation).lat, ]); } } catch (error) { diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/index.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/index.ts similarity index 100% rename from x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/index.js rename to x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/index.ts diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx index 37ecbfdebab11..aa128e3c7d8ff 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { getDefaultDynamicProperties } from '../../styles/vector/vector_style_defaults'; import { GeoJsonVectorLayer } from '../../layers/vector_layer'; -// @ts-ignore import { ESPewPewSource, sourceTitle } from './es_pew_pew_source'; import { VectorStyle } from '../../styles/vector/vector_style'; import { @@ -24,7 +23,11 @@ import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes'; // @ts-ignore import { CreateSourceEditor } from './create_source_editor'; import { LayerWizard, RenderWizardArguments } from '../../layers'; -import { ColorDynamicOptions, SizeDynamicOptions } from '../../../../common/descriptor_types'; +import { + ColorDynamicOptions, + ESPewPewSourceDescriptor, + SizeDynamicOptions, +} from '../../../../common/descriptor_types'; import { Point2PointLayerIcon } from '../../layers/wizards/icons/point_2_point_layer_icon'; export const point2PointLayerWizardConfig: LayerWizard = { @@ -36,7 +39,7 @@ export const point2PointLayerWizardConfig: LayerWizard = { }), icon: Point2PointLayerIcon, renderWizard: ({ previewLayers }: RenderWizardArguments) => { - const onSourceConfigChange = (sourceConfig: unknown) => { + const onSourceConfigChange = (sourceConfig: Partial) => { if (!sourceConfig) { previewLayers([]); return; From 51ae0208dc381c82d8ba224f155b7ced2ba73d1b Mon Sep 17 00:00:00 2001 From: Constance Date: Fri, 20 May 2022 14:30:36 -0700 Subject: [PATCH 132/150] Upgrade EUI to v55.1.3 (#132451) * Upgrade EUI to 55.1.3 backport * [Deprecation] Remove `watchedItemProps` from EuiContextMenu usage - should no longer be necessary * Update snapshots with new data-popover attr * Fix failing FTR test - Now that EuiContextMenu focus is restored correctly, there is a tooltip around the popover toggle that's blocking an above item that the test wants to click - swapping the order so that the tooltip does not block the clicked item should work * Fix 2nd maps FTR test with blocking tooltip Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 +- src/dev/license_checker/config.ts | 2 +- .../public/components/field_picker/field_search.tsx | 1 - .../saved_objects/public/finder/saved_object_finder.tsx | 6 +----- .../footer/settings/__snapshots__/settings.test.tsx.snap | 6 +++++- .../markdown_editor/plugins/lens/saved_objects_finder.tsx | 6 +----- .../lens/public/indexpattern_datasource/datapanel.tsx | 1 - .../application/components/anomalies_table/links_menu.tsx | 6 +----- .../edit_role/spaces_popover_list/spaces_popover_list.tsx | 1 - .../spaces/public/nav_control/components/spaces_menu.tsx | 1 - .../test/functional/apps/maps/group1/layer_visibility.js | 2 ++ x-pack/test/functional/apps/maps/group1/sample_data.js | 2 +- yarn.lock | 8 ++++---- 13 files changed, 17 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 36a1cd9a5ffad..e5fffb5b3a394 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.2.0-canary.2", "@elastic/ems-client": "8.3.2", - "@elastic/eui": "55.1.2", + "@elastic/eui": "55.1.3", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.1", diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index f10fb0231352d..66e2664b2e8b4 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -77,6 +77,6 @@ export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint '@elastic/ems-client@8.3.2': ['Elastic License 2.0'], - '@elastic/eui@55.1.2': ['SSPL-1.0 OR Elastic License 2.0'], + '@elastic/eui@55.1.3': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/src/plugins/presentation_util/public/components/field_picker/field_search.tsx b/src/plugins/presentation_util/public/components/field_picker/field_search.tsx index 3f3dcfdef5c8b..d3307f71988f1 100644 --- a/src/plugins/presentation_util/public/components/field_picker/field_search.tsx +++ b/src/plugins/presentation_util/public/components/field_picker/field_search.tsx @@ -103,7 +103,6 @@ export function FieldSearch({ })} ( } > - + {this.props.showFilter && ( ( can navigate Autoplay Settings 1`] = ` aria-live="off" aria-modal="true" class="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPopover__panel euiPopover__panel--top" + data-popover-panel="true" role="dialog" style="top: -16px; left: -22px; will-change: transform, opacity; z-index: 2000;" tabindex="0" @@ -108,6 +109,7 @@ exports[` can navigate Autoplay Settings 2`] = ` aria-live="off" aria-modal="true" class="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPopover__panel euiPopover__panel--top euiPopover__panel-isOpen" + data-popover-panel="true" role="dialog" style="top: -16px; left: -22px; z-index: 2000;" tabindex="0" @@ -359,6 +361,7 @@ exports[` can navigate Toolbar Settings, closes when activated 1`] = aria-live="off" aria-modal="true" class="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPopover__panel euiPopover__panel--top" + data-popover-panel="true" role="dialog" style="top: -16px; left: -22px; will-change: transform, opacity; z-index: 2000;" tabindex="0" @@ -457,6 +460,7 @@ exports[` can navigate Toolbar Settings, closes when activated 2`] = aria-live="off" aria-modal="true" class="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPopover__panel euiPopover__panel--top euiPopover__panel-isOpen" + data-popover-panel="true" role="dialog" style="top: -16px; left: -22px; z-index: 2000;" tabindex="0" @@ -631,4 +635,4 @@ exports[` can navigate Toolbar Settings, closes when activated 2`] = `; -exports[` can navigate Toolbar Settings, closes when activated 3`] = `"

You are in a dialog. To close this dialog, hit escape.

Settings
Hide Toolbar
Hide the toolbar when the mouse is not within the Canvas?
"`; +exports[` can navigate Toolbar Settings, closes when activated 3`] = `"

You are in a dialog. To close this dialog, hit escape.

Settings
Hide Toolbar
Hide the toolbar when the mouse is not within the Canvas?
"`; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/saved_objects_finder.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/saved_objects_finder.tsx index 7d7ce5d638489..3f2b3c2420629 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/saved_objects_finder.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/saved_objects_finder.tsx @@ -394,10 +394,7 @@ export class SavedObjectFinderUi extends React.Component< } > - + {this.props.showFilter && ( ( ( { ]); return ( - + ); }; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx index 7e1c5eb545a28..9ddc698ef2c2b 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx @@ -73,7 +73,6 @@ export class SpacesPopoverList extends Component { title: i18n.translate('xpack.security.management.editRole.spacesPopoverList.popoverTitle', { defaultMessage: 'Spaces', }), - watchedItemProps: ['data-search-term'], }; if (this.props.spaces.length >= SPACE_SEARCH_COUNT_THRESHOLD) { diff --git a/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx b/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx index 6e268d4711bb5..6f5158423ca51 100644 --- a/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx +++ b/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx @@ -69,7 +69,6 @@ class SpacesMenuUI extends Component { id: 'xpack.spaces.navControl.spacesMenu.changeCurrentSpaceTitle', defaultMessage: 'Change current space', }), - watchedItemProps: ['data-search-term'], }; if (this.props.spaces.length >= SPACE_SEARCH_COUNT_THRESHOLD) { diff --git a/x-pack/test/functional/apps/maps/group1/layer_visibility.js b/x-pack/test/functional/apps/maps/group1/layer_visibility.js index cf6051cde8be7..a9bbefbff86ca 100644 --- a/x-pack/test/functional/apps/maps/group1/layer_visibility.js +++ b/x-pack/test/functional/apps/maps/group1/layer_visibility.js @@ -10,6 +10,7 @@ import expect from '@kbn/expect'; export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps']); const inspector = getService('inspector'); + const testSubjects = getService('testSubjects'); const security = getService('security'); describe('layer visibility', () => { @@ -31,6 +32,7 @@ export default function ({ getPageObjects, getService }) { it('should fetch layer data when layer is made visible', async () => { await PageObjects.maps.toggleLayerVisibility('logstash'); + await testSubjects.click('mapLayerTOC'); // Tooltip blocks clicks otherwise const hits = await PageObjects.maps.getHits(); expect(hits).to.equal('5'); }); diff --git a/x-pack/test/functional/apps/maps/group1/sample_data.js b/x-pack/test/functional/apps/maps/group1/sample_data.js index cf8bd4c85cf26..62df1d3859a45 100644 --- a/x-pack/test/functional/apps/maps/group1/sample_data.js +++ b/x-pack/test/functional/apps/maps/group1/sample_data.js @@ -165,8 +165,8 @@ export default function ({ getPageObjects, getService, updateBaselines }) { describe('web logs', () => { before(async () => { await PageObjects.maps.loadSavedMap('[Logs] Total Requests and Bytes'); - await PageObjects.maps.toggleLayerVisibility('Road map - desaturated'); await PageObjects.maps.toggleLayerVisibility('Total Requests by Destination'); + await PageObjects.maps.toggleLayerVisibility('Road map - desaturated'); await PageObjects.timePicker.setCommonlyUsedTime('sample_data range'); await PageObjects.maps.enterFullScreen(); await PageObjects.maps.closeLegend(); diff --git a/yarn.lock b/yarn.lock index 88a23a226d0e8..35c60d9444f32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1503,10 +1503,10 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@55.1.2": - version "55.1.2" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-55.1.2.tgz#dd0b42f5b26c5800d6a9cb2d4c2fe1afce9d3f07" - integrity sha512-wwZz5KxMIMFlqEsoCRiQBJDc4CrluS1d0sCOmQ5lhIzKhYc91MdxnqCk2i6YkhL4sSDf2Y9KAEuMXa+uweOWUA== +"@elastic/eui@55.1.3": + version "55.1.3" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-55.1.3.tgz#976142b88156caab2ce896102a1e35fecdaa2647" + integrity sha512-Hf6eN9YKOKAQMMS9OV5pHLUkzpKKAxGYNVSfc/KK7hN9BlhlHH4OaZIQP3Psgf5GKoqhZrldT/N65hujk3rlLA== dependencies: "@types/chroma-js" "^2.0.0" "@types/lodash" "^4.14.160" From 788dd2e718bb4af112c43319cffcb900281d7073 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Fri, 20 May 2022 16:02:05 -0600 Subject: [PATCH 133/150] [Security Solution] Fixes sorting and tooltips on columns for non-ECS fields that are only one level deep (#132570) ## [Security Solution] Fixes sorting and tooltips on columns for non-ECS fields that are only one level deep This PR fixes , an issue where Timeline columns for non-ECS fields that are only one level deep couldn't be sorted, and displayed incomplete metadata in the column's tooltip. ### Before ![test_field_1_actual_tooltip](https://user-images.githubusercontent.com/4459398/169208299-51d9296a-15e1-4eb0-bc31-a0df6a63f0c5.png) _Before: The column is **not** sortable, and the tooltip displays incomplete metadata_ ### After ![after](https://user-images.githubusercontent.com/4459398/169414767-7274a795-015f-4805-8c3f-b233ead994ea.png) _After: The column is sortable, and the tooltip displays the expected metadata_ ### Desk testing See the _Steps to reproduce_ section of for testing details. --- .../body/column_headers/helpers.test.ts | 232 +++++++++++++++++- .../timeline/body/column_headers/helpers.ts | 27 +- .../components/t_grid/body/helpers.test.tsx | 2 +- 3 files changed, 251 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts index 84cc6e60d928c..2a23b5e993637 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts @@ -6,11 +6,12 @@ */ import { mockBrowserFields } from '../../../../../common/containers/source/mock'; - -import { defaultHeaders } from './default_headers'; -import { getColumnWidthFromType, getColumnHeaders } from './helpers'; -import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants'; import '../../../../../common/mock/match_media'; +import { BrowserFields } from '../../../../../../common/search_strategy'; +import { ColumnHeaderOptions } from '../../../../../../common/types'; +import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants'; +import { defaultHeaders } from './default_headers'; +import { getColumnWidthFromType, getColumnHeaders, getRootCategory } from './helpers'; describe('helpers', () => { describe('getColumnWidthFromType', () => { @@ -23,6 +24,32 @@ describe('helpers', () => { }); }); + describe('getRootCategory', () => { + const baseFields = ['@timestamp', '_id', 'message']; + + baseFields.forEach((field) => { + test(`it returns the 'base' category for the ${field} field`, () => { + expect( + getRootCategory({ + field, + browserFields: mockBrowserFields, + }) + ).toEqual('base'); + }); + }); + + test(`it echos the field name for a field that's NOT in the base category`, () => { + const field = 'test_field_1'; + + expect( + getRootCategory({ + field, + browserFields: mockBrowserFields, + }) + ).toEqual(field); + }); + }); + describe('getColumnHeaders', () => { test('should return a full object of ColumnHeader from the default header', () => { const expectedData = [ @@ -80,5 +107,202 @@ describe('helpers', () => { ); expect(getColumnHeaders(mockHeader, mockBrowserFields)).toEqual(expectedData); }); + + test('it should return the expected metadata for the `_id` field, which is one level deep, and belongs to the `base` category', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: '_id', + initialWidth: 180, + }, + ]; + + expect(getColumnHeaders(headers, mockBrowserFields)).toEqual([ + { + aggregatable: false, + category: 'base', + columnHeaderType: 'not-filtered', + description: 'Each document has an _id that uniquely identifies it', + esTypes: [], + example: 'Y-6TfmcB0WOhS6qyMv3s', + id: '_id', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + initialWidth: 180, + name: '_id', + searchable: true, + type: 'string', + }, + ]); + }); + + test('it should return the expected metadata for a field one level deep that does NOT belong to the `base` category', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: 'test_field_1', // one level deep, but does NOT belong to the `base` category + initialWidth: 180, + }, + ]; + + const oneLevelDeep: BrowserFields = { + test_field_1: { + fields: { + test_field_1: { + aggregatable: true, + category: 'test_field_1', + esTypes: ['keyword'], + format: 'string', + indexes: [ + '-*elastic-cloud-logs-*', + '.alerts-security.alerts-default', + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + ], + name: 'test_field_1', + readFromDocValues: true, + searchable: true, + type: 'string', + }, + }, + }, + }; + + expect(getColumnHeaders(headers, oneLevelDeep)).toEqual([ + { + aggregatable: true, + category: 'test_field_1', + columnHeaderType: 'not-filtered', + esTypes: ['keyword'], + format: 'string', + id: 'test_field_1', + indexes: [ + '-*elastic-cloud-logs-*', + '.alerts-security.alerts-default', + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + ], + initialWidth: 180, + name: 'test_field_1', + readFromDocValues: true, + searchable: true, + type: 'string', + }, + ]); + }); + + test('it should return the expected metadata for a field that is more than one level deep', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: 'foo.bar', // two levels deep + initialWidth: 180, + }, + ]; + + const twoLevelsDeep: BrowserFields = { + foo: { + fields: { + 'foo.bar': { + aggregatable: true, + category: 'foo', + esTypes: ['keyword'], + format: 'string', + indexes: [ + '-*elastic-cloud-logs-*', + '.alerts-security.alerts-default', + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + ], + name: 'foo.bar', + readFromDocValues: true, + searchable: true, + type: 'string', + }, + }, + }, + }; + + expect(getColumnHeaders(headers, twoLevelsDeep)).toEqual([ + { + aggregatable: true, + category: 'foo', + columnHeaderType: 'not-filtered', + esTypes: ['keyword'], + format: 'string', + id: 'foo.bar', + indexes: [ + '-*elastic-cloud-logs-*', + '.alerts-security.alerts-default', + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + ], + initialWidth: 180, + name: 'foo.bar', + readFromDocValues: true, + searchable: true, + type: 'string', + }, + ]); + }); + + test('it should return the expected metadata for an UNKNOWN field one level deep', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: 'unknown', // one level deep, but not contained in the `BrowserFields` + initialWidth: 180, + }, + ]; + + expect(getColumnHeaders(headers, mockBrowserFields)).toEqual([ + { + columnHeaderType: 'not-filtered', + id: 'unknown', + initialWidth: 180, + }, + ]); + }); + + test('it should return the expected metadata for an UNKNOWN field that is more than one level deep', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: 'unknown.more.than.one.level', // more than one level deep, and not contained in the `BrowserFields` + initialWidth: 180, + }, + ]; + + expect(getColumnHeaders(headers, mockBrowserFields)).toEqual([ + { + columnHeaderType: 'not-filtered', + id: 'unknown.more.than.one.level', + initialWidth: 180, + }, + ]); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts index b1ea4899615a6..1779c39ce7b31 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts @@ -5,12 +5,28 @@ * 2.0. */ -import { get } from 'lodash/fp'; +import { has, get } from 'lodash/fp'; import { ColumnHeaderOptions } from '../../../../../../common/types'; import { BrowserFields } from '../../../../../common/containers/source'; import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants'; +/** + * Returns the root category for fields that are only one level, e.g. `_id` or `test_field_1` + * + * The `base` category will be returned for fields that are members of `base`, + * e.g. the `@timestamp`, `_id`, and `message` fields. + * + * The field name will be echoed-back for all other fields, e.g. `test_field_1` + */ +export const getRootCategory = ({ + browserFields, + field, +}: { + browserFields: BrowserFields; + field: string; +}): string => (has(`base.fields.${field}`, browserFields) ? 'base' : field); + /** Enriches the column headers with field details from the specified browserFields */ export const getColumnHeaders = ( headers: ColumnHeaderOptions[], @@ -19,13 +35,14 @@ export const getColumnHeaders = ( return headers ? headers.map((header) => { const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] + const category = + splitHeader.length > 1 + ? splitHeader[0] + : getRootCategory({ field: header.id, browserFields }); return { ...header, - ...get( - [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], - browserFields - ), + ...get([category, 'fields', header.id], browserFields), }; }) : []; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx index 444ba878d6709..253c3ca78b487 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx @@ -138,7 +138,7 @@ describe('helpers', () => { ]); }); - test('it defaults to a `columnType` of empty string when a column does NOT has a corresponding entry in `columnHeaders`', () => { + test('it defaults to a `columnType` of empty string when a column does NOT have a corresponding entry in `columnHeaders`', () => { const withUnknownColumn: Array<{ id: string; direction: 'asc' | 'desc'; From fb1eeb0945e25928a2516133055f048ff098166f Mon Sep 17 00:00:00 2001 From: Georgii Gorbachev Date: Sat, 21 May 2022 00:21:53 +0200 Subject: [PATCH 134/150] [Security Solution][Detections] Add new fields to the rule model: Related Integrations, Required Fields, and Setup (#132409) **Addresses partially:** https://github.com/elastic/security-team/issues/2083, https://github.com/elastic/security-team/issues/558, https://github.com/elastic/security-team/issues/2856, https://github.com/elastic/security-team/issues/1801 (internal tickets) ## Summary **TL;DR:** With this PR, it's now possible to specify `related_integrations`, `required_fields`, and `setup` fields in prebuilt rules in https://github.com/elastic/detection-rules. They are returned within rules in the API responses. This PR: - Adds 3 new fields to the model of Security detection rules. These fields are common to all of the rule types we have. - **Related Integrations**. It's a list of Fleet integrations associated with a given rule. It's assumed that if the user installs them, the rule might start to work properly because it will start receiving source events potentially matching the rule's query. - **Required Fields**. It's a list of event fields that must be present in the source indices of a given rule. - **Setup Guide**. It's any instructions for the user for setting up their environment in order to start receiving source events for a given rule. It's a text. Markdown is supported. It's similar to the Investigation Guide that we show on the Details page. - Adjusts API endpoints accordingly: - These fields are for prebuilt rules only and are supposed to be read-only in the UI. - Specifying these fields in the request parameters of the create/update/patch rule API endpoints is not supported. - These fields are returned in all responses that contain rules. If they are missing in a rule, default values are returned (empty array, empty string). - When duplicating a prebuilt rule, these fields are being reset to their default value (empty array, empty string). - Export/Import is supported. Edge case / supported hack: it's possible to specify these fields manually in a ndjson doc and import with a rule. - The fields are being copied to `kibana.alert.rule.parameters` field of an alert document, which is mapped as a flattened field type. No special handling for the new fields was needed there. - Adjusts tests accordingly. ## Related Integrations Example (part of a rule returned from the API): ```json { "related_integrations": [ { "package": "windows", "version": "1.5.x" }, { "package": "azure", "integration": "activitylogs", "version": "~1.1.6" } ], } ``` Schema: ```ts /** * Related integration is a potential dependency of a rule. It's assumed that if the user installs * one of the related integrations of a rule, the rule might start to work properly because it will * have source events (generated by this integration) potentially matching the rule's query. * * NOTE: Proper work is not guaranteed, because a related integration, if installed, can be * configured differently or generate data that is not necessarily relevant for this rule. * * Related integration is a combination of a Fleet package and (optionally) one of the * package's "integrations" that this package contains. It is represented by 3 properties: * * - `package`: name of the package (required, unique id) * - `version`: version of the package (required, semver-compatible) * - `integration`: name of the integration of this package (optional, id within the package) * * There are Fleet packages like `windows` that contain only one integration; in this case, * `integration` should be unspecified. There are also packages like `aws` and `azure` that contain * several integrations; in this case, `integration` should be specified. * * @example * const x: RelatedIntegration = { * package: 'windows', * version: '1.5.x', * }; * * @example * const x: RelatedIntegration = { * package: 'azure', * version: '~1.1.6', * integration: 'activitylogs', * }; */ export type RelatedIntegration = t.TypeOf; export const RelatedIntegration = t.exact( t.intersection([ t.type({ package: NonEmptyString, version: NonEmptyString, }), t.partial({ integration: NonEmptyString, }), ]) ); ``` ## Required Fields Example (part of a rule returned from the API): ```json { "required_fields": [ { "name": "event.action", "type": "keyword", "ecs": true }, { "name": "event.code", "type": "keyword", "ecs": true }, { "name": "winlog.event_data.AttributeLDAPDisplayName", "type": "keyword", "ecs": false } ], } ``` Schema: ```ts /** * Almost all types of Security rules check source event documents for a match to some kind of * query or filter. If a document has certain field with certain values, then it's a match and * the rule will generate an alert. * * Required field is an event field that must be present in the source indices of a given rule. * * @example * const standardEcsField: RequiredField = { * name: 'event.action', * type: 'keyword', * ecs: true, * }; * * @example * const nonEcsField: RequiredField = { * name: 'winlog.event_data.AttributeLDAPDisplayName', * type: 'keyword', * ecs: false, * }; */ export type RequiredField = t.TypeOf; export const RequiredField = t.exact( t.type({ name: NonEmptyString, type: NonEmptyString, ecs: t.boolean, }) ); ``` ## Setup Guide Example (part of a rule returned from the API): ```json { "setup": "## Config\n\nThe 'PowerShell Script Block Logging' logging policy must be enabled.\nSteps to implement the logging policy with with Advanced Audit Configuration:\n\n```\nComputer Configuration > \nAdministrative Templates > \nWindows PowerShell > \nTurn on PowerShell Script Block Logging (Enable)\n```\n\nSteps to implement the logging policy via registry:\n\n```\nreg add \"hklm\\SOFTWARE\\Policies\\Microsoft\\Windows\\PowerShell\\ScriptBlockLogging\" /v EnableScriptBlockLogging /t REG_DWORD /d 1\n```\n", } ``` Schema: ```ts /** * Any instructions for the user for setting up their environment in order to start receiving * source events for a given rule. * * It's a multiline text. Markdown is supported. */ export type SetupGuide = t.TypeOf; export const SetupGuide = t.string; ``` ## Details on the schema This PR adjusts all the 6 rule schemas we have: 1. Alerting Framework rule `params` schema: - `security_solution/server/lib/detection_engine/schemas/rule_schemas.ts` - `security_solution/server/lib/detection_engine/schemas/rule_converters.ts` 2. HTTP API main old schema: - `security_solution/common/detection_engine/schemas/response/rules_schema.ts` 3. HTTP API main new schema: - `security_solution/common/detection_engine/schemas/request/rule_schemas.ts` 4. Prebuilt rule schema: - `security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts` 5. Import rule schema: - `security_solution/common/detection_engine/schemas/request/import_rules_schema.ts` 6. Rule schema used on the frontend side: - `security_solution/public/detections/containers/detection_engine/rules/types.ts` Names of the fields on the HTTP API level: - `related_integrations` - `required_fields` - `setup` Names of the fields on the Alerting Framework level: - `params.relatedIntegrations` - `params.requiredFields` - `params.setup` ## Next steps - Create a new endpoint for returning installed Fleet integrations (gonna be a separate PR). - Rebase https://github.com/elastic/kibana/pull/131475 on top of this PR after merge. - Cover the new fields with dedicated tests (gonna be a separate PR). - Update API docs (gonna be a separate PR). - Address the tech debt of having 6 different schemas (gonna create a ticket for that). ### Checklist - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../detection_engine/schemas/common/index.ts | 1 + .../schemas/common/rule_params.ts | 146 +++++++ .../request/add_prepackaged_rules_schema.ts | 8 +- .../schemas/request/import_rules_schema.ts | 8 +- .../schemas/request/patch_rules_schema.ts | 2 +- .../schemas/request/rule_schemas.ts | 11 + .../schemas/response/rules_schema.mocks.ts | 6 + .../schemas/response/rules_schema.ts | 6 + .../security_solution/cypress/objects/rule.ts | 9 +- .../containers/detection_engine/rules/mock.ts | 9 + .../detection_engine/rules/types.ts | 6 + .../detection_engine/rules/use_rule.test.tsx | 3 + .../rules/use_rule_with_fallback.test.tsx | 3 + .../rules/all/__mocks__/mock.ts | 6 + .../schedule_notification_actions.test.ts | 3 + ...dule_throttle_notification_actions.test.ts | 3 + .../routes/__mocks__/utils.ts | 3 + .../routes/rules/utils/import_rules_utils.ts | 9 + .../routes/rules/validate.test.ts | 3 + .../factories/utils/build_alert.test.ts | 6 + .../rules/create_rules.mock.ts | 9 + .../detection_engine/rules/create_rules.ts | 6 + .../rules/duplicate_rule.test.ts | 391 +++++++++++++----- .../detection_engine/rules/duplicate_rule.ts | 16 +- .../rules/get_export_all.test.ts | 3 + .../rules/get_export_by_object_ids.test.ts | 6 + .../rules/install_prepacked_rules.ts | 6 + .../lib/detection_engine/rules/patch_rules.ts | 9 + .../lib/detection_engine/rules/types.ts | 9 + .../rules/update_prepacked_rules.ts | 9 + .../detection_engine/rules/update_rules.ts | 3 + .../lib/detection_engine/rules/utils.test.ts | 9 + .../lib/detection_engine/rules/utils.ts | 8 +- .../schemas/rule_converters.ts | 6 + .../schemas/rule_schemas.mock.ts | 3 + .../detection_engine/schemas/rule_schemas.ts | 8 +- .../signals/__mocks__/es_results.ts | 9 + .../basic/tests/create_rules.ts | 3 + .../group1/create_rules.ts | 3 + .../group6/alerts/alerts_compatibility.ts | 6 + .../utils/get_complex_rule_output.ts | 3 + .../utils/get_simple_rule_output.ts | 3 + 42 files changed, 660 insertions(+), 119 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_params.ts diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/index.ts index 4ef5d6178d5a5..615eb3f05876e 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/index.ts @@ -6,4 +6,5 @@ */ export * from './rule_monitoring'; +export * from './rule_params'; export * from './schemas'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_params.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_params.ts new file mode 100644 index 0000000000000..b9588a26bb35b --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_params.ts @@ -0,0 +1,146 @@ +/* + * 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 * as t from 'io-ts'; +import { NonEmptyString } from '@kbn/securitysolution-io-ts-types'; + +// ------------------------------------------------------------------------------------------------- +// Related integrations + +/** + * Related integration is a potential dependency of a rule. It's assumed that if the user installs + * one of the related integrations of a rule, the rule might start to work properly because it will + * have source events (generated by this integration) potentially matching the rule's query. + * + * NOTE: Proper work is not guaranteed, because a related integration, if installed, can be + * configured differently or generate data that is not necessarily relevant for this rule. + * + * Related integration is a combination of a Fleet package and (optionally) one of the + * package's "integrations" that this package contains. It is represented by 3 properties: + * + * - `package`: name of the package (required, unique id) + * - `version`: version of the package (required, semver-compatible) + * - `integration`: name of the integration of this package (optional, id within the package) + * + * There are Fleet packages like `windows` that contain only one integration; in this case, + * `integration` should be unspecified. There are also packages like `aws` and `azure` that contain + * several integrations; in this case, `integration` should be specified. + * + * @example + * const x: RelatedIntegration = { + * package: 'windows', + * version: '1.5.x', + * }; + * + * @example + * const x: RelatedIntegration = { + * package: 'azure', + * version: '~1.1.6', + * integration: 'activitylogs', + * }; + */ +export type RelatedIntegration = t.TypeOf; +export const RelatedIntegration = t.exact( + t.intersection([ + t.type({ + package: NonEmptyString, + version: NonEmptyString, + }), + t.partial({ + integration: NonEmptyString, + }), + ]) +); + +/** + * Array of related integrations. + * + * @example + * const x: RelatedIntegrationArray = [ + * { + * package: 'windows', + * version: '1.5.x', + * }, + * { + * package: 'azure', + * version: '~1.1.6', + * integration: 'activitylogs', + * }, + * ]; + */ +export type RelatedIntegrationArray = t.TypeOf; +export const RelatedIntegrationArray = t.array(RelatedIntegration); + +// ------------------------------------------------------------------------------------------------- +// Required fields + +/** + * Almost all types of Security rules check source event documents for a match to some kind of + * query or filter. If a document has certain field with certain values, then it's a match and + * the rule will generate an alert. + * + * Required field is an event field that must be present in the source indices of a given rule. + * + * @example + * const standardEcsField: RequiredField = { + * name: 'event.action', + * type: 'keyword', + * ecs: true, + * }; + * + * @example + * const nonEcsField: RequiredField = { + * name: 'winlog.event_data.AttributeLDAPDisplayName', + * type: 'keyword', + * ecs: false, + * }; + */ +export type RequiredField = t.TypeOf; +export const RequiredField = t.exact( + t.type({ + name: NonEmptyString, + type: NonEmptyString, + ecs: t.boolean, + }) +); + +/** + * Array of event fields that must be present in the source indices of a given rule. + * + * @example + * const x: RequiredFieldArray = [ + * { + * name: 'event.action', + * type: 'keyword', + * ecs: true, + * }, + * { + * name: 'event.code', + * type: 'keyword', + * ecs: true, + * }, + * { + * name: 'winlog.event_data.AttributeLDAPDisplayName', + * type: 'keyword', + * ecs: false, + * }, + * ]; + */ +export type RequiredFieldArray = t.TypeOf; +export const RequiredFieldArray = t.array(RequiredField); + +// ------------------------------------------------------------------------------------------------- +// Setup guide + +/** + * Any instructions for the user for setting up their environment in order to start receiving + * source events for a given rule. + * + * It's a multiline text. Markdown is supported. + */ +export type SetupGuide = t.TypeOf; +export const SetupGuide = t.string; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts index 618aee3379316..27ebf9a608ffa 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts @@ -72,7 +72,10 @@ import { Author, event_category_override, namespace, -} from '../common/schemas'; + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, +} from '../common'; /** * Big differences between this schema and the createRulesSchema @@ -117,8 +120,11 @@ export const addPrepackagedRulesSchema = t.intersection([ meta, // defaults to "undefined" if not set during decode machine_learning_job_id, // defaults to "undefined" if not set during decode max_signals: DefaultMaxSignalsNumber, // defaults to DEFAULT_MAX_SIGNALS (100) if not set during decode + related_integrations: RelatedIntegrationArray, // defaults to "undefined" if not set during decode + required_fields: RequiredFieldArray, // defaults to "undefined" if not set during decode risk_score_mapping: DefaultRiskScoreMappingArray, // defaults to empty risk score mapping array if not set during decode rule_name_override, // defaults to "undefined" if not set during decode + setup: SetupGuide, // defaults to "undefined" if not set during decode severity_mapping: DefaultSeverityMappingArray, // defaults to empty actions array if not set during decode tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts index 63c41e45e42d0..8cee4183d6ee7 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts @@ -80,7 +80,10 @@ import { timestamp_override, Author, event_category_override, -} from '../common/schemas'; + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, +} from '../common'; /** * Differences from this and the createRulesSchema are @@ -129,8 +132,11 @@ export const importRulesSchema = t.intersection([ meta, // defaults to "undefined" if not set during decode machine_learning_job_id, // defaults to "undefined" if not set during decode max_signals: DefaultMaxSignalsNumber, // defaults to DEFAULT_MAX_SIGNALS (100) if not set during decode + related_integrations: RelatedIntegrationArray, // defaults to "undefined" if not set during decode + required_fields: RequiredFieldArray, // defaults to "undefined" if not set during decode risk_score_mapping: DefaultRiskScoreMappingArray, // defaults to empty risk score mapping array if not set during decode rule_name_override, // defaults to "undefined" if not set during decode + setup: SetupGuide, // defaults to "undefined" if not set during decode severity_mapping: DefaultSeverityMappingArray, // defaults to empty actions array if not set during decode tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts index 8c801e75af08c..6678681471b38 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts @@ -61,7 +61,7 @@ import { rule_name_override, timestamp_override, event_category_override, -} from '../common/schemas'; +} from '../common'; /** * All of the patch elements should default to undefined if not set diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts index 69a748c3bd95c..9aef9ac8f2651 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts @@ -67,6 +67,9 @@ import { created_by, namespace, ruleExecutionSummary, + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, } from '../common'; export const createSchema = < @@ -412,6 +415,14 @@ const responseRequiredFields = { updated_by, created_at, created_by, + + // NOTE: For now, Related Integrations, Required Fields and Setup Guide are supported for prebuilt + // rules only. We don't want to allow users to edit these 3 fields via the API. If we added them + // to baseParams.defaultable, they would become a part of the request schema as optional fields. + // This is why we add them here, in order to add them only to the response schema. + related_integrations: RelatedIntegrationArray, + required_fields: RequiredFieldArray, + setup: SetupGuide, }; const responseOptionalFields = { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts index 0642481b62a6a..eeaab6dc50021 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts @@ -68,6 +68,9 @@ export const getRulesSchemaMock = (anchorDate: string = ANCHOR_DATE): RulesSchem rule_id: 'query-rule-id', interval: '5m', exceptions_list: getListArrayMock(), + related_integrations: [], + required_fields: [], + setup: '', }); export const getRulesMlSchemaMock = (anchorDate: string = ANCHOR_DATE): RulesSchema => { @@ -132,6 +135,9 @@ export const getThreatMatchingSchemaPartialMock = (enabled = false): Partial; diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 32c55e22ae7c9..de2de9bd78160 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -442,7 +442,9 @@ export const expectedExportedRule = (ruleResponse: Cypress.Response severity, query, } = ruleResponse.body; - const rule = { + + // NOTE: Order of the properties in this object matters for the tests to work. + const rule: RulesSchema = { id, updated_at: updatedAt, updated_by: updatedBy, @@ -469,6 +471,9 @@ export const expectedExportedRule = (ruleResponse: Cypress.Response version: 1, exceptions_list: [], immutable: false, + related_integrations: [], + required_fields: [], + setup: '', type: 'query', language: 'kuery', index: getIndexPatterns(), @@ -476,6 +481,8 @@ export const expectedExportedRule = (ruleResponse: Cypress.Response throttle: 'no_actions', actions: [], }; + + // NOTE: Order of the properties in this object matters for the tests to work. const details = { exported_count: 1, exported_rules_count: 1, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts index 8c1737a4519a7..8a23cbf9e4318 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts @@ -38,6 +38,9 @@ export const savedRuleMock: Rule = { max_signals: 100, query: "user.email: 'root@elastic.co'", references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'high', severity_mapping: [], tags: ['APM'], @@ -80,6 +83,9 @@ export const rulesMock: FetchRulesResponse = { 'event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection', filters: [], references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'high', severity_mapping: [], updated_by: 'elastic', @@ -115,6 +121,9 @@ export const rulesMock: FetchRulesResponse = { query: 'event.kind:alert and event.module:endgame and event.action:rules_engine_event', filters: [], references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'medium', severity_mapping: [], updated_by: 'elastic', diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index ddd65674274be..d6e278599d62d 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -34,6 +34,9 @@ import { BulkAction, BulkActionEditPayload, ruleExecutionSummary, + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, } from '../../../../../common/detection_engine/schemas/common'; import { @@ -102,11 +105,14 @@ export const RuleSchema = t.intersection([ name: t.string, max_signals: t.number, references: t.array(t.string), + related_integrations: RelatedIntegrationArray, + required_fields: RequiredFieldArray, risk_score: t.number, risk_score_mapping, rule_id: t.string, severity, severity_mapping, + setup: SetupGuide, tags: t.array(t.string), type, to: t.string, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.test.tsx index 096463872fc01..3ca18552a85ef 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.test.tsx @@ -67,9 +67,12 @@ describe('useRule', () => { max_signals: 100, query: "user.email: 'root@elastic.co'", references: [], + related_integrations: [], + required_fields: [], risk_score: 75, risk_score_mapping: [], rule_id: 'bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf', + setup: '', severity: 'high', severity_mapping: [], tags: ['APM'], diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx index d7c4ad8772bd2..1816fd4c5a7af 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx @@ -78,9 +78,12 @@ describe('useRuleWithFallback', () => { "name": "Test rule", "query": "user.email: 'root@elastic.co'", "references": Array [], + "related_integrations": Array [], + "required_fields": Array [], "risk_score": 75, "risk_score_mapping": Array [], "rule_id": "bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf", + "setup": "", "severity": "high", "severity_mapping": Array [], "tags": Array [ diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts index 77de8902be33a..d9f16242a544a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -70,6 +70,9 @@ export const mockRule = (id: string): Rule => ({ timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', timeline_title: 'Untitled timeline', meta: { from: '0m' }, + related_integrations: [], + required_fields: [], + setup: '', severity: 'low', severity_mapping: [], updated_by: 'elastic', @@ -133,6 +136,9 @@ export const mockRuleWithEverything = (id: string): Rule => ({ timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', timeline_title: 'Titled timeline', meta: { from: '0m' }, + related_integrations: [], + required_fields: [], + setup: '', severity: 'low', severity_mapping: [], updated_by: 'elastic', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.test.ts index d97eff43aeb8d..04e8f2130e88f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.test.ts @@ -51,6 +51,9 @@ describe('schedule_notification_actions', () => { note: '# sample markdown', version: 1, exceptionsList: [], + relatedIntegrations: [], + requiredFields: [], + setup: '', }; it('Should schedule actions with unflatted and legacy context', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_throttle_notification_actions.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_throttle_notification_actions.test.ts index d7293275c9c49..72ddb96301c47 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_throttle_notification_actions.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_throttle_notification_actions.test.ts @@ -59,6 +59,9 @@ describe('schedule_throttle_notification_actions', () => { note: '# sample markdown', version: 1, exceptionsList: [], + relatedIntegrations: [], + requiredFields: [], + setup: '', }; }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts index 2622493a51dc1..54bf6133f9e37 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts @@ -90,4 +90,7 @@ export const getOutputRuleAlertForRest = (): Omit< note: '# Investigative notes', version: 1, execution_summary: undefined, + related_integrations: [], + required_fields: [], + setup: '', }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts index d603784fc7081..8f87c1cdc0467 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts @@ -117,10 +117,13 @@ export const importRules = async ({ index, interval, max_signals: maxSignals, + related_integrations: relatedIntegrations, + required_fields: requiredFields, risk_score: riskScore, risk_score_mapping: riskScoreMapping, rule_name_override: ruleNameOverride, name, + setup, severity, severity_mapping: severityMapping, tags, @@ -192,9 +195,12 @@ export const importRules = async ({ interval, maxSignals, name, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, + setup, severity, severityMapping, tags, @@ -250,10 +256,13 @@ export const importRules = async ({ index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts index 0b8c49cdb4d17..833361e7e22bf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts @@ -63,6 +63,9 @@ export const ruleOutput = (): RulesSchema => ({ note: '# Investigative notes', timeline_title: 'some-timeline-title', timeline_id: 'some-timeline-id', + related_integrations: [], + required_fields: [], + setup: '', }); describe('validate', () => { 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 5768306999f79..083f495366480 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 @@ -126,6 +126,9 @@ describe('buildAlert', () => { ], to: 'now', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', version: 1, exceptions_list: [ { @@ -303,6 +306,9 @@ describe('buildAlert', () => { ], to: 'now', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', version: 1, exceptions_list: [ { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts index 1a41adb4f6da5..3c7acccae703a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts @@ -32,11 +32,14 @@ export const getCreateRulesOptionsMock = (): CreateRulesOptions => ({ index: ['index-123'], interval: '5m', maxSignals: 100, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: 80, riskScoreMapping: [], ruleNameOverride: undefined, outputIndex: 'output-1', name: 'Query with a rule id', + setup: undefined, severity: 'high', severityMapping: [], tags: [], @@ -85,11 +88,14 @@ export const getCreateMlRulesOptionsMock = (): CreateRulesOptions => ({ index: ['index-123'], interval: '5m', maxSignals: 100, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: 80, riskScoreMapping: [], ruleNameOverride: undefined, outputIndex: 'output-1', name: 'Machine Learning Job', + setup: undefined, severity: 'high', severityMapping: [], tags: [], @@ -141,12 +147,15 @@ export const getCreateThreatMatchRulesOptionsMock = (): CreateRulesOptions => ({ outputIndex: 'output-1', query: 'user.name: root or user.name: admin', references: ['http://www.example.com'], + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: 80, riskScoreMapping: [], ruleId: 'rule-1', ruleNameOverride: undefined, rulesClient: rulesClientMock.create(), savedId: 'savedId-123', + setup: undefined, severity: 'high', severityMapping: [], tags: [], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts index 24017adc20626..726964cdf3596 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts @@ -46,11 +46,14 @@ export const createRules = async ({ index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, outputIndex, name, + setup, severity, severityMapping, tags, @@ -109,9 +112,12 @@ export const createRules = async ({ : undefined, filters, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, + setup, severity, severityMapping, threat, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts index 04d8e66a076fb..cab22e136f529 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts @@ -6,6 +6,8 @@ */ import uuid from 'uuid'; +import { SanitizedRule } from '@kbn/alerting-plugin/common'; +import { RuleParams } from '../schemas/rule_schemas'; import { duplicateRule } from './duplicate_rule'; jest.mock('uuid', () => ({ @@ -13,120 +15,287 @@ jest.mock('uuid', () => ({ })); describe('duplicateRule', () => { - it('should return a copy of rule with new ruleId', () => { - (uuid.v4 as jest.Mock).mockReturnValue('newId'); - - expect( - duplicateRule({ - id: 'oldTestRuleId', - notifyWhen: 'onActiveAlert', - name: 'test', - tags: ['test'], - alertTypeId: 'siem.signals', - consumer: 'siem', - params: { - savedId: undefined, - author: [], - description: 'test', - ruleId: 'oldTestRuleId', - falsePositives: [], - from: 'now-360s', - immutable: false, - license: '', - outputIndex: '.siem-signals-default', - meta: undefined, - maxSignals: 100, - riskScore: 42, - riskScoreMapping: [], - severity: 'low', - severityMapping: [], - threat: [], - to: 'now', - references: [], - version: 1, - exceptionsList: [], - type: 'query', - language: 'kuery', - index: [], - query: 'process.args : "chmod"', - filters: [], - buildingBlockType: undefined, - namespace: undefined, - note: undefined, - timelineId: undefined, - timelineTitle: undefined, - ruleNameOverride: undefined, - timestampOverride: undefined, - }, - schedule: { - interval: '5m', - }, + const createTestRule = (): SanitizedRule => ({ + id: 'some id', + notifyWhen: 'onActiveAlert', + name: 'Some rule', + tags: ['some tag'], + alertTypeId: 'siem.queryRule', + consumer: 'siem', + params: { + savedId: undefined, + author: [], + description: 'Some description.', + ruleId: 'some ruleId', + falsePositives: [], + from: 'now-360s', + immutable: false, + license: '', + outputIndex: '.siem-signals-default', + meta: undefined, + maxSignals: 100, + relatedIntegrations: [], + requiredFields: [], + riskScore: 42, + riskScoreMapping: [], + severity: 'low', + severityMapping: [], + setup: 'Some setup guide.', + threat: [], + to: 'now', + references: [], + version: 1, + exceptionsList: [], + type: 'query', + language: 'kuery', + index: [], + query: 'process.args : "chmod"', + filters: [], + buildingBlockType: undefined, + namespace: undefined, + note: undefined, + timelineId: undefined, + timelineTitle: undefined, + ruleNameOverride: undefined, + timestampOverride: undefined, + }, + schedule: { + interval: '5m', + }, + enabled: false, + actions: [], + throttle: null, + apiKeyOwner: 'kibana', + createdBy: 'kibana', + updatedBy: 'kibana', + muteAll: false, + mutedInstanceIds: [], + updatedAt: new Date(2021, 0), + createdAt: new Date(2021, 0), + scheduledTaskId: undefined, + executionStatus: { + lastExecutionDate: new Date(2021, 0), + status: 'ok', + }, + }); + + beforeAll(() => { + (uuid.v4 as jest.Mock).mockReturnValue('new ruleId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('returns an object with fields copied from a given rule', () => { + const rule = createTestRule(); + const result = duplicateRule(rule); + + expect(result).toEqual({ + name: expect.anything(), // covered in a separate test + params: { + ...rule.params, + ruleId: expect.anything(), // covered in a separate test + }, + tags: rule.tags, + alertTypeId: rule.alertTypeId, + consumer: rule.consumer, + schedule: rule.schedule, + actions: rule.actions, + throttle: null, // TODO: fix? + notifyWhen: null, // TODO: fix? + enabled: false, // covered in a separate test + }); + }); + + it('appends [Duplicate] to the name', () => { + const rule = createTestRule(); + rule.name = 'PowerShell Keylogging Script'; + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + name: 'PowerShell Keylogging Script [Duplicate]', + }) + ); + }); + + it('generates a new ruleId', () => { + const rule = createTestRule(); + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + ruleId: 'new ruleId', + }), + }) + ); + }); + + it('makes sure the duplicated rule is disabled', () => { + const rule = createTestRule(); + rule.enabled = true; + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ enabled: false, - actions: [], - throttle: null, - apiKeyOwner: 'kibana', - createdBy: 'kibana', - updatedBy: 'kibana', - muteAll: false, - mutedInstanceIds: [], - updatedAt: new Date(2021, 0), - createdAt: new Date(2021, 0), - scheduledTaskId: undefined, - executionStatus: { - lastExecutionDate: new Date(2021, 0), - status: 'ok', - }, }) - ).toMatchInlineSnapshot(` - Object { - "actions": Array [], - "alertTypeId": "siem.queryRule", - "consumer": "siem", - "enabled": false, - "name": "test [Duplicate]", - "notifyWhen": null, - "params": Object { - "author": Array [], - "buildingBlockType": undefined, - "description": "test", - "exceptionsList": Array [], - "falsePositives": Array [], - "filters": Array [], - "from": "now-360s", - "immutable": false, - "index": Array [], - "language": "kuery", - "license": "", - "maxSignals": 100, - "meta": undefined, - "namespace": undefined, - "note": undefined, - "outputIndex": ".siem-signals-default", - "query": "process.args : \\"chmod\\"", - "references": Array [], - "riskScore": 42, - "riskScoreMapping": Array [], - "ruleId": "newId", - "ruleNameOverride": undefined, - "savedId": undefined, - "severity": "low", - "severityMapping": Array [], - "threat": Array [], - "timelineId": undefined, - "timelineTitle": undefined, - "timestampOverride": undefined, - "to": "now", - "type": "query", - "version": 1, + ); + }); + + describe('when duplicating a prebuilt (immutable) rule', () => { + const createPrebuiltRule = () => { + const rule = createTestRule(); + rule.params.immutable = true; + return rule; + }; + + it('transforms it to a custom (mutable) rule', () => { + const rule = createPrebuiltRule(); + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + immutable: false, + }), + }) + ); + }); + + it('resets related integrations to an empty array', () => { + const rule = createPrebuiltRule(); + rule.params.relatedIntegrations = [ + { + package: 'aws', + version: '~1.2.3', + integration: 'route53', }, - "schedule": Object { - "interval": "5m", + ]; + + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + relatedIntegrations: [], + }), + }) + ); + }); + + it('resets required fields to an empty array', () => { + const rule = createPrebuiltRule(); + rule.params.requiredFields = [ + { + name: 'event.action', + type: 'keyword', + ecs: true, }, - "tags": Array [ - "test", - ], - "throttle": null, - } - `); + ]; + + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + requiredFields: [], + }), + }) + ); + }); + + it('resets setup guide to an empty string', () => { + const rule = createPrebuiltRule(); + rule.params.setup = `## Config\n\nThe 'Audit Detailed File Share' audit policy must be configured...`; + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + setup: '', + }), + }) + ); + }); + }); + + describe('when duplicating a custom (mutable) rule', () => { + const createCustomRule = () => { + const rule = createTestRule(); + rule.params.immutable = false; + return rule; + }; + + it('keeps it custom', () => { + const rule = createCustomRule(); + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + immutable: false, + }), + }) + ); + }); + + it('copies related integrations as is', () => { + const rule = createCustomRule(); + rule.params.relatedIntegrations = [ + { + package: 'aws', + version: '~1.2.3', + integration: 'route53', + }, + ]; + + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + relatedIntegrations: rule.params.relatedIntegrations, + }), + }) + ); + }); + + it('copies required fields as is', () => { + const rule = createCustomRule(); + rule.params.requiredFields = [ + { + name: 'event.action', + type: 'keyword', + ecs: true, + }, + ]; + + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + requiredFields: rule.params.requiredFields, + }), + }) + ); + }); + + it('copies setup guide as is', () => { + const rule = createCustomRule(); + rule.params.setup = `## Config\n\nThe 'Audit Detailed File Share' audit policy must be configured...`; + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + setup: rule.params.setup, + }), + }) + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts index 4ef21d0450517..81af1533498ee 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts @@ -22,7 +22,16 @@ const DUPLICATE_TITLE = i18n.translate( ); export const duplicateRule = (rule: SanitizedRule): InternalRuleCreate => { - const newRuleId = uuid.v4(); + // Generate a new static ruleId + const ruleId = uuid.v4(); + + // If it's a prebuilt rule, reset Related Integrations, Required Fields and Setup Guide. + // We do this because for now we don't allow the users to edit these fields for custom rules. + const isPrebuilt = rule.params.immutable; + const relatedIntegrations = isPrebuilt ? [] : rule.params.relatedIntegrations; + const requiredFields = isPrebuilt ? [] : rule.params.requiredFields; + const setup = isPrebuilt ? '' : rule.params.setup; + return { name: `${rule.name} [${DUPLICATE_TITLE}]`, tags: rule.tags, @@ -31,7 +40,10 @@ export const duplicateRule = (rule: SanitizedRule): InternalRuleCrea params: { ...rule.params, immutable: false, - ruleId: newRuleId, + ruleId, + relatedIntegrations, + requiredFields, + setup, }, schedule: rule.schedule, enabled: false, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts index de80a8ba8c26b..68fad65a8ff7e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts @@ -85,6 +85,9 @@ describe('getExportAll', () => { name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', meta: { someMeta: 'someField' }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts index f297f375dda0b..e31c1444cd9fc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts @@ -82,6 +82,9 @@ describe('get_export_by_object_ids', () => { name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', meta: { someMeta: 'someField' }, @@ -191,6 +194,9 @@ describe('get_export_by_object_ids', () => { name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', meta: { someMeta: 'someField' }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts index bffa0bc39eb91..1ef4f14b17b6b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts @@ -39,10 +39,13 @@ export const installPrepackagedRules = ( index, interval, max_signals: maxSignals, + related_integrations: relatedIntegrations, + required_fields: requiredFields, risk_score: riskScore, risk_score_mapping: riskScoreMapping, rule_name_override: ruleNameOverride, name, + setup, severity, severity_mapping: severityMapping, tags, @@ -95,10 +98,13 @@ export const installPrepackagedRules = ( index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index ad2443b34fa95..e5f87b7cdb2e2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -54,11 +54,14 @@ export const patchRules = async ({ index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, rule, name, + setup, severity, severityMapping, tags, @@ -108,10 +111,13 @@ export const patchRules = async ({ index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, @@ -158,9 +164,12 @@ export const patchRules = async ({ filters, index, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, + setup, severity, severityMapping, threat, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 8b560d0edea0f..eeb0e88e53d47 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -93,6 +93,9 @@ import { RuleNameOverrideOrUndefined, EventCategoryOverrideOrUndefined, NamespaceOrUndefined, + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, } from '../../../../common/detection_engine/schemas/common'; import { PartialFilter } from '../types'; @@ -161,11 +164,14 @@ export interface CreateRulesOptions { interval: Interval; license: LicenseOrUndefined; maxSignals: MaxSignals; + relatedIntegrations: RelatedIntegrationArray | undefined; + requiredFields: RequiredFieldArray | undefined; riskScore: RiskScore; riskScoreMapping: RiskScoreMapping; ruleNameOverride: RuleNameOverrideOrUndefined; outputIndex: OutputIndex; name: Name; + setup: SetupGuide | undefined; severity: Severity; severityMapping: SeverityMapping; tags: Tags; @@ -225,11 +231,14 @@ interface PatchRulesFieldsOptions { interval: IntervalOrUndefined; license: LicenseOrUndefined; maxSignals: MaxSignalsOrUndefined; + relatedIntegrations: RelatedIntegrationArray | undefined; + requiredFields: RequiredFieldArray | undefined; riskScore: RiskScoreOrUndefined; riskScoreMapping: RiskScoreMappingOrUndefined; ruleNameOverride: RuleNameOverrideOrUndefined; outputIndex: OutputIndexOrUndefined; name: NameOrUndefined; + setup: SetupGuide | undefined; severity: SeverityOrUndefined; severityMapping: SeverityMappingOrUndefined; tags: TagsOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index ad35e11d35668..079af5b82d608 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -83,10 +83,13 @@ export const createPromises = ( index, interval, max_signals: maxSignals, + related_integrations: relatedIntegrations, + required_fields: requiredFields, risk_score: riskScore, risk_score_mapping: riskScoreMapping, rule_name_override: ruleNameOverride, name, + setup, severity, severity_mapping: severityMapping, tags, @@ -169,10 +172,13 @@ export const createPromises = ( index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, @@ -220,10 +226,13 @@ export const createPromises = ( index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index ba65b76f01c4a..7c981a5481ff9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -54,9 +54,12 @@ export const updateRules = async ({ timelineTitle: ruleUpdate.timeline_title, meta: ruleUpdate.meta, maxSignals: ruleUpdate.max_signals ?? DEFAULT_MAX_SIGNALS, + relatedIntegrations: existingRule.params.relatedIntegrations, + requiredFields: existingRule.params.requiredFields, riskScore: ruleUpdate.risk_score, riskScoreMapping: ruleUpdate.risk_score_mapping ?? [], ruleNameOverride: ruleUpdate.rule_name_override, + setup: existingRule.params.setup, severity: ruleUpdate.severity, severityMapping: ruleUpdate.severity_mapping ?? [], threat: ruleUpdate.threat ?? [], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts index 0952da3182e01..43ac38f447abc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts @@ -127,10 +127,13 @@ describe('utils', () => { index: undefined, interval: undefined, maxSignals: undefined, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: undefined, riskScoreMapping: undefined, ruleNameOverride: undefined, name: undefined, + setup: undefined, severity: undefined, severityMapping: undefined, tags: undefined, @@ -179,10 +182,13 @@ describe('utils', () => { index: undefined, interval: undefined, maxSignals: undefined, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: undefined, riskScoreMapping: undefined, ruleNameOverride: undefined, name: undefined, + setup: undefined, severity: undefined, severityMapping: undefined, tags: undefined, @@ -231,10 +237,13 @@ describe('utils', () => { index: undefined, interval: undefined, maxSignals: undefined, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: undefined, riskScoreMapping: undefined, ruleNameOverride: undefined, name: undefined, + setup: undefined, severity: undefined, severityMapping: undefined, tags: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts index dd25676a758e4..4ac138e1629f3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts @@ -56,7 +56,10 @@ import { TimestampOverrideOrUndefined, EventCategoryOverrideOrUndefined, NamespaceOrUndefined, -} from '../../../../common/detection_engine/schemas/common/schemas'; + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, +} from '../../../../common/detection_engine/schemas/common'; import { PartialFilter } from '../types'; import { RuleParams } from '../schemas/rule_schemas'; import { @@ -107,11 +110,14 @@ export interface UpdateProperties { index: IndexOrUndefined; interval: IntervalOrUndefined; maxSignals: MaxSignalsOrUndefined; + relatedIntegrations: RelatedIntegrationArray | undefined; + requiredFields: RequiredFieldArray | undefined; riskScore: RiskScoreOrUndefined; riskScoreMapping: RiskScoreMappingOrUndefined; ruleNameOverride: RuleNameOverrideOrUndefined; outputIndex: OutputIndexOrUndefined; name: NameOrUndefined; + setup: SetupGuide | undefined; severity: SeverityOrUndefined; severityMapping: SeverityMappingOrUndefined; tags: TagsOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts index fd80bec1f6ad9..356436058b55c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts @@ -161,6 +161,9 @@ export const convertCreateAPIToInternalSchema = ( note: input.note, version: input.version ?? 1, exceptionsList: input.exceptions_list ?? [], + relatedIntegrations: [], + requiredFields: [], + setup: '', ...typeSpecificParams, }, schedule: { interval: input.interval ?? '5m' }, @@ -276,6 +279,9 @@ export const commonParamsCamelToSnake = (params: BaseRuleParams) => { version: params.version, exceptions_list: params.exceptionsList, immutable: params.immutable, + related_integrations: params.relatedIntegrations ?? [], + required_fields: params.requiredFields ?? [], + setup: params.setup ?? '', }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts index edaacf38d7712..9e3fa6a906da9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts @@ -51,6 +51,9 @@ const getBaseRuleParams = (): BaseRuleParams => { threat: getThreatMock(), version: 1, exceptionsList: getListArrayMock(), + relatedIntegrations: [], + requiredFields: [], + setup: '', }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts index 47e49e5f9c467..d1776136f6513 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts @@ -72,7 +72,10 @@ import { updatedByOrNull, created_at, updated_at, -} from '../../../../common/detection_engine/schemas/common/schemas'; + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, +} from '../../../../common/detection_engine/schemas/common'; import { SERVER_APP_ID } from '../../../../common/constants'; const nonEqlLanguages = t.keyof({ kuery: null, lucene: null }); @@ -105,6 +108,9 @@ export const baseRuleParams = t.exact( references, version, exceptionsList: listArray, + relatedIntegrations: t.union([RelatedIntegrationArray, t.undefined]), + requiredFields: t.union([RequiredFieldArray, t.undefined]), + setup: t.union([SetupGuide, t.undefined]), }) ); export type BaseRuleParams = t.TypeOf; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 9213d6c5b278c..03074b9560553 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -157,6 +157,9 @@ export const expectedRule = (): RulesSchema => { timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', exceptions_list: getListArrayMock(), + related_integrations: [], + required_fields: [], + setup: '', }; }; @@ -624,6 +627,9 @@ export const sampleSignalHit = (): SignalHit => ({ rule_id: 'query-rule-id', interval: '5m', exceptions_list: getListArrayMock(), + related_integrations: [], + required_fields: [], + setup: '', }, depth: 1, }, @@ -685,6 +691,9 @@ export const sampleThresholdSignalHit = (): SignalHit => ({ rule_id: 'query-rule-id', interval: '5m', exceptions_list: getListArrayMock(), + related_integrations: [], + required_fields: [], + setup: '', }, depth: 1, }, diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts index 7d32af43d1913..aff63d635c976 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts @@ -89,6 +89,9 @@ export default ({ getService }: FtrProviderContext) => { name: 'Simple Rule Query', query: 'user.name: root or user.name: admin', references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'high', severity_mapping: [], updated_by: 'elastic', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts index 1b7e22fb21c57..966420c90b8d2 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts @@ -171,6 +171,9 @@ export default ({ getService }: FtrProviderContext) => { name: 'Simple Rule Query', query: 'user.name: root or user.name: admin', references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'high', severity_mapping: [], updated_by: 'elastic', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts index 865185387c57c..5382ba5fd18f4 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts @@ -353,6 +353,9 @@ export default ({ getService }: FtrProviderContext) => { language: 'kuery', index: ['.siem-signals-*'], query: '*:*', + related_integrations: [], + required_fields: [], + setup: '', }, 'kibana.alert.rule.actions': [], 'kibana.alert.rule.created_by': 'elastic', @@ -518,6 +521,9 @@ export default ({ getService }: FtrProviderContext) => { language: 'kuery', index: ['.alerts-security.alerts-default'], query: '*:*', + related_integrations: [], + required_fields: [], + setup: '', }, 'kibana.alert.rule.actions': [], 'kibana.alert.rule.created_by': 'elastic', diff --git a/x-pack/test/detection_engine_api_integration/utils/get_complex_rule_output.ts b/x-pack/test/detection_engine_api_integration/utils/get_complex_rule_output.ts index 98fdfa99cbd3c..81a169636605b 100644 --- a/x-pack/test/detection_engine_api_integration/utils/get_complex_rule_output.ts +++ b/x-pack/test/detection_engine_api_integration/utils/get_complex_rule_output.ts @@ -97,4 +97,7 @@ export const getComplexRuleOutput = (ruleId = 'rule-1'): Partial => version: 1, query: 'user.name: root or user.name: admin', exceptions_list: [], + related_integrations: [], + required_fields: [], + setup: '', }); diff --git a/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts b/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts index 30dc7eecb9256..ca8b04e66f3fc 100644 --- a/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts +++ b/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts @@ -26,11 +26,14 @@ export const getSimpleRuleOutput = (ruleId = 'rule-1', enabled = false): Partial language: 'kuery', output_index: '.siem-signals-default', max_signals: 100, + related_integrations: [], + required_fields: [], risk_score: 1, risk_score_mapping: [], name: 'Simple Rule Query', query: 'user.name: root or user.name: admin', references: [], + setup: '', severity: 'high', severity_mapping: [], updated_by: 'elastic', From 383239e77c165bfb77100a68c4a92c4ed7fb8fe2 Mon Sep 17 00:00:00 2001 From: Kfir Peled <61654899+kfirpeled@users.noreply.github.com> Date: Sun, 22 May 2022 13:18:42 +0300 Subject: [PATCH 135/150] [Cloud Posture] Findings - Group by resource - Fixed bug not showing results (#132529) --- .../findings_by_resource_table.test.tsx | 30 ++++++++---- .../findings_by_resource_table.tsx | 48 +++++++++++++++---- .../use_findings_by_resource.ts | 34 +++++++++---- .../create_indices/latest_findings_mapping.ts | 26 +++++++--- 4 files changed, 103 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx index a6b8f3b863401..9cc87d98e54f8 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx @@ -21,14 +21,23 @@ import { TestProvider } from '../../../test/test_provider'; const chance = new Chance(); -const getFakeFindingsByResource = (): CspFindingsByResource => ({ - resource_id: chance.guid(), - cis_sections: [chance.word(), chance.word()], - failed_findings: { - total: chance.integer(), - normalized: chance.integer({ min: 0, max: 1 }), - }, -}); +const getFakeFindingsByResource = (): CspFindingsByResource => { + const count = chance.integer(); + const total = chance.integer() + count + 1; + const normalized = count / total; + + return { + resource_id: chance.guid(), + resource_name: chance.word(), + resource_subtype: chance.word(), + cis_sections: [chance.word(), chance.word()], + failed_findings: { + count, + normalized, + total_findings: total, + }, + }; +}; type TableProps = PropsOf; @@ -74,8 +83,11 @@ describe('', () => { ); expect(row).toBeInTheDocument(); expect(within(row).getByText(item.resource_id)).toBeInTheDocument(); + if (item.resource_name) expect(within(row).getByText(item.resource_name)).toBeInTheDocument(); + if (item.resource_subtype) + expect(within(row).getByText(item.resource_subtype)).toBeInTheDocument(); expect(within(row).getByText(item.cis_sections.join(', '))).toBeInTheDocument(); - expect(within(row).getByText(formatNumber(item.failed_findings.total))).toBeInTheDocument(); + expect(within(row).getByText(formatNumber(item.failed_findings.count))).toBeInTheDocument(); expect( within(row).getByText(new RegExp(numeral(item.failed_findings.normalized).format('0%'))) ).toBeInTheDocument(); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx index 2e96306ad3a69..80da922225893 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx @@ -9,12 +9,12 @@ import { EuiEmptyPrompt, EuiBasicTable, EuiTextColor, - EuiFlexGroup, - EuiFlexItem, type EuiTableFieldDataColumnType, type CriteriaWithPagination, type Pagination, + EuiToolTip, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import numeral from '@elastic/numeral'; import { Link, generatePath } from 'react-router-dom'; @@ -81,6 +81,26 @@ const columns: Array> = [ ), }, + { + field: 'resource_subtype', + truncateText: true, + name: ( + + ), + }, + { + field: 'resource_name', + truncateText: true, + name: ( + + ), + }, { field: 'cis_sections', truncateText: true, @@ -102,14 +122,22 @@ const columns: Array> = [ /> ), render: (failedFindings: CspFindingsByResource['failed_findings']) => ( - - - {formatNumber(failedFindings.total)} - - - ({numeral(failedFindings.normalized).format('0%')}) - - + + <> + + {formatNumber(failedFindings.count)} + + ({numeral(failedFindings.normalized).format('0%')}) + + ), }, ]; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts index 880b2be868e6f..e2da77c8ba2a2 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts @@ -14,7 +14,7 @@ import { showErrorToast } from '../latest_findings/use_latest_findings'; import type { FindingsBaseEsQuery, FindingsQueryResult } from '../types'; // a large number to probably get all the buckets -const MAX_BUCKETS = 60 * 1000; +const MAX_BUCKETS = 1000 * 1000; interface UseResourceFindingsOptions extends FindingsBaseEsQuery { from: NonNullable; @@ -43,6 +43,8 @@ interface FindingsByResourceAggs { interface FindingsAggBucket extends estypes.AggregationsStringRareTermsBucketKeys { failed_findings: estypes.AggregationsMultiBucketBase; + name: estypes.AggregationsMultiBucketAggregateBase; + subtype: estypes.AggregationsMultiBucketAggregateBase; cis_sections: estypes.AggregationsMultiBucketAggregateBase; } @@ -57,10 +59,16 @@ export const getFindingsByResourceAggQuery = ({ query, size: 0, aggs: { - resource_total: { cardinality: { field: 'resource.id.keyword' } }, + resource_total: { cardinality: { field: 'resource.id' } }, resources: { - terms: { field: 'resource.id.keyword', size: MAX_BUCKETS }, + terms: { field: 'resource.id', size: MAX_BUCKETS }, aggs: { + name: { + terms: { field: 'resource.name', size: 1 }, + }, + subtype: { + terms: { field: 'resource.sub_type', size: 1 }, + }, cis_sections: { terms: { field: 'rule.section.keyword' }, }, @@ -117,16 +125,24 @@ export const useFindingsByResource = ({ index, query, from, size }: UseResourceF ); }; -const createFindingsByResource = (bucket: FindingsAggBucket) => { - if (!Array.isArray(bucket.cis_sections.buckets)) +const createFindingsByResource = (resource: FindingsAggBucket) => { + if ( + !Array.isArray(resource.cis_sections.buckets) || + !Array.isArray(resource.name.buckets) || + !Array.isArray(resource.subtype.buckets) + ) throw new Error('expected buckets to be an array'); return { - resource_id: bucket.key, - cis_sections: bucket.cis_sections.buckets.map((v) => v.key), + resource_id: resource.key, + resource_name: resource.name.buckets.map((v) => v.key).at(0), + resource_subtype: resource.subtype.buckets.map((v) => v.key).at(0), + cis_sections: resource.cis_sections.buckets.map((v) => v.key), failed_findings: { - total: bucket.failed_findings.doc_count, - normalized: bucket.doc_count > 0 ? bucket.failed_findings.doc_count / bucket.doc_count : 0, + count: resource.failed_findings.doc_count, + normalized: + resource.doc_count > 0 ? resource.failed_findings.doc_count / resource.doc_count : 0, + total_findings: resource.doc_count, }, }; }; diff --git a/x-pack/plugins/cloud_security_posture/server/create_indices/latest_findings_mapping.ts b/x-pack/plugins/cloud_security_posture/server/create_indices/latest_findings_mapping.ts index 9ebe4c3cf4038..57305fd2df7c4 100644 --- a/x-pack/plugins/cloud_security_posture/server/create_indices/latest_findings_mapping.ts +++ b/x-pack/plugins/cloud_security_posture/server/create_indices/latest_findings_mapping.ts @@ -50,20 +50,32 @@ export const latestFindingsMapping: MappingTypeMapping = { properties: { type: { type: 'keyword', - ignore_above: 256, + ignore_above: 1024, }, id: { - type: 'text', + type: 'keyword', + ignore_above: 1024, + fields: { + text: { + type: 'text', + }, + }, }, name: { - type: 'text', + type: 'keyword', + ignore_above: 1024, + fields: { + text: { + type: 'text', + }, + }, }, sub_type: { - type: 'text', + ignore_above: 1024, + type: 'keyword', fields: { - keyword: { - ignore_above: 1024, - type: 'keyword', + text: { + type: 'text', }, }, }, From fbaf0588d0ed72ba5f1f405252b93bb6584333f8 Mon Sep 17 00:00:00 2001 From: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com> Date: Sun, 22 May 2022 17:14:23 -0700 Subject: [PATCH 136/150] [RAM] Add shareable rules list (#132437) * Shareable rules list * Hide snooze panel in rules list * Address comments and added tests * Fix tests * Fix tests * Fix lint * Address design comments and fix tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/experimental_features.ts | 4 +- .../hooks/use_load_rule_aggregations.test.ts | 111 +++ .../hooks/use_load_rule_aggregations.ts | 83 ++ .../application/hooks/use_load_rules.test.ts | 378 +++++++ .../application/hooks/use_load_rules.ts | 185 ++++ .../application/hooks/use_load_tags.test.ts | 54 + .../public/application/hooks/use_load_tags.ts | 45 + .../rule_event_log_list_sandbox.tsx | 3 +- .../rules_list_sandbox.tsx | 16 + .../shareable_components_sandbox.tsx | 2 + .../application/lib/rule_api/aggregate.ts | 20 +- .../public/application/lib/rule_api/index.ts | 2 + .../public/application/lib/rule_api/rules.ts | 24 +- .../public/application/sections/index.tsx | 3 + .../components/action_type_filter.tsx | 89 +- .../rule_execution_status_filter.tsx | 108 +- .../components/rule_status_dropdown.tsx | 100 +- .../components/rule_status_filter.test.tsx | 14 +- .../components/rule_status_filter.tsx | 33 +- .../rules_list/components/rule_tag_filter.tsx | 50 +- .../rules_list/components/rules_list.test.tsx | 90 +- .../rules_list/components/rules_list.tsx | 941 +++--------------- .../rules_list_auto_refresh.test.tsx | 87 ++ .../components/rules_list_auto_refresh.tsx | 122 +++ .../components/rules_list_notify_badge.tsx | 224 +++++ .../components/rules_list_table.tsx | 724 ++++++++++++++ .../rules_list/components/type_filter.tsx | 102 +- .../public/common/get_rules_list.tsx | 13 + .../triggers_actions_ui/public/mocks.ts | 4 + .../triggers_actions_ui/public/plugin.ts | 5 + .../apps/triggers_actions_ui/index.ts | 1 + .../triggers_actions_ui/rule_tag_filter.ts | 20 - .../apps/triggers_actions_ui/rules_list.ts | 34 + 33 files changed, 2624 insertions(+), 1067 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx create mode 100644 x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts diff --git a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts index 33f5fdc44afcd..3265469bea640 100644 --- a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts +++ b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts @@ -15,8 +15,8 @@ export const allowedExperimentalValues = Object.freeze({ rulesListDatagrid: true, internalAlertsTable: false, internalShareableComponentsSandbox: false, - ruleTagFilter: false, - ruleStatusFilter: false, + ruleTagFilter: true, + ruleStatusFilter: true, rulesDetailLogs: true, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts new file mode 100644 index 0000000000000..b00101da6be83 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadRuleAggregations } from './use_load_rule_aggregations'; +import { RuleStatus } from '../../types'; + +const MOCK_TAGS = ['a', 'b', 'c']; + +const MOCK_AGGS = { + ruleEnabledStatus: { enabled: 2, disabled: 0 }, + ruleExecutionStatus: { ok: 1, active: 2, error: 3, pending: 4, unknown: 5, warning: 6 }, + ruleMutedStatus: { muted: 0, unmuted: 2 }, + ruleTags: MOCK_TAGS, +}; + +jest.mock('../lib/rule_api', () => ({ + loadRuleAggregations: jest.fn(), +})); + +const { loadRuleAggregations } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); + +describe('useLoadRuleAggregations', () => { + beforeEach(() => { + loadRuleAggregations.mockResolvedValue(MOCK_AGGS); + jest.clearAllMocks(); + }); + + it('should call loadRuleAggregations API and handle result', async () => { + const params = { + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + await waitForNextUpdate(); + }); + + expect(loadRuleAggregations).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesStatusesTotal).toEqual(MOCK_AGGS.ruleExecutionStatus); + }); + + it('should call loadRuleAggregation API with params and handle result', async () => { + const params = { + searchText: 'test', + typesFilter: ['type1', 'type2'], + actionTypesFilter: ['action1', 'action2'], + ruleExecutionStatusesFilter: ['status1', 'status2'], + ruleStatusesFilter: ['enabled', 'snoozed'] as RuleStatus[], + tagsFilter: ['tag1', 'tag2'], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + await waitForNextUpdate(); + }); + + expect(loadRuleAggregations).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesStatusesTotal).toEqual(MOCK_AGGS.ruleExecutionStatus); + }); + + it('should call onError if API fails', async () => { + loadRuleAggregations.mockRejectedValue(''); + const params = { + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + }); + + expect(onError).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts new file mode 100644 index 0000000000000..75f9e18ec2328 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts @@ -0,0 +1,83 @@ +/* + * 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 { useState, useCallback, useMemo } from 'react'; +import { RuleExecutionStatusValues } from '@kbn/alerting-plugin/common'; +import { loadRuleAggregations, LoadRuleAggregationsProps } from '../lib/rule_api'; +import { useKibana } from '../../common/lib/kibana'; + +type UseLoadRuleAggregationsProps = Omit & { + onError: (message: string) => void; +}; + +export function useLoadRuleAggregations({ + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + onError, +}: UseLoadRuleAggregationsProps) { + const { http } = useKibana().services; + + const [rulesStatusesTotal, setRulesStatusesTotal] = useState>( + RuleExecutionStatusValues.reduce>( + (prev: Record, status: string) => ({ + ...prev, + [status]: 0, + }), + {} + ) + ); + + const internalLoadRuleAggregations = useCallback(async () => { + try { + const rulesAggs = await loadRuleAggregations({ + http, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + }); + if (rulesAggs?.ruleExecutionStatus) { + setRulesStatusesTotal(rulesAggs.ruleExecutionStatus); + } + } catch (e) { + onError( + i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleStatusInfoMessage', + { + defaultMessage: 'Unable to load rule status info', + } + ) + ); + } + }, [ + http, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + onError, + setRulesStatusesTotal, + ]); + + return useMemo( + () => ({ + loadRuleAggregations: internalLoadRuleAggregations, + rulesStatusesTotal, + setRulesStatusesTotal, + }), + [internalLoadRuleAggregations, rulesStatusesTotal, setRulesStatusesTotal] + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts new file mode 100644 index 0000000000000..a309beeca58aa --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts @@ -0,0 +1,378 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadRules } from './use_load_rules'; +import { + RuleExecutionStatusErrorReasons, + RuleExecutionStatusWarningReasons, +} from '@kbn/alerting-plugin/common'; +import { RuleStatus } from '../../types'; + +jest.mock('../lib/rule_api', () => ({ + loadRules: jest.fn(), +})); + +const { loadRules } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); +const onPage = jest.fn(); + +const mockedRulesData = [ + { + id: '1', + name: 'test rule', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '1s' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 1000000, + }, + { + success: true, + duration: 200000, + }, + { + success: false, + duration: 300000, + }, + ], + calculated_metrics: { + success_ratio: 0.66, + p50: 200000, + p95: 300000, + p99: 300000, + }, + }, + }, + }, + { + id: '2', + name: 'test rule ok', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'ok', + lastDuration: 61000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 100000, + }, + { + success: true, + duration: 500000, + }, + ], + calculated_metrics: { + success_ratio: 1, + p50: 0, + p95: 100000, + p99: 500000, + }, + }, + }, + }, + { + id: '3', + name: 'test rule pending', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'pending', + lastDuration: 30234, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [{ success: false, duration: 100 }], + calculated_metrics: { + success_ratio: 0, + }, + }, + }, + }, + { + id: '4', + name: 'test rule error', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 122000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.Unknown, + message: 'test', + }, + }, + }, + { + id: '5', + name: 'test rule license error', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.License, + message: 'test', + }, + }, + }, + { + id: '6', + name: 'test rule warning', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'warning', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'test', + }, + }, + }, +]; + +const MOCK_RULE_DATA = { + page: 1, + perPage: 10000, + total: 4, + data: mockedRulesData, +}; + +describe('useLoadRules', () => { + beforeEach(() => { + loadRules.mockResolvedValue(MOCK_RULE_DATA); + jest.clearAllMocks(); + }); + + it('should call loadRules API and handle result', async () => { + const params = { + page: { + index: 0, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + expect(result.current.initialLoad).toBeTruthy(); + expect(result.current.noData).toBeTruthy(); + expect(result.current.rulesState.isLoading).toBeFalsy(); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(result.current.initialLoad).toBeFalsy(); + expect(result.current.noData).toBeFalsy(); + expect(result.current.rulesState.isLoading).toBeFalsy(); + + expect(onPage).toBeCalledTimes(0); + expect(loadRules).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesState.data).toEqual(expect.arrayContaining(MOCK_RULE_DATA.data)); + expect(result.current.rulesState.totalItemCount).toEqual(MOCK_RULE_DATA.total); + }); + + it('should call loadRules API with params and handle result', async () => { + const params = { + page: { + index: 0, + size: 25, + }, + searchText: 'test', + typesFilter: ['type1', 'type2'], + actionTypesFilter: ['action1', 'action2'], + ruleExecutionStatusesFilter: ['status1', 'status2'], + ruleStatusesFilter: ['enabled', 'snoozed'] as RuleStatus[], + tagsFilter: ['tag1', 'tag2'], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(loadRules).toBeCalledWith(expect.objectContaining(params)); + }); + + it('should reset the page if the data is fetched while paged', async () => { + loadRules.mockResolvedValue({ + ...MOCK_RULE_DATA, + data: [], + }); + + const params = { + page: { + index: 1, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(onPage).toHaveBeenCalledWith({ + index: 0, + size: 25, + }); + }); + + it('should call onError if API fails', async () => { + loadRules.mockRejectedValue(''); + const params = { + page: { + index: 0, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + }); + + expect(onError).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts new file mode 100644 index 0000000000000..4afdfd4f26a72 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useMemo, useCallback, useReducer } from 'react'; +import { i18n } from '@kbn/i18n'; +import { isEmpty } from 'lodash'; +import { Rule, Pagination } from '../../types'; +import { loadRules, LoadRulesProps } from '../lib/rule_api'; +import { useKibana } from '../../common/lib/kibana'; + +interface RuleState { + isLoading: boolean; + data: Rule[]; + totalItemCount: number; +} + +type UseLoadRulesProps = Omit & { + onPage: (pagination: Pagination) => void; + onError: (message: string) => void; +}; + +interface UseLoadRulesState { + rulesState: RuleState; + noData: boolean; + initialLoad: boolean; +} + +enum ActionTypes { + SET_RULE_STATE = 'SET_RULE_STATE', + SET_LOADING = 'SET_LOADING', + SET_INITIAL_LOAD = 'SET_INITIAL_LOAD', + SET_NO_DATA = 'SET_NO_DATA', +} + +interface Action { + type: ActionTypes; + payload: boolean | RuleState; +} + +const initialState: UseLoadRulesState = { + rulesState: { + isLoading: false, + data: [], + totalItemCount: 0, + }, + noData: true, + initialLoad: true, +}; + +const reducer = (state: UseLoadRulesState, action: Action) => { + const { type, payload } = action; + switch (type) { + case ActionTypes.SET_RULE_STATE: + return { + ...state, + rulesState: payload as RuleState, + }; + case ActionTypes.SET_LOADING: + return { + ...state, + rulesState: { + ...state.rulesState, + isLoading: payload as boolean, + }, + }; + case ActionTypes.SET_INITIAL_LOAD: + return { + ...state, + initialLoad: payload as boolean, + }; + case ActionTypes.SET_NO_DATA: + return { + ...state, + noData: payload as boolean, + }; + default: + return state; + } +}; + +export function useLoadRules({ + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + onPage, + onError, +}: UseLoadRulesProps) { + const { http } = useKibana().services; + const [state, dispatch] = useReducer(reducer, initialState); + + const setRulesState = useCallback( + (rulesState: RuleState) => { + dispatch({ + type: ActionTypes.SET_RULE_STATE, + payload: rulesState, + }); + }, + [dispatch] + ); + + const internalLoadRules = useCallback(async () => { + dispatch({ type: ActionTypes.SET_LOADING, payload: true }); + + try { + const rulesResponse = await loadRules({ + http, + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + }); + + dispatch({ + type: ActionTypes.SET_RULE_STATE, + payload: { + isLoading: false, + data: rulesResponse.data, + totalItemCount: rulesResponse.total, + }, + }); + + if (!rulesResponse.data?.length && page.index > 0) { + onPage({ ...page, index: 0 }); + } + + const isFilterApplied = !( + isEmpty(searchText) && + isEmpty(typesFilter) && + isEmpty(actionTypesFilter) && + isEmpty(ruleExecutionStatusesFilter) && + isEmpty(ruleStatusesFilter) && + isEmpty(tagsFilter) + ); + + dispatch({ + type: ActionTypes.SET_NO_DATA, + payload: rulesResponse.data.length === 0 && !isFilterApplied, + }); + } catch (e) { + onError( + i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRulesMessage', { + defaultMessage: 'Unable to load rules', + }) + ); + dispatch({ type: ActionTypes.SET_LOADING, payload: false }); + } + dispatch({ type: ActionTypes.SET_INITIAL_LOAD, payload: false }); + }, [ + http, + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + dispatch, + onPage, + onError, + ]); + + return useMemo( + () => ({ + rulesState: state.rulesState, + noData: state.noData, + initialLoad: state.initialLoad, + loadRules: internalLoadRules, + setRulesState, + }), + [state, setRulesState, internalLoadRules] + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts new file mode 100644 index 0000000000000..8973d869e0724 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadTags } from './use_load_tags'; + +const MOCK_TAGS = ['a', 'b', 'c']; + +jest.mock('../lib/rule_api', () => ({ + loadRuleTags: jest.fn(), +})); + +const { loadRuleTags } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); + +describe('useLoadTags', () => { + beforeEach(() => { + loadRuleTags.mockResolvedValue({ + ruleTags: MOCK_TAGS, + }); + jest.clearAllMocks(); + }); + + it('should call loadRuleTags API and handle result', async () => { + const { result, waitForNextUpdate } = renderHook(() => useLoadTags({ onError })); + + await act(async () => { + result.current.loadTags(); + await waitForNextUpdate(); + }); + + expect(loadRuleTags).toBeCalled(); + expect(result.current.tags).toEqual(MOCK_TAGS); + }); + + it('should call onError if API fails', async () => { + loadRuleTags.mockRejectedValue(''); + + const { result } = renderHook(() => useLoadTags({ onError })); + + await act(async () => { + result.current.loadTags(); + }); + + expect(loadRuleTags).toBeCalled(); + expect(onError).toBeCalled(); + expect(result.current.tags).toEqual([]); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts new file mode 100644 index 0000000000000..3357f43a012f1 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { useState, useCallback, useMemo } from 'react'; +import { loadRuleTags } from '../lib/rule_api'; +import { useKibana } from '../../common/lib/kibana'; + +interface UseLoadTagsProps { + onError: (message: string) => void; +} + +export function useLoadTags(props: UseLoadTagsProps) { + const { onError } = props; + const { http } = useKibana().services; + const [tags, setTags] = useState([]); + + const internalLoadTags = useCallback(async () => { + try { + const ruleTagsAggs = await loadRuleTags({ http }); + if (ruleTagsAggs?.ruleTags) { + setTags(ruleTagsAggs.ruleTags); + } + } catch (e) { + onError( + i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTags', { + defaultMessage: 'Unable to load rule tags', + }) + ); + } + }, [http, setTags, onError]); + + return useMemo( + () => ({ + tags, + loadTags: internalLoadTags, + setTags, + }), + [tags, internalLoadTags, setTags] + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx index 4af95523dce29..ba45800e49bcb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import React from 'react'; import { getRuleEventLogListLazy } from '../../../common/get_rule_event_log_list'; export const RuleEventLogListSandbox = () => { @@ -39,5 +40,5 @@ export const RuleEventLogListSandbox = () => { }), }; - return getRuleEventLogListLazy(props); + return
{getRuleEventLogListLazy(props)}
; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx new file mode 100644 index 0000000000000..7702b914cfd36 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { getRulesListLazy } from '../../../common/get_rules_list'; + +const style = { + flex: 1, +}; + +export const RulesListSandbox = () => { + return
{getRulesListLazy()}
; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx index af5a05acdf19a..018f0a8794c33 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx @@ -11,6 +11,7 @@ import { RuleTagFilterSandbox } from './rule_tag_filter_sandbox'; import { RuleStatusFilterSandbox } from './rule_status_filter_sandbox'; import { RuleTagBadgeSandbox } from './rule_tag_badge_sandbox'; import { RuleEventLogListSandbox } from './rule_event_log_list_sandbox'; +import { RulesListSandbox } from './rules_list_sandbox'; export const InternalShareableComponentsSandbox: React.FC<{}> = () => { return ( @@ -19,6 +20,7 @@ export const InternalShareableComponentsSandbox: React.FC<{}> = () => { + ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts index 1df6177443657..5df7cfc374f89 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts @@ -44,6 +44,16 @@ export async function loadRuleTags({ http }: { http: HttpSetup }): Promise { +}: LoadRuleAggregationsProps): Promise { const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts index d0e7728498c5b..64d6b18b7ca5c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts @@ -7,6 +7,7 @@ export { alertingFrameworkHealth } from './health'; export { mapFiltersToKql } from './map_filters_to_kql'; +export type { LoadRuleAggregationsProps } from './aggregate'; export { loadRuleAggregations, loadRuleTags } from './aggregate'; export { createRule } from './create'; export { deleteRules } from './delete'; @@ -17,6 +18,7 @@ export { loadRuleSummary } from './rule_summary'; export { muteAlertInstance } from './mute_alert'; export { muteRule, muteRules } from './mute'; export { loadRuleTypes } from './rule_types'; +export type { LoadRulesProps } from './rules'; export { loadRules } from './rules'; export { loadRuleState } from './state'; export type { LoadExecutionLogAggregationsProps } from './load_execution_log_aggregations'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts index 6e527989cc91f..3db1cb8b0214d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts @@ -11,6 +11,18 @@ import { Rule, Pagination, Sorting, RuleStatus } from '../../../types'; import { mapFiltersToKql } from './map_filters_to_kql'; import { transformRule } from './common_transformations'; +export interface LoadRulesProps { + http: HttpSetup; + page: Pagination; + searchText?: string; + typesFilter?: string[]; + actionTypesFilter?: string[]; + tagsFilter?: string[]; + ruleExecutionStatusesFilter?: string[]; + ruleStatusesFilter?: RuleStatus[]; + sort?: Sorting; +} + const rewriteResponseRes = (results: Array>): Rule[] => { return results.map((item) => transformRule(item)); }; @@ -25,17 +37,7 @@ export async function loadRules({ ruleStatusesFilter, tagsFilter, sort = { field: 'name', direction: 'asc' }, -}: { - http: HttpSetup; - page: Pagination; - searchText?: string; - typesFilter?: string[]; - actionTypesFilter?: string[]; - tagsFilter?: string[]; - ruleExecutionStatusesFilter?: string[]; - ruleStatusesFilter?: RuleStatus[]; - sort?: Sorting; -}): Promise<{ +}: LoadRulesProps): Promise<{ page: number; perPage: number; total: number; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx index 979630d2a5a99..bd2ef041535f3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx @@ -44,3 +44,6 @@ export const RuleTagBadge = suspendedComponentWithProps( export const RuleEventLogList = suspendedComponentWithProps( lazy(() => import('./rule_details/components/rule_event_log_list')) ); +export const RulesList = suspendedComponentWithProps( + lazy(() => import('./rules_list/components/rules_list')) +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx index a136413d53e42..38d1a62de699a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFilterGroup, EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; import { ActionType } from '../../../../types'; interface ActionTypeFilterProps { @@ -29,47 +29,52 @@ export const ActionTypeFilter: React.FunctionComponent = // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedValues]); + const onClick = useCallback( + (item: ActionType) => { + return () => { + const isPreviouslyChecked = selectedValues.includes(item.id); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item.id)); + } else { + setSelectedValues(selectedValues.concat(item.id)); + } + }; + }, + [selectedValues, setSelectedValues] + ); + return ( - - setIsPopoverOpen(false)} - button={ - 0} - numActiveFilters={selectedValues.length} - numFilters={selectedValues.length} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} - data-test-subj="actionTypeFilterButton" + setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + data-test-subj="actionTypeFilterButton" + > + + + } + > +
+ {actionTypes.map((item) => ( + - - - } - > -
- {actionTypes.map((item) => ( - { - const isPreviouslyChecked = selectedValues.includes(item.id); - if (isPreviouslyChecked) { - setSelectedValues(selectedValues.filter((val) => val !== item.id)); - } else { - setSelectedValues(selectedValues.concat(item.id)); - } - }} - checked={selectedValues.includes(item.id) ? 'on' : undefined} - data-test-subj={`actionType${item.id}FilterOption`} - > - {item.name} - - ))} -
- - + {item.name} +
+ ))} +
+
); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx index 9acb8489fa09a..e5bb7ffd1b0e4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx @@ -5,15 +5,9 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiFilterGroup, - EuiPopover, - EuiFilterButton, - EuiFilterSelectItem, - EuiHealth, -} from '@elastic/eui'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem, EuiHealth } from '@elastic/eui'; import { RuleExecutionStatuses, RuleExecutionStatusValues } from '@kbn/alerting-plugin/common'; import { rulesStatusesTranslationsMapping } from '../translations'; @@ -22,6 +16,8 @@ interface RuleExecutionStatusFilterProps { onChange?: (selectedRuleStatusesIds: string[]) => void; } +const sortedRuleExecutionStatusValues = [...RuleExecutionStatusValues].sort(); + export const RuleExecutionStatusFilter: React.FunctionComponent = ({ selectedStatuses, onChange, @@ -29,6 +25,14 @@ export const RuleExecutionStatusFilter: React.FunctionComponent(selectedStatuses); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const onTogglePopover = useCallback(() => { + setIsPopoverOpen((prevIsPopoverOpen) => !prevIsPopoverOpen); + }, [setIsPopoverOpen]); + + const onClosePopover = useCallback(() => { + setIsPopoverOpen(false); + }, [setIsPopoverOpen]); + useEffect(() => { if (onChange) { onChange(selectedValues); @@ -41,51 +45,49 @@ export const RuleExecutionStatusFilter: React.FunctionComponent - setIsPopoverOpen(false)} - button={ - 0} - numActiveFilters={selectedValues.length} - numFilters={selectedValues.length} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} - data-test-subj="ruleExecutionStatusFilterButton" - > - - - } - > -
- {[...RuleExecutionStatusValues].sort().map((item: RuleExecutionStatuses) => { - const healthColor = getHealthColor(item); - return ( - { - const isPreviouslyChecked = selectedValues.includes(item); - if (isPreviouslyChecked) { - setSelectedValues(selectedValues.filter((val) => val !== item)); - } else { - setSelectedValues(selectedValues.concat(item)); - } - }} - checked={selectedValues.includes(item) ? 'on' : undefined} - data-test-subj={`ruleExecutionStatus${item}FilterOption`} - > - {rulesStatusesTranslationsMapping[item]} - - ); - })} -
-
-
+ 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={onTogglePopover} + data-test-subj="ruleExecutionStatusFilterButton" + > + + + } + > +
+ {sortedRuleExecutionStatusValues.map((item: RuleExecutionStatuses) => { + const healthColor = getHealthColor(item); + return ( + { + const isPreviouslyChecked = selectedValues.includes(item); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item)); + } else { + setSelectedValues(selectedValues.concat(item)); + } + }} + checked={selectedValues.includes(item) ? 'on' : undefined} + data-test-subj={`ruleExecutionStatus${item}FilterOption`} + > + {rulesStatusesTranslationsMapping[item]} + + ); + })} +
+
); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx index 7c6a71e893f96..194bf86030e56 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx @@ -33,7 +33,7 @@ import { parseInterval } from '../../../../../common'; import { Rule } from '../../../../types'; -type SnoozeUnit = 'm' | 'h' | 'd' | 'w' | 'M'; +export type SnoozeUnit = 'm' | 'h' | 'd' | 'w' | 'M'; const SNOOZE_END_TIME_FORMAT = 'LL @ LT'; type DropdownRuleRecord = Pick; @@ -48,6 +48,7 @@ export interface ComponentOpts { isEditable: boolean; previousSnoozeInterval?: string | null; direction?: 'column' | 'row'; + hideSnoozeOption?: boolean; } const COMMON_SNOOZE_TIMES: Array<[number, SnoozeUnit]> = [ @@ -58,9 +59,9 @@ const COMMON_SNOOZE_TIMES: Array<[number, SnoozeUnit]> = [ ]; const PREV_SNOOZE_INTERVAL_KEY = 'triggersActionsUi_previousSnoozeInterval'; -const usePreviousSnoozeInterval: (p?: string | null) => [string | null, (n: string) => void] = ( - propsInterval -) => { +export const usePreviousSnoozeInterval: ( + p?: string | null +) => [string | null, (n: string) => void] = (propsInterval) => { const intervalFromStorage = localStorage.getItem(PREV_SNOOZE_INTERVAL_KEY); const usePropsInterval = typeof propsInterval !== 'undefined'; const interval = usePropsInterval ? propsInterval : intervalFromStorage; @@ -74,7 +75,7 @@ const usePreviousSnoozeInterval: (p?: string | null) => [string | null, (n: stri return [previousSnoozeInterval, storeAndSetPreviousSnoozeInterval]; }; -const isRuleSnoozed = (rule: { isSnoozedUntil?: Date | null; muteAll: boolean }) => +export const isRuleSnoozed = (rule: { isSnoozedUntil?: Date | null; muteAll: boolean }) => Boolean( (rule.isSnoozedUntil && new Date(rule.isSnoozedUntil).getTime() > Date.now()) || rule.muteAll ); @@ -88,6 +89,7 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ unsnoozeRule, isEditable, previousSnoozeInterval: propsPreviousSnoozeInterval, + hideSnoozeOption = false, direction = 'column', }: ComponentOpts) => { const [isEnabled, setIsEnabled] = useState(rule.enabled); @@ -224,6 +226,7 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ isSnoozed={isSnoozed} snoozeEndTime={rule.isSnoozedUntil} previousSnoozeInterval={previousSnoozeInterval} + hideSnoozeOption={hideSnoozeOption} />
) : ( @@ -245,6 +248,7 @@ interface RuleStatusMenuProps { isSnoozed: boolean; snoozeEndTime?: Date | null; previousSnoozeInterval: string | null; + hideSnoozeOption?: boolean; } const RuleStatusMenu: React.FunctionComponent = ({ @@ -255,6 +259,7 @@ const RuleStatusMenu: React.FunctionComponent = ({ isSnoozed, snoozeEndTime, previousSnoozeInterval, + hideSnoozeOption = false, }) => { const enableRule = useCallback(() => { if (isSnoozed) { @@ -290,6 +295,44 @@ const RuleStatusMenu: React.FunctionComponent = ({ ); } + const getSnoozeMenuItem = () => { + if (!hideSnoozeOption) { + return [ + { + name: snoozeButtonTitle, + icon: isEnabled && isSnoozed ? 'check' : 'empty', + panel: 1, + disabled: !isEnabled, + 'data-test-subj': 'statusDropdownSnoozeItem', + }, + ]; + } + return []; + }; + + const getSnoozePanel = () => { + if (!hideSnoozeOption) { + return [ + { + id: 1, + width: 360, + title: SNOOZE, + content: ( + + + + ), + }, + ]; + } + return []; + }; + const panels = [ { id: 0, @@ -307,28 +350,10 @@ const RuleStatusMenu: React.FunctionComponent = ({ onClick: disableRule, 'data-test-subj': 'statusDropdownDisabledItem', }, - { - name: snoozeButtonTitle, - icon: isEnabled && isSnoozed ? 'check' : 'empty', - panel: 1, - disabled: !isEnabled, - 'data-test-subj': 'statusDropdownSnoozeItem', - }, + ...getSnoozeMenuItem(), ], }, - { - id: 1, - width: 360, - title: SNOOZE, - content: ( - - ), - }, + ...getSnoozePanel(), ]; return ; @@ -336,13 +361,15 @@ const RuleStatusMenu: React.FunctionComponent = ({ interface SnoozePanelProps { interval?: string; + isLoading?: boolean; applySnooze: (value: number | -1, unit?: SnoozeUnit) => void; showCancel: boolean; previousSnoozeInterval: string | null; } -const SnoozePanel: React.FunctionComponent = ({ +export const SnoozePanel: React.FunctionComponent = ({ interval = '3d', + isLoading = false, applySnooze, showCancel, previousSnoozeInterval, @@ -394,9 +421,9 @@ const SnoozePanel: React.FunctionComponent = ({ ); return ( - + <> - + = ({ /> - + {i18n.translate('xpack.triggersActionsUI.sections.rulesList.applySnooze', { defaultMessage: 'Apply', })} @@ -471,7 +502,12 @@ const SnoozePanel: React.FunctionComponent = ({ - + Cancel snooze @@ -479,11 +515,11 @@ const SnoozePanel: React.FunctionComponent = ({ )} - + ); }; -const futureTimeToInterval = (time?: Date | null) => { +export const futureTimeToInterval = (time?: Date | null) => { if (!time) return; const relativeTime = moment(time).locale('en').fromNow(true); const [valueStr, unitStr] = relativeTime.split(' '); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx index f1f2957f9cada..a7d3bdfb8e2e0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx @@ -7,12 +7,12 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; +import { EuiFilterButton, EuiSelectableListItem } from '@elastic/eui'; import { RuleStatusFilter } from './rule_status_filter'; const onChangeMock = jest.fn(); -describe('rule_state_filter', () => { +describe('RuleStatusFilter', () => { beforeEach(() => { onChangeMock.mockReset(); }); @@ -22,7 +22,7 @@ describe('rule_state_filter', () => { ); - expect(wrapper.find(EuiFilterSelectItem).exists()).toBeFalsy(); + expect(wrapper.find(EuiSelectableListItem).exists()).toBeFalsy(); expect(wrapper.find(EuiFilterButton).exists()).toBeTruthy(); expect(wrapper.find('.euiNotificationBadge').text()).toEqual('0'); @@ -37,7 +37,7 @@ describe('rule_state_filter', () => { wrapper.find(EuiFilterButton).simulate('click'); - const statusItems = wrapper.find(EuiFilterSelectItem); + const statusItems = wrapper.find(EuiSelectableListItem); expect(statusItems.length).toEqual(3); }); @@ -48,17 +48,17 @@ describe('rule_state_filter', () => { wrapper.find(EuiFilterButton).simulate('click'); - wrapper.find(EuiFilterSelectItem).at(0).simulate('click'); + wrapper.find(EuiSelectableListItem).at(0).simulate('click'); expect(onChangeMock).toHaveBeenCalledWith(['enabled']); wrapper.setProps({ selectedStatuses: ['enabled'], }); - wrapper.find(EuiFilterSelectItem).at(0).simulate('click'); + wrapper.find(EuiSelectableListItem).at(0).simulate('click'); expect(onChangeMock).toHaveBeenCalledWith([]); - wrapper.find(EuiFilterSelectItem).at(1).simulate('click'); + wrapper.find(EuiSelectableListItem).at(1).simulate('click'); expect(onChangeMock).toHaveBeenCalledWith(['enabled', 'disabled']); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx index 6d286ec6d09d7..f26b3f54c587e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx @@ -6,7 +6,13 @@ */ import React, { useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFilterButton, EuiPopover, EuiFilterGroup, EuiFilterSelectItem } from '@elastic/eui'; +import { + EuiFilterButton, + EuiPopover, + EuiFilterGroup, + EuiSelectableListItem, + EuiButtonEmpty, +} from '@elastic/eui'; import { RuleStatus } from '../../../../types'; const statuses: RuleStatus[] = ['enabled', 'disabled', 'snoozed']; @@ -53,6 +59,24 @@ export const RuleStatusFilter = (props: RuleStatusFilterProps) => { setIsPopoverOpen((prevIsOpen) => !prevIsOpen); }, [setIsPopoverOpen]); + const renderClearAll = () => { + return ( +
+ onChange([])} + > + Clear all + +
+ ); + }; + return ( { > } @@ -77,7 +101,7 @@ export const RuleStatusFilter = (props: RuleStatusFilterProps) => {
{statuses.map((status) => { return ( - { checked={selectedStatuses.includes(status) ? 'on' : undefined} > {status} - + ); })} + {renderClearAll()}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx index 636bcaf1acb22..47b93ff19c6ea 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx @@ -9,7 +9,6 @@ import React, { useMemo, useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSelectable, - EuiFilterGroup, EuiFilterButton, EuiPopover, EuiSelectableProps, @@ -103,29 +102,32 @@ export const RuleTagFilter = (props: RuleTagFilterProps) => { }; return ( - - - - {(list, search) => ( - <> - {search} - - {list} - - )} - - - + + + {(list, search) => ( + <> + {search} + + {list} + + )} + + ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index 7827033138fbb..893d6cf7bc5ad 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -365,7 +365,7 @@ describe('Update Api Key', () => { fireEvent.click(screen.getByText('Update')); }); expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' })); - expect(loadRules).toHaveBeenCalledTimes(2); + expect(loadRules).toHaveBeenCalledTimes(3); expect(screen.queryByText("You can't recover the old API key")).not.toBeInTheDocument(); expect(addSuccess).toHaveBeenCalledWith('API key has been updated'); }); @@ -390,7 +390,7 @@ describe('Update Api Key', () => { fireEvent.click(screen.getByText('Update')); }); expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' })); - expect(loadRules).toHaveBeenCalledTimes(2); + expect(loadRules).toHaveBeenCalledTimes(3); expect( screen.queryByText('You will not be able to recover the old API key') ).not.toBeInTheDocument(); @@ -514,7 +514,6 @@ describe('rules_list component with items', () => { // eslint-disable-next-line react-hooks/rules-of-hooks useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; wrapper = mountWithIntl(); - await act(async () => { await nextTick(); wrapper.update(); @@ -561,7 +560,7 @@ describe('rules_list component with items', () => { .simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe('Start time of the last run.'); @@ -580,7 +579,7 @@ describe('rules_list component with items', () => { wrapper.find('[data-test-subj="ruleInterval-config-tooltip-0"]').first().simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe( @@ -605,7 +604,7 @@ describe('rules_list component with items', () => { wrapper.find('[data-test-subj="rulesTableCell-durationTooltip"]').first().simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe( @@ -627,7 +626,7 @@ describe('rules_list component with items', () => { wrapper.find('EuiButtonEmpty[data-test-subj="ruleStatus-error-license-fix"]').length ).toEqual(1); - expect(wrapper.find('[data-test-subj="refreshRulesButton"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="rulesListAutoRefresh"]').exists()).toBeTruthy(); expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-error"]').first().text()).toEqual( 'Error' @@ -724,7 +723,7 @@ describe('rules_list component with items', () => { .first() .simulate('click'); - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); // Percentile Selection @@ -740,7 +739,7 @@ describe('rules_list component with items', () => { // Select P95 percentileOptions.at(1).simulate('click'); - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect( @@ -795,18 +794,6 @@ describe('rules_list component with items', () => { jest.clearAllMocks(); }); - it('loads rules when refresh button is clicked', async () => { - await setup(); - wrapper.find('[data-test-subj="refreshRulesButton"]').first().simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(loadRules).toHaveBeenCalled(); - }); - it('renders license errors and manage license modal on click', async () => { global.open = jest.fn(); await setup(); @@ -854,7 +841,7 @@ describe('rules_list component with items', () => { it('sorts rules when clicking the status control column', async () => { await setup(); wrapper - .find('[data-test-subj="tableHeaderCell_enabled_8"] .euiTableHeaderButton') + .find('[data-test-subj="tableHeaderCell_enabled_9"] .euiTableHeaderButton') .first() .simulate('click'); @@ -923,21 +910,37 @@ describe('rules_list component with items', () => { loadRules.mockReset(); await setup(); - expect(loadRules.mock.calls[0][0].ruleStatusesFilter).toEqual([]); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: [], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterButton"] button').simulate('click'); wrapper.find('[data-test-subj="ruleStatusFilterOption-enabled"]').first().simulate('click'); - expect(loadRules.mock.calls[1][0].ruleStatusesFilter).toEqual(['enabled']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled'], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterOption-snoozed"]').first().simulate('click'); - expect(loadRules.mock.calls[2][0].ruleStatusesFilter).toEqual(['enabled', 'snoozed']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled', 'snoozed'], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterOption-snoozed"]').first().simulate('click'); - expect(loadRules.mock.calls[3][0].ruleStatusesFilter).toEqual(['enabled']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled'], + }) + ); }); it('does not render the tag filter is the feature flag is off', async () => { @@ -956,7 +959,11 @@ describe('rules_list component with items', () => { loadRules.mockReset(); await setup(); - expect(loadRules.mock.calls[0][0].tagsFilter).toEqual([]); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: [], + }) + ); wrapper.find('[data-test-subj="ruleTagFilterButton"] button').simulate('click'); @@ -967,11 +974,19 @@ describe('rules_list component with items', () => { tagFilterListItems.at(0).simulate('click'); - expect(loadRules.mock.calls[1][0].tagsFilter).toEqual(['a']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: ['a'], + }) + ); tagFilterListItems.at(1).simulate('click'); - expect(loadRules.mock.calls[2][0].tagsFilter).toEqual(['a', 'b']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: ['a', 'b'], + }) + ); }); }); @@ -1255,4 +1270,21 @@ describe('rules_list with disabled items', () => { wrapper.find('EuiIconTip[data-test-subj="ruleDisabledByLicenseTooltip"]').props().content ).toEqual('This rule type requires a Platinum license.'); }); + + it('clicking the notify badge shows the snooze panel', async () => { + await setup(); + + expect(wrapper.find('[data-test-subj="snoozePanel"]').exists()).toBeFalsy(); + + wrapper + .find('[data-test-subj="rulesTableCell-rulesListNotify"]') + .first() + .simulate('mouseenter'); + + expect(wrapper.find('[data-test-subj="rulesListNotifyBadge"]').exists()).toBeTruthy(); + + wrapper.find('[data-test-subj="rulesListNotifyBadge"]').first().simulate('click'); + + expect(wrapper.find('[data-test-subj="snoozePanel"]').exists()).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index 9c3f1415e6641..b8afb2d3124ef 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -8,49 +8,36 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { i18n } from '@kbn/i18n'; -import { capitalize, sortBy } from 'lodash'; import moment from 'moment'; +import { capitalize, sortBy } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useEffect, useState, useMemo, ReactNode, useCallback } from 'react'; +import React, { useEffect, useState, ReactNode, useCallback, useMemo } from 'react'; import { - EuiBasicTable, EuiButton, EuiFieldSearch, EuiFlexGroup, EuiFlexItem, - EuiIconTip, + EuiFilterGroup, EuiSpacer, EuiLink, EuiEmptyPrompt, - EuiButtonEmpty, EuiHealth, EuiText, - EuiToolTip, EuiTableSortingType, EuiButtonIcon, EuiHorizontalRule, EuiSelectableOption, EuiIcon, - EuiScreenReaderOnly, - RIGHT_ALIGNMENT, EuiDescriptionList, - EuiTableFieldDataColumnType, - EuiTableComputedColumnType, - EuiTableActionsColumnType, EuiCallOut, } from '@elastic/eui'; import { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option'; import { useHistory } from 'react-router-dom'; -import { isEmpty } from 'lodash'; import { RuleExecutionStatus, - RuleExecutionStatusValues, ALERTS_FEATURE_ID, RuleExecutionStatusErrorReasons, - formatDuration, - parseDuration, - MONITORING_HISTORY_LIMIT, } from '@kbn/alerting-plugin/common'; import { ActionType, @@ -69,11 +56,8 @@ import { RuleQuickEditButtonsWithApi as RuleQuickEditButtons } from '../../commo import { CollapsedItemActionsWithApi as CollapsedItemActions } from './collapsed_item_actions'; import { TypeFilter } from './type_filter'; import { ActionTypeFilter } from './action_type_filter'; -import { RuleExecutionStatusFilter, getHealthColor } from './rule_execution_status_filter'; +import { RuleExecutionStatusFilter } from './rule_execution_status_filter'; import { - loadRules, - loadRuleAggregations, - loadRuleTags, loadRuleTypes, disableRule, enableRule, @@ -87,23 +71,21 @@ import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capab import { routeToRuleDetails, DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation'; import { EmptyPrompt } from '../../../components/prompts/empty_prompt'; -import { rulesStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; +import { ALERT_STATUS_LICENSE_ERROR } from '../translations'; import { useKibana } from '../../../../common/lib/kibana'; import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../../common/constants'; import './rules_list.scss'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; import { ManageLicenseModal } from './manage_license_modal'; -import { checkRuleTypeEnabled } from '../../../lib/check_rule_type_enabled'; -import { RuleStatusDropdown } from './rule_status_dropdown'; -import { RuleTagBadge } from './rule_tag_badge'; -import { PercentileSelectablePopover } from './percentile_selectable_popover'; -import { RuleDurationFormat } from './rule_duration_format'; -import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils'; -import { getFormattedSuccessRatio } from '../../../lib/monitoring_utils'; import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; import { RuleTagFilter } from './rule_tag_filter'; import { RuleStatusFilter } from './rule_status_filter'; import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; +import { useLoadRules } from '../../../hooks/use_load_rules'; +import { useLoadTags } from '../../../hooks/use_load_tags'; +import { useLoadRuleAggregations } from '../../../hooks/use_load_rule_aggregations'; +import { RulesListTable, convertRulesToTableItems } from './rules_list_table'; +import { RulesListAutoRefresh } from './rules_list_auto_refresh'; import { UpdateApiKeyModalConfirmation } from '../../../components/update_api_key_modal_confirmation'; const ENTER_KEY = 13; @@ -113,17 +95,6 @@ interface RuleTypeState { isInitialized: boolean; data: RuleTypeIndex; } -interface RuleState { - isLoading: boolean; - data: Rule[]; - totalItemCount: number; -} - -const percentileOrdinals = { - [Percentiles.P50]: '50th', - [Percentiles.P95]: '95th', - [Percentiles.P99]: '99th', -}; export const percentileFields = { [Percentiles.P50]: 'monitoring.execution.calculated_metrics.p50', @@ -149,8 +120,6 @@ export const RulesList: React.FunctionComponent = () => { } = useKibana().services; const canExecuteActions = hasExecuteActionsCapability(capabilities); - const [initialLoad, setInitialLoad] = useState(true); - const [noData, setNoData] = useState(true); const [config, setConfig] = useState({ isUsingSecurity: false }); const [actionTypes, setActionTypes] = useState([]); const [selectedIds, setSelectedIds] = useState([]); @@ -162,16 +131,15 @@ export const RulesList: React.FunctionComponent = () => { const [actionTypesFilter, setActionTypesFilter] = useState([]); const [ruleExecutionStatusesFilter, setRuleExecutionStatusesFilter] = useState([]); const [ruleStatusesFilter, setRuleStatusesFilter] = useState([]); - const [tags, setTags] = useState([]); const [tagsFilter, setTagsFilter] = useState([]); const [ruleFlyoutVisible, setRuleFlyoutVisibility] = useState(false); const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); const [currentRuleToEdit, setCurrentRuleToEdit] = useState(null); - const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState>( {} ); const [showErrors, setShowErrors] = useState(false); + const [lastUpdate, setLastUpdate] = useState(''); const isRuleTagFilterEnabled = getIsExperimentalFeatureEnabled('ruleTagFilter'); const isRuleStatusFilterEnabled = getIsExperimentalFeatureEnabled('ruleStatusFilter'); @@ -185,13 +153,6 @@ export const RulesList: React.FunctionComponent = () => { const [percentileOptions, setPercentileOptions] = useState(initialPercentileOptions); - const selectedPercentile = useMemo(() => { - const selectedOption = percentileOptions.find((option) => option.checked === 'on'); - if (selectedOption) { - return Percentiles[selectedOption.key as Percentiles]; - } - }, [percentileOptions]); - const [sort, setSort] = useState['sort']>({ field: 'name', direction: 'asc', @@ -200,27 +161,52 @@ export const RulesList: React.FunctionComponent = () => { licenseType: string; ruleTypeId: string; } | null>(null); - const [rulesStatusesTotal, setRulesStatusesTotal] = useState>( - RuleExecutionStatusValues.reduce( - (prev: Record, status: string) => - ({ - ...prev, - [status]: 0, - } as Record), - {} - ) - ); const [ruleTypesState, setRuleTypesState] = useState({ isLoading: false, isInitialized: false, data: new Map(), }); - const [rulesState, setRulesState] = useState({ - isLoading: false, - data: [], - totalItemCount: 0, - }); + const [rulesToDelete, setRulesToDelete] = useState([]); + + const hasAnyAuthorizedRuleType = useMemo(() => { + return ruleTypesState.isInitialized && ruleTypesState.data.size > 0; + }, [ruleTypesState]); + + const onError = useCallback( + (message: string) => { + toasts.addDanger(message); + }, + [toasts] + ); + + const { rulesState, setRulesState, loadRules, noData, initialLoad } = useLoadRules({ + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + onPage: setPage, + onError, + }); + + const { tags, loadTags } = useLoadTags({ + onError, + }); + + const { loadRuleAggregations, rulesStatusesTotal } = useLoadRuleAggregations({ + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + onError, + }); + const [rulesToUpdateAPIKey, setRulesToUpdateAPIKey] = useState([]); const onRuleEdit = (ruleItem: RuleTableItem) => { setEditFlyoutVisibility(true); @@ -230,20 +216,30 @@ export const RulesList: React.FunctionComponent = () => { const isRuleTypeEditableInContext = (ruleTypeId: string) => ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; - useEffect(() => { - loadRulesData(); + const loadData = useCallback(async () => { + if (!ruleTypesState || !hasAnyAuthorizedRuleType) { + return; + } + await loadRules(); + await loadRuleAggregations(); + if (isRuleStatusFilterEnabled) { + await loadTags(); + } + setLastUpdate(moment().format()); }, [ + loadRules, + loadTags, + loadRuleAggregations, + setLastUpdate, + isRuleStatusFilterEnabled, + hasAnyAuthorizedRuleType, ruleTypesState, - page, - searchText, - percentileOptions, - JSON.stringify(typesFilter), - JSON.stringify(actionTypesFilter), - JSON.stringify(ruleExecutionStatusesFilter), - JSON.stringify(ruleStatusesFilter), - JSON.stringify(tagsFilter), ]); + useEffect(() => { + loadData(); + }, [loadData, percentileOptions]); + useEffect(() => { (async () => { try { @@ -289,218 +285,6 @@ export const RulesList: React.FunctionComponent = () => { })(); }, []); - async function loadRulesData() { - const hasAnyAuthorizedRuleType = ruleTypesState.isInitialized && ruleTypesState.data.size > 0; - if (hasAnyAuthorizedRuleType) { - setRulesState({ ...rulesState, isLoading: true }); - try { - const rulesResponse = await loadRules({ - http, - page, - searchText, - typesFilter, - actionTypesFilter, - ruleExecutionStatusesFilter, - ruleStatusesFilter, - tagsFilter, - sort, - }); - await loadRuleTagsAggs(); - await loadRuleAggs(); - setRulesState({ - isLoading: false, - data: rulesResponse.data, - totalItemCount: rulesResponse.total, - }); - - if (!rulesResponse.data?.length && page.index > 0) { - setPage({ ...page, index: 0 }); - } - - const isFilterApplied = !( - isEmpty(searchText) && - isEmpty(typesFilter) && - isEmpty(actionTypesFilter) && - isEmpty(ruleExecutionStatusesFilter) && - isEmpty(ruleStatusesFilter) && - isEmpty(tagsFilter) - ); - - setNoData(rulesResponse.data.length === 0 && !isFilterApplied); - } catch (e) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.unableToLoadRulesMessage', - { - defaultMessage: 'Unable to load rules', - } - ), - }); - setRulesState({ ...rulesState, isLoading: false }); - } - setInitialLoad(false); - } - } - - async function loadRuleAggs() { - try { - const rulesAggs = await loadRuleAggregations({ - http, - searchText, - typesFilter, - actionTypesFilter, - ruleExecutionStatusesFilter, - ruleStatusesFilter, - tagsFilter, - }); - if (rulesAggs?.ruleExecutionStatus) { - setRulesStatusesTotal(rulesAggs.ruleExecutionStatus); - } - } catch (e) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleStatusInfoMessage', - { - defaultMessage: 'Unable to load rule status info', - } - ), - }); - } - } - - async function loadRuleTagsAggs() { - if (!isRuleTagFilterEnabled) { - return; - } - try { - const ruleTagsAggs = await loadRuleTags({ http }); - if (ruleTagsAggs?.ruleTags) { - setTags(ruleTagsAggs.ruleTags); - } - } catch (e) { - toasts.addDanger({ - title: i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTags', { - defaultMessage: 'Unable to load rule tags', - }), - }); - } - } - - const renderRuleStatusDropdown = (ruleEnabled: boolean | undefined, item: RuleTableItem) => { - return ( - await disableRule({ http, id: item.id })} - enableRule={async () => await enableRule({ http, id: item.id })} - snoozeRule={async (snoozeEndTime: string | -1, interval: string | null) => { - await snoozeRule({ http, id: item.id, snoozeEndTime }); - }} - unsnoozeRule={async () => await unsnoozeRule({ http, id: item.id })} - rule={item} - onRuleChanged={() => loadRulesData()} - isEditable={item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId)} - /> - ); - }; - - const renderRuleExecutionStatus = (executionStatus: RuleExecutionStatus, item: RuleTableItem) => { - const healthColor = getHealthColor(executionStatus.status); - const tooltipMessage = - executionStatus.status === 'error' ? `Error: ${executionStatus?.error?.message}` : null; - const isLicenseError = - executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; - const statusMessage = isLicenseError - ? ALERT_STATUS_LICENSE_ERROR - : rulesStatusesTranslationsMapping[executionStatus.status]; - - const health = ( - - {statusMessage} - - ); - - const healthWithTooltip = tooltipMessage ? ( - - {health} - - ) : ( - health - ); - - return ( - - {healthWithTooltip} - {isLicenseError && ( - - - setManageLicenseModalOpts({ - licenseType: ruleTypesState.data.get(item.ruleTypeId)?.minimumLicenseRequired!, - ruleTypeId: item.ruleTypeId, - }) - } - > - - - - )} - - ); - }; - - const renderPercentileColumnName = () => { - return ( - - - - {selectedPercentile}  - - - - - - ); - }; - - const renderPercentileCellValue = (value: number) => { - return ( - - - - ); - }; - - const getPercentileColumn = () => { - return { - mobileOptions: { header: false }, - field: percentileFields[selectedPercentile!], - width: '16%', - name: renderPercentileColumnName(), - 'data-test-subj': 'rulesTableCell-ruleExecutionPercentile', - sortable: true, - truncateText: false, - render: renderPercentileCellValue, - }; - }; - const buildErrorListItems = (_executionStatus: RuleExecutionStatus) => { const hasErrorMessage = _executionStatus.status === 'error'; const errorMessage = _executionStatus?.error?.message; @@ -563,383 +347,6 @@ export const RulesList: React.FunctionComponent = () => { }); }, [showErrors, rulesState]); - const getRulesTableColumns = (): Array< - | EuiTableFieldDataColumnType - | EuiTableComputedColumnType - | EuiTableActionsColumnType - > => { - return [ - { - field: 'name', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.nameTitle', - { defaultMessage: 'Name' } - ), - sortable: true, - truncateText: true, - width: '30%', - 'data-test-subj': 'rulesTableCell-name', - render: (name: string, rule: RuleTableItem) => { - const ruleType = ruleTypesState.data.get(rule.ruleTypeId); - const checkEnabledResult = checkRuleTypeEnabled(ruleType); - const link = ( - <> - - - - - { - history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); - }} - > - {name} - - - - {!checkEnabledResult.isEnabled && ( - - )} - - - - - - {rule.ruleType} - - - - - ); - return <>{link}; - }, - }, - { - field: 'tags', - name: '', - sortable: false, - width: '50px', - 'data-test-subj': 'rulesTableCell-tagsPopover', - render: (ruleTags: string[], item: RuleTableItem) => { - return ruleTags.length > 0 ? ( - setTagPopoverOpenIndex(item.index)} - onClose={() => setTagPopoverOpenIndex(-1)} - /> - ) : null; - }, - }, - { - field: 'executionStatus.lastExecutionDate', - name: ( - - - Last run{' '} - - - - ), - sortable: true, - width: '15%', - 'data-test-subj': 'rulesTableCell-lastExecutionDate', - render: (date: Date) => { - if (date) { - return ( - <> - - - {moment(date).format('MMM D, YYYY HH:mm:ssa')} - - - - {moment(date).fromNow()} - - - - - ); - } - }, - }, - { - field: 'schedule.interval', - width: '6%', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.scheduleTitle', - { defaultMessage: 'Interval' } - ), - sortable: false, - truncateText: false, - 'data-test-subj': 'rulesTableCell-interval', - render: (interval: string, item: RuleTableItem) => { - const durationString = formatDuration(interval); - return ( - <> - - {durationString} - - {item.showIntervalWarning && ( - - { - if (item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId)) { - onRuleEdit(item); - } - }} - iconType="flag" - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.intervalIconAriaLabel', - { defaultMessage: 'Below configured minimum interval' } - )} - /> - - )} - - - - ); - }, - }, - { - field: 'executionStatus.lastDuration', - width: '12%', - name: ( - - - Duration{' '} - - - - ), - sortable: true, - truncateText: false, - 'data-test-subj': 'rulesTableCell-duration', - render: (value: number, item: RuleTableItem) => { - const showDurationWarning = shouldShowDurationWarning( - ruleTypesState.data.get(item.ruleTypeId), - value - ); - - return ( - <> - {} - {showDurationWarning && ( - - )} - - ); - }, - }, - getPercentileColumn(), - { - field: 'monitoring.execution.calculated_metrics.success_ratio', - width: '12%', - name: ( - - - Success ratio{' '} - - - - ), - sortable: true, - truncateText: false, - 'data-test-subj': 'rulesTableCell-successRatio', - render: (value: number) => { - return ( - - {value !== undefined ? getFormattedSuccessRatio(value) : 'N/A'} - - ); - }, - }, - { - field: 'executionStatus.status', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastResponseTitle', - { defaultMessage: 'Last response' } - ), - sortable: true, - truncateText: false, - width: '120px', - 'data-test-subj': 'rulesTableCell-lastResponse', - render: (_executionStatus: RuleExecutionStatus, item: RuleTableItem) => { - return renderRuleExecutionStatus(item.executionStatus, item); - }, - }, - { - field: 'enabled', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.stateTitle', - { defaultMessage: 'State' } - ), - sortable: true, - truncateText: false, - width: '10%', - 'data-test-subj': 'rulesTableCell-status', - render: (_enabled: boolean | undefined, item: RuleTableItem) => { - return renderRuleStatusDropdown(item.enabled, item); - }, - }, - { - name: '', - width: '90px', - render(item: RuleTableItem) { - return ( - - - - {item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId) ? ( - - onRuleEdit(item)} - iconType={'pencil'} - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editAriaLabel', - { defaultMessage: 'Edit' } - )} - /> - - ) : null} - {item.isEditable ? ( - - setRulesToDelete([item.id])} - iconType={'trash'} - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.deleteAriaLabel', - { defaultMessage: 'Delete' } - )} - /> - - ) : null} - - - - loadRulesData()} - setRulesToDelete={setRulesToDelete} - onEditRule={() => onRuleEdit(item)} - onUpdateAPIKey={setRulesToUpdateAPIKey} - /> - - - ); - }, - }, - { - align: RIGHT_ALIGNMENT, - width: '40px', - isExpander: true, - name: ( - - Expand rows - - ), - render: (item: RuleTableItem) => { - const _executionStatus = item.executionStatus; - const hasErrorMessage = _executionStatus.status === 'error'; - const isLicenseError = - _executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; - - return isLicenseError || hasErrorMessage ? ( - toggleErrorMessage(_executionStatus, item)} - aria-label={itemIdToExpandedRowMap[item.id] ? 'Collapse' : 'Expand'} - iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'} - /> - ) : null; - }, - }, - ]; - }; - const authorizedRuleTypes = [...ruleTypesState.data.values()]; const authorizedToCreateAnyRules = authorizedRuleTypes.some( (ruleType) => ruleType.authorizedConsumers[ALERTS_FEATURE_ID]?.all @@ -979,13 +386,29 @@ export const RulesList: React.FunctionComponent = () => { return []; }; - const getRuleStatusFilter = () => { + const renderRuleStatusFilter = () => { if (isRuleStatusFilterEnabled) { - return [ - , - ]; + return ( + + ); } - return []; + return null; + }; + + const onDisableRule = (rule: RuleTableItem) => { + return disableRule({ http, id: rule.id }); + }; + + const onEnableRule = (rule: RuleTableItem) => { + return enableRule({ http, id: rule.id }); + }; + + const onSnoozeRule = (rule: RuleTableItem, snoozeEndTime: string | -1) => { + return snoozeRule({ http, id: rule.id, snoozeEndTime }); + }; + + const onUnsnoozeRule = (rule: RuleTableItem) => { + return unsnoozeRule({ http, id: rule.id }); }; const toolsRight = [ @@ -999,8 +422,6 @@ export const RulesList: React.FunctionComponent = () => { }) )} />, - ...getRuleTagFilter(), - ...getRuleStatusFilter(), { selectedStatuses={ruleExecutionStatusesFilter} onChange={(ids: string[]) => setRuleExecutionStatusesFilter(ids)} />, - - - , + ...getRuleTagFilter(), ]; const authorizedToModifySelectedRules = selectedIds.length @@ -1074,7 +484,7 @@ export const RulesList: React.FunctionComponent = () => { })} onPerformingAction={() => setIsPerformingAction(true)} onActionPerformed={() => { - loadRulesData(); + loadData(); setIsPerformingAction(false); }} setRulesToDelete={setRulesToDelete} @@ -1119,20 +529,19 @@ export const RulesList: React.FunctionComponent = () => { )} />
+ {renderRuleStatusFilter()} - + {toolsRight.map((tool, index: number) => ( - - {tool} - + {tool} ))} - + - + { /> + {rulesStatusesTotal.error > 0 && ( @@ -1235,64 +645,66 @@ export const RulesList: React.FunctionComponent = () => { )} - - ({ - 'data-test-subj': 'rule-row', - className: !ruleTypesState.data.get(item.ruleTypeId)?.enabledInLicense - ? 'actRulesList__tableRowDisabled' - : '', - })} - cellProps={(item: RuleTableItem) => ({ - 'data-test-subj': 'cell', - className: !ruleTypesState.data.get(item.ruleTypeId)?.enabledInLicense - ? 'actRulesList__tableCellDisabled' - : '', - })} - data-test-subj="rulesList" - pagination={{ - pageIndex: page.index, - pageSize: page.size, - /* Don't display rule count until we have the rule types initialized */ - totalItemCount: ruleTypesState.isInitialized === false ? 0 : rulesState.totalItemCount, - }} - selection={{ - selectable: (rule: RuleTableItem) => rule.isEditable, - onSelectionChange(updatedSelectedItemsList: RuleTableItem[]) { - setSelectedIds(updatedSelectedItemsList.map((item) => item.id)); - }, + loadData()} + onRuleClick={(rule) => { + history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); }} - onChange={({ - page: changedPage, - sort: changedSort, - }: { - page?: Pagination; - sort?: EuiTableSortingType['sort']; - }) => { - if (changedPage) { - setPage(changedPage); - } - if (changedSort) { - setSort(changedSort); + onRuleEditClick={(rule) => { + if (rule.isEditable && isRuleTypeEditableInContext(rule.ruleTypeId)) { + onRuleEdit(rule); } }} - itemIdToExpandedRowMap={itemIdToExpandedRowMap} - isExpandable={true} + onRuleDeleteClick={(rule) => setRulesToDelete([rule.id])} + onManageLicenseClick={(rule) => + setManageLicenseModalOpts({ + licenseType: ruleTypesState.data.get(rule.ruleTypeId)?.minimumLicenseRequired!, + ruleTypeId: rule.ruleTypeId, + }) + } + onSelectionChange={(updatedSelectedItemsList) => + setSelectedIds(updatedSelectedItemsList.map((item) => item.id)) + } + onPercentileOptionsChange={setPercentileOptions} + onDisableRule={onDisableRule} + onEnableRule={onEnableRule} + onSnoozeRule={onSnoozeRule} + onUnsnoozeRule={onUnsnoozeRule} + renderCollapsedItemActions={(rule) => ( + loadData()} + setRulesToDelete={setRulesToDelete} + onEditRule={() => onRuleEdit(rule)} + onUpdateAPIKey={setRulesToUpdateAPIKey} + /> + )} + renderRuleError={(rule) => { + const _executionStatus = rule.executionStatus; + const hasErrorMessage = _executionStatus.status === 'error'; + const isLicenseError = + _executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; + + return isLicenseError || hasErrorMessage ? ( + toggleErrorMessage(_executionStatus, rule)} + aria-label={itemIdToExpandedRowMap[rule.id] ? 'Collapse' : 'Expand'} + iconType={itemIdToExpandedRowMap[rule.id] ? 'arrowUp' : 'arrowDown'} + /> + ) : null; + }} + config={config} /> {manageLicenseModalOpts && ( { onDeleted={async () => { setRulesToDelete([]); setSelectedIds([]); - await loadRulesData(); + await loadData(); }} onErrors={async () => { - // Refresh the rules from the server, some rules may have been deleted - await loadRulesData(); + // Refresh the rules from the server, some rules may have beend deleted + await loadData(); setRulesToDelete([]); }} onCancel={() => { @@ -1364,7 +776,7 @@ export const RulesList: React.FunctionComponent = () => { }} onUpdated={async () => { setRulesToUpdateAPIKey([]); - await loadRulesData(); + await loadData(); }} /> @@ -1378,7 +790,7 @@ export const RulesList: React.FunctionComponent = () => { actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} ruleTypeIndex={ruleTypesState.data} - onSave={loadRulesData} + onSave={loadData} /> )} {editFlyoutVisible && currentRuleToEdit && ( @@ -1392,7 +804,7 @@ export const RulesList: React.FunctionComponent = () => { ruleType={ ruleTypesState.data.get(currentRuleToEdit.ruleTypeId) as RuleType } - onSave={loadRulesData} + onSave={loadData} /> )}
@@ -1427,30 +839,3 @@ const noPermissionPrompt = ( function filterRulesById(rules: Rule[], ids: string[]): Rule[] { return rules.filter((rule) => ids.includes(rule.id)); } - -interface ConvertRulesToTableItemsOpts { - rules: Rule[]; - ruleTypeIndex: RuleTypeIndex; - canExecuteActions: boolean; - config: TriggersActionsUiConfig; -} - -function convertRulesToTableItems(opts: ConvertRulesToTableItemsOpts): RuleTableItem[] { - const { rules, ruleTypeIndex, canExecuteActions, config } = opts; - const minimumDuration = config.minimumScheduleInterval - ? parseDuration(config.minimumScheduleInterval.value) - : 0; - return rules.map((rule, index: number) => { - return { - ...rule, - index, - actionsCount: rule.actions.length, - ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, - isEditable: - hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && - (canExecuteActions || (!canExecuteActions && !rule.actions.length)), - enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, - showIntervalWarning: parseDuration(rule.schedule.interval) < minimumDuration, - }; - }); -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx new file mode 100644 index 0000000000000..9e17561ce652b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import moment from 'moment'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { act } from 'react-dom/test-utils'; +import { RulesListAutoRefresh } from './rules_list_auto_refresh'; + +const onRefresh = jest.fn(); + +describe('RulesListAutoRefresh', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the update text correctly', async () => { + jest.useFakeTimers('modern').setSystemTime(moment('1990-01-01').toDate()); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated a few seconds ago'); + + await act(async () => { + jest.advanceTimersByTime(1 * 60 * 1000); + }); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated a minute ago'); + + await act(async () => { + jest.advanceTimersByTime(1 * 60 * 1000); + }); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated 2 minutes ago'); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + }); + + it('calls onRefresh when it auto refreshes', async () => { + jest.useFakeTimers('modern').setSystemTime(moment('1990-01-01').toDate()); + + mountWithIntl( + + ); + + expect(onRefresh).toHaveBeenCalledTimes(0); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(2); + + await act(async () => { + jest.advanceTimersByTime(10 * 1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(12); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx new file mode 100644 index 0000000000000..eea8d8e5f1bbe --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx @@ -0,0 +1,122 @@ +/* + * 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 React, { useCallback, useEffect, useState, useRef } from 'react'; +import moment from 'moment'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiAutoRefreshButton } from '@elastic/eui'; + +interface RulesListAutoRefreshProps { + lastUpdate: string; + initialUpdateInterval?: number; + onRefresh: () => void; +} + +const flexGroupStyle = { + marginLeft: 'auto', +}; + +const getLastUpdateText = (lastUpdate: string) => { + if (!moment(lastUpdate).isValid()) { + return ''; + } + return i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListAutoRefresh.lastUpdateText', + { + defaultMessage: 'Updated {lastUpdateText}', + values: { + lastUpdateText: moment(lastUpdate).fromNow(), + }, + } + ); +}; + +const TEXT_UPDATE_INTERVAL = 60 * 1000; +const DEFAULT_REFRESH_INTERVAL = 5 * 60 * 1000; +const MIN_REFRESH_INTERVAL = 1000; + +export const RulesListAutoRefresh = (props: RulesListAutoRefreshProps) => { + const { lastUpdate, initialUpdateInterval = DEFAULT_REFRESH_INTERVAL, onRefresh } = props; + + const [isPaused, setIsPaused] = useState(false); + const [refreshInterval, setRefreshInterval] = useState( + Math.max(initialUpdateInterval, MIN_REFRESH_INTERVAL) + ); + const [lastUpdateText, setLastUpdateText] = useState(''); + + const cachedOnRefresh = useRef<() => void>(() => {}); + const textUpdateTimeout = useRef(); + const refreshTimeout = useRef(); + + useEffect(() => { + cachedOnRefresh.current = onRefresh; + }, [onRefresh]); + + useEffect(() => { + setLastUpdateText(getLastUpdateText(lastUpdate)); + + const poll = () => { + textUpdateTimeout.current = window.setTimeout(() => { + setLastUpdateText(getLastUpdateText(lastUpdate)); + poll(); + }, TEXT_UPDATE_INTERVAL); + }; + poll(); + + return () => { + if (textUpdateTimeout.current) { + clearTimeout(textUpdateTimeout.current); + } + }; + }, [lastUpdate, setLastUpdateText]); + + useEffect(() => { + if (isPaused) { + return; + } + + const poll = () => { + refreshTimeout.current = window.setTimeout(() => { + cachedOnRefresh.current(); + poll(); + }, refreshInterval); + }; + poll(); + + return () => { + if (refreshTimeout.current) { + clearTimeout(refreshTimeout.current); + } + }; + }, [isPaused, refreshInterval]); + + const onRefreshChange = useCallback( + ({ isPaused: newIsPaused, refreshInterval: newRefreshInterval }) => { + setIsPaused(newIsPaused); + setRefreshInterval(newRefreshInterval); + }, + [setIsPaused, setRefreshInterval] + ); + + return ( + + + + {lastUpdateText} + + + + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx new file mode 100644 index 0000000000000..1f03c76a7de0b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx @@ -0,0 +1,224 @@ +/* + * 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, { useCallback, useMemo, useState } from 'react'; +import moment from 'moment'; +import { EuiButton, EuiButtonIcon, EuiPopover, EuiText, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isRuleSnoozed } from './rule_status_dropdown'; +import { RuleTableItem } from '../../../../types'; +import { + SnoozePanel, + futureTimeToInterval, + usePreviousSnoozeInterval, + SnoozeUnit, +} from './rule_status_dropdown'; + +export interface RulesListNotifyBadgeProps { + rule: RuleTableItem; + isOpen: boolean; + previousSnoozeInterval?: string | null; + onClick: React.MouseEventHandler; + onClose: () => void; + onRuleChanged: () => void; + snoozeRule: (snoozeEndTime: string | -1, interval: string | null) => Promise; + unsnoozeRule: () => Promise; +} + +const openSnoozePanelAriaLabel = i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.openSnoozePanel', + { defaultMessage: 'Open snooze panel' } +); + +export const RulesListNotifyBadge: React.FunctionComponent = (props) => { + const { + rule, + isOpen, + previousSnoozeInterval: propsPreviousSnoozeInterval, + onClick, + onClose, + onRuleChanged, + snoozeRule, + unsnoozeRule, + } = props; + + const { isSnoozedUntil, muteAll } = rule; + + const [previousSnoozeInterval, setPreviousSnoozeInterval] = usePreviousSnoozeInterval( + propsPreviousSnoozeInterval + ); + + const [isLoading, setIsLoading] = useState(false); + + const isSnoozedIndefinitely = muteAll; + + const isSnoozed = useMemo(() => { + return isRuleSnoozed(rule); + }, [rule]); + + const isScheduled = useMemo(() => { + // TODO: Implement scheduled check + return false; + }, []); + + const formattedSnoozeText = useMemo(() => { + if (!isSnoozedUntil) { + return ''; + } + return moment(isSnoozedUntil).format('MMM D'); + }, [isSnoozedUntil]); + + const snoozeTooltipText = useMemo(() => { + if (isSnoozedIndefinitely) { + return i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.snoozedIndefinitelyTooltip', + { defaultMessage: 'Notifications snoozed indefinitely' } + ); + } + if (isScheduled) { + return ''; + // TODO: Implement scheduled tooltip + } + if (isSnoozed) { + return i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.snoozedTooltip', + { + defaultMessage: 'Notifications snoozed for {snoozeTime}', + values: { + snoozeTime: moment(isSnoozedUntil).fromNow(true), + }, + } + ); + } + return ''; + }, [isSnoozedIndefinitely, isScheduled, isSnoozed, isSnoozedUntil]); + + const snoozedButton = useMemo(() => { + return ( + + {formattedSnoozeText} + + ); + }, [formattedSnoozeText, onClick]); + + const scheduledSnoozeButton = useMemo(() => { + // TODO: Implement scheduled snooze button + return ( + + {formattedSnoozeText} + + ); + }, [formattedSnoozeText, onClick]); + + const unsnoozedButton = useMemo(() => { + return ( + + ); + }, [isOpen, onClick]); + + const indefiniteSnoozeButton = useMemo(() => { + return ( + + ); + }, [onClick]); + + const button = useMemo(() => { + if (isScheduled) { + return scheduledSnoozeButton; + } + if (isSnoozedIndefinitely) { + return indefiniteSnoozeButton; + } + if (isSnoozed) { + return snoozedButton; + } + return unsnoozedButton; + }, [ + isSnoozed, + isScheduled, + isSnoozedIndefinitely, + scheduledSnoozeButton, + snoozedButton, + indefiniteSnoozeButton, + unsnoozedButton, + ]); + + const buttonWithToolTip = useMemo(() => { + if (isOpen) { + return button; + } + return {button}; + }, [isOpen, button, snoozeTooltipText]); + + const snoozeRuleAndStoreInterval = useCallback( + (newSnoozeEndTime: string | -1, interval: string | null) => { + if (interval) { + setPreviousSnoozeInterval(interval); + } + return snoozeRule(newSnoozeEndTime, interval); + }, + [setPreviousSnoozeInterval, snoozeRule] + ); + + const onChangeSnooze = useCallback( + async (value: number, unit?: SnoozeUnit) => { + setIsLoading(true); + try { + if (value === -1) { + await snoozeRuleAndStoreInterval(-1, null); + } else if (value !== 0) { + const newSnoozeEndTime = moment().add(value, unit).toISOString(); + await snoozeRuleAndStoreInterval(newSnoozeEndTime, `${value}${unit}`); + } else await unsnoozeRule(); + onRuleChanged(); + } finally { + onClose(); + setIsLoading(false); + } + }, + [onRuleChanged, onClose, snoozeRuleAndStoreInterval, unsnoozeRule, setIsLoading] + ); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx new file mode 100644 index 0000000000000..53a3b4b69f8c0 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx @@ -0,0 +1,724 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useMemo, useState } from 'react'; +import moment from 'moment'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiLink, + EuiButtonEmpty, + EuiHealth, + EuiText, + EuiToolTip, + EuiTableSortingType, + EuiButtonIcon, + EuiSelectableOption, + EuiIcon, + EuiScreenReaderOnly, + RIGHT_ALIGNMENT, + EuiTableFieldDataColumnType, + EuiTableComputedColumnType, + EuiTableActionsColumnType, +} from '@elastic/eui'; +import { + RuleExecutionStatus, + RuleExecutionStatusErrorReasons, + formatDuration, + parseDuration, + MONITORING_HISTORY_LIMIT, +} from '@kbn/alerting-plugin/common'; +import { rulesStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; +import { getHealthColor } from './rule_execution_status_filter'; +import { + Rule, + RuleTableItem, + RuleTypeIndex, + Pagination, + Percentiles, + TriggersActionsUiConfig, + RuleTypeRegistryContract, +} from '../../../../types'; +import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils'; +import { PercentileSelectablePopover } from './percentile_selectable_popover'; +import { RuleDurationFormat } from './rule_duration_format'; +import { checkRuleTypeEnabled } from '../../../lib/check_rule_type_enabled'; +import { getFormattedSuccessRatio } from '../../../lib/monitoring_utils'; +import { hasAllPrivilege } from '../../../lib/capabilities'; +import { RuleTagBadge } from './rule_tag_badge'; +import { RuleStatusDropdown } from './rule_status_dropdown'; +import { RulesListNotifyBadge } from './rules_list_notify_badge'; + +interface RuleTypeState { + isLoading: boolean; + isInitialized: boolean; + data: RuleTypeIndex; +} + +export interface RuleState { + isLoading: boolean; + data: Rule[]; + totalItemCount: number; +} + +const percentileOrdinals = { + [Percentiles.P50]: '50th', + [Percentiles.P95]: '95th', + [Percentiles.P99]: '99th', +}; + +export const percentileFields = { + [Percentiles.P50]: 'monitoring.execution.calculated_metrics.p50', + [Percentiles.P95]: 'monitoring.execution.calculated_metrics.p95', + [Percentiles.P99]: 'monitoring.execution.calculated_metrics.p99', +}; + +const EMPTY_OBJECT = {}; +const EMPTY_HANDLER = () => {}; +const EMPTY_RENDER = () => null; + +interface ConvertRulesToTableItemsOpts { + rules: Rule[]; + ruleTypeIndex: RuleTypeIndex; + canExecuteActions: boolean; + config: TriggersActionsUiConfig; +} + +export interface RulesListTableProps { + rulesState: RuleState; + ruleTypesState: RuleTypeState; + ruleTypeRegistry: RuleTypeRegistryContract; + isLoading?: boolean; + sort: EuiTableSortingType['sort']; + page: Pagination; + percentileOptions: EuiSelectableOption[]; + canExecuteActions?: boolean; + itemIdToExpandedRowMap?: Record; + config: TriggersActionsUiConfig; + onSort?: (sort: EuiTableSortingType['sort']) => void; + onPage?: (page: Pagination) => void; + onRuleClick?: (rule: RuleTableItem) => void; + onRuleEditClick?: (rule: RuleTableItem) => void; + onRuleDeleteClick?: (rule: RuleTableItem) => void; + onManageLicenseClick?: (rule: RuleTableItem) => void; + onTagClick?: (rule: RuleTableItem) => void; + onTagClose?: (rule: RuleTableItem) => void; + onSelectionChange?: (updatedSelectedItemsList: RuleTableItem[]) => void; + onPercentileOptionsChange?: (options: EuiSelectableOption[]) => void; + onRuleChanged: () => void; + onEnableRule: (rule: RuleTableItem) => Promise; + onDisableRule: (rule: RuleTableItem) => Promise; + onSnoozeRule: (rule: RuleTableItem, snoozeEndTime: string | -1) => Promise; + onUnsnoozeRule: (rule: RuleTableItem) => Promise; + renderCollapsedItemActions?: (rule: RuleTableItem) => React.ReactNode; + renderRuleError?: (rule: RuleTableItem) => React.ReactNode; +} + +interface ConvertRulesToTableItemsOpts { + rules: Rule[]; + ruleTypeIndex: RuleTypeIndex; + canExecuteActions: boolean; + config: TriggersActionsUiConfig; +} + +export function convertRulesToTableItems(opts: ConvertRulesToTableItemsOpts): RuleTableItem[] { + const { rules, ruleTypeIndex, canExecuteActions, config } = opts; + const minimumDuration = config.minimumScheduleInterval + ? parseDuration(config.minimumScheduleInterval.value) + : 0; + return rules.map((rule, index: number) => { + return { + ...rule, + index, + actionsCount: rule.actions.length, + ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, + isEditable: + hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && + (canExecuteActions || (!canExecuteActions && !rule.actions.length)), + enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, + showIntervalWarning: parseDuration(rule.schedule.interval) < minimumDuration, + }; + }); +} + +export const RulesListTable = (props: RulesListTableProps) => { + const { + rulesState, + ruleTypesState, + ruleTypeRegistry, + isLoading = false, + canExecuteActions = false, + sort, + page, + percentileOptions, + itemIdToExpandedRowMap = EMPTY_OBJECT, + config = EMPTY_OBJECT as TriggersActionsUiConfig, + onSort = EMPTY_HANDLER, + onPage = EMPTY_HANDLER, + onRuleClick = EMPTY_HANDLER, + onRuleEditClick = EMPTY_HANDLER, + onRuleDeleteClick = EMPTY_HANDLER, + onManageLicenseClick = EMPTY_HANDLER, + onSelectionChange = EMPTY_HANDLER, + onPercentileOptionsChange = EMPTY_HANDLER, + onRuleChanged = EMPTY_HANDLER, + onEnableRule = EMPTY_HANDLER, + onDisableRule = EMPTY_HANDLER, + onSnoozeRule = EMPTY_HANDLER, + onUnsnoozeRule = EMPTY_HANDLER, + renderCollapsedItemActions = EMPTY_RENDER, + renderRuleError = EMPTY_RENDER, + } = props; + + const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); + const [currentlyOpenNotify, setCurrentlyOpenNotify] = useState(); + + const selectedPercentile = useMemo(() => { + const selectedOption = percentileOptions.find((option) => option.checked === 'on'); + if (selectedOption) { + return Percentiles[selectedOption.key as Percentiles]; + } + }, [percentileOptions]); + + const renderPercentileColumnName = () => { + return ( + + + + {selectedPercentile}  + + + + + + ); + }; + + const renderPercentileCellValue = (value: number) => { + return ( + + + + ); + }; + + const renderRuleStatusDropdown = (ruleEnabled: boolean | undefined, rule: RuleTableItem) => { + return ( + await onDisableRule(rule)} + enableRule={async () => await onEnableRule(rule)} + snoozeRule={async (snoozeEndTime: string | -1, interval: string | null) => { + await onSnoozeRule(rule, snoozeEndTime); + }} + unsnoozeRule={async () => await onUnsnoozeRule(rule)} + rule={rule} + onRuleChanged={onRuleChanged} + isEditable={rule.isEditable && isRuleTypeEditableInContext(rule.ruleTypeId)} + /> + ); + }; + + const isRuleTypeEditableInContext = (ruleTypeId: string) => + ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; + + const renderRuleExecutionStatus = (executionStatus: RuleExecutionStatus, rule: RuleTableItem) => { + const healthColor = getHealthColor(executionStatus.status); + const tooltipMessage = + executionStatus.status === 'error' ? `Error: ${executionStatus?.error?.message}` : null; + const isLicenseError = + executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; + const statusMessage = isLicenseError + ? ALERT_STATUS_LICENSE_ERROR + : rulesStatusesTranslationsMapping[executionStatus.status]; + + const health = ( + + {statusMessage} + + ); + + const healthWithTooltip = tooltipMessage ? ( + + {health} + + ) : ( + health + ); + + return ( + + {healthWithTooltip} + {isLicenseError && ( + + onManageLicenseClick(rule)} + > + + + + )} + + ); + }; + + const getRulesTableColumns = (): Array< + | EuiTableFieldDataColumnType + | EuiTableComputedColumnType + | EuiTableActionsColumnType + > => { + return [ + { + field: 'name', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.nameTitle', + { defaultMessage: 'Name' } + ), + sortable: true, + truncateText: true, + width: '30%', + 'data-test-subj': 'rulesTableCell-name', + render: (name: string, rule: RuleTableItem) => { + const ruleType = ruleTypesState.data.get(rule.ruleTypeId); + const checkEnabledResult = checkRuleTypeEnabled(ruleType); + const link = ( + <> + + + + + onRuleClick(rule)}> + {name} + + + + {!checkEnabledResult.isEnabled && ( + + )} + + + + + + {rule.ruleType} + + + + + ); + return <>{link}; + }, + }, + { + field: 'tags', + name: '', + sortable: false, + width: '50px', + 'data-test-subj': 'rulesTableCell-tagsPopover', + render: (ruleTags: string[], rule: RuleTableItem) => { + return ruleTags.length > 0 ? ( + setTagPopoverOpenIndex(rule.index)} + onClose={() => setTagPopoverOpenIndex(-1)} + /> + ) : null; + }, + }, + { + field: 'executionStatus.lastExecutionDate', + name: ( + + + Last run{' '} + + + + ), + sortable: true, + width: '15%', + 'data-test-subj': 'rulesTableCell-lastExecutionDate', + render: (date: Date) => { + if (date) { + return ( + <> + + + {moment(date).format('MMM D, YYYY HH:mm:ssa')} + + + + {moment(date).fromNow()} + + + + + ); + } + }, + }, + { + name: 'Notify', + width: '16%', + 'data-test-subj': 'rulesTableCell-rulesListNotify', + render: (rule: RuleTableItem) => { + return ( + setCurrentlyOpenNotify(rule.id)} + onClose={() => setCurrentlyOpenNotify('')} + onRuleChanged={onRuleChanged} + snoozeRule={async (snoozeEndTime: string | -1, interval: string | null) => { + await onSnoozeRule(rule, snoozeEndTime); + }} + unsnoozeRule={async () => await onUnsnoozeRule(rule)} + /> + ); + }, + }, + { + field: 'schedule.interval', + width: '6%', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.scheduleTitle', + { defaultMessage: 'Interval' } + ), + sortable: false, + truncateText: false, + 'data-test-subj': 'rulesTableCell-interval', + render: (interval: string, rule: RuleTableItem) => { + const durationString = formatDuration(interval); + return ( + <> + + {durationString} + + {rule.showIntervalWarning && ( + + onRuleEditClick(rule)} + iconType="flag" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.intervalIconAriaLabel', + { defaultMessage: 'Below configured minimum interval' } + )} + /> + + )} + + + + ); + }, + }, + { + field: 'executionStatus.lastDuration', + width: '12%', + name: ( + + + Duration{' '} + + + + ), + sortable: true, + truncateText: false, + 'data-test-subj': 'rulesTableCell-duration', + render: (value: number, rule: RuleTableItem) => { + const showDurationWarning = shouldShowDurationWarning( + ruleTypesState.data.get(rule.ruleTypeId), + value + ); + + return ( + <> + {} + {showDurationWarning && ( + + )} + + ); + }, + }, + { + mobileOptions: { header: false }, + field: percentileFields[selectedPercentile!], + width: '16%', + name: renderPercentileColumnName(), + 'data-test-subj': 'rulesTableCell-ruleExecutionPercentile', + sortable: true, + truncateText: false, + render: renderPercentileCellValue, + }, + { + field: 'monitoring.execution.calculated_metrics.success_ratio', + width: '12%', + name: ( + + + Success ratio{' '} + + + + ), + sortable: true, + truncateText: false, + 'data-test-subj': 'rulesTableCell-successRatio', + render: (value: number) => { + return ( + + {value !== undefined ? getFormattedSuccessRatio(value) : 'N/A'} + + ); + }, + }, + { + field: 'executionStatus.status', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastResponseTitle', + { defaultMessage: 'Last response' } + ), + sortable: true, + truncateText: false, + width: '120px', + 'data-test-subj': 'rulesTableCell-lastResponse', + render: (_executionStatus: RuleExecutionStatus, rule: RuleTableItem) => { + return renderRuleExecutionStatus(rule.executionStatus, rule); + }, + }, + { + field: 'enabled', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.stateTitle', + { defaultMessage: 'State' } + ), + sortable: true, + truncateText: false, + width: '10%', + 'data-test-subj': 'rulesTableCell-status', + render: (_enabled: boolean | undefined, rule: RuleTableItem) => { + return renderRuleStatusDropdown(rule.enabled, rule); + }, + }, + { + name: '', + width: '90px', + render(rule: RuleTableItem) { + return ( + + + + {rule.isEditable && isRuleTypeEditableInContext(rule.ruleTypeId) ? ( + + onRuleEditClick(rule)} + iconType={'pencil'} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editAriaLabel', + { defaultMessage: 'Edit' } + )} + /> + + ) : null} + {rule.isEditable ? ( + + onRuleDeleteClick(rule)} + iconType={'trash'} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.deleteAriaLabel', + { defaultMessage: 'Delete' } + )} + /> + + ) : null} + + + {renderCollapsedItemActions(rule)} + + ); + }, + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + name: ( + + Expand rows + + ), + render: renderRuleError, + }, + ]; + }; + + return ( + ({ + 'data-test-subj': 'rule-row', + className: !ruleTypesState.data.get(rule.ruleTypeId)?.enabledInLicense + ? 'actRulesList__tableRowDisabled' + : '', + })} + cellProps={(rule: RuleTableItem) => ({ + 'data-test-subj': 'cell', + className: !ruleTypesState.data.get(rule.ruleTypeId)?.enabledInLicense + ? 'actRulesList__tableCellDisabled' + : '', + })} + data-test-subj="rulesList" + pagination={{ + pageIndex: page.index, + pageSize: page.size, + /* Don't display rule count until we have the rule types initialized */ + totalItemCount: ruleTypesState.isInitialized === false ? 0 : rulesState.totalItemCount, + }} + selection={{ + selectable: (rule: RuleTableItem) => rule.isEditable, + onSelectionChange, + }} + onChange={({ + page: changedPage, + sort: changedSort, + }: { + page?: Pagination; + sort?: EuiTableSortingType['sort']; + }) => { + if (changedPage) { + onPage(changedPage); + } + if (changedSort) { + onSort(changedSort); + } + }} + itemIdToExpandedRowMap={itemIdToExpandedRowMap} + isExpandable={true} + /> + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx index 6ce697f65f898..f8cb70745911c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx @@ -7,13 +7,7 @@ import React, { Fragment, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiFilterGroup, - EuiPopover, - EuiFilterButton, - EuiFilterSelectItem, - EuiTitle, -} from '@elastic/eui'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem, EuiTitle } from '@elastic/eui'; interface TypeFilterProps { options: Array<{ @@ -41,53 +35,51 @@ export const TypeFilter: React.FunctionComponent = ({ }, [selectedValues]); return ( - - setIsPopoverOpen(false)} - button={ - 0} - numActiveFilters={selectedValues.length} - numFilters={selectedValues.length} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} - data-test-subj="ruleTypeFilterButton" - > - - - } - > -
- {options.map((groupItem, groupIndex) => ( - - -

{groupItem.groupName}

-
- {groupItem.subOptions.map((item, index) => ( - { - const isPreviouslyChecked = selectedValues.includes(item.value); - if (isPreviouslyChecked) { - setSelectedValues(selectedValues.filter((val) => val !== item.value)); - } else { - setSelectedValues(selectedValues.concat(item.value)); - } - }} - checked={selectedValues.includes(item.value) ? 'on' : undefined} - data-test-subj={`ruleType${item.value}FilterOption`} - > - {item.name} - - ))} -
- ))} -
-
-
+ setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + data-test-subj="ruleTypeFilterButton" + > + + + } + > +
+ {options.map((groupItem, groupIndex) => ( + + +

{groupItem.groupName}

+
+ {groupItem.subOptions.map((item, index) => ( + { + const isPreviouslyChecked = selectedValues.includes(item.value); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item.value)); + } else { + setSelectedValues(selectedValues.concat(item.value)); + } + }} + checked={selectedValues.includes(item.value) ? 'on' : undefined} + data-test-subj={`ruleType${item.value}FilterOption`} + > + {item.name} + + ))} +
+ ))} +
+
); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx new file mode 100644 index 0000000000000..b315668c4fab9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { RulesList } from '../application/sections'; + +export const getRulesListLazy = () => { + return ; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/mocks.ts b/x-pack/plugins/triggers_actions_ui/public/mocks.ts index 75ca6d8fd2987..605d83a8eb32e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/mocks.ts +++ b/x-pack/plugins/triggers_actions_ui/public/mocks.ts @@ -30,6 +30,7 @@ import { getRuleTagFilterLazy } from './common/get_rule_tag_filter'; import { getRuleStatusFilterLazy } from './common/get_rule_status_filter'; import { getRuleTagBadgeLazy } from './common/get_rule_tag_badge'; import { getRuleEventLogListLazy } from './common/get_rule_event_log_list'; +import { getRulesListLazy } from './common/get_rules_list'; import { getAlertsTableStateLazy } from './common/get_alerts_table_state'; import { AlertsTableStateProps } from './application/sections/alerts_table/alerts_table_state'; @@ -85,6 +86,9 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart { getRuleEventLogList: (props) => { return getRuleEventLogListLazy(props); }, + getRulesList: () => { + return getRulesListLazy(); + }, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index f9df34a5e4abb..f2237ff22f4ae 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -35,6 +35,7 @@ import { getRuleTagFilterLazy } from './common/get_rule_tag_filter'; import { getRuleStatusFilterLazy } from './common/get_rule_status_filter'; import { getRuleTagBadgeLazy } from './common/get_rule_tag_badge'; import { getRuleEventLogListLazy } from './common/get_rule_event_log_list'; +import { getRulesListLazy } from './common/get_rules_list'; import { ExperimentalFeaturesService } from './common/experimental_features_service'; import { ExperimentalFeatures, @@ -91,6 +92,7 @@ export interface TriggersAndActionsUIPublicPluginStart { getRuleStatusFilter: (props: RuleStatusFilterProps) => ReactElement; getRuleTagBadge: (props: RuleTagBadgeProps) => ReactElement; getRuleEventLogList: (props: RuleEventLogListProps) => ReactElement; + getRulesList: () => ReactElement; } interface PluginsSetup { @@ -279,6 +281,9 @@ export class Plugin getRuleEventLogList: (props: RuleEventLogListProps) => { return getRuleEventLogListLazy(props); }, + getRulesList: () => { + return getRulesListLazy(); + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts index c5ed118c105bb..832cf6c7a9078 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts @@ -20,5 +20,6 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { loadTestFile(require.resolve('./rule_status_filter')); loadTestFile(require.resolve('./rule_tag_badge')); loadTestFile(require.resolve('./rule_event_log_list')); + loadTestFile(require.resolve('./rules_list')); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts index 77d57e2819db5..15ea8fc302622 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts @@ -10,7 +10,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); - const find = getService('find'); const PageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); const esArchiver = getService('esArchiver'); @@ -31,24 +30,5 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const exists = await testSubjects.exists('ruleTagFilter'); expect(exists).to.be(true); }); - - it('should allow tag filters to be selected', async () => { - let badge = await find.byCssSelector('.euiFilterButton__notification'); - expect(await badge.getVisibleText()).to.be('0'); - - await testSubjects.click('ruleTagFilter'); - await testSubjects.click('ruleTagFilterOption-tag1'); - - badge = await find.byCssSelector('.euiFilterButton__notification'); - expect(await badge.getVisibleText()).to.be('1'); - - await testSubjects.click('ruleTagFilterOption-tag2'); - - badge = await find.byCssSelector('.euiFilterButton__notification'); - expect(await badge.getVisibleText()).to.be('2'); - - await testSubjects.click('ruleTagFilterOption-tag1'); - expect(await badge.getVisibleText()).to.be('1'); - }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts new file mode 100644 index 0000000000000..30baba0caaa08 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const esArchiver = getService('esArchiver'); + + describe('Rules list', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); + await PageObjects.common.navigateToUrlWithBrowserHistory( + 'triggersActions', + '/__components_sandbox' + ); + }); + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts'); + }); + + it('shoud load from shareable lazy loader', async () => { + await testSubjects.find('rulesList'); + const exists = await testSubjects.exists('rulesList'); + expect(exists).to.be(true); + }); + }); +}; From 40df1f3dbffa6fd0b50d95ac9663ba158756ae25 Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Mon, 23 May 2022 08:45:50 +0200 Subject: [PATCH 137/150] [Osquery] Add labels, move osquery schema link (#132584) --- .../integration/all/add_integration.spec.ts | 4 ++- .../osquery/cypress/screens/live_query.ts | 4 ++- .../osquery/cypress/tasks/live_query.ts | 2 +- .../osquery/public/agents/agents_table.tsx | 28 ++++++++++--------- .../osquery/public/agents/translations.ts | 2 +- .../public/live_queries/form/index.tsx | 1 - .../form/live_query_query_field.tsx | 2 -- .../osquery/public/saved_queries/constants.ts | 14 ++++++++++ .../saved_queries/saved_queries_dropdown.tsx | 16 ++++------- 9 files changed, 42 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts index d6f8e14381bc2..b1a3d26d850d0 100644 --- a/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts @@ -101,7 +101,9 @@ describe('ALL - Add Integration', () => { findFormFieldByRowsLabelAndType('Name', 'Integration'); findFormFieldByRowsLabelAndType('Scheduled agent policies (optional)', '{downArrow} {enter}'); findAndClickButton('Add query'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) + cy.react('EuiComboBox', { + props: { placeholder: 'Search for a query to run, or write a new query below' }, + }) .click() .type('{downArrow} {enter}'); cy.contains(/^Save$/).click(); diff --git a/x-pack/plugins/osquery/cypress/screens/live_query.ts b/x-pack/plugins/osquery/cypress/screens/live_query.ts index ce29edc2c9187..d3be652c24c2c 100644 --- a/x-pack/plugins/osquery/cypress/screens/live_query.ts +++ b/x-pack/plugins/osquery/cypress/screens/live_query.ts @@ -14,4 +14,6 @@ export const RESULTS_TABLE = 'osqueryResultsTable'; export const RESULTS_TABLE_BUTTON = 'dataGridFullScreenButton'; export const RESULTS_TABLE_CELL_WRRAPER = 'EuiDataGridHeaderCellWrapper'; export const getSavedQueriesDropdown = () => - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }); + cy.react('EuiComboBox', { + props: { placeholder: 'Search for a query to run, or write a new query below' }, + }); diff --git a/x-pack/plugins/osquery/cypress/tasks/live_query.ts b/x-pack/plugins/osquery/cypress/tasks/live_query.ts index d43516be2bc35..3a1f1b0930edf 100644 --- a/x-pack/plugins/osquery/cypress/tasks/live_query.ts +++ b/x-pack/plugins/osquery/cypress/tasks/live_query.ts @@ -13,7 +13,7 @@ export const BIG_QUERY = 'select * from processes, users limit 200;'; export const selectAllAgents = () => { cy.react('AgentsTable').find('input').should('not.be.disabled'); cy.react('AgentsTable EuiComboBox', { - props: { placeholder: 'Select agents or groups' }, + props: { placeholder: 'Select agents or groups to query' }, }).click(); cy.react('EuiFilterSelectItem').contains('All agents').should('exist'); cy.react('AgentsTable EuiComboBox').type('{downArrow}{enter}{esc}'); diff --git a/x-pack/plugins/osquery/public/agents/agents_table.tsx b/x-pack/plugins/osquery/public/agents/agents_table.tsx index 75d073c4d9292..f4baf70cf5593 100644 --- a/x-pack/plugins/osquery/public/agents/agents_table.tsx +++ b/x-pack/plugins/osquery/public/agents/agents_table.tsx @@ -7,7 +7,7 @@ import { find } from 'lodash/fp'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { EuiComboBox, EuiHealth, EuiHighlight, EuiSpacer } from '@elastic/eui'; +import { EuiComboBox, EuiHealth, EuiFormRow, EuiHighlight, EuiSpacer } from '@elastic/eui'; import deepEqual from 'fast-deep-equal'; import useDebounce from 'react-use/lib/useDebounce'; @@ -190,18 +190,20 @@ const AgentsTableComponent: React.FC = ({ agentSelection, onCh return (
- + + + {numAgentsSelected > 0 ? {generateSelectedAgentsMessage(numAgentsSelected)} : ''}
diff --git a/x-pack/plugins/osquery/public/agents/translations.ts b/x-pack/plugins/osquery/public/agents/translations.ts index 209761b4c8bdf..643284596da1d 100644 --- a/x-pack/plugins/osquery/public/agents/translations.ts +++ b/x-pack/plugins/osquery/public/agents/translations.ts @@ -40,7 +40,7 @@ export const AGENT_SELECTION_LABEL = i18n.translate('xpack.osquery.agents.select }); export const SELECT_AGENT_LABEL = i18n.translate('xpack.osquery.agents.selectAgentLabel', { - defaultMessage: `Select agents or groups`, + defaultMessage: `Select agents or groups to query`, }); export const ERROR_ALL_AGENTS = i18n.translate('xpack.osquery.agents.errorSearchDescription', { diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index bba443be9569a..505550508874f 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -254,7 +254,6 @@ const LiveQueryFormComponent: React.FC = ({ disabled={isSavedQueryDisabled} onChange={handleSavedQueryChange} /> - )} = ({ isInvalid={typeof error === 'string'} error={error} fullWidth - labelAppend={} isDisabled={!permissions.writeLiveQueries || disabled} > {!permissions.writeLiveQueries || disabled ? ( diff --git a/x-pack/plugins/osquery/public/saved_queries/constants.ts b/x-pack/plugins/osquery/public/saved_queries/constants.ts index 8edcfd00d1788..5dc23354322cd 100644 --- a/x-pack/plugins/osquery/public/saved_queries/constants.ts +++ b/x-pack/plugins/osquery/public/saved_queries/constants.ts @@ -4,6 +4,20 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { i18n } from '@kbn/i18n'; export const SAVED_QUERIES_ID = 'savedQueryList'; export const SAVED_QUERY_ID = 'savedQuery'; + +export const QUERIES_DROPDOWN_LABEL = i18n.translate( + 'xpack.osquery.savedQueries.dropdown.searchFieldPlaceholder', + { + defaultMessage: `Search for a query to run, or write a new query below`, + } +); +export const QUERIES_DROPDOWN_SEARCH_FIELD_LABEL = i18n.translate( + 'xpack.osquery.savedQueries.dropdown.searchFieldLabel', + { + defaultMessage: `Query`, + } +); diff --git a/x-pack/plugins/osquery/public/saved_queries/saved_queries_dropdown.tsx b/x-pack/plugins/osquery/public/saved_queries/saved_queries_dropdown.tsx index 784a2375ad1a6..6722ade12ad16 100644 --- a/x-pack/plugins/osquery/public/saved_queries/saved_queries_dropdown.tsx +++ b/x-pack/plugins/osquery/public/saved_queries/saved_queries_dropdown.tsx @@ -9,9 +9,9 @@ import { find } from 'lodash/fp'; import { EuiCodeBlock, EuiFormRow, EuiComboBox, EuiTextColor } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { SimpleSavedObject } from '@kbn/core/public'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import styled from 'styled-components'; +import { QUERIES_DROPDOWN_LABEL, QUERIES_DROPDOWN_SEARCH_FIELD_LABEL } from './constants'; +import { OsquerySchemaLink } from '../components/osquery_schema_link'; import { useSavedQueries } from './use_saved_queries'; import { useFormData } from '../shared_imports'; @@ -133,20 +133,14 @@ const SavedQueriesDropdownComponent: React.FC = ({ return ( - } + label={QUERIES_DROPDOWN_SEARCH_FIELD_LABEL} + labelAppend={} fullWidth > Date: Mon, 23 May 2022 10:12:54 +0200 Subject: [PATCH 138/150] [DOCS] Updates alerting authorization docs with info on retaining API keys (#132402) Co-authored-by: Lisa Cawley --- docs/user/alerting/alerting-setup.asciidoc | 64 +++++++++++++++++----- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/docs/user/alerting/alerting-setup.asciidoc b/docs/user/alerting/alerting-setup.asciidoc index 6643f8d0ec870..9e3fb54e39444 100644 --- a/docs/user/alerting/alerting-setup.asciidoc +++ b/docs/user/alerting/alerting-setup.asciidoc @@ -5,35 +5,47 @@ Set up ++++ -Alerting is automatically enabled in {kib}, but might require some additional configuration. +Alerting is automatically enabled in {kib}, but might require some additional +configuration. [float] [[alerting-prerequisites]] === Prerequisites If you are using an *on-premises* Elastic Stack deployment: -* In the kibana.yml configuration file, add the <> setting. -* For emails to have a footer with a link back to {kib}, set the <> configuration setting. +* In the kibana.yml configuration file, add the +<> +setting. +* For emails to have a footer with a link back to {kib}, set the +<> configuration setting. -If you are using an *on-premises* Elastic Stack deployment with <>: +If you are using an *on-premises* Elastic Stack deployment with +<>: -* If you are unable to access {kib} Alerting, ensure that you have not {ref}/security-settings.html#api-key-service-settings[explicitly disabled API keys]. +* If you are unable to access {kib} Alerting, ensure that you have not +{ref}/security-settings.html#api-key-service-settings[explicitly disabled API keys]. -The alerting framework uses queries that require the `search.allow_expensive_queries` setting to be `true`. See the scripts {ref}/query-dsl-script-query.html#_allow_expensive_queries_4[documentation]. +The alerting framework uses queries that require the +`search.allow_expensive_queries` setting to be `true`. See the scripts +{ref}/query-dsl-script-query.html#_allow_expensive_queries_4[documentation]. [float] [[alerting-setup-production]] === Production considerations and scaling guidance -When relying on alerting and actions as mission critical services, make sure you follow the <>. +When relying on alerting and actions as mission critical services, make sure you +follow the +<>. -See <> for more information on the scalability of Alerting. +See <> for more information on the scalability of +Alerting. [float] [[alerting-security]] === Security -To access alerting in a space, a user must have access to one of the following features: +To access alerting in a space, a user must have access to one of the following +features: * Alerting * <> @@ -43,31 +55,53 @@ To access alerting in a space, a user must have access to one of the following f * <> * <> -See <> for more information on configuring roles that provide access to these features. -Also note that a user will need +read+ privileges for the *Actions and Connectors* feature to attach actions to a rule or to edit a rule that has an action attached to it. +See <> for more information on +configuring roles that provide access to these features. +Also note that a user will need +read+ privileges for the +*Actions and Connectors* feature to attach actions to a rule or to edit a rule +that has an action attached to it. [float] [[alerting-restricting-actions]] ==== Restrict actions -For security reasons you may wish to limit the extent to which {kib} can connect to external services. <> allows you to disable certain <> and allowlist the hostnames that {kib} can connect with. +For security reasons you may wish to limit the extent to which {kib} can connect +to external services. <> allows you to disable certain +<> and allowlist the hostnames that {kib} can connect with. [float] [[alerting-spaces]] === Space isolation -Rules and connectors are isolated to the {kib} space in which they were created. A rule or connector created in one space will not be visible in another. +Rules and connectors are isolated to the {kib} space in which they were created. +A rule or connector created in one space will not be visible in another. [float] [[alerting-authorization]] === Authorization -Rules are authorized using an <> associated with the last user to edit the rule. This API key captures a snapshot of the user's privileges at the time of edit and is subsequently used to run all background tasks associated with the rule, including condition checks like {es} queries and triggered actions. The following rule actions will re-generate the API key: +Rules are authorized using an <> associated with the last user +to edit the rule. This API key captures a snapshot of the user's privileges at +the time of the edit. They are subsequently used to run all background tasks +associated with the rule, including condition checks like {es} queries and +triggered actions. The following rule actions will re-generate the API key: * Creating a rule * Updating a rule +When you disable a rule, it retains the associated API key which is re-used when +the rule is enabled. If the API key is missing when you enable the rule (for +example, in the case of imported rules), it generates a new key that has your +security privileges. + +You can update an API key manually in +**{stack-manage-app} > {rules-ui}** or in the rule details page by selecting +**Update API key** in the actions menu. + [IMPORTANT] ============================================== -If a rule requires certain privileges, such as index privileges, to run, and a user without those privileges updates the rule, the rule will no longer function. Conversely, if a user with greater or administrator privileges modifies the rule, it will begin running with increased privileges. +If a rule requires certain privileges, such as index privileges, to run, and a +user without those privileges updates the rule, the rule will no longer +function. Conversely, if a user with greater or administrator privileges +modifies the rule, it will begin running with increased privileges. ============================================== From a3646eb2b82e5d790c548882d976e1f16245d118 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Mon, 23 May 2022 10:17:12 +0200 Subject: [PATCH 139/150] [Security Solutions] Refactor breadcrumbs to support new menu structure (#131624) * Refactor breadcrumbs to support new structure * Fix code style * Fix more code style * Fix unit test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/common/components/link_to/index.ts | 3 +- .../breadcrumbs/get_breadcrumbs_for_page.ts | 42 + .../navigation/breadcrumbs/index.test.ts | 1166 +++++++++++------ .../navigation/breadcrumbs/index.ts | 242 ++-- .../common/components/navigation/helpers.ts | 4 +- .../components/navigation/index.test.tsx | 62 +- .../common/components/navigation/index.tsx | 8 - .../index.tsx | 8 - .../detection_engine/rules/utils.test.ts | 29 - .../pages/detection_engine/rules/utils.ts | 40 +- .../public/hosts/pages/details/utils.ts | 24 +- .../public/management/common/breadcrumbs.ts | 2 +- .../public/network/pages/details/index.tsx | 2 +- .../public/network/pages/details/utils.ts | 29 +- .../public/timelines/pages/index.tsx | 25 +- .../public/users/pages/details/utils.ts | 25 +- 16 files changed, 976 insertions(+), 735 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/get_breadcrumbs_for_page.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.test.ts diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts index 0db0699628cc0..ba86842106e23 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts @@ -48,7 +48,7 @@ export const useFormatUrl = (page: SecurityPageName) => { return { formatUrl, search }; }; -type GetSecuritySolutionUrl = (param: { +export type GetSecuritySolutionUrl = (param: { deepLinkId: SecurityPageName; path?: string; absolute?: boolean; @@ -63,6 +63,7 @@ export const useGetSecuritySolutionUrl = () => { ({ deepLinkId, path = '', absolute = false, skipSearch = false }) => { const search = needsUrlState(deepLinkId) ? getUrlStateQueryString() : ''; const formattedPath = formatPath(path, search, skipSearch); + return getAppUrl({ deepLinkId, path: formattedPath, absolute }); }, [getAppUrl, getUrlStateQueryString] diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/get_breadcrumbs_for_page.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/get_breadcrumbs_for_page.ts new file mode 100644 index 0000000000000..c70d7d24fcb94 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/get_breadcrumbs_for_page.ts @@ -0,0 +1,42 @@ +/* + * 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 { ChromeBreadcrumb } from '@kbn/core/public'; +import { SecurityPageName } from '../../../../app/types'; +import { APP_NAME } from '../../../../../common/constants'; +import { getAppLandingUrl } from '../../link_to/redirect_to_landing'; + +import { GetSecuritySolutionUrl } from '../../link_to'; +import { getAncestorLinksInfo } from '../../../links'; +import { GenericNavRecord } from '../types'; + +export const getLeadingBreadcrumbsForSecurityPage = ( + pageName: SecurityPageName, + getSecuritySolutionUrl: GetSecuritySolutionUrl, + navTabs: GenericNavRecord, + isGroupedNavigationEnabled: boolean +): [ChromeBreadcrumb, ...ChromeBreadcrumb[]] => { + const landingPath = getSecuritySolutionUrl({ deepLinkId: SecurityPageName.landing }); + + const siemRootBreadcrumb: ChromeBreadcrumb = { + text: APP_NAME, + href: getAppLandingUrl(landingPath), + }; + + const breadcrumbs: ChromeBreadcrumb[] = getAncestorLinksInfo(pageName).map(({ title, id }) => { + const newTitle = title; + // Get title from navTabs because pages title on the new structure might be different. + const oldTitle = navTabs[id] ? navTabs[id].name : title; + + return { + text: isGroupedNavigationEnabled ? newTitle : oldTitle, + href: getSecuritySolutionUrl({ deepLinkId: id }), + }; + }); + + return [siemRootBreadcrumb, ...breadcrumbs]; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.test.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.test.ts index 7d2bfaa405cb2..05dd7145ba785 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.test.ts @@ -7,15 +7,35 @@ import '../../../mock/match_media'; import { encodeIpv6 } from '../../../lib/helpers'; -import { getBreadcrumbsForRoute, useSetBreadcrumbs } from '.'; +import { getBreadcrumbsForRoute, ObjectWithNavTabs, useSetBreadcrumbs } from '.'; import { HostsTableType } from '../../../../hosts/store/model'; import { RouteSpyState, SiemRouteType } from '../../../utils/route/types'; -import { TabNavigationProps } from '../tab_navigation/types'; import { NetworkRouteType } from '../../../../network/pages/navigation/types'; import { TimelineTabs } from '../../../../../common/types/timeline'; import { AdministrationSubTab } from '../../../../management/types'; import { renderHook } from '@testing-library/react-hooks'; import { TestProviders } from '../../../mock'; +import { GetSecuritySolutionUrl } from '../../link_to'; +import { APP_UI_ID } from '../../../../../common/constants'; +import { useDeepEqualSelector } from '../../../hooks/use_selector'; +import { useIsGroupedNavigationEnabled } from '../helpers'; +import { navTabs } from '../../../../app/home/home_navigations'; +import { getAppLinks } from '../../../links/app_links'; +import { allowedExperimentalValues } from '../../../../../common/experimental_features'; +import { StartPlugins } from '../../../../types'; +import { coreMock } from '@kbn/core/public/mocks'; +import { updateAppLinks } from '../../../links'; + +jest.mock('../../../hooks/use_selector'); + +const mockUseIsGroupedNavigationEnabled = useIsGroupedNavigationEnabled as jest.Mock; +jest.mock('../helpers', () => { + const original = jest.requireActual('../helpers'); + return { + ...original, + useIsGroupedNavigationEnabled: jest.fn(), + }; +}); const setBreadcrumbsMock = jest.fn(); const chromeMock = { @@ -40,412 +60,824 @@ const getMockObject = ( pageName: string, pathName: string, detailName: string | undefined -): RouteSpyState & TabNavigationProps => ({ +): RouteSpyState & ObjectWithNavTabs => ({ detailName, - navTabs: { - cases: { - disabled: false, - href: '/app/security/cases', - id: 'cases', - name: 'Cases', - urlKey: 'cases', - }, - hosts: { - disabled: false, - href: '/app/security/hosts', - id: 'hosts', - name: 'Hosts', - urlKey: 'host', - }, - network: { - disabled: false, - href: '/app/security/network', - id: 'network', - name: 'Network', - urlKey: 'network', - }, - overview: { - disabled: false, - href: '/app/security/overview', - id: 'overview', - name: 'Overview', - urlKey: 'overview', - }, - timelines: { - disabled: false, - href: '/app/security/timelines', - id: 'timelines', - name: 'Timelines', - urlKey: 'timeline', - }, - alerts: { - disabled: false, - href: '/app/security/alerts', - id: 'alerts', - name: 'Alerts', - urlKey: 'alerts', - }, - exceptions: { - disabled: false, - href: '/app/security/exceptions', - id: 'exceptions', - name: 'Exceptions', - urlKey: 'exceptions', - }, - rules: { - disabled: false, - href: '/app/security/rules', - id: 'rules', - name: 'Rules', - urlKey: 'rules', - }, - }, + navTabs, pageName, pathName, search: '', tabName: mockDefaultTab(pageName) as HostsTableType, - query: { query: '', language: 'kuery' }, - filters: [], - timeline: { - activeTab: TimelineTabs.query, - id: '', - isOpen: false, - graphEventId: '', - }, - timerange: { - global: { - linkTo: ['timeline'], - timerange: { - from: '2019-05-16T23:10:43.696Z', - fromStr: 'now-24h', - kind: 'relative', - to: '2019-05-17T23:10:43.697Z', - toStr: 'now', +}); + +(useDeepEqualSelector as jest.Mock).mockImplementation(() => { + return { + urlState: { + query: { query: '', language: 'kuery' }, + filters: [], + timeline: { + activeTab: TimelineTabs.query, + id: '', + isOpen: false, + graphEventId: '', }, - }, - timeline: { - linkTo: ['global'], timerange: { - from: '2019-05-16T23:10:43.696Z', - fromStr: 'now-24h', - kind: 'relative', - to: '2019-05-17T23:10:43.697Z', - toStr: 'now', + global: { + linkTo: ['timeline'], + timerange: { + from: '2019-05-16T23:10:43.696Z', + fromStr: 'now-24h', + kind: 'relative', + to: '2019-05-17T23:10:43.697Z', + toStr: 'now', + }, + }, + timeline: { + linkTo: ['global'], + timerange: { + from: '2019-05-16T23:10:43.696Z', + fromStr: 'now-24h', + kind: 'relative', + to: '2019-05-17T23:10:43.697Z', + toStr: 'now', + }, + }, }, + sourcerer: {}, }, - }, - sourcerer: {}, + }; }); -// The string returned is different from what getUrlForApp returns, but does not matter for the purposes of this test. -const getUrlForAppMock = ( - appId: string, - options?: { deepLinkId?: string; path?: string; absolute?: boolean } -) => `${appId}${options?.deepLinkId ? `/${options.deepLinkId}` : ''}${options?.path ?? ''}`; +// The string returned is different from what getSecuritySolutionUrl returns, but does not matter for the purposes of this test. +const getSecuritySolutionUrl: GetSecuritySolutionUrl = ({ + deepLinkId, + path, +}: { + deepLinkId?: string; + path?: string; + absolute?: boolean; +}) => `${APP_UI_ID}${deepLinkId ? `/${deepLinkId}` : ''}${path ?? ''}`; + +jest.mock('../../../lib/kibana/kibana_react', () => { + return { + useKibana: () => ({ + services: { + chrome: undefined, + application: { + navigateToApp: jest.fn(), + getUrlForApp: (appId: string, options?: { path?: string; deepLinkId?: boolean }) => + `${appId}/${options?.deepLinkId ?? ''}${options?.path ?? ''}`, + }, + }, + }), + }; +}); describe('Navigation Breadcrumbs', () => { + beforeAll(async () => { + const appLinks = await getAppLinks(coreMock.createStart(), {} as StartPlugins); + updateAppLinks(appLinks, { + experimentalFeatures: allowedExperimentalValues, + capabilities: { + navLinks: {}, + management: {}, + catalogue: {}, + actions: { show: true, crud: true }, + siem: { + show: true, + crud: true, + }, + }, + }); + }); + const hostName = 'siem-kibana'; const ipv4 = '192.0.2.255'; const ipv6 = '2001:db8:ffff:ffff:ffff:ffff:ffff:ffff'; const ipv6Encoded = encodeIpv6(ipv6); - describe('getBreadcrumbsForRoute', () => { - test('should return Host breadcrumbs when supplied host pathname', () => { - const breadcrumbs = getBreadcrumbsForRoute( - getMockObject('hosts', '/', undefined), - getUrlForAppMock - ); - expect(breadcrumbs).toEqual([ - { - href: 'securitySolutionUI/get_started', - text: 'Security', - }, - { - href: "securitySolutionUI/hosts?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", - text: 'Hosts', - }, - { - href: '', - text: 'Authentications', - }, - ]); + describe('Old Architecture', () => { + beforeAll(() => { + mockUseIsGroupedNavigationEnabled.mockReturnValue(false); }); - test('should return Network breadcrumbs when supplied network pathname', () => { - const breadcrumbs = getBreadcrumbsForRoute( - getMockObject('network', '/', undefined), - getUrlForAppMock - ); - expect(breadcrumbs).toEqual([ - { text: 'Security', href: 'securitySolutionUI/get_started' }, - { - text: 'Network', - href: "securitySolutionUI/network?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", - }, - { - text: 'Flows', - href: '', - }, - ]); - }); + describe('getBreadcrumbsForRoute', () => { + test('should return Overview breadcrumbs when supplied overview pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('overview', '/', undefined), + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual([ + { + href: 'securitySolutionUI/get_started', + text: 'Security', + }, + { + href: '', + text: 'Overview', + }, + ]); + }); - test('should return Timelines breadcrumbs when supplied timelines pathname', () => { - const breadcrumbs = getBreadcrumbsForRoute( - getMockObject('timelines', '/', undefined), - getUrlForAppMock - ); - expect(breadcrumbs).toEqual([ - { text: 'Security', href: 'securitySolutionUI/get_started' }, - { - text: 'Timelines', - href: "securitySolutionUI/timelines?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", - }, - ]); - }); + test('should return Host breadcrumbs when supplied hosts pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('hosts', '/', undefined), + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual([ + { + href: 'securitySolutionUI/get_started', + text: 'Security', + }, + { + href: 'securitySolutionUI/hosts', + text: 'Hosts', + }, + { + href: '', + text: 'Authentications', + }, + ]); + }); - test('should return Host Details breadcrumbs when supplied a pathname with hostName', () => { - const breadcrumbs = getBreadcrumbsForRoute( - getMockObject('hosts', '/', hostName), - getUrlForAppMock - ); - expect(breadcrumbs).toEqual([ - { text: 'Security', href: 'securitySolutionUI/get_started' }, - { - text: 'Hosts', - href: "securitySolutionUI/hosts?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", - }, - { - text: 'siem-kibana', - href: "securitySolutionUI/hosts/siem-kibana?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", - }, - { text: 'Authentications', href: '' }, - ]); - }); + test('should return Network breadcrumbs when supplied network pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('network', '/', undefined), + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Network', + href: 'securitySolutionUI/network', + }, + { + text: 'Flows', + href: '', + }, + ]); + }); - test('should return IP Details breadcrumbs when supplied pathname with ipv4', () => { - const breadcrumbs = getBreadcrumbsForRoute( - getMockObject('network', '/', ipv4), - getUrlForAppMock - ); - expect(breadcrumbs).toEqual([ - { text: 'Security', href: 'securitySolutionUI/get_started' }, - { - text: 'Network', - href: "securitySolutionUI/network?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", - }, - { - text: ipv4, - href: `securitySolutionUI/network/ip/${ipv4}/source?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))`, - }, - { text: 'Flows', href: '' }, - ]); - }); + test('should return Timelines breadcrumbs when supplied timelines pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('timelines', '/', undefined), + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Timelines', + href: '', + }, + ]); + }); - test('should return IP Details breadcrumbs when supplied pathname with ipv6', () => { - const breadcrumbs = getBreadcrumbsForRoute( - getMockObject('network', '/', ipv6Encoded), - getUrlForAppMock - ); - expect(breadcrumbs).toEqual([ - { text: 'Security', href: 'securitySolutionUI/get_started' }, - { - text: 'Network', - href: "securitySolutionUI/network?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", - }, - { - text: ipv6, - href: `securitySolutionUI/network/ip/${ipv6Encoded}/source?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))`, - }, - { text: 'Flows', href: '' }, - ]); - }); + test('should return Host Details breadcrumbs when supplied a pathname with hostName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('hosts', '/', hostName), + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Hosts', + href: 'securitySolutionUI/hosts', + }, + { + text: 'siem-kibana', + href: 'securitySolutionUI/hosts/siem-kibana', + }, + { text: 'Authentications', href: '' }, + ]); + }); - test('should return Alerts breadcrumbs when supplied alerts pathname', () => { - const breadcrumbs = getBreadcrumbsForRoute( - getMockObject('alerts', '/alerts', undefined), - getUrlForAppMock - ); - expect(breadcrumbs).toEqual([ - { text: 'Security', href: 'securitySolutionUI/get_started' }, - { - text: 'Alerts', - href: '', - }, - ]); - }); + test('should return IP Details breadcrumbs when supplied pathname with ipv4', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('network', '/', ipv4), + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Network', + href: 'securitySolutionUI/network', + }, + { + text: ipv4, + href: `securitySolutionUI/network/ip/${ipv4}/source`, + }, + { text: 'Flows', href: '' }, + ]); + }); - test('should return Exceptions breadcrumbs when supplied exceptions pathname', () => { - const breadcrumbs = getBreadcrumbsForRoute( - getMockObject('exceptions', '/exceptions', undefined), - getUrlForAppMock - ); - expect(breadcrumbs).toEqual([ - { text: 'Security', href: 'securitySolutionUI/get_started' }, - { - text: 'Exceptions', - href: '', - }, - ]); - }); + test('should return IP Details breadcrumbs when supplied pathname with ipv6', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('network', '/', ipv6Encoded), + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Network', + href: 'securitySolutionUI/network', + }, + { + text: ipv6, + href: `securitySolutionUI/network/ip/${ipv6Encoded}/source`, + }, + { text: 'Flows', href: '' }, + ]); + }); - test('should return Rules breadcrumbs when supplied rules pathname', () => { - const breadcrumbs = getBreadcrumbsForRoute( - getMockObject('rules', '/rules', undefined), - getUrlForAppMock - ); - expect(breadcrumbs).toEqual([ - { text: 'Security', href: 'securitySolutionUI/get_started' }, - { - text: 'Rules', - href: "securitySolutionUI/rules?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", - }, - ]); - }); + test('should return Alerts breadcrumbs when supplied alerts pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('alerts', '/alerts', undefined), + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Alerts', + href: '', + }, + ]); + }); - test('should return Rules breadcrumbs when supplied rules Creation pathname', () => { - const breadcrumbs = getBreadcrumbsForRoute( - getMockObject('rules', '/rules/create', undefined), - getUrlForAppMock - ); - expect(breadcrumbs).toEqual([ - { text: 'Security', href: 'securitySolutionUI/get_started' }, - { - text: 'Rules', - href: "securitySolutionUI/rules?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", - }, - { - text: 'Create', - href: '', - }, - ]); - }); + test('should return Exceptions breadcrumbs when supplied exceptions pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('exceptions', '/exceptions', undefined), + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Exception lists', + href: '', + }, + ]); + }); - test('should return Rules breadcrumbs when supplied rules Details pathname', () => { - const mockDetailName = '5a4a0460-d822-11eb-8962-bfd4aff0a9b3'; - const mockRuleName = 'ALERT_RULE_NAME'; - const breadcrumbs = getBreadcrumbsForRoute( - { - ...getMockObject('rules', `/rules/id/${mockDetailName}`, undefined), - detailName: mockDetailName, - state: { - ruleName: mockRuleName, + test('should return Rules breadcrumbs when supplied rules pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('rules', '/rules', undefined), + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Rules', + href: '', }, - }, - getUrlForAppMock - ); - expect(breadcrumbs).toEqual([ - { text: 'Security', href: 'securitySolutionUI/get_started' }, - { - text: 'Rules', - href: "securitySolutionUI/rules?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", - }, - { - text: mockRuleName, - href: `securitySolutionUI/rules/id/${mockDetailName}?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))`, - }, - ]); - }); + ]); + }); - test('should return Rules breadcrumbs when supplied rules Edit pathname', () => { - const mockDetailName = '5a4a0460-d822-11eb-8962-bfd4aff0a9b3'; - const mockRuleName = 'ALERT_RULE_NAME'; - const breadcrumbs = getBreadcrumbsForRoute( - { - ...getMockObject('rules', `/rules/id/${mockDetailName}/edit`, undefined), - detailName: mockDetailName, - state: { - ruleName: mockRuleName, + test('should return Rules breadcrumbs when supplied rules Creation pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('rules', '/rules/create', undefined), + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Rules', + href: 'securitySolutionUI/rules', }, - }, - getUrlForAppMock - ); - expect(breadcrumbs).toEqual([ - { text: 'Security', href: 'securitySolutionUI/get_started' }, - { - text: 'Rules', - href: "securitySolutionUI/rules?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", - }, - { - text: 'ALERT_RULE_NAME', - href: `securitySolutionUI/rules/id/${mockDetailName}?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))`, - }, - { - text: 'Edit', - href: '', - }, - ]); + { + text: 'Create', + href: '', + }, + ]); + }); + + test('should return Rules breadcrumbs when supplied rules Details pageName', () => { + const mockDetailName = '5a4a0460-d822-11eb-8962-bfd4aff0a9b3'; + const mockRuleName = 'ALERT_RULE_NAME'; + const breadcrumbs = getBreadcrumbsForRoute( + { + ...getMockObject('rules', `/rules/id/${mockDetailName}`, undefined), + detailName: mockDetailName, + state: { + ruleName: mockRuleName, + }, + }, + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Rules', + href: 'securitySolutionUI/rules', + }, + { + text: mockRuleName, + href: ``, + }, + ]); + }); + + test('should return Rules breadcrumbs when supplied rules Edit pageName', () => { + const mockDetailName = '5a4a0460-d822-11eb-8962-bfd4aff0a9b3'; + const mockRuleName = 'ALERT_RULE_NAME'; + const breadcrumbs = getBreadcrumbsForRoute( + { + ...getMockObject('rules', `/rules/id/${mockDetailName}/edit`, undefined), + detailName: mockDetailName, + state: { + ruleName: mockRuleName, + }, + }, + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Rules', + href: 'securitySolutionUI/rules', + }, + { + text: 'ALERT_RULE_NAME', + href: `securitySolutionUI/rules/id/${mockDetailName}`, + }, + { + text: 'Edit', + href: '', + }, + ]); + }); + + test('should return null breadcrumbs when supplied Cases pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('cases', '/', undefined), + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual(null); + }); + + test('should return null breadcrumbs when supplied Cases details pageName', () => { + const sampleCase = { + id: 'my-case-id', + name: 'Case name', + }; + const breadcrumbs = getBreadcrumbsForRoute( + { + ...getMockObject('cases', `/${sampleCase.id}`, sampleCase.id), + state: { caseTitle: sampleCase.name }, + }, + getSecuritySolutionUrl, + false + ); + expect(breadcrumbs).toEqual(null); + }); + + test('should return Admin breadcrumbs when supplied endpoints pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('administration', '/endpoints', undefined), + getSecuritySolutionUrl, + false + ); + + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Endpoints', + href: '', + }, + ]); + }); }); - test('should return null breadcrumbs when supplied Cases pathname', () => { - const breadcrumbs = getBreadcrumbsForRoute( - getMockObject('cases', '/', undefined), - getUrlForAppMock - ); - expect(breadcrumbs).toEqual(null); + describe('setBreadcrumbs()', () => { + test('should call chrome breadcrumb service with correct breadcrumbs', () => { + const navigateToUrlMock = jest.fn(); + const { result } = renderHook(() => useSetBreadcrumbs(), { wrapper: TestProviders }); + result.current(getMockObject('hosts', '/', hostName), chromeMock, navigateToUrlMock); + + expect(setBreadcrumbsMock).toBeCalledWith([ + expect.objectContaining({ + text: 'Security', + href: "securitySolutionUI/get_started?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", + onClick: expect.any(Function), + }), + expect.objectContaining({ + text: 'Hosts', + href: "securitySolutionUI/hosts?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", + onClick: expect.any(Function), + }), + expect.objectContaining({ + text: 'siem-kibana', + href: "securitySolutionUI/hosts/siem-kibana?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", + onClick: expect.any(Function), + }), + { + text: 'Authentications', + href: '', + }, + ]); + }); }); + }); - test('should return null breadcrumbs when supplied Cases details pathname', () => { - const sampleCase = { - id: 'my-case-id', - name: 'Case name', - }; - const breadcrumbs = getBreadcrumbsForRoute( - { - ...getMockObject('cases', `/${sampleCase.id}`, sampleCase.id), - state: { caseTitle: sampleCase.name }, - }, - getUrlForAppMock - ); - expect(breadcrumbs).toEqual(null); + describe('New Architecture', () => { + beforeAll(() => { + mockUseIsGroupedNavigationEnabled.mockReturnValue(true); }); - test('should return Admin breadcrumbs when supplied endpoints pathname', () => { - const breadcrumbs = getBreadcrumbsForRoute( - getMockObject('administration', '/endpoints', undefined), - getUrlForAppMock - ); - expect(breadcrumbs).toEqual([ - { text: 'Security', href: 'securitySolutionUI/get_started' }, - { - text: 'Endpoints', - href: '', - }, - ]); + describe('getBreadcrumbsForRoute', () => { + test('should return Overview breadcrumbs when supplied overview pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('overview', '/', undefined), + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual([ + { + href: 'securitySolutionUI/get_started', + text: 'Security', + }, + { + href: 'securitySolutionUI/dashboards', + text: 'Dashboards', + }, + { + href: '', + text: 'Overview', + }, + ]); + }); + + test('should return Host breadcrumbs when supplied hosts pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('hosts', '/', undefined), + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual([ + { + href: 'securitySolutionUI/get_started', + text: 'Security', + }, + { + href: 'securitySolutionUI/threat_hunting', + text: 'Threat Hunting', + }, + { + href: 'securitySolutionUI/hosts', + text: 'Hosts', + }, + { + href: '', + text: 'Authentications', + }, + ]); + }); + + test('should return Network breadcrumbs when supplied network pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('network', '/', undefined), + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + href: 'securitySolutionUI/threat_hunting', + text: 'Threat Hunting', + }, + { + text: 'Network', + href: 'securitySolutionUI/network', + }, + { + text: 'Flows', + href: '', + }, + ]); + }); + + test('should return Timelines breadcrumbs when supplied timelines pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('timelines', '/', undefined), + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Timelines', + href: '', + }, + ]); + }); + + test('should return Host Details breadcrumbs when supplied a pathname with hostName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('hosts', '/', hostName), + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + href: 'securitySolutionUI/threat_hunting', + text: 'Threat Hunting', + }, + { + text: 'Hosts', + href: 'securitySolutionUI/hosts', + }, + { + text: 'siem-kibana', + href: 'securitySolutionUI/hosts/siem-kibana', + }, + { text: 'Authentications', href: '' }, + ]); + }); + + test('should return IP Details breadcrumbs when supplied pathname with ipv4', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('network', '/', ipv4), + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + href: 'securitySolutionUI/threat_hunting', + text: 'Threat Hunting', + }, + { + text: 'Network', + href: 'securitySolutionUI/network', + }, + { + text: ipv4, + href: `securitySolutionUI/network/ip/${ipv4}/source`, + }, + { text: 'Flows', href: '' }, + ]); + }); + + test('should return IP Details breadcrumbs when supplied pathname with ipv6', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('network', '/', ipv6Encoded), + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + href: 'securitySolutionUI/threat_hunting', + text: 'Threat Hunting', + }, + { + text: 'Network', + href: 'securitySolutionUI/network', + }, + { + text: ipv6, + href: `securitySolutionUI/network/ip/${ipv6Encoded}/source`, + }, + { text: 'Flows', href: '' }, + ]); + }); + + test('should return Alerts breadcrumbs when supplied alerts pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('alerts', '/alerts', undefined), + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Alerts', + href: '', + }, + ]); + }); + + test('should return Exceptions breadcrumbs when supplied exceptions pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('exceptions', '/exceptions', undefined), + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Manage', + href: 'securitySolutionUI/administration', + }, + { + text: 'Exception lists', + href: '', + }, + ]); + }); + + test('should return Rules breadcrumbs when supplied rules pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('rules', '/rules', undefined), + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Manage', + href: 'securitySolutionUI/administration', + }, + { + text: 'Rules', + href: '', + }, + ]); + }); + + test('should return Rules breadcrumbs when supplied rules Creation pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('rules', '/rules/create', undefined), + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Manage', + href: 'securitySolutionUI/administration', + }, + { + text: 'Rules', + href: 'securitySolutionUI/rules', + }, + { + text: 'Create', + href: '', + }, + ]); + }); + + test('should return Rules breadcrumbs when supplied rules Details pageName', () => { + const mockDetailName = '5a4a0460-d822-11eb-8962-bfd4aff0a9b3'; + const mockRuleName = 'ALERT_RULE_NAME'; + const breadcrumbs = getBreadcrumbsForRoute( + { + ...getMockObject('rules', `/rules/id/${mockDetailName}`, undefined), + detailName: mockDetailName, + state: { + ruleName: mockRuleName, + }, + }, + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Manage', + href: 'securitySolutionUI/administration', + }, + { + text: 'Rules', + href: 'securitySolutionUI/rules', + }, + { + text: mockRuleName, + href: ``, + }, + ]); + }); + + test('should return Rules breadcrumbs when supplied rules Edit pageName', () => { + const mockDetailName = '5a4a0460-d822-11eb-8962-bfd4aff0a9b3'; + const mockRuleName = 'ALERT_RULE_NAME'; + const breadcrumbs = getBreadcrumbsForRoute( + { + ...getMockObject('rules', `/rules/id/${mockDetailName}/edit`, undefined), + detailName: mockDetailName, + state: { + ruleName: mockRuleName, + }, + }, + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Manage', + href: 'securitySolutionUI/administration', + }, + { + text: 'Rules', + href: 'securitySolutionUI/rules', + }, + { + text: 'ALERT_RULE_NAME', + href: `securitySolutionUI/rules/id/${mockDetailName}`, + }, + { + text: 'Edit', + href: '', + }, + ]); + }); + + test('should return null breadcrumbs when supplied Cases pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('cases', '/', undefined), + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual(null); + }); + + test('should return null breadcrumbs when supplied Cases details pageName', () => { + const sampleCase = { + id: 'my-case-id', + name: 'Case name', + }; + const breadcrumbs = getBreadcrumbsForRoute( + { + ...getMockObject('cases', `/${sampleCase.id}`, sampleCase.id), + state: { caseTitle: sampleCase.name }, + }, + getSecuritySolutionUrl, + true + ); + expect(breadcrumbs).toEqual(null); + }); + + test('should return Admin breadcrumbs when supplied endpoints pageName', () => { + const breadcrumbs = getBreadcrumbsForRoute( + getMockObject('administration', '/endpoints', undefined), + getSecuritySolutionUrl, + true + ); + + expect(breadcrumbs).toEqual([ + { text: 'Security', href: 'securitySolutionUI/get_started' }, + { + text: 'Manage', + href: 'securitySolutionUI/administration', + }, + { + text: 'Endpoints', + href: '', + }, + ]); + }); }); - }); - describe('setBreadcrumbs()', () => { - test('should call chrome breadcrumb service with correct breadcrumbs', () => { - const navigateToUrlMock = jest.fn(); - const { result } = renderHook(() => useSetBreadcrumbs(), { wrapper: TestProviders }); - result.current( - getMockObject('hosts', '/', hostName), - chromeMock, - getUrlForAppMock, - navigateToUrlMock - ); - expect(setBreadcrumbsMock).toBeCalledWith([ - expect.objectContaining({ - text: 'Security', - href: 'securitySolutionUI/get_started', - onClick: expect.any(Function), - }), - expect.objectContaining({ - text: 'Hosts', - href: "securitySolutionUI/hosts?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", - onClick: expect.any(Function), - }), - expect.objectContaining({ - text: 'siem-kibana', - href: "securitySolutionUI/hosts/siem-kibana?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))", - onClick: expect.any(Function), - }), - { - text: 'Authentications', - href: '', - }, - ]); + describe('setBreadcrumbs()', () => { + test('should call chrome breadcrumb service with correct breadcrumbs', () => { + const navigateToUrlMock = jest.fn(); + const { result } = renderHook(() => useSetBreadcrumbs(), { wrapper: TestProviders }); + result.current(getMockObject('hosts', '/', hostName), chromeMock, navigateToUrlMock); + const searchString = + "?sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2019-05-16T23:10:43.696Z',fromStr:now-24h,kind:relative,to:'2019-05-17T23:10:43.697Z',toStr:now)))"; + + expect(setBreadcrumbsMock).toBeCalledWith([ + expect.objectContaining({ + text: 'Security', + href: `securitySolutionUI/get_started${searchString}`, + onClick: expect.any(Function), + }), + expect.objectContaining({ + text: 'Threat Hunting', + href: `securitySolutionUI/threat_hunting`, + onClick: expect.any(Function), + }), + expect.objectContaining({ + text: 'Hosts', + href: `securitySolutionUI/hosts${searchString}`, + onClick: expect.any(Function), + }), + expect.objectContaining({ + text: 'siem-kibana', + href: `securitySolutionUI/hosts/siem-kibana${searchString}`, + onClick: expect.any(Function), + }), + { + text: 'Authentications', + href: '', + }, + ]); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts index 3c2e103c0dfd3..ba4835bf776c9 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts @@ -5,43 +5,50 @@ * 2.0. */ -import { getOr, omit } from 'lodash/fp'; +import { last, omit } from 'lodash/fp'; import { useDispatch } from 'react-redux'; import { ChromeBreadcrumb } from '@kbn/core/public'; -import { APP_NAME, APP_UI_ID } from '../../../../../common/constants'; import { StartServices } from '../../../../types'; -import { getBreadcrumbs as getHostDetailsBreadcrumbs } from '../../../../hosts/pages/details/utils'; -import { getBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../../network/pages/details'; -import { getBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../../detections/pages/detection_engine/rules/utils'; -import { getBreadcrumbs as getTimelinesBreadcrumbs } from '../../../../timelines/pages'; -import { getBreadcrumbs as getUsersBreadcrumbs } from '../../../../users/pages/details/utils'; -import { getBreadcrumbs as getAdminBreadcrumbs } from '../../../../management/common/breadcrumbs'; +import { getTrailingBreadcrumbs as getHostDetailsBreadcrumbs } from '../../../../hosts/pages/details/utils'; +import { getTrailingBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../../network/pages/details'; +import { getTrailingBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../../detections/pages/detection_engine/rules/utils'; +import { getTrailingBreadcrumbs as getUsersBreadcrumbs } from '../../../../users/pages/details/utils'; +import { getTrailingBreadcrumbs as getAdminBreadcrumbs } from '../../../../management/common/breadcrumbs'; import { SecurityPageName } from '../../../../app/types'; import { RouteSpyState, HostRouteSpyState, NetworkRouteSpyState, - TimelineRouteSpyState, AdministrationRouteSpyState, UsersRouteSpyState, } from '../../../utils/route/types'; -import { getAppLandingUrl } from '../../link_to/redirect_to_landing'; import { timelineActions } from '../../../../timelines/store/timeline'; import { TimelineId } from '../../../../../common/types/timeline'; -import { TabNavigationProps } from '../tab_navigation/types'; -import { getSearch } from '../helpers'; -import { GetUrlForApp, NavigateToUrl, SearchNavTab } from '../types'; +import { GenericNavRecord, NavigateToUrl } from '../types'; +import { getLeadingBreadcrumbsForSecurityPage } from './get_breadcrumbs_for_page'; +import { GetSecuritySolutionUrl, useGetSecuritySolutionUrl } from '../../link_to'; +import { useIsGroupedNavigationEnabled } from '../helpers'; + +export interface ObjectWithNavTabs { + navTabs: GenericNavRecord; +} export const useSetBreadcrumbs = () => { const dispatch = useDispatch(); + const getSecuritySolutionUrl = useGetSecuritySolutionUrl(); + const isGroupedNavigationEnabled = useIsGroupedNavigationEnabled(); + return ( - spyState: RouteSpyState & TabNavigationProps, + spyState: RouteSpyState & ObjectWithNavTabs, chrome: StartServices['chrome'], - getUrlForApp: GetUrlForApp, navigateToUrl: NavigateToUrl ) => { - const breadcrumbs = getBreadcrumbsForRoute(spyState, getUrlForApp); + const breadcrumbs = getBreadcrumbsForRoute( + spyState, + getSecuritySolutionUrl, + isGroupedNavigationEnabled + ); if (breadcrumbs) { chrome.setBreadcrumbs( breadcrumbs.map((breadcrumb) => ({ @@ -64,158 +71,103 @@ export const useSetBreadcrumbs = () => { }; }; -const isNetworkRoutes = (spyState: RouteSpyState): spyState is NetworkRouteSpyState => - spyState != null && spyState.pageName === SecurityPageName.network; - -const isHostsRoutes = (spyState: RouteSpyState): spyState is HostRouteSpyState => - spyState != null && spyState.pageName === SecurityPageName.hosts; - -const isUsersRoutes = (spyState: RouteSpyState): spyState is UsersRouteSpyState => - spyState != null && spyState.pageName === SecurityPageName.users; - -const isTimelinesRoutes = (spyState: RouteSpyState): spyState is TimelineRouteSpyState => - spyState != null && spyState.pageName === SecurityPageName.timelines; - -const isCaseRoutes = (spyState: RouteSpyState): spyState is RouteSpyState => - spyState != null && spyState.pageName === SecurityPageName.case; - -const isAdminRoutes = (spyState: RouteSpyState): spyState is AdministrationRouteSpyState => - spyState != null && spyState.pageName === SecurityPageName.administration; - -const isRulesRoutes = (spyState: RouteSpyState): spyState is AdministrationRouteSpyState => - spyState != null && - (spyState.pageName === SecurityPageName.rules || - spyState.pageName === SecurityPageName.rulesCreate); - -// eslint-disable-next-line complexity export const getBreadcrumbsForRoute = ( - object: RouteSpyState & TabNavigationProps, - getUrlForApp: GetUrlForApp + object: RouteSpyState & ObjectWithNavTabs, + getSecuritySolutionUrl: GetSecuritySolutionUrl, + isGroupedNavigationEnabled: boolean ): ChromeBreadcrumb[] | null => { const spyState: RouteSpyState = omit('navTabs', object); - const landingPath = getUrlForApp(APP_UI_ID, { deepLinkId: SecurityPageName.landing }); - - const siemRootBreadcrumb: ChromeBreadcrumb = { - text: APP_NAME, - href: getAppLandingUrl(landingPath), - }; - if (isHostsRoutes(spyState) && object.navTabs) { - const tempNav: SearchNavTab = { urlKey: 'host', isDetailPage: false }; - let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; - if (spyState.tabName != null) { - urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; - } - return [ - siemRootBreadcrumb, - ...getHostDetailsBreadcrumbs( - spyState, - urlStateKeys.reduce( - (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], - [] - ), - getUrlForApp - ), - ]; + if (!spyState || !object.navTabs || !spyState.pageName || isCaseRoutes(spyState)) { + return null; } - if (isNetworkRoutes(spyState) && object.navTabs) { - const tempNav: SearchNavTab = { urlKey: 'network', isDetailPage: false }; - let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; - if (spyState.tabName != null) { - urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; + + const newMenuLeadingBreadcrumbs = getLeadingBreadcrumbsForSecurityPage( + spyState.pageName as SecurityPageName, + getSecuritySolutionUrl, + object.navTabs, + isGroupedNavigationEnabled + ); + + // last newMenuLeadingBreadcrumbs is the current page + const pageBreadcrumb = newMenuLeadingBreadcrumbs[newMenuLeadingBreadcrumbs.length - 1]; + const siemRootBreadcrumb = newMenuLeadingBreadcrumbs[0]; + + const leadingBreadcrumbs = isGroupedNavigationEnabled + ? newMenuLeadingBreadcrumbs + : [siemRootBreadcrumb, pageBreadcrumb]; + + // Admin URL works differently. All admin pages are under '/administration' + if (isAdminRoutes(spyState)) { + if (isGroupedNavigationEnabled) { + return emptyLastBreadcrumbUrl([...leadingBreadcrumbs, ...getAdminBreadcrumbs(spyState)]); + } else { + return [ + ...(siemRootBreadcrumb ? [siemRootBreadcrumb] : []), + ...getAdminBreadcrumbs(spyState), + ]; } - return [ - siemRootBreadcrumb, - ...getIPDetailsBreadcrumbs( - spyState, - urlStateKeys.reduce( - (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], - [] - ), - getUrlForApp - ), - ]; } - if (isUsersRoutes(spyState) && object.navTabs) { - const tempNav: SearchNavTab = { urlKey: 'users', isDetailPage: false }; - let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; - if (spyState.tabName != null) { - urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; - } + return emptyLastBreadcrumbUrl([ + ...leadingBreadcrumbs, + ...getTrailingBreadcrumbsForRoutes(spyState, getSecuritySolutionUrl), + ]); +}; - return [ - siemRootBreadcrumb, - ...getUsersBreadcrumbs( - spyState, - urlStateKeys.reduce( - (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], - [] - ), - getUrlForApp - ), - ]; +const getTrailingBreadcrumbsForRoutes = ( + spyState: RouteSpyState, + getSecuritySolutionUrl: GetSecuritySolutionUrl +): ChromeBreadcrumb[] => { + if (isHostsRoutes(spyState)) { + return getHostDetailsBreadcrumbs(spyState, getSecuritySolutionUrl); + } + if (isNetworkRoutes(spyState)) { + return getIPDetailsBreadcrumbs(spyState, getSecuritySolutionUrl); } - if (isRulesRoutes(spyState) && object.navTabs) { - const tempNav: SearchNavTab = { urlKey: SecurityPageName.rules, isDetailPage: false }; - let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; - if (spyState.tabName != null) { - urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; - } - - return [ - siemRootBreadcrumb, - ...getDetectionRulesBreadcrumbs( - spyState, - urlStateKeys.reduce( - (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], - [] - ), - getUrlForApp - ), - ]; + if (isUsersRoutes(spyState)) { + return getUsersBreadcrumbs(spyState, getSecuritySolutionUrl); } - if (isCaseRoutes(spyState) && object.navTabs) { - return null; // controlled by Cases routes + if (isRulesRoutes(spyState)) { + return getDetectionRulesBreadcrumbs(spyState, getSecuritySolutionUrl); } - if (isTimelinesRoutes(spyState) && object.navTabs) { - const tempNav: SearchNavTab = { urlKey: 'timeline', isDetailPage: false }; - const urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; + return []; +}; - return [ - siemRootBreadcrumb, - ...getTimelinesBreadcrumbs( - spyState, - urlStateKeys.reduce( - (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], - [] - ), - getUrlForApp - ), - ]; - } +const isNetworkRoutes = (spyState: RouteSpyState): spyState is NetworkRouteSpyState => + spyState.pageName === SecurityPageName.network; - if (isAdminRoutes(spyState) && object.navTabs) { - return [siemRootBreadcrumb, ...getAdminBreadcrumbs(spyState)]; - } +const isHostsRoutes = (spyState: RouteSpyState): spyState is HostRouteSpyState => + spyState.pageName === SecurityPageName.hosts; + +const isUsersRoutes = (spyState: RouteSpyState): spyState is UsersRouteSpyState => + spyState.pageName === SecurityPageName.users; + +const isCaseRoutes = (spyState: RouteSpyState) => spyState.pageName === SecurityPageName.case; + +const isAdminRoutes = (spyState: RouteSpyState): spyState is AdministrationRouteSpyState => + spyState.pageName === SecurityPageName.administration; + +const isRulesRoutes = (spyState: RouteSpyState): spyState is AdministrationRouteSpyState => + spyState.pageName === SecurityPageName.rules || + spyState.pageName === SecurityPageName.rulesCreate; + +const emptyLastBreadcrumbUrl = (breadcrumbs: ChromeBreadcrumb[]) => { + const leadingBreadCrumbs = breadcrumbs.slice(0, -1); + const lastBreadcrumb = last(breadcrumbs); - if ( - spyState != null && - object.navTabs && - spyState.pageName && - object.navTabs[spyState.pageName] - ) { + if (lastBreadcrumb) { return [ - siemRootBreadcrumb, + ...leadingBreadCrumbs, { - text: object.navTabs[spyState.pageName].name, + ...lastBreadcrumb, href: '', }, ]; } - return null; + return breadcrumbs; }; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/helpers.ts b/x-pack/plugins/security_solution/public/common/components/navigation/helpers.ts index 5569d8c85afa8..b2d91492b3ae1 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/helpers.ts @@ -9,8 +9,6 @@ import { isEmpty } from 'lodash/fp'; import { Location } from 'history'; import type { Filter, Query } from '@kbn/es-query'; -import { useUiSetting$ } from '../../lib/kibana'; -import { ENABLE_GROUPED_NAVIGATION } from '../../../../common/constants'; import { UrlInputsModel } from '../../store/inputs/model'; import { TimelineUrl } from '../../../timelines/store/timeline/model'; import { CONSTANTS } from '../url_state/constants'; @@ -24,6 +22,8 @@ import { import { SearchNavTab } from './types'; import { SourcererUrlState } from '../../store/sourcerer/model'; import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; +import { useUiSetting$ } from '../../lib/kibana'; +import { ENABLE_GROUPED_NAVIGATION } from '../../../../common/constants'; export const getSearch = (tab: SearchNavTab, urlState: UrlState): string => { if (tab && tab.urlKey != null && !isAdministration(tab.urlKey)) { diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx index d14c8a51a66ee..f70b77b15dc8c 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx @@ -111,44 +111,12 @@ describe('SIEM Navigation', () => { pageName: 'hosts', pathName: '/', search: '', - sourcerer: {}, state: undefined, tabName: 'authentications', - query: { query: '', language: 'kuery' }, - filters: [], flowTarget: undefined, savedQuery: undefined, - timeline: { - activeTab: TimelineTabs.query, - id: '', - isOpen: false, - graphEventId: '', - }, - timerange: { - global: { - linkTo: ['timeline'], - timerange: { - from: '2019-05-16T23:10:43.696Z', - fromStr: 'now-24h', - kind: 'relative', - to: '2019-05-17T23:10:43.697Z', - toStr: 'now', - }, - }, - timeline: { - linkTo: ['global'], - timerange: { - from: '2019-05-16T23:10:43.696Z', - fromStr: 'now-24h', - kind: 'relative', - to: '2019-05-17T23:10:43.697Z', - toStr: 'now', - }, - }, - }, }, undefined, - mockGetUrlForApp, mockNavigateToUrl ); }); @@ -163,43 +131,15 @@ describe('SIEM Navigation', () => { 2, { detailName: undefined, - filters: [], flowTarget: undefined, navTabs, + search: '', pageName: 'network', pathName: '/', - query: { language: 'kuery', query: '' }, - savedQuery: undefined, - search: '', - sourcerer: {}, state: undefined, tabName: 'authentications', - timeline: { id: '', isOpen: false, activeTab: TimelineTabs.query, graphEventId: '' }, - timerange: { - global: { - linkTo: ['timeline'], - timerange: { - from: '2019-05-16T23:10:43.696Z', - fromStr: 'now-24h', - kind: 'relative', - to: '2019-05-17T23:10:43.697Z', - toStr: 'now', - }, - }, - timeline: { - linkTo: ['global'], - timerange: { - from: '2019-05-16T23:10:43.696Z', - fromStr: 'now-24h', - kind: 'relative', - to: '2019-05-17T23:10:43.697Z', - toStr: 'now', - }, - }, - }, }, undefined, - mockGetUrlForApp, mockNavigateToUrl ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/index.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/index.tsx index f8b9251f4ff91..8491171e65bca 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/index.tsx @@ -49,22 +49,15 @@ export const TabNavigationComponent: React.FC< setBreadcrumbs( { detailName, - filters: urlState.filters, flowTarget, navTabs, pageName, pathName, - query: urlState.query, - savedQuery: urlState.savedQuery, search, - sourcerer: urlState.sourcerer, state, tabName, - timeline: urlState.timeline, - timerange: urlState.timerange, }, chrome, - getUrlForApp, navigateToUrl ); } @@ -74,7 +67,6 @@ export const TabNavigationComponent: React.FC< pathName, search, navTabs, - urlState, state, detailName, flowTarget, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx index 870ab15906f71..c20cf6414ae5d 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx @@ -45,22 +45,15 @@ export const useSecuritySolutionNavigation = () => { setBreadcrumbs( { detailName, - filters: urlState.filters, flowTarget, navTabs: enabledNavTabs, pageName, pathName, - query: urlState.query, - savedQuery: urlState.savedQuery, search, - sourcerer: urlState.sourcerer, state, tabName, - timeline: urlState.timeline, - timerange: urlState.timerange, }, chrome, - getUrlForApp, navigateToUrl ); } @@ -69,7 +62,6 @@ export const useSecuritySolutionNavigation = () => { pageName, pathName, search, - urlState, state, detailName, flowTarget, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.test.ts deleted file mode 100644 index d405837a4f7f2..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getBreadcrumbs } from './utils'; - -const getUrlForAppMock = (appId: string, options?: { path?: string; absolute?: boolean }) => - `${appId}${options?.path ?? ''}`; - -describe('getBreadcrumbs', () => { - it('Does not render for incorrect params', () => { - expect( - getBreadcrumbs( - { - pageName: 'pageName', - detailName: 'detailName', - tabName: undefined, - search: '', - pathName: 'pathName', - }, - [], - getUrlForAppMock - ) - ).toEqual([]); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts index b4778bb8c24ea..21737d307f3fd 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts @@ -5,19 +5,14 @@ * 2.0. */ -import { isEmpty } from 'lodash/fp'; - import { ChromeBreadcrumb } from '@kbn/core/public'; -import { - getRulesUrl, - getRuleDetailsUrl, -} from '../../../../common/components/link_to/redirect_to_detection_engine'; +import { getRuleDetailsUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; import * as i18nRules from './translations'; import { RouteSpyState } from '../../../../common/utils/route/types'; -import { GetUrlForApp } from '../../../../common/components/navigation/types'; import { SecurityPageName } from '../../../../app/types'; -import { APP_UI_ID, RULES_PATH } from '../../../../../common/constants'; +import { RULES_PATH } from '../../../../../common/constants'; import { RuleStep, RuleStepsOrder } from './types'; +import { GetSecuritySolutionUrl } from '../../../../common/components/link_to'; export const ruleStepsOrder: RuleStepsOrder = [ RuleStep.defineRule, @@ -26,47 +21,26 @@ export const ruleStepsOrder: RuleStepsOrder = [ RuleStep.ruleActions, ]; -const getRulesBreadcrumb = (pathname: string, search: string[], getUrlForApp: GetUrlForApp) => { - const tabPath = pathname.split('/')[1]; - - if (tabPath === 'rules') { - return { - text: i18nRules.PAGE_TITLE, - href: getUrlForApp(APP_UI_ID, { - deepLinkId: SecurityPageName.rules, - path: getRulesUrl(!isEmpty(search[0]) ? search[0] : ''), - }), - }; - } -}; - const isRuleCreatePage = (pathname: string) => pathname.includes(RULES_PATH) && pathname.includes('/create'); const isRuleEditPage = (pathname: string) => pathname.includes(RULES_PATH) && pathname.includes('/edit'); -export const getBreadcrumbs = ( +export const getTrailingBreadcrumbs = ( params: RouteSpyState, - search: string[], - getUrlForApp: GetUrlForApp + getSecuritySolutionUrl: GetSecuritySolutionUrl ): ChromeBreadcrumb[] => { let breadcrumb: ChromeBreadcrumb[] = []; - const rulesBreadcrumb = getRulesBreadcrumb(params.pathName, search, getUrlForApp); - - if (rulesBreadcrumb) { - breadcrumb = [...breadcrumb, rulesBreadcrumb]; - } - if (params.detailName && params.state?.ruleName) { breadcrumb = [ ...breadcrumb, { text: params.state.ruleName, - href: getUrlForApp(APP_UI_ID, { + href: getSecuritySolutionUrl({ deepLinkId: SecurityPageName.rules, - path: getRuleDetailsUrl(params.detailName, !isEmpty(search[0]) ? search[0] : ''), + path: getRuleDetailsUrl(params.detailName, ''), }), }, ]; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/utils.ts b/x-pack/plugins/security_solution/public/hosts/pages/details/utils.ts index 859790b4f342e..061dba0c37358 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/utils.ts +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { get, isEmpty } from 'lodash/fp'; +import { get } from 'lodash/fp'; import { ChromeBreadcrumb } from '@kbn/core/public'; import { hostsModel } from '../../store'; @@ -14,9 +14,8 @@ import { getHostDetailsUrl } from '../../../common/components/link_to/redirect_t import * as i18n from '../translations'; import { HostRouteSpyState } from '../../../common/utils/route/types'; -import { GetUrlForApp } from '../../../common/components/navigation/types'; -import { APP_UI_ID } from '../../../../common/constants'; import { SecurityPageName } from '../../../app/types'; +import { GetSecuritySolutionUrl } from '../../../common/components/link_to'; export const type = hostsModel.HostsType.details; @@ -31,28 +30,19 @@ const TabNameMappedToI18nKey: Record = { [HostsTableType.sessions]: i18n.NAVIGATION_SESSIONS_TITLE, }; -export const getBreadcrumbs = ( +export const getTrailingBreadcrumbs = ( params: HostRouteSpyState, - search: string[], - getUrlForApp: GetUrlForApp + getSecuritySolutionUrl: GetSecuritySolutionUrl ): ChromeBreadcrumb[] => { - let breadcrumb = [ - { - text: i18n.PAGE_TITLE, - href: getUrlForApp(APP_UI_ID, { - path: !isEmpty(search[0]) ? search[0] : '', - deepLinkId: SecurityPageName.hosts, - }), - }, - ]; + let breadcrumb: ChromeBreadcrumb[] = []; if (params.detailName != null) { breadcrumb = [ ...breadcrumb, { text: params.detailName, - href: getUrlForApp(APP_UI_ID, { - path: getHostDetailsUrl(params.detailName, !isEmpty(search[0]) ? search[0] : ''), + href: getSecuritySolutionUrl({ + path: getHostDetailsUrl(params.detailName, ''), deepLinkId: SecurityPageName.hosts, }), }, diff --git a/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts b/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts index 49b4214d60bd6..2fec83e423917 100644 --- a/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts +++ b/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts @@ -20,7 +20,7 @@ const TabNameMappedToI18nKey: Record = { [AdministrationSubTab.blocklist]: BLOCKLIST, }; -export function getBreadcrumbs(params: AdministrationRouteSpyState): ChromeBreadcrumb[] { +export function getTrailingBreadcrumbs(params: AdministrationRouteSpyState): ChromeBreadcrumb[] { return [ ...(params?.tabName ? [params?.tabName] : []).map((tabName) => ({ text: TabNameMappedToI18nKey[tabName], diff --git a/x-pack/plugins/security_solution/public/network/pages/details/index.tsx b/x-pack/plugins/security_solution/public/network/pages/details/index.tsx index e01ab13722bf2..f28798af68dc2 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/index.tsx @@ -50,7 +50,7 @@ import { SecurityPageName } from '../../../app/types'; import { useSourcererDataView } from '../../../common/containers/sourcerer'; import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; import { LandingPageComponent } from '../../../common/components/landing_page'; -export { getBreadcrumbs } from './utils'; +export { getTrailingBreadcrumbs } from './utils'; const NetworkDetailsManage = manageQuery(IpOverview); diff --git a/x-pack/plugins/security_solution/public/network/pages/details/utils.ts b/x-pack/plugins/security_solution/public/network/pages/details/utils.ts index 044c1d22a6348..d0d885fc47a79 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/utils.ts +++ b/x-pack/plugins/security_solution/public/network/pages/details/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { get, isEmpty } from 'lodash/fp'; +import { get } from 'lodash/fp'; import { ChromeBreadcrumb } from '@kbn/core/public'; import { decodeIpv6 } from '../../../common/lib/helpers'; @@ -14,9 +14,8 @@ import { networkModel } from '../../store'; import * as i18n from '../translations'; import { NetworkRouteType } from '../navigation/types'; import { NetworkRouteSpyState } from '../../../common/utils/route/types'; -import { GetUrlForApp } from '../../../common/components/navigation/types'; -import { APP_UI_ID } from '../../../../common/constants'; import { SecurityPageName } from '../../../app/types'; +import { GetSecuritySolutionUrl } from '../../../common/components/link_to'; export const type = networkModel.NetworkType.details; const TabNameMappedToI18nKey: Record = { @@ -28,33 +27,19 @@ const TabNameMappedToI18nKey: Record = { [NetworkRouteType.tls]: i18n.NAVIGATION_TLS_TITLE, }; -export const getBreadcrumbs = ( +export const getTrailingBreadcrumbs = ( params: NetworkRouteSpyState, - search: string[], - getUrlForApp: GetUrlForApp + getSecuritySolutionUrl: GetSecuritySolutionUrl ): ChromeBreadcrumb[] => { - let breadcrumb = [ - { - text: i18n.PAGE_TITLE, - href: getUrlForApp(APP_UI_ID, { - deepLinkId: SecurityPageName.network, - path: !isEmpty(search[0]) ? search[0] : '', - }), - }, - ]; + let breadcrumb: ChromeBreadcrumb[] = []; if (params.detailName != null) { breadcrumb = [ - ...breadcrumb, { text: decodeIpv6(params.detailName), - href: getUrlForApp(APP_UI_ID, { + href: getSecuritySolutionUrl({ deepLinkId: SecurityPageName.network, - path: getNetworkDetailsUrl( - params.detailName, - params.flowTarget, - !isEmpty(search[0]) ? search[0] : '' - ), + path: getNetworkDetailsUrl(params.detailName, params.flowTarget, ''), }), }, ]; diff --git a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx index b2c813087f8db..5ad969adba5cd 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx @@ -5,39 +5,20 @@ * 2.0. */ -import { isEmpty } from 'lodash/fp'; import React from 'react'; import { Switch, Route, Redirect } from 'react-router-dom'; -import { ChromeBreadcrumb } from '@kbn/core/public'; - import { TimelineType } from '../../../common/types/timeline'; -import { TimelineRouteSpyState } from '../../common/utils/route/types'; import { TimelinesPage } from './timelines_page'; -import { PAGE_TITLE } from './translations'; + import { appendSearch } from '../../common/components/link_to/helpers'; -import { GetUrlForApp } from '../../common/components/navigation/types'; -import { APP_UI_ID, TIMELINES_PATH } from '../../../common/constants'; -import { SecurityPageName } from '../../app/types'; + +import { TIMELINES_PATH } from '../../../common/constants'; const timelinesPagePath = `${TIMELINES_PATH}/:tabName(${TimelineType.default}|${TimelineType.template})`; const timelinesDefaultPath = `${TIMELINES_PATH}/${TimelineType.default}`; -export const getBreadcrumbs = ( - params: TimelineRouteSpyState, - search: string[], - getUrlForApp: GetUrlForApp -): ChromeBreadcrumb[] => [ - { - text: PAGE_TITLE, - href: getUrlForApp(APP_UI_ID, { - deepLinkId: SecurityPageName.timelines, - path: !isEmpty(search[0]) ? search[0] : '', - }), - }, -]; - export const Timelines = React.memo(() => ( diff --git a/x-pack/plugins/security_solution/public/users/pages/details/utils.ts b/x-pack/plugins/security_solution/public/users/pages/details/utils.ts index 26ed75997a85d..a9b3cb30ef84a 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/utils.ts +++ b/x-pack/plugins/security_solution/public/users/pages/details/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { get, isEmpty } from 'lodash/fp'; +import { get } from 'lodash/fp'; import { ChromeBreadcrumb } from '@kbn/core/public'; import { usersModel } from '../../store'; @@ -14,9 +14,8 @@ import { getUsersDetailsUrl } from '../../../common/components/link_to/redirect_ import * as i18n from '../translations'; import { UsersRouteSpyState } from '../../../common/utils/route/types'; -import { GetUrlForApp } from '../../../common/components/navigation/types'; -import { APP_UI_ID } from '../../../../common/constants'; import { SecurityPageName } from '../../../app/types'; +import { GetSecuritySolutionUrl } from '../../../common/components/link_to'; export const type = usersModel.UsersType.details; @@ -30,28 +29,18 @@ const TabNameMappedToI18nKey: Record = { [UsersTableType.risk]: i18n.NAVIGATION_RISK_TITLE, }; -export const getBreadcrumbs = ( +export const getTrailingBreadcrumbs = ( params: UsersRouteSpyState, - search: string[], - getUrlForApp: GetUrlForApp + getSecuritySolutionUrl: GetSecuritySolutionUrl ): ChromeBreadcrumb[] => { - let breadcrumb = [ - { - text: i18n.PAGE_TITLE, - href: getUrlForApp(APP_UI_ID, { - path: !isEmpty(search[0]) ? search[0] : '', - deepLinkId: SecurityPageName.users, - }), - }, - ]; + let breadcrumb: ChromeBreadcrumb[] = []; if (params.detailName != null) { breadcrumb = [ - ...breadcrumb, { text: params.detailName, - href: getUrlForApp(APP_UI_ID, { - path: getUsersDetailsUrl(params.detailName, !isEmpty(search[0]) ? search[0] : ''), + href: getSecuritySolutionUrl({ + path: getUsersDetailsUrl(params.detailName, ''), deepLinkId: SecurityPageName.users, }), }, From e0944d17ece72cdddbdf07b41f5209e9ffda048c Mon Sep 17 00:00:00 2001 From: Nodir Latipov Date: Mon, 23 May 2022 13:27:24 +0500 Subject: [PATCH 140/150] [Unified search] Use the DataViews service (#130008) * feat: cleanup deprecated service and type * fix: rollback test * refact: replace deprecated type * refact: changed deprecation type * feat: added comments to deprecated imports that can't be cleaned up in this PR * refact: rollback query_string_input.test file --- .../public/actions/apply_filter_action.ts | 4 +++- .../apply_filters/apply_filter_popover_content.tsx | 2 +- .../public/apply_filters/apply_filters_popover.tsx | 2 +- .../providers/kql_query_suggestion/conjunction.test.ts | 2 +- .../providers/kql_query_suggestion/field.test.ts | 3 ++- .../providers/kql_query_suggestion/field.tsx | 1 + .../providers/kql_query_suggestion/operator.test.ts | 2 +- .../providers/kql_query_suggestion/value.test.ts | 2 +- .../providers/kql_query_suggestion/value.ts | 4 +++- .../providers/query_suggestion_provider.ts | 5 +++-- .../providers/value_suggestion_provider.ts | 10 ++++------ .../public/filter_bar/filter_editor/index.tsx | 8 ++++---- .../filter_editor/lib/filter_editor_utils.test.ts | 2 +- .../filter_editor/lib/filter_editor_utils.ts | 10 +++++----- .../filter_bar/filter_editor/lib/filter_label.tsx | 2 +- .../filter_bar/filter_editor/lib/filter_operators.ts | 8 ++++---- .../filter_bar/filter_editor/phrase_suggestor.tsx | 6 +++--- .../filter_bar/filter_editor/range_value_input.tsx | 4 ++-- .../filter_bar/filter_editor/value_input_type.tsx | 4 ++-- .../create_index_pattern_select.tsx | 4 ++-- .../index_pattern_select/index_pattern_select.tsx | 4 ++-- .../public/search_bar/create_search_bar.tsx | 3 ++- .../public/search_bar/lib/use_filter_manager.ts | 3 ++- .../public/test_helpers/get_stub_filter.ts | 3 ++- .../unified_search/public/utils/helpers.test.ts | 4 ++-- src/plugins/unified_search/public/utils/helpers.ts | 4 ++-- .../server/autocomplete/terms_agg.test.ts | 5 ++++- .../unified_search/server/autocomplete/terms_agg.ts | 9 +++++---- .../server/autocomplete/terms_enum.test.ts | 9 ++++++++- .../unified_search/server/autocomplete/terms_enum.ts | 4 ++-- 30 files changed, 76 insertions(+), 57 deletions(-) diff --git a/src/plugins/unified_search/public/actions/apply_filter_action.ts b/src/plugins/unified_search/public/actions/apply_filter_action.ts index 36524cf3ff826..465d6d33890de 100644 --- a/src/plugins/unified_search/public/actions/apply_filter_action.ts +++ b/src/plugins/unified_search/public/actions/apply_filter_action.ts @@ -10,7 +10,9 @@ import { i18n } from '@kbn/i18n'; import { ThemeServiceSetup } from '@kbn/core/public'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { Action, createAction, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; -import { Filter, FilterManager, TimefilterContract, esFilters } from '@kbn/data-plugin/public'; +// for cleanup esFilters need to fix the issue https://github.com/elastic/kibana/issues/131292 +import { FilterManager, TimefilterContract, esFilters } from '@kbn/data-plugin/public'; +import type { Filter } from '@kbn/es-query'; import { getOverlays, getIndexPatterns } from '../services'; import { applyFiltersPopover } from '../apply_filters'; diff --git a/src/plugins/unified_search/public/apply_filters/apply_filter_popover_content.tsx b/src/plugins/unified_search/public/apply_filters/apply_filter_popover_content.tsx index 9017fbf40ee2f..8119127e87e2c 100644 --- a/src/plugins/unified_search/public/apply_filters/apply_filter_popover_content.tsx +++ b/src/plugins/unified_search/public/apply_filters/apply_filter_popover_content.tsx @@ -24,7 +24,7 @@ import { mapAndFlattenFilters, getFieldDisplayValueFromFilter, } from '@kbn/data-plugin/public'; -import { Filter } from '@kbn/data-plugin/common'; +import type { Filter } from '@kbn/es-query'; import { DataView } from '@kbn/data-views-plugin/public'; import { FilterLabel } from '../filter_bar'; diff --git a/src/plugins/unified_search/public/apply_filters/apply_filters_popover.tsx b/src/plugins/unified_search/public/apply_filters/apply_filters_popover.tsx index 4cefbd1a202a0..8c515ae4e6d78 100644 --- a/src/plugins/unified_search/public/apply_filters/apply_filters_popover.tsx +++ b/src/plugins/unified_search/public/apply_filters/apply_filters_popover.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { Filter } from '@kbn/data-plugin/common'; +import type { Filter } from '@kbn/es-query'; import { DataView } from '@kbn/data-views-plugin/common'; type CancelFnType = () => void; diff --git a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts index 24a27bcb99fbe..d553538329874 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts +++ b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts @@ -7,7 +7,7 @@ */ import { coreMock } from '@kbn/core/public/mocks'; -import { KueryNode } from '@kbn/data-plugin/public'; +import type { KueryNode } from '@kbn/es-query'; import { setupGetConjunctionSuggestions } from './conjunction'; import { QuerySuggestionGetFnArgs } from '../query_suggestion_provider'; diff --git a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/field.test.ts b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/field.test.ts index 4446fcf685bde..085ba3dc0979f 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/field.test.ts +++ b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/field.test.ts @@ -8,7 +8,8 @@ import indexPatternResponse from './__fixtures__/index_pattern_response.json'; -import { indexPatterns as indexPatternsUtils, KueryNode } from '@kbn/data-plugin/public'; +import { indexPatterns as indexPatternsUtils } from '@kbn/data-plugin/public'; +import type { KueryNode } from '@kbn/es-query'; import { setupGetFieldSuggestions } from './field'; import { QuerySuggestionGetFnArgs } from '../query_suggestion_provider'; import { coreMock } from '@kbn/core/public/mocks'; diff --git a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/field.tsx b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/field.tsx index 723b7e6896229..37f9c4658b81a 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/field.tsx +++ b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/field.tsx @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +// for replace IFieldType => DataViewField need to fix the issue https://github.com/elastic/kibana/issues/131292 import { IFieldType, indexPatterns as indexPatternsUtils } from '@kbn/data-plugin/public'; import { flatten } from 'lodash'; import { sortPrefixFirst } from './sort_prefix_first'; diff --git a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/operator.test.ts b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/operator.test.ts index a40678ad4ac16..7e2340fdb043a 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/operator.test.ts +++ b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/operator.test.ts @@ -9,7 +9,7 @@ import indexPatternResponse from './__fixtures__/index_pattern_response.json'; import { setupGetOperatorSuggestions } from './operator'; -import { KueryNode } from '@kbn/data-plugin/public'; +import type { KueryNode } from '@kbn/es-query'; import { QuerySuggestionGetFnArgs } from '../query_suggestion_provider'; import { coreMock } from '@kbn/core/public/mocks'; diff --git a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/value.test.ts b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/value.test.ts index 3405d26824a26..e852e8e11f347 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/value.test.ts +++ b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/value.test.ts @@ -10,7 +10,7 @@ import { setupGetValueSuggestions } from './value'; import indexPatternResponse from './__fixtures__/index_pattern_response.json'; import { coreMock } from '@kbn/core/public/mocks'; -import { KueryNode } from '@kbn/data-plugin/public'; +import type { KueryNode } from '@kbn/es-query'; import { QuerySuggestionGetFnArgs } from '../query_suggestion_provider'; const mockKueryNode = (kueryNode: Partial) => kueryNode as unknown as KueryNode; diff --git a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/value.ts b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/value.ts index 06b0fc9639a3c..0bbf416d99a2e 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/value.ts +++ b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/value.ts @@ -8,7 +8,9 @@ import { flatten } from 'lodash'; import { CoreSetup } from '@kbn/core/public'; -import { IFieldType, IIndexPattern } from '@kbn/data-plugin/public'; +// for replace IIndexPattern => DataView and IFieldType => DataViewField +// need to fix the issue https://github.com/elastic/kibana/issues/131292 +import type { IIndexPattern, IFieldType } from '@kbn/data-views-plugin/common'; import { escapeQuotes } from './lib/escape_kuery'; import { KqlQuerySuggestionProvider } from './types'; import type { UnifiedSearchPublicPluginStart } from '../../../types'; diff --git a/src/plugins/unified_search/public/autocomplete/providers/query_suggestion_provider.ts b/src/plugins/unified_search/public/autocomplete/providers/query_suggestion_provider.ts index 056fcb716054a..2e0e5c793f82f 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/query_suggestion_provider.ts +++ b/src/plugins/unified_search/public/autocomplete/providers/query_suggestion_provider.ts @@ -7,7 +7,8 @@ */ import { ValueSuggestionsMethod } from '@kbn/data-plugin/common'; -import { IFieldType, IIndexPattern } from '@kbn/data-plugin/common'; +// for replace IIndexPattern => DataView need to fix the issue https://github.com/elastic/kibana/issues/131292 +import type { DataViewField, IIndexPattern } from '@kbn/data-views-plugin/common'; export enum QuerySuggestionTypes { Field = 'field', @@ -47,7 +48,7 @@ export interface QuerySuggestionBasic { /** @public **/ export interface QuerySuggestionField extends QuerySuggestionBasic { type: QuerySuggestionTypes.Field; - field: IFieldType; + field: DataViewField; } /** @public **/ diff --git a/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts b/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts index 2c25fe0230501..8d08a9de2577d 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts +++ b/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts @@ -9,12 +9,10 @@ import { CoreSetup } from '@kbn/core/public'; import dateMath from '@kbn/datemath'; import { memoize } from 'lodash'; -import { - IIndexPattern, - IFieldType, - UI_SETTINGS, - ValueSuggestionsMethod, -} from '@kbn/data-plugin/common'; +import { UI_SETTINGS, ValueSuggestionsMethod } from '@kbn/data-plugin/common'; +// for replace IIndexPattern => DataView and IFieldType => DataViewField +// need to fix the issue https://github.com/elastic/kibana/issues/131292 +import type { IIndexPattern, IFieldType } from '@kbn/data-views-plugin/common'; import type { TimefilterSetup } from '@kbn/data-plugin/public'; import { AutocompleteUsageCollector } from '../collectors'; diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/index.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/index.tsx index 490d6480b28c9..6a3d7192ab905 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/index.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/index.tsx @@ -33,7 +33,7 @@ import { import { get } from 'lodash'; import React, { Component } from 'react'; import { XJsonLang } from '@kbn/monaco'; -import { DataView, IFieldType } from '@kbn/data-views-plugin/common'; +import { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { getIndexPatternFromFilter } from '@kbn/data-plugin/public'; import { CodeEditor } from '@kbn/kibana-react-plugin/public'; import { GenericComboBox, GenericComboBoxProps } from './generic_combo_box'; @@ -61,7 +61,7 @@ export interface Props { interface State { selectedIndexPattern?: DataView; - selectedField?: IFieldType; + selectedField?: DataViewField; selectedOperator?: Operator; params: any; useCustomLabel: boolean; @@ -447,7 +447,7 @@ class FilterEditorUI extends Component { this.setState({ selectedIndexPattern, selectedField, selectedOperator, params }); }; - private onFieldChange = ([selectedField]: IFieldType[]) => { + private onFieldChange = ([selectedField]: DataViewField[]) => { const selectedOperator = undefined; const params = undefined; this.setState({ selectedField, selectedOperator, params }); @@ -529,7 +529,7 @@ function IndexPatternComboBox(props: GenericComboBoxProps) { return GenericComboBox(props); } -function FieldComboBox(props: GenericComboBoxProps) { +function FieldComboBox(props: GenericComboBoxProps) { return GenericComboBox(props); } diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.test.ts b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.test.ts index d6c44228eb72f..07ce05d039582 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.test.ts +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.test.ts @@ -14,7 +14,7 @@ import { stubIndexPattern, stubFields, } from '@kbn/data-plugin/common/stubs'; -import { toggleFilterNegated } from '@kbn/data-plugin/common'; +import { toggleFilterNegated } from '@kbn/es-query'; import { getFieldFromFilter, getFilterableFields, diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.ts b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.ts index f85b9a9e788d8..0863d10fe0c10 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.ts +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.ts @@ -10,8 +10,8 @@ import dateMath from '@kbn/datemath'; import { Filter, FieldFilter } from '@kbn/es-query'; import { ES_FIELD_TYPES } from '@kbn/field-types'; import isSemverValid from 'semver/functions/valid'; -import { isFilterable, IFieldType, IpAddress } from '@kbn/data-plugin/common'; -import { DataView } from '@kbn/data-views-plugin/common'; +import { isFilterable, IpAddress } from '@kbn/data-plugin/common'; +import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { FILTER_OPERATORS, Operator } from './filter_operators'; export function getFieldFromFilter(filter: FieldFilter, indexPattern: DataView) { @@ -28,7 +28,7 @@ export function getFilterableFields(indexPattern: DataView) { return indexPattern.fields.filter(isFilterable); } -export function getOperatorOptions(field: IFieldType) { +export function getOperatorOptions(field: DataViewField) { return FILTER_OPERATORS.filter((operator) => { if (operator.field) return operator.field(field); if (operator.fieldTypes) return operator.fieldTypes.includes(field.type); @@ -36,7 +36,7 @@ export function getOperatorOptions(field: IFieldType) { }); } -export function validateParams(params: any, field: IFieldType) { +export function validateParams(params: any, field: DataViewField) { switch (field.type) { case 'date': const moment = typeof params === 'string' ? dateMath.parse(params) : null; @@ -59,7 +59,7 @@ export function validateParams(params: any, field: IFieldType) { export function isFilterValid( indexPattern?: DataView, - field?: IFieldType, + field?: DataViewField, operator?: Operator, params?: any ) { diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_label.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_label.tsx index 601cf68141c49..35c05316465f8 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_label.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_label.tsx @@ -9,7 +9,7 @@ import React, { Fragment } from 'react'; import { EuiTextColor } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Filter, FILTERS } from '@kbn/data-plugin/common'; +import { Filter, FILTERS } from '@kbn/es-query'; import { existsOperator, isOneOfOperator } from './filter_operators'; import type { FilterLabelStatus } from '../../filter_item/filter_item'; diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_operators.ts b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_operators.ts index c1e4d5361e3f8..6143158d69d5c 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_operators.ts +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_operators.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { FILTERS } from '@kbn/es-query'; import { ES_FIELD_TYPES } from '@kbn/field-types'; -import { IFieldType } from '@kbn/data-views-plugin/common'; +import { DataViewField } from '@kbn/data-views-plugin/common'; export interface Operator { message: string; @@ -25,7 +25,7 @@ export interface Operator { * A filter predicate for a field, * takes precedence over {@link fieldTypes} */ - field?: (field: IFieldType) => boolean; + field?: (field: DataViewField) => boolean; } export const isOperator = { @@ -68,7 +68,7 @@ export const isBetweenOperator = { }), type: FILTERS.RANGE, negate: false, - field: (field: IFieldType) => { + field: (field: DataViewField) => { if (['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'].includes(field.type)) return true; @@ -84,7 +84,7 @@ export const isNotBetweenOperator = { }), type: FILTERS.RANGE, negate: true, - field: (field: IFieldType) => { + field: (field: DataViewField) => { if (['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'].includes(field.type)) return true; diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_suggestor.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_suggestor.tsx index 50acadea2a990..dc987421e2661 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_suggestor.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_suggestor.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { withKibana, KibanaReactContextValue } from '@kbn/kibana-react-plugin/public'; -import { IFieldType, UI_SETTINGS } from '@kbn/data-plugin/common'; -import { DataView } from '@kbn/data-views-plugin/common'; +import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { IDataPluginServices } from '@kbn/data-plugin/public'; import { debounce } from 'lodash'; @@ -18,7 +18,7 @@ import { getAutocomplete } from '../../services'; export interface PhraseSuggestorProps { kibana: KibanaReactContextValue; indexPattern: DataView; - field: IFieldType; + field: DataViewField; timeRangeForSuggestionsOverride?: boolean; } diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/range_value_input.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/range_value_input.tsx index 3c1046d928981..26a25886ac866 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/range_value_input.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/range_value_input.tsx @@ -12,7 +12,7 @@ import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; import { get } from 'lodash'; import React from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { IFieldType } from '@kbn/data-plugin/common'; +import type { DataViewField } from '@kbn/data-views-plugin/common'; import { ValueInputType } from './value_input_type'; interface RangeParams { @@ -23,7 +23,7 @@ interface RangeParams { type RangeParamsPartial = Partial; interface Props { - field: IFieldType; + field: DataViewField; value?: RangeParams; onChange: (params: RangeParamsPartial) => void; intl: InjectedIntl; diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/value_input_type.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/value_input_type.tsx index 1e50e92cec7bb..a87888ed85c93 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/value_input_type.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/value_input_type.tsx @@ -10,12 +10,12 @@ import { EuiFieldNumber, EuiFieldText, EuiSelect } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; import { isEmpty } from 'lodash'; import React, { Component } from 'react'; -import { IFieldType } from '@kbn/data-views-plugin/common'; +import type { DataViewField } from '@kbn/data-views-plugin/common'; import { validateParams } from './lib/filter_editor_utils'; interface Props { value?: string | number; - field: IFieldType; + field: DataViewField; onChange: (value: string | number | boolean) => void; onBlur?: (value: string | number | boolean) => void; placeholder: string; diff --git a/src/plugins/unified_search/public/index_pattern_select/create_index_pattern_select.tsx b/src/plugins/unified_search/public/index_pattern_select/create_index_pattern_select.tsx index 04b15aac84778..4dc7dc0f3b57b 100644 --- a/src/plugins/unified_search/public/index_pattern_select/create_index_pattern_select.tsx +++ b/src/plugins/unified_search/public/index_pattern_select/create_index_pattern_select.tsx @@ -8,11 +8,11 @@ import React from 'react'; -import { IndexPatternsContract } from '@kbn/data-plugin/public'; +import type { DataViewsContract } from '@kbn/data-views-plugin/public'; import { IndexPatternSelect, IndexPatternSelectProps } from '.'; // Takes in stateful runtime dependencies and pre-wires them to the component -export function createIndexPatternSelect(indexPatternService: IndexPatternsContract) { +export function createIndexPatternSelect(indexPatternService: DataViewsContract) { return (props: IndexPatternSelectProps) => ( ); diff --git a/src/plugins/unified_search/public/index_pattern_select/index_pattern_select.tsx b/src/plugins/unified_search/public/index_pattern_select/index_pattern_select.tsx index 335787d2ee38a..81534575d10b1 100644 --- a/src/plugins/unified_search/public/index_pattern_select/index_pattern_select.tsx +++ b/src/plugins/unified_search/public/index_pattern_select/index_pattern_select.tsx @@ -11,7 +11,7 @@ import React, { Component } from 'react'; import { Required } from '@kbn/utility-types'; import { EuiComboBox, EuiComboBoxProps } from '@elastic/eui'; -import { IndexPatternsContract } from '@kbn/data-plugin/public'; +import type { DataViewsContract } from '@kbn/data-views-plugin/public'; export type IndexPatternSelectProps = Required< Omit< @@ -26,7 +26,7 @@ export type IndexPatternSelectProps = Required< }; export type IndexPatternSelectInternalProps = IndexPatternSelectProps & { - indexPatternService: IndexPatternsContract; + indexPatternService: DataViewsContract; }; interface IndexPatternSelectState { diff --git a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx index c73aa258863ed..a90098ebcf156 100644 --- a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx @@ -12,7 +12,8 @@ import { CoreStart } from '@kbn/core/public'; import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { QueryStart, SavedQuery, DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { Filter, Query, TimeRange } from '@kbn/data-plugin/common'; +import { Query, TimeRange } from '@kbn/data-plugin/common'; +import type { Filter } from '@kbn/es-query'; import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import { SearchBar } from '.'; import type { SearchBarOwnProps } from '.'; diff --git a/src/plugins/unified_search/public/search_bar/lib/use_filter_manager.ts b/src/plugins/unified_search/public/search_bar/lib/use_filter_manager.ts index 511e05e043b26..a6d0487cb90c7 100644 --- a/src/plugins/unified_search/public/search_bar/lib/use_filter_manager.ts +++ b/src/plugins/unified_search/public/search_bar/lib/use_filter_manager.ts @@ -8,7 +8,8 @@ import { useState, useEffect } from 'react'; import { Subscription } from 'rxjs'; -import { DataPublicPluginStart, Filter } from '@kbn/data-plugin/public'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { Filter } from '@kbn/es-query'; interface UseFilterManagerProps { filters?: Filter[]; diff --git a/src/plugins/unified_search/public/test_helpers/get_stub_filter.ts b/src/plugins/unified_search/public/test_helpers/get_stub_filter.ts index 2954526d7ede8..10444d1d19055 100644 --- a/src/plugins/unified_search/public/test_helpers/get_stub_filter.ts +++ b/src/plugins/unified_search/public/test_helpers/get_stub_filter.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { Filter, FilterStateStore } from '@kbn/data-plugin/public'; +import { FilterStateStore } from '@kbn/data-plugin/public'; +import type { Filter } from '@kbn/es-query'; export function getFilter( store: FilterStateStore, diff --git a/src/plugins/unified_search/public/utils/helpers.test.ts b/src/plugins/unified_search/public/utils/helpers.test.ts index 4659e35602228..803d6c53bb007 100644 --- a/src/plugins/unified_search/public/utils/helpers.test.ts +++ b/src/plugins/unified_search/public/utils/helpers.test.ts @@ -7,11 +7,11 @@ */ import { getFieldValidityAndErrorMessage } from './helpers'; -import { IFieldType } from '@kbn/data-views-plugin/common'; +import { DataViewField } from '@kbn/data-views-plugin/common'; const mockField = { type: 'date', -} as IFieldType; +} as DataViewField; describe('Check field validity and error message', () => { it('should return a message that the entered date is not incorrect', () => { diff --git a/src/plugins/unified_search/public/utils/helpers.ts b/src/plugins/unified_search/public/utils/helpers.ts index 1c056636c67b8..6f0a605fa0e14 100644 --- a/src/plugins/unified_search/public/utils/helpers.ts +++ b/src/plugins/unified_search/public/utils/helpers.ts @@ -6,14 +6,14 @@ * Side Public License, v 1. */ -import type { IFieldType } from '@kbn/data-views-plugin/common'; +import type { DataViewField } from '@kbn/data-views-plugin/common'; import { i18n } from '@kbn/i18n'; import { KBN_FIELD_TYPES } from '@kbn/data-plugin/public'; import { isEmpty } from 'lodash'; import { validateParams } from '../filter_bar/filter_editor/lib/filter_editor_utils'; export const getFieldValidityAndErrorMessage = ( - field: IFieldType, + field: DataViewField, value?: string | undefined ): { isInvalid: boolean; errorMessage?: string } => { const type = field.type; diff --git a/src/plugins/unified_search/server/autocomplete/terms_agg.test.ts b/src/plugins/unified_search/server/autocomplete/terms_agg.test.ts index f27fa9d594f97..03ceffc73b34f 100644 --- a/src/plugins/unified_search/server/autocomplete/terms_agg.test.ts +++ b/src/plugins/unified_search/server/autocomplete/terms_agg.test.ts @@ -10,6 +10,7 @@ import { coreMock } from '@kbn/core/server/mocks'; import { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; import { ConfigSchema } from '../../config'; import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import type { DataViewField } from '@kbn/data-views-plugin/common'; import { termsAggSuggestions } from './terms_agg'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { duration } from 'moment'; @@ -22,6 +23,8 @@ const configMock = { }, } as unknown as ConfigSchema; +const dataViewFieldMock = { name: 'field_name', type: 'string' } as DataViewField; + // @ts-expect-error not full interface const mockResponse = { aggregations: { @@ -50,7 +53,7 @@ describe('terms agg suggestions', () => { 'fieldName', 'query', [], - { name: 'field_name', type: 'string' } + dataViewFieldMock ); const [[args]] = esClientMock.search.mock.calls; diff --git a/src/plugins/unified_search/server/autocomplete/terms_agg.ts b/src/plugins/unified_search/server/autocomplete/terms_agg.ts index ffdaca8caad4b..c7d303e526ca8 100644 --- a/src/plugins/unified_search/server/autocomplete/terms_agg.ts +++ b/src/plugins/unified_search/server/autocomplete/terms_agg.ts @@ -9,7 +9,8 @@ import { get, map } from 'lodash'; import { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { IFieldType, getFieldSubtypeNested } from '@kbn/data-plugin/common'; +import { getFieldSubtypeNested } from '@kbn/data-plugin/common'; +import type { FieldSpec } from '@kbn/data-views-plugin/common'; import { ConfigSchema } from '../../config'; import { findIndexPatternById, getFieldByName } from '../data_views'; @@ -21,7 +22,7 @@ export async function termsAggSuggestions( fieldName: string, query: string, filters?: estypes.QueryDslQueryContainer[], - field?: IFieldType, + field?: FieldSpec, abortSignal?: AbortSignal ) { const autocompleteSearchOptions = { @@ -54,11 +55,11 @@ export async function termsAggSuggestions( async function getBody( // eslint-disable-next-line @typescript-eslint/naming-convention { timeout, terminate_after }: Record, - field: IFieldType | string, + field: FieldSpec | string, query: string, filters: estypes.QueryDslQueryContainer[] = [] ) { - const isFieldObject = (f: any): f is IFieldType => Boolean(f && f.name); + const isFieldObject = (f: any): f is FieldSpec => Boolean(f && f.name); // https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html#_standard_operators const getEscapedQuery = (q: string = '') => diff --git a/src/plugins/unified_search/server/autocomplete/terms_enum.test.ts b/src/plugins/unified_search/server/autocomplete/terms_enum.test.ts index bc2a4e010a765..f0209e66ee58d 100644 --- a/src/plugins/unified_search/server/autocomplete/terms_enum.test.ts +++ b/src/plugins/unified_search/server/autocomplete/terms_enum.test.ts @@ -12,12 +12,19 @@ import { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/serve import { ConfigSchema } from '../../config'; import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; import { TermsEnumResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { DataViewField } from '@kbn/data-views-plugin/common'; let savedObjectsClientMock: jest.Mocked; let esClientMock: DeeplyMockedKeys; const configMock = { autocomplete: { valueSuggestions: { tiers: ['data_hot', 'data_warm', 'data_content'] } }, } as ConfigSchema; +const dataViewFieldMock = { + name: 'field_name', + type: 'string', + searchable: true, + aggregatable: true, +} as DataViewField; const mockResponse = { terms: ['whoa', 'amazing'] }; jest.mock('../data_views'); @@ -39,7 +46,7 @@ describe('_terms_enum suggestions', () => { 'fieldName', 'query', [], - { name: 'field_name', type: 'string', searchable: true, aggregatable: true } + dataViewFieldMock ); const [[args]] = esClientMock.termsEnum.mock.calls; diff --git a/src/plugins/unified_search/server/autocomplete/terms_enum.ts b/src/plugins/unified_search/server/autocomplete/terms_enum.ts index 924b5b3a1671e..3e8207eb644e5 100644 --- a/src/plugins/unified_search/server/autocomplete/terms_enum.ts +++ b/src/plugins/unified_search/server/autocomplete/terms_enum.ts @@ -8,7 +8,7 @@ import { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { IFieldType } from '@kbn/data-plugin/common'; +import type { FieldSpec } from '@kbn/data-views-plugin/common'; import { findIndexPatternById, getFieldByName } from '../data_views'; import { ConfigSchema } from '../../config'; @@ -20,7 +20,7 @@ export async function termsEnumSuggestions( fieldName: string, query: string, filters?: estypes.QueryDslQueryContainer[], - field?: IFieldType, + field?: FieldSpec, abortSignal?: AbortSignal ) { const { tiers } = config.autocomplete.valueSuggestions; From ae8b6c8beb3ad0b3f30de3f520fdf9dcb4e23e01 Mon Sep 17 00:00:00 2001 From: "Lucas F. da Costa" Date: Mon, 23 May 2022 09:29:11 +0100 Subject: [PATCH 141/150] [Uptime] Fix bug causing all monitors to be saved to all locations [solves #132314] (#132325) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../formatters/format_configs.test.ts | 2 + .../formatters/format_configs.ts | 1 - .../synthetics_service.test.ts | 72 ++++++++++++++++++- .../synthetics_service/synthetics_service.ts | 4 +- 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.test.ts b/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.test.ts index c30d9af766b48..48d052d35a1f8 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.test.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.test.ts @@ -53,6 +53,7 @@ describe('formatMonitorConfig', () => { expect(yamlConfig).toEqual({ 'check.request.method': 'GET', enabled: true, + locations: [], max_redirects: '0', name: 'Test', password: '3z9SBOQWW5F0UrdqLVFqlF6z', @@ -110,6 +111,7 @@ describe('formatMonitorConfig', () => { 'filter_journeys.tags': ['dev'], ignore_https_errors: false, name: 'Test', + locations: [], schedule: '@every 3m', screenshots: 'on', 'source.inline.script': diff --git a/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.ts b/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.ts index e2a1bf1b869ed..ea298992d2246 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.ts @@ -15,7 +15,6 @@ const UI_KEYS_TO_SKIP = [ ConfigKey.DOWNLOAD_SPEED, ConfigKey.LATENCY, ConfigKey.IS_THROTTLING_ENABLED, - ConfigKey.LOCATIONS, ConfigKey.REVISION, 'secrets', ]; diff --git a/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.test.ts b/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.test.ts index 305f1d15a4823..952e18ce9c884 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.test.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.test.ts @@ -5,10 +5,13 @@ * 2.0. */ -import { SyntheticsService } from './synthetics_service'; +jest.mock('axios', () => jest.fn()); + +import { SyntheticsService, SyntheticsConfig } from './synthetics_service'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { loggerMock } from '@kbn/core/server/logging/logger.mock'; import { UptimeServerSetup } from '../legacy_uptime/lib/adapters'; +import axios, { AxiosResponse } from 'axios'; describe('SyntheticsService', () => { const mockEsClient = { @@ -67,4 +70,71 @@ describe('SyntheticsService', () => { }, ]); }); + + describe('addConfig', () => { + afterEach(() => jest.restoreAllMocks()); + + it('saves configs only to the selected locations', async () => { + serverMock.config = { service: { devUrl: 'http://localhost' } }; + const service = new SyntheticsService(logger, serverMock, { + username: 'dev', + password: '12345', + }); + + service.apiClient.locations = [ + { + id: 'selected', + label: 'Selected Location', + url: 'example.com/1', + geo: { + lat: 0, + lon: 0, + }, + isServiceManaged: true, + }, + { + id: 'not selected', + label: 'Not Selected Location', + url: 'example.com/2', + geo: { + lat: 0, + lon: 0, + }, + isServiceManaged: true, + }, + ]; + + jest.spyOn(service, 'getApiKey').mockResolvedValue({ name: 'example', id: 'i', apiKey: 'k' }); + jest.spyOn(service, 'getOutput').mockResolvedValue({ hosts: ['es'], api_key: 'i:k' }); + + const payload = { + type: 'http', + enabled: true, + schedule: { + number: '3', + unit: 'm', + }, + name: 'my mon', + locations: [{ id: 'selected', isServiceManaged: true }], + urls: 'http://google.com', + max_redirects: '0', + password: '', + proxy_url: '', + id: '7af7e2f0-d5dc-11ec-87ac-bdfdb894c53d', + fields: { config_id: '7af7e2f0-d5dc-11ec-87ac-bdfdb894c53d' }, + fields_under_root: true, + }; + + (axios as jest.MockedFunction).mockResolvedValue({} as AxiosResponse); + + await service.addConfig(payload as SyntheticsConfig); + + expect(axios).toHaveBeenCalledTimes(1); + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'example.com/1/monitors', + }) + ); + }); + }); }); diff --git a/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.ts b/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.ts index f655dd6d4cc8c..b1af1717e1a1c 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.ts @@ -48,7 +48,7 @@ const SYNTHETICS_SERVICE_SYNC_MONITORS_TASK_TYPE = const SYNTHETICS_SERVICE_SYNC_MONITORS_TASK_ID = 'UPTIME:SyntheticsService:sync-task'; const SYNTHETICS_SERVICE_SYNC_INTERVAL_DEFAULT = '5m'; -type SyntheticsConfig = SyntheticsMonitorWithId & { +export type SyntheticsConfig = SyntheticsMonitorWithId & { fields_under_root?: boolean; fields?: { config_id: string; run_once?: boolean; test_run_id?: string }; }; @@ -56,7 +56,7 @@ type SyntheticsConfig = SyntheticsMonitorWithId & { export class SyntheticsService { private logger: Logger; private readonly server: UptimeServerSetup; - private apiClient: ServiceAPIClient; + public apiClient: ServiceAPIClient; private readonly config: ServiceConfig; private readonly esHosts: string[]; From 37d40d7343510a6eb5457ad123b700882f46f627 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Mon, 23 May 2022 04:56:34 -0400 Subject: [PATCH 142/150] [Synthetics] fix browser type as default in monitor management (#132572) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../synthetics/e2e/journeys/monitor_details.journey.ts | 4 ++-- .../synthetics/e2e/journeys/monitor_name.journey.ts | 8 ++++---- .../synthetics/e2e/page_objects/monitor_management.tsx | 1 + .../fleet_package/contexts/policy_config_context.tsx | 4 ++-- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/synthetics/e2e/journeys/monitor_details.journey.ts b/x-pack/plugins/synthetics/e2e/journeys/monitor_details.journey.ts index 192fcf06c3095..3ddf0cebd0cf3 100644 --- a/x-pack/plugins/synthetics/e2e/journeys/monitor_details.journey.ts +++ b/x-pack/plugins/synthetics/e2e/journeys/monitor_details.journey.ts @@ -40,12 +40,12 @@ journey('MonitorDetails', async ({ page, params }: { page: Page; params: any }) step('create basic monitor', async () => { await uptime.enableMonitorManagement(); await uptime.clickAddMonitor(); - await uptime.createBasicMonitorDetails({ + await uptime.createBasicHTTPMonitorDetails({ name, locations: ['US Central'], apmServiceName: 'synthetics', + url: 'https://www.google.com', }); - await uptime.fillByTestSubj('syntheticsUrlField', 'https://www.google.com'); await uptime.confirmAndSave(); }); diff --git a/x-pack/plugins/synthetics/e2e/journeys/monitor_name.journey.ts b/x-pack/plugins/synthetics/e2e/journeys/monitor_name.journey.ts index a21627548aeb1..a9dd2c4633402 100644 --- a/x-pack/plugins/synthetics/e2e/journeys/monitor_name.journey.ts +++ b/x-pack/plugins/synthetics/e2e/journeys/monitor_name.journey.ts @@ -21,12 +21,12 @@ journey(`MonitorName`, async ({ page, params }: { page: Page; params: any }) => const uptime = monitorManagementPageProvider({ page, kibanaUrl: params.kibanaUrl }); const createBasicMonitor = async () => { - await uptime.createBasicMonitorDetails({ + await uptime.createBasicHTTPMonitorDetails({ name, locations: ['US Central'], apmServiceName: 'synthetics', + url: 'https://www.google.com', }); - await uptime.fillByTestSubj('syntheticsUrlField', 'https://www.google.com'); }; before(async () => { @@ -52,12 +52,12 @@ journey(`MonitorName`, async ({ page, params }: { page: Page; params: any }) => step(`shows error if name already exists`, async () => { await uptime.navigateToAddMonitor(); - await uptime.createBasicMonitorDetails({ + await uptime.createBasicHTTPMonitorDetails({ name, locations: ['US Central'], apmServiceName: 'synthetics', + url: 'https://www.google.com', }); - await uptime.fillByTestSubj('syntheticsUrlField', 'https://www.google.com'); await uptime.assertText({ text: 'Monitor name already exists.' }); diff --git a/x-pack/plugins/synthetics/e2e/page_objects/monitor_management.tsx b/x-pack/plugins/synthetics/e2e/page_objects/monitor_management.tsx index eb13c3678f47e..91d8151c29701 100644 --- a/x-pack/plugins/synthetics/e2e/page_objects/monitor_management.tsx +++ b/x-pack/plugins/synthetics/e2e/page_objects/monitor_management.tsx @@ -189,6 +189,7 @@ export function monitorManagementPageProvider({ apmServiceName: string; locations: string[]; }) { + await this.selectMonitorType('http'); await this.createBasicMonitorDetails({ name, apmServiceName, locations }); await this.fillByTestSubj('syntheticsUrlField', url); }, diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/fleet_package/contexts/policy_config_context.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/fleet_package/contexts/policy_config_context.tsx index a9cdc2c78d86d..99419e2ca9145 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/fleet_package/contexts/policy_config_context.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/fleet_package/contexts/policy_config_context.tsx @@ -121,10 +121,10 @@ export function PolicyConfigContextProvider({ const isAddMonitorRoute = useRouteMatch(MONITOR_ADD_ROUTE); useEffect(() => { - if (isAddMonitorRoute) { + if (isAddMonitorRoute?.isExact) { setMonitorType(DataStream.BROWSER); } - }, [isAddMonitorRoute]); + }, [isAddMonitorRoute?.isExact]); const value = useMemo(() => { return { From 7591fb61556bc56aaae725d6db51bd80e958d91b Mon Sep 17 00:00:00 2001 From: Giorgos Bamparopoulos Date: Mon, 23 May 2022 10:37:03 +0100 Subject: [PATCH 143/150] Fix agent config indicator when applied through fleet integration (#131820) * Fix agent config indicator when applied through fleet integration * Add synthrace scenario Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/elastic-apm-synthtrace/src/index.ts | 1 + .../src/lib/agent_config/agent_config.ts | 24 +++++ .../lib/agent_config/agent_config_fields.ts | 25 ++++++ .../src/lib/agent_config/index.ts | 9 ++ .../src/lib/agent_config/observer.ts | 21 +++++ .../src/lib/apm/instance.ts | 2 +- .../src/lib/apm/metricset.ts | 6 +- .../src/lib/stream_processor.ts | 4 +- .../src/scripts/examples/04_agent_config.ts | 36 ++++++++ .../configuration_types.d.ts | 2 +- .../convert_settings_to_string.ts | 32 ++++--- .../find_exact_configuration.ts | 24 +++-- ...t_config_applied_to_agent_through_fleet.ts | 60 +++++++++++++ .../list_configurations.ts | 23 +++-- .../settings/agent_configuration/route.ts | 10 ++- .../add_agent_config_metrics.ts | 31 +++++++ .../agent_configuration.spec.ts | 89 ++++++++++++++++++- 17 files changed, 363 insertions(+), 36 deletions(-) create mode 100644 packages/elastic-apm-synthtrace/src/lib/agent_config/agent_config.ts create mode 100644 packages/elastic-apm-synthtrace/src/lib/agent_config/agent_config_fields.ts create mode 100644 packages/elastic-apm-synthtrace/src/lib/agent_config/index.ts create mode 100644 packages/elastic-apm-synthtrace/src/lib/agent_config/observer.ts create mode 100644 packages/elastic-apm-synthtrace/src/scripts/examples/04_agent_config.ts create mode 100644 x-pack/plugins/apm/server/routes/settings/agent_configuration/get_config_applied_to_agent_through_fleet.ts create mode 100644 x-pack/test/apm_api_integration/tests/settings/agent_configuration/add_agent_config_metrics.ts rename x-pack/test/apm_api_integration/tests/settings/{ => agent_configuration}/agent_configuration.spec.ts (85%) diff --git a/packages/elastic-apm-synthtrace/src/index.ts b/packages/elastic-apm-synthtrace/src/index.ts index ab6a3e3731be7..3e7a2f1d59190 100644 --- a/packages/elastic-apm-synthtrace/src/index.ts +++ b/packages/elastic-apm-synthtrace/src/index.ts @@ -9,6 +9,7 @@ export { timerange } from './lib/timerange'; export { apm } from './lib/apm'; export { stackMonitoring } from './lib/stack_monitoring'; +export { observer } from './lib/agent_config'; export { cleanWriteTargets } from './lib/utils/clean_write_targets'; export { createLogger, LogLevel } from './lib/utils/create_logger'; diff --git a/packages/elastic-apm-synthtrace/src/lib/agent_config/agent_config.ts b/packages/elastic-apm-synthtrace/src/lib/agent_config/agent_config.ts new file mode 100644 index 0000000000000..5ec90035141da --- /dev/null +++ b/packages/elastic-apm-synthtrace/src/lib/agent_config/agent_config.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { AgentConfigFields } from './agent_config_fields'; +import { Metricset } from '../apm/metricset'; + +export class AgentConfig extends Metricset { + constructor() { + super({ + 'metricset.name': 'agent_config', + agent_config_applied: 1, + }); + } + + etag(etag: string) { + this.fields['labels.etag'] = etag; + return this; + } +} diff --git a/packages/elastic-apm-synthtrace/src/lib/agent_config/agent_config_fields.ts b/packages/elastic-apm-synthtrace/src/lib/agent_config/agent_config_fields.ts new file mode 100644 index 0000000000000..82b0963cee6e6 --- /dev/null +++ b/packages/elastic-apm-synthtrace/src/lib/agent_config/agent_config_fields.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { ApmFields } from '../apm/apm_fields'; + +export type AgentConfigFields = Pick< + ApmFields, + | '@timestamp' + | 'processor.event' + | 'processor.name' + | 'metricset.name' + | 'observer' + | 'ecs.version' + | 'event.ingested' +> & + Partial<{ + 'labels.etag': string; + agent_config_applied: number; + 'event.agent_id_status': string; + }>; diff --git a/packages/elastic-apm-synthtrace/src/lib/agent_config/index.ts b/packages/elastic-apm-synthtrace/src/lib/agent_config/index.ts new file mode 100644 index 0000000000000..204a12386b275 --- /dev/null +++ b/packages/elastic-apm-synthtrace/src/lib/agent_config/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { observer } from './observer'; diff --git a/packages/elastic-apm-synthtrace/src/lib/agent_config/observer.ts b/packages/elastic-apm-synthtrace/src/lib/agent_config/observer.ts new file mode 100644 index 0000000000000..189f3f62abb39 --- /dev/null +++ b/packages/elastic-apm-synthtrace/src/lib/agent_config/observer.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 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 { AgentConfigFields } from './agent_config_fields'; +import { AgentConfig } from './agent_config'; +import { Entity } from '../entity'; + +export class Observer extends Entity { + agentConfig() { + return new AgentConfig(); + } +} + +export function observer() { + return new Observer({}); +} diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/instance.ts b/packages/elastic-apm-synthtrace/src/lib/apm/instance.ts index 4051d7e8241da..9a7664e9518ce 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/instance.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/instance.ts @@ -45,7 +45,7 @@ export class Instance extends Entity { } appMetrics(metrics: ApmApplicationMetricFields) { - return new Metricset({ + return new Metricset({ ...this.fields, 'metricset.name': 'app', ...metrics, diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/metricset.ts b/packages/elastic-apm-synthtrace/src/lib/apm/metricset.ts index 88177e816a852..515af829c6a5a 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/metricset.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/metricset.ts @@ -7,10 +7,10 @@ */ import { Serializable } from '../serializable'; -import { ApmFields } from './apm_fields'; +import { Fields } from '../entity'; -export class Metricset extends Serializable { - constructor(fields: ApmFields) { +export class Metricset extends Serializable { + constructor(fields: TFields) { super({ 'processor.event': 'metric', 'processor.name': 'metric', diff --git a/packages/elastic-apm-synthtrace/src/lib/stream_processor.ts b/packages/elastic-apm-synthtrace/src/lib/stream_processor.ts index e1cb332996e23..a6f8f923b3714 100644 --- a/packages/elastic-apm-synthtrace/src/lib/stream_processor.ts +++ b/packages/elastic-apm-synthtrace/src/lib/stream_processor.ts @@ -211,7 +211,9 @@ export class StreamProcessor { const eventType = d.processor.event as keyof ApmElasticsearchOutputWriteTargets; let dataStream = writeTargets[eventType]; if (eventType === 'metric') { - if (!d.service?.name) { + if (d.metricset?.name === 'agent_config') { + dataStream = 'metrics-apm.internal-default'; + } else if (!d.service?.name) { dataStream = 'metrics-apm.app-default'; } else { if (!d.transaction && !d.span) { diff --git a/packages/elastic-apm-synthtrace/src/scripts/examples/04_agent_config.ts b/packages/elastic-apm-synthtrace/src/scripts/examples/04_agent_config.ts new file mode 100644 index 0000000000000..ec6d57eba4b61 --- /dev/null +++ b/packages/elastic-apm-synthtrace/src/scripts/examples/04_agent_config.ts @@ -0,0 +1,36 @@ +/* + * 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 { observer, timerange } from '../..'; +import { Scenario } from '../scenario'; +import { getLogger } from '../utils/get_common_services'; +import { RunOptions } from '../utils/parse_run_cli_flags'; +import { AgentConfigFields } from '../../lib/agent_config/agent_config_fields'; + +const scenario: Scenario = async (runOptions: RunOptions) => { + const logger = getLogger(runOptions); + + return { + generate: ({ from, to }) => { + const agentConfig = observer().agentConfig(); + + const range = timerange(from, to); + return range + .interval('30s') + .rate(1) + .generator((timestamp) => { + const events = logger.perf('generating_agent_config_events', () => { + return agentConfig.etag('test-etag').timestamp(timestamp); + }); + return events; + }); + }, + }; +}; + +export default scenario; diff --git a/x-pack/plugins/apm/common/agent_configuration/configuration_types.d.ts b/x-pack/plugins/apm/common/agent_configuration/configuration_types.d.ts index 0f315c1583f1a..88302dea91200 100644 --- a/x-pack/plugins/apm/common/agent_configuration/configuration_types.d.ts +++ b/x-pack/plugins/apm/common/agent_configuration/configuration_types.d.ts @@ -15,6 +15,6 @@ export type AgentConfigurationIntake = t.TypeOf< export type AgentConfiguration = { '@timestamp': number; applied_by_agent?: boolean; - etag?: string; + etag: string; agent_name?: string; } & AgentConfigurationIntake; diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration/convert_settings_to_string.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration/convert_settings_to_string.ts index d52b048bc6b46..a0b3fa2e45c54 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration/convert_settings_to_string.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration/convert_settings_to_string.ts @@ -13,17 +13,27 @@ import { AgentConfiguration } from '../../../../common/agent_configuration/confi export function convertConfigSettingsToString( hit: SearchHit ) { - const config = hit._source; + const { settings } = hit._source; - if (config.settings?.transaction_sample_rate) { - config.settings.transaction_sample_rate = - config.settings.transaction_sample_rate.toString(); - } + const convertedConfigSettings = { + ...settings, + ...(settings?.transaction_sample_rate + ? { + transaction_sample_rate: settings.transaction_sample_rate.toString(), + } + : {}), + ...(settings?.transaction_max_spans + ? { + transaction_max_spans: settings.transaction_max_spans.toString(), + } + : {}), + }; - if (config.settings?.transaction_max_spans) { - config.settings.transaction_max_spans = - config.settings.transaction_max_spans.toString(); - } - - return hit; + return { + ...hit, + _source: { + ...hit._source, + settings: convertedConfigSettings, + }, + }; } diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration/find_exact_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration/find_exact_configuration.ts index 18e2fe0f34a6d..f32e53a1ad1dd 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration/find_exact_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration/find_exact_configuration.ts @@ -13,6 +13,7 @@ import { } from '../../../../common/elasticsearch_fieldnames'; import { Setup } from '../../../lib/helpers/setup_request'; import { convertConfigSettingsToString } from './convert_settings_to_string'; +import { getConfigsAppliedToAgentsThroughFleet } from './get_config_applied_to_agent_through_fleet'; export async function findExactConfiguration({ service, @@ -40,16 +41,27 @@ export async function findExactConfiguration({ }, }; - const resp = await internalClient.search( - 'find_exact_agent_configuration', - params - ); + const [agentConfig, configsAppliedToAgentsThroughFleet] = await Promise.all([ + internalClient.search( + 'find_exact_agent_configuration', + params + ), + getConfigsAppliedToAgentsThroughFleet({ setup }), + ]); - const hit = resp.hits.hits[0] as SearchHit | undefined; + const hit = agentConfig.hits.hits[0] as + | SearchHit + | undefined; if (!hit) { return; } - return convertConfigSettingsToString(hit); + return { + id: hit._id, + ...convertConfigSettingsToString(hit)._source, + applied_by_agent: + hit._source.applied_by_agent || + configsAppliedToAgentsThroughFleet.hasOwnProperty(hit._source.etag), + }; } diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration/get_config_applied_to_agent_through_fleet.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration/get_config_applied_to_agent_through_fleet.ts new file mode 100644 index 0000000000000..351c21b43c1e9 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration/get_config_applied_to_agent_through_fleet.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { termQuery, rangeQuery } from '@kbn/observability-plugin/server'; +import datemath from '@kbn/datemath'; +import { METRICSET_NAME } from '../../../../common/elasticsearch_fieldnames'; +import { Setup } from '../../../lib/helpers/setup_request'; + +export async function getConfigsAppliedToAgentsThroughFleet({ + setup, +}: { + setup: Setup; +}) { + const { internalClient, indices } = setup; + + const params = { + index: indices.metric, + size: 0, + body: { + query: { + bool: { + filter: [ + ...termQuery(METRICSET_NAME, 'agent_config'), + ...rangeQuery( + datemath.parse('now-15m')!.valueOf(), + datemath.parse('now')!.valueOf() + ), + ], + }, + }, + aggs: { + config_by_etag: { + terms: { + field: 'labels.etag', + size: 200, + }, + }, + }, + }, + }; + + const response = await internalClient.search( + 'get_config_applied_to_agent_through_fleet', + params + ); + + return ( + response.aggregations?.config_by_etag.buckets.reduce( + (configsAppliedToAgentsThroughFleet, bucket) => { + configsAppliedToAgentsThroughFleet[bucket.key as string] = true; + return configsAppliedToAgentsThroughFleet; + }, + {} as Record + ) ?? {} + ); +} diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration/list_configurations.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration/list_configurations.ts index bc105106cb5e4..416cb50c0a801 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration/list_configurations.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration/list_configurations.ts @@ -8,6 +8,7 @@ import { Setup } from '../../../lib/helpers/setup_request'; import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; import { convertConfigSettingsToString } from './convert_settings_to_string'; +import { getConfigsAppliedToAgentsThroughFleet } from './get_config_applied_to_agent_through_fleet'; export async function listConfigurations({ setup }: { setup: Setup }) { const { internalClient, indices } = setup; @@ -17,12 +18,22 @@ export async function listConfigurations({ setup }: { setup: Setup }) { size: 200, }; - const resp = await internalClient.search( - 'list_agent_configuration', - params - ); + const [agentConfigs, configsAppliedToAgentsThroughFleet] = await Promise.all([ + internalClient.search( + 'list_agent_configuration', + params + ), + getConfigsAppliedToAgentsThroughFleet({ setup }), + ]); - return resp.hits.hits + return agentConfigs.hits.hits .map(convertConfigSettingsToString) - .map((hit) => hit._source); + .map((hit) => { + return { + ...hit._source, + applied_by_agent: + hit._source.applied_by_agent || + configsAppliedToAgentsThroughFleet.hasOwnProperty(hit._source.etag), + }; + }); } diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration/route.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration/route.ts index 72869ef165fa2..3d9abebeeef2b 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration/route.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration/route.ts @@ -38,7 +38,9 @@ const agentConfigurationRoute = createApmServerRoute({ >; }> => { const setup = await setupRequest(resources); + const configurations = await listConfigurations({ setup }); + return { configurations }; }, }); @@ -71,7 +73,7 @@ const getSingleAgentConfigurationRoute = createApmServerRoute({ throw Boom.notFound(); } - return config._source; + return config; }, }); @@ -102,11 +104,11 @@ const deleteAgentConfigurationRoute = createApmServerRoute({ } logger.info( - `Deleting config ${service.name}/${service.environment} (${config._id})` + `Deleting config ${service.name}/${service.environment} (${config.id})` ); const deleteConfigurationResult = await deleteConfiguration({ - configurationId: config._id, + configurationId: config.id, setup, }); @@ -162,7 +164,7 @@ const createOrUpdateAgentConfigurationRoute = createApmServerRoute({ ); await createOrUpdateConfiguration({ - configurationId: config?._id, + configurationId: config?.id, configurationIntake: body, setup, }); diff --git a/x-pack/test/apm_api_integration/tests/settings/agent_configuration/add_agent_config_metrics.ts b/x-pack/test/apm_api_integration/tests/settings/agent_configuration/add_agent_config_metrics.ts new file mode 100644 index 0000000000000..f0329a220c71a --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/settings/agent_configuration/add_agent_config_metrics.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { timerange, observer } from '@elastic/apm-synthtrace'; +import type { ApmSynthtraceEsClient } from '@elastic/apm-synthtrace'; + +export async function addAgentConfigMetrics({ + synthtraceEsClient, + start, + end, + etag, +}: { + synthtraceEsClient: ApmSynthtraceEsClient; + start: number; + end: number; + etag?: string; +}) { + const agentConfig = observer().agentConfig(); + + const agentConfigEvents = [ + timerange(start, end) + .interval('1m') + .rate(1) + .generator((timestamp) => agentConfig.etag(etag ?? 'test-etag').timestamp(timestamp)), + ]; + + await synthtraceEsClient.index(agentConfigEvents); +} diff --git a/x-pack/test/apm_api_integration/tests/settings/agent_configuration.spec.ts b/x-pack/test/apm_api_integration/tests/settings/agent_configuration/agent_configuration.spec.ts similarity index 85% rename from x-pack/test/apm_api_integration/tests/settings/agent_configuration.spec.ts rename to x-pack/test/apm_api_integration/tests/settings/agent_configuration/agent_configuration.spec.ts index ecf5b87e82d70..e4960791eee5a 100644 --- a/x-pack/test/apm_api_integration/tests/settings/agent_configuration.spec.ts +++ b/x-pack/test/apm_api_integration/tests/settings/agent_configuration/agent_configuration.spec.ts @@ -11,14 +11,17 @@ import expect from '@kbn/expect'; import { omit, orderBy } from 'lodash'; import { AgentConfigurationIntake } from '@kbn/apm-plugin/common/agent_configuration/configuration_types'; import { AgentConfigSearchParams } from '@kbn/apm-plugin/server/routes/settings/agent_configuration/route'; - -import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api'; +import moment from 'moment'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { addAgentConfigMetrics } from './add_agent_config_metrics'; export default function agentConfigurationTests({ getService }: FtrProviderContext) { const registry = getService('registry'); const apmApiClient = getService('apmApiClient'); const log = getService('log'); + const synthtraceEsClient = getService('synthtraceEsClient'); const archiveName = 'apm_8.0.0'; @@ -77,6 +80,18 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte }); } + function findExactConfiguration(name: string, environment: string) { + return apmApiClient.readUser({ + endpoint: 'GET /api/apm/settings/agent-configuration/view', + params: { + query: { + name, + environment, + }, + }, + }); + } + registry.when( 'agent configuration when no data is loaded', { config: 'basic', archives: [] }, @@ -297,7 +312,7 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte service: { name: 'myservice', environment: 'production' }, settings: { transaction_sample_rate: '0.9' }, }; - let etag: string | undefined; + let etag: string; before(async () => { log.debug('creating agent configuration'); @@ -371,6 +386,74 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte } ); + registry.when( + 'Agent configurations through fleet', + { config: 'basic', archives: ['apm_mappings_only_8.0.0'] }, + () => { + const name = 'myservice'; + const environment = 'development'; + const testConfig = { + service: { name, environment }, + settings: { transaction_sample_rate: '0.9' }, + }; + + let agentConfiguration: + | APIReturnType<'GET /api/apm/settings/agent-configuration/view'> + | undefined; + + before(async () => { + log.debug('creating agent configuration'); + await createConfiguration(testConfig); + const { body } = await findExactConfiguration(name, environment); + agentConfiguration = body; + }); + + after(async () => { + await deleteConfiguration(testConfig); + }); + + it(`should have 'applied_by_agent=false' when there are no agent config metrics for this etag`, async () => { + expect(agentConfiguration?.applied_by_agent).to.be(false); + }); + + describe('when there are agent config metrics for this etag', () => { + before(async () => { + const start = new Date().getTime(); + const end = moment(start).add(15, 'minutes').valueOf(); + + await addAgentConfigMetrics({ + synthtraceEsClient, + start, + end, + etag: agentConfiguration?.etag, + }); + }); + + after(() => synthtraceEsClient.clean()); + + it(`should have 'applied_by_agent=true' when getting a config from all configurations`, async () => { + const { + body: { configurations }, + } = await getAllConfigurations(); + + const updatedConfig = configurations.find( + (x) => x.service.name === name && x.service.environment === environment + ); + + expect(updatedConfig?.applied_by_agent).to.be(true); + }); + + it(`should have 'applied_by_agent=true' when getting a single config`, async () => { + const { + body: { applied_by_agent: appliedByAgent }, + } = await findExactConfiguration(name, environment); + + expect(appliedByAgent).to.be(true); + }); + }); + } + ); + registry.when( 'agent configuration when data is loaded', { config: 'basic', archives: [archiveName] }, From 2cddced8c3e0da5831fd160700ea80ead8540a07 Mon Sep 17 00:00:00 2001 From: Jordan <51442161+JordanSh@users.noreply.github.com> Date: Mon, 23 May 2022 12:50:55 +0300 Subject: [PATCH 144/150] [Cloud Posture] Trendline query changes (#132680) --- .../cloud_posture_score_chart.tsx | 7 ++++++- .../routes/compliance_dashboard/get_trends.ts | 14 +++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx index 540402b986e5b..9fd7806d27665 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx @@ -129,7 +129,12 @@ const ComplianceTrendChart = ({ trend }: { trend: PostureTrend[] }) => { xAccessor={'timestamp'} yAccessors={['postureScore']} /> - + ({ index: BENCHMARK_SCORE_INDEX_DEFAULT_NS, - size: 5, + size: 99, sort: '@timestamp:desc', + query: { + bool: { + must: { + range: { + '@timestamp': { + gte: 'now-1d', + lte: 'now', + }, + }, + }, + }, + }, }); export type Trends = Array<{ From 693b3e85a49c62c58effe22bbf4aecd90a0bb246 Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Mon, 23 May 2022 11:54:29 +0200 Subject: [PATCH 145/150] [Osquery] Add Osquery to Alert context menu (#131790) --- .../cypress/integration/all/alerts.spec.ts | 11 ++++-- .../common/ecs/agent/index.ts | 1 + .../timeline_actions/alert_context_menu.tsx | 38 ++++++++++++++++--- .../osquery/osquery_action_item.tsx | 20 +++++----- .../use_osquery_context_action_item.tsx | 27 +++++++++++++ .../timeline/eql/helpers.test.ts | 12 ++++++ .../timeline/factory/helpers/constants.ts | 1 + 7 files changed, 90 insertions(+), 20 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detections/components/osquery/use_osquery_context_action_item.tsx diff --git a/x-pack/plugins/osquery/cypress/integration/all/alerts.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/alerts.spec.ts index 21d3584b9fc46..4ef3e263df01c 100644 --- a/x-pack/plugins/osquery/cypress/integration/all/alerts.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/alerts.spec.ts @@ -34,10 +34,9 @@ describe('Alert Event Details', () => { runKbnArchiverScript(ArchiverMethod.UNLOAD, 'rule'); }); - it('should be able to run live query', () => { + it('should prepare packs and alert rules', () => { const PACK_NAME = 'testpack'; const RULE_NAME = 'Test-rule'; - const TIMELINE_NAME = 'Untitled timeline'; navigateTo('/app/osquery/packs'); preparePack(PACK_NAME); findAndClickButton('Edit'); @@ -57,8 +56,14 @@ describe('Alert Event Details', () => { cy.getBySel('ruleSwitch').should('have.attr', 'aria-checked', 'false'); cy.getBySel('ruleSwitch').click(); cy.getBySel('ruleSwitch').should('have.attr', 'aria-checked', 'true'); + }); + + it('should be able to run live query and add to timeline (-depending on the previous test)', () => { + const TIMELINE_NAME = 'Untitled timeline'; cy.visit('/app/security/alerts'); - cy.wait(500); + cy.getBySel('header-page-title').contains('Alerts').should('exist'); + cy.getBySel('timeline-context-menu-button').first().click({ force: true }); + cy.getBySel('osquery-action-item').should('exist').contains('Run Osquery'); cy.getBySel('expand-event').first().click(); cy.getBySel('take-action-dropdown-btn').click(); cy.getBySel('osquery-action-item').click(); diff --git a/x-pack/plugins/security_solution/common/ecs/agent/index.ts b/x-pack/plugins/security_solution/common/ecs/agent/index.ts index 2332b60f1a3ca..7084214a9b876 100644 --- a/x-pack/plugins/security_solution/common/ecs/agent/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/agent/index.ts @@ -7,4 +7,5 @@ export interface AgentEcs { type?: string[]; + id?: string[]; } diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 05a91f094ed38..efc4666b7bd61 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -13,6 +13,8 @@ import { connect, ConnectedProps } from 'react-redux'; import { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; import { get } from 'lodash/fp'; import { DEFAULT_ACTION_BUTTON_WIDTH } from '@kbn/timelines-plugin/public'; +import { useOsqueryContextActionItem } from '../../osquery/use_osquery_context_action_item'; +import { OsqueryFlyout } from '../../osquery/osquery_flyout'; import { useRouteSpy } from '../../../../common/utils/route/use_route_spy'; import { buildGetAlertByIdQuery } from '../../../../common/components/exceptions/helpers'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; @@ -63,6 +65,7 @@ const AlertContextMenuComponent: React.FC { const [isPopoverOpen, setPopover] = useState(false); + const [isOsqueryFlyoutOpen, setOsqueryFlyoutOpen] = useState(false); const [routeProps] = useRouteSpy(); const onMenuItemClick = useCallback(() => { @@ -186,18 +189,38 @@ const AlertContextMenuComponent: React.FC get(0, ecsRowData?.agent?.id), [ecsRowData]); + + const handleOnOsqueryClick = useCallback(() => { + setOsqueryFlyoutOpen((prevValue) => !prevValue); + setPopover(false); + }, []); + + const { osqueryActionItems } = useOsqueryContextActionItem({ handleClick: handleOnOsqueryClick }); + const items: React.ReactElement[] = useMemo( () => !isEvent && ruleId - ? [...addToCaseActionItems, ...statusActionItems, ...exceptionActionItems] - : [...addToCaseActionItems, ...eventFilterActionItems], + ? [ + ...addToCaseActionItems, + ...statusActionItems, + ...exceptionActionItems, + ...(agentId ? osqueryActionItems : []), + ] + : [ + ...addToCaseActionItems, + ...eventFilterActionItems, + ...(agentId ? osqueryActionItems : []), + ], [ - statusActionItems, - addToCaseActionItems, - eventFilterActionItems, - exceptionActionItems, isEvent, ruleId, + addToCaseActionItems, + statusActionItems, + exceptionActionItems, + agentId, + osqueryActionItems, + eventFilterActionItems, ] ); @@ -239,6 +262,9 @@ const AlertContextMenuComponent: React.FC )} + {isOsqueryFlyoutOpen && agentId && ecsRowData != null && ( + + )} ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_action_item.tsx b/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_action_item.tsx index ca61e2f3ebf6d..e27a13ef217e3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_action_item.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_action_item.tsx @@ -13,14 +13,12 @@ interface IProps { handleClick: () => void; } -export const OsqueryActionItem = ({ handleClick }: IProps) => { - return ( - - {ACTION_OSQUERY} - - ); -}; +export const OsqueryActionItem = ({ handleClick }: IProps) => ( + + {ACTION_OSQUERY} + +); diff --git a/x-pack/plugins/security_solution/public/detections/components/osquery/use_osquery_context_action_item.tsx b/x-pack/plugins/security_solution/public/detections/components/osquery/use_osquery_context_action_item.tsx new file mode 100644 index 0000000000000..41a78eb32619f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/osquery/use_osquery_context_action_item.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { OsqueryActionItem } from './osquery_action_item'; +import { useKibana } from '../../../common/lib/kibana'; + +interface IProps { + handleClick: () => void; +} + +export const useOsqueryContextActionItem = ({ handleClick }: IProps) => { + const osqueryActionItem = useMemo( + () => , + [handleClick] + ); + const permissions = useKibana().services.application.capabilities.osquery; + + return { + osqueryActionItems: + permissions?.writeLiveQueries || permissions?.runSavedQueries ? [osqueryActionItem] : [], + }; +}; diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/eql/helpers.test.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/eql/helpers.test.ts index 5c6a0ac0bd416..10a4fae0a036d 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/eql/helpers.test.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/eql/helpers.test.ts @@ -208,6 +208,9 @@ describe('Search Strategy EQL helper', () => { "_id": "qhymg3cBX5UUcOOYP3Ec", "_index": ".ds-logs-endpoint.events.security-default-2021.02.05-000005", "agent": Object { + "id": Array [ + "1d15cf9e-3dc7-5b97-f586-743f7c2518b2", + ], "type": Array [ "endpoint", ], @@ -335,6 +338,9 @@ describe('Search Strategy EQL helper', () => { "_id": "qxymg3cBX5UUcOOYP3Ec", "_index": ".ds-logs-endpoint.events.security-default-2021.02.05-000005", "agent": Object { + "id": Array [ + "1d15cf9e-3dc7-5b97-f586-743f7c2518b2", + ], "type": Array [ "endpoint", ], @@ -476,6 +482,9 @@ describe('Search Strategy EQL helper', () => { "_id": "rBymg3cBX5UUcOOYP3Ec", "_index": ".ds-logs-endpoint.events.security-default-2021.02.05-000005", "agent": Object { + "id": Array [ + "1d15cf9e-3dc7-5b97-f586-743f7c2518b2", + ], "type": Array [ "endpoint", ], @@ -592,6 +601,9 @@ describe('Search Strategy EQL helper', () => { "_id": "pxymg3cBX5UUcOOYP3Ec", "_index": ".ds-logs-endpoint.events.process-default-2021.02.02-000005", "agent": Object { + "id": Array [ + "1d15cf9e-3dc7-5b97-f586-743f7c2518b2", + ], "type": Array [ "endpoint", ], diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts index 211edec96b8ac..068b52b8cd821 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts @@ -94,6 +94,7 @@ export const TIMELINE_EVENTS_FIELDS = [ 'event.timezone', 'event.type', 'agent.type', + 'agent.id', 'auditd.result', 'auditd.session', 'auditd.data.acct', From b59fb972c65c485c3f2811ae415d43a2bb5951f7 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Mon, 23 May 2022 12:02:43 +0200 Subject: [PATCH 146/150] [Security Solution] Update use_url_state to work with new side nav (#132518) * Fix landing pages browser tab title * Fix new navigation url state * Fix unit tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/url_state/index.test.tsx | 69 +++++++++++ .../url_state/index_mocked.test.tsx | 111 +++++++++++++++++- .../components/url_state/use_url_state.tsx | 23 +++- 3 files changed, 197 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx index faf3fe10da079..cb49215ee8c9c 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx @@ -25,6 +25,11 @@ import { UrlStateContainerPropTypes } from './types'; import { useUrlStateHooks } from './use_url_state'; import { waitFor } from '@testing-library/react'; import { useLocation } from 'react-router-dom'; +import { updateAppLinks } from '../../links'; +import { getAppLinks } from '../../links/app_links'; +import { StartPlugins } from '../../../types'; +import { allowedExperimentalValues } from '../../../../common/experimental_features'; +import { coreMock } from '@kbn/core/public/mocks'; let mockProps: UrlStateContainerPropTypes; @@ -78,10 +83,36 @@ jest.mock('react-router-dom', () => { }; }); +const mockedUseIsGroupedNavigationEnabled = jest.fn(); +jest.mock('../navigation/helpers', () => ({ + useIsGroupedNavigationEnabled: () => mockedUseIsGroupedNavigationEnabled(), +})); + describe('UrlStateContainer', () => { + beforeAll(async () => { + mockedUseIsGroupedNavigationEnabled.mockReturnValue(false); + + const appLinks = await getAppLinks(coreMock.createStart(), {} as StartPlugins); + updateAppLinks(appLinks, { + experimentalFeatures: allowedExperimentalValues, + capabilities: { + navLinks: {}, + management: {}, + catalogue: {}, + actions: { show: true, crud: true }, + siem: { + show: true, + crud: true, + }, + }, + }); + }); + afterEach(() => { jest.clearAllMocks(); + mockedUseIsGroupedNavigationEnabled.mockReturnValue(false); }); + describe('handleInitialize', () => { describe('URL state updates redux', () => { describe('relative timerange actions are called with correct data on component mount', () => { @@ -226,6 +257,44 @@ describe('UrlStateContainer', () => { expect(mockHistory.replace).not.toHaveBeenCalled(); }); + it("it doesn't update URL state when on admin page and grouped nav disabled", () => { + mockedUseIsGroupedNavigationEnabled.mockReturnValue(false); + mockProps = getMockPropsObj({ + page: CONSTANTS.unknown, + examplePath: '/administration', + namespaceLower: 'administration', + pageName: SecurityPageName.administration, + detailName: undefined, + }).noSearch.undefinedQuery; + + (useLocation as jest.Mock).mockReturnValue({ + pathname: mockProps.pathName, + }); + + mount( useUrlStateHooks(args)} />); + + expect(mockHistory.replace.mock.calls[0][0].search).toBe('?'); + }); + + it("it doesn't update URL state when on admin page and grouped nav enabled", () => { + mockedUseIsGroupedNavigationEnabled.mockReturnValue(true); + mockProps = getMockPropsObj({ + page: CONSTANTS.unknown, + examplePath: '/dashboards', + namespaceLower: 'dashboards', + pageName: SecurityPageName.dashboardsLanding, + detailName: undefined, + }).noSearch.undefinedQuery; + + (useLocation as jest.Mock).mockReturnValue({ + pathname: mockProps.pathName, + }); + + mount( useUrlStateHooks(args)} />); + + expect(mockHistory.replace.mock.calls[0][0].search).toBe('?'); + }); + it('it removes empty AppQuery state from URL', () => { mockProps = { ...getMockProps( diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx index 4063ecdb73935..011621b95a0c4 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx @@ -16,7 +16,12 @@ import { getFilterQuery, getMockPropsObj, mockHistory, testCases } from './test_ import { UrlStateContainerPropTypes } from './types'; import { useUrlStateHooks } from './use_url_state'; import { useLocation } from 'react-router-dom'; -import { MANAGEMENT_PATH } from '../../../../common/constants'; +import { DASHBOARDS_PATH, MANAGEMENT_PATH } from '../../../../common/constants'; +import { getAppLinks } from '../../links/app_links'; +import { StartPlugins } from '../../../types'; +import { updateAppLinks } from '../../links'; +import { allowedExperimentalValues } from '../../../../common/experimental_features'; +import { coreMock } from '@kbn/core/public/mocks'; let mockProps: UrlStateContainerPropTypes; @@ -45,7 +50,31 @@ jest.mock('react-redux', () => { }; }); +const mockedUseIsGroupedNavigationEnabled = jest.fn(); +jest.mock('../navigation/helpers', () => ({ + useIsGroupedNavigationEnabled: () => mockedUseIsGroupedNavigationEnabled(), +})); + describe('UrlStateContainer - lodash.throttle mocked to test update url', () => { + beforeAll(async () => { + mockedUseIsGroupedNavigationEnabled.mockReturnValue(false); + + const appLinks = await getAppLinks(coreMock.createStart(), {} as StartPlugins); + updateAppLinks(appLinks, { + experimentalFeatures: allowedExperimentalValues, + capabilities: { + navLinks: {}, + management: {}, + catalogue: {}, + actions: { show: true, crud: true }, + siem: { + show: true, + crud: true, + }, + }, + }); + }); + afterEach(() => { jest.clearAllMocks(); jest.resetAllMocks(); @@ -210,7 +239,8 @@ describe('UrlStateContainer - lodash.throttle mocked to test update url', () => }); }); - test("administration page doesn't has query string", () => { + test("administration page doesn't has query string when grouped nav disabled", () => { + mockedUseIsGroupedNavigationEnabled.mockReturnValue(false); mockProps = getMockPropsObj({ page: CONSTANTS.networkPage, examplePath: '/network', @@ -285,6 +315,83 @@ describe('UrlStateContainer - lodash.throttle mocked to test update url', () => state: '', }); }); + + test("dashboards page doesn't has query string when grouped nav enabled", () => { + mockedUseIsGroupedNavigationEnabled.mockReturnValue(true); + mockProps = getMockPropsObj({ + page: CONSTANTS.networkPage, + examplePath: '/network', + namespaceLower: 'network', + pageName: SecurityPageName.network, + detailName: undefined, + }).noSearch.definedQuery; + + const urlState = { + ...mockProps.urlState, + [CONSTANTS.appQuery]: getFilterQuery(), + [CONSTANTS.timerange]: { + global: { + [CONSTANTS.timerange]: { + from: '2020-07-07T08:20:18.966Z', + fromStr: 'now-24h', + kind: 'relative', + to: '2020-07-08T08:20:18.966Z', + toStr: 'now', + }, + linkTo: ['timeline'], + }, + timeline: { + [CONSTANTS.timerange]: { + from: '2020-07-07T08:20:18.966Z', + fromStr: 'now-24h', + kind: 'relative', + to: '2020-07-08T08:20:18.966Z', + toStr: 'now', + }, + linkTo: ['global'], + }, + }, + }; + + const updatedMockProps = { + ...getMockPropsObj({ + ...mockProps, + page: CONSTANTS.unknown, + examplePath: DASHBOARDS_PATH, + namespaceLower: 'dashboards', + pageName: SecurityPageName.dashboardsLanding, + detailName: undefined, + }).noSearch.definedQuery, + urlState, + }; + + (useLocation as jest.Mock).mockReturnValue({ + pathname: mockProps.pathName, + }); + + const wrapper = mount( + useUrlStateHooks(args)} + /> + ); + + (useLocation as jest.Mock).mockReturnValue({ + pathname: updatedMockProps.pathName, + }); + + wrapper.setProps({ + hookProps: updatedMockProps, + }); + + wrapper.update(); + expect(mockHistory.replace.mock.calls[1][0]).toStrictEqual({ + hash: '', + pathname: DASHBOARDS_PATH, + search: '?', + state: '', + }); + }); }); describe('handleInitialize', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx index 3245d647227ad..e787b3a750e91 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx @@ -40,6 +40,9 @@ import { import { TimelineUrl } from '../../../timelines/store/timeline/model'; import { UrlInputsModel } from '../../store/inputs/model'; import { queryTimelineByIdOnUrlChange } from './query_timeline_by_id_on_url_change'; +import { getLinkInfo } from '../../links'; +import { SecurityPageName } from '../../../app/types'; +import { useIsGroupedNavigationEnabled } from '../navigation/helpers'; function usePrevious(value: PreviousLocationUrlState) { const ref = useRef(value); @@ -62,7 +65,9 @@ export const useUrlStateHooks = ({ const { filterManager, savedQueries } = useKibana().services.data.query; const { pathname: browserPathName } = useLocation(); const prevProps = usePrevious({ pathName, pageName, urlState, search }); + const isGroupedNavEnabled = useIsGroupedNavigationEnabled(); + const linkInfo = pageName ? getLinkInfo(pageName as SecurityPageName) : undefined; const { setInitialStateFromUrl, updateTimeline, updateTimelineIsLoading } = useSetInitialStateFromUrl(); @@ -70,9 +75,10 @@ export const useUrlStateHooks = ({ (type: UrlStateType) => { const urlStateUpdatesToStore: UrlStateToRedux[] = []; const urlStateUpdatesToLocation: ReplaceStateInLocation[] = []; + const skipUrlState = isGroupedNavEnabled ? linkInfo?.skipUrlState : isAdministration(type); // Delete all query strings from URL when the page is security/administration (Manage menu group) - if (isAdministration(type)) { + if (skipUrlState) { ALL_URL_STATE_KEYS.forEach((urlKey: KeyUrlState) => { urlStateUpdatesToLocation.push({ urlStateToReplace: '', @@ -146,6 +152,8 @@ export const useUrlStateHooks = ({ setInitialStateFromUrl, urlState, isFirstPageLoad, + isGroupedNavEnabled, + linkInfo?.skipUrlState, ] ); @@ -159,8 +167,9 @@ export const useUrlStateHooks = ({ if (browserPathName !== pathName) return; const type: UrlStateType = getUrlType(pageName); + const skipUrlState = isGroupedNavEnabled ? linkInfo?.skipUrlState : isAdministration(type); - if (!deepEqual(urlState, prevProps.urlState) && !isFirstPageLoad && !isAdministration(type)) { + if (!deepEqual(urlState, prevProps.urlState) && !isFirstPageLoad && !skipUrlState) { const urlStateUpdatesToLocation: ReplaceStateInLocation[] = ALL_URL_STATE_KEYS.map( (urlKey: KeyUrlState) => ({ urlStateToReplace: getUrlStateKeyValue(urlState, urlKey), @@ -186,11 +195,17 @@ export const useUrlStateHooks = ({ browserPathName, handleInitialize, search, + isGroupedNavEnabled, + linkInfo?.skipUrlState, ]); useEffect(() => { - document.title = `${getTitle(pageName, navTabs)} - Kibana`; - }, [pageName, navTabs]); + if (!isGroupedNavEnabled) { + document.title = `${getTitle(pageName, navTabs)} - Kibana`; + } else { + document.title = `${linkInfo?.title ?? ''} - Kibana`; + } + }, [pageName, navTabs, isGroupedNavEnabled, linkInfo]); useEffect(() => { queryTimelineByIdOnUrlChange({ From c993ff2a4fa10898d5a6e15aeb1d0848534ae48e Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Mon, 23 May 2022 06:25:17 -0400 Subject: [PATCH 147/150] [Workplace Search] Add categories to source data for internal connectors (#132671) --- .../workplace_search/constants.ts | 101 +++++++++++++++++- .../views/content_sources/source_data.tsx | 98 ++++++++++++++++- 2 files changed, 192 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 1ffb6c74d25fa..9e39b86242a90 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -366,21 +366,90 @@ export const SOURCE_OBJ_TYPES = { }; export const SOURCE_CATEGORIES = { + ACCOUNT_MANAGEMENT: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.categories.accountManagement', + { + defaultMessage: 'Account management', + } + ), + ATLASSIAN: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.atlassian', { + defaultMessage: 'Atlassian', + }), + BUG_TRACKING: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.categories.bugTracking', + { + defaultMessage: 'Bug tracking', + } + ), + CHAT: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.chat', { + defaultMessage: 'Chat', + }), CLOUD: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.cloud', { defaultMessage: 'Cloud', }), - COMMUNICATIONS: i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.sources.categories.communications', + CODE_REPOSITORY: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.categories.codeRepository', + { + defaultMessage: 'Code repository', + } + ), + COLLABORATION: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.categories.collaboration', + { + defaultMessage: 'Collaboration', + } + ), + COMMUNICATION: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.categories.communication', + { + defaultMessage: 'Communication', + } + ), + CRM: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.crm', { + defaultMessage: 'CRM', + }), + CUSTOMER_RELATIONSHIP_MANAGEMENT: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.categories.customerRelationshipManagement', + { + defaultMessage: 'Customer relationship management', + } + ), + CUSTOMER_SERVICE: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.categories.customerService', { - defaultMessage: 'Communications', + defaultMessage: 'Customer service', } ), + EMAIL: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.email', { + defaultMessage: 'Email', + }), FILE_SHARING: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.categories.fileSharing', { - defaultMessage: 'File Sharing', + defaultMessage: 'File sharing', + } + ), + GOOGLE: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.google', { + defaultMessage: 'Google', + }), + GSUITE: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.gsuite', { + defaultMessage: 'GSuite', + }), + HELP: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.help', { + defaultMessage: 'Help', + }), + HELPDESK: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.helpdesk', { + defaultMessage: 'Helpdesk', + }), + INSTANT_MESSAGING: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.categories.instantMessaging', + { + defaultMessage: 'Instant messaging', } ), + INTRANET: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.intranet', { + defaultMessage: 'Intranet', + }), MICROSOFT: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.microsoft', { defaultMessage: 'Microsoft', }), @@ -393,9 +462,33 @@ export const SOURCE_CATEGORIES = { defaultMessage: 'Productivity', } ), + PROJECT_MANAGEMENT: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.categories.projectManagement', + { + defaultMessage: 'Project management', + } + ), + SOFTWARE: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.software', { + defaultMessage: 'Software', + }), STORAGE: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.storage', { defaultMessage: 'Storage', }), + TICKETING: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.ticketing', { + defaultMessage: 'Ticketing', + }), + VERSION_CONTROL: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.categories.versionControl', + { + defaultMessage: 'Version control', + } + ), + WIKI: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.wiki', { + defaultMessage: 'Wiki', + }), + WORKFLOW: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.workflow', { + defaultMessage: 'Workflow', + }), }; export const API_KEYS_TITLE = i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index 181cd8b7c9a73..6188c37b20057 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -42,6 +42,11 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.BOX, serviceType: 'box', + categories: [ + SOURCE_CATEGORIES.FILE_SHARING, + SOURCE_CATEGORIES.STORAGE, + SOURCE_CATEGORIES.CLOUD, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -69,6 +74,7 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.CONFLUENCE, serviceType: 'confluence_cloud', + categories: [SOURCE_CATEGORIES.WIKI, SOURCE_CATEGORIES.ATLASSIAN, SOURCE_CATEGORIES.INTRANET], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -103,6 +109,7 @@ export const staticSourceData: SourceDataItem[] = [ name: SOURCE_NAMES.CONFLUENCE_CONNECTOR_PACKAGE, serviceType: 'external', baseServiceType: 'confluence_cloud', + categories: [SOURCE_CATEGORIES.WIKI, SOURCE_CATEGORIES.ATLASSIAN, SOURCE_CATEGORIES.INTRANET], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -136,6 +143,7 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.CONFLUENCE_SERVER, serviceType: 'confluence_server', + categories: [SOURCE_CATEGORIES.WIKI, SOURCE_CATEGORIES.ATLASSIAN, SOURCE_CATEGORIES.INTRANET], configuration: { isPublicKey: true, hasOauthRedirect: true, @@ -166,6 +174,11 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.DROPBOX, serviceType: 'dropbox', + categories: [ + SOURCE_CATEGORIES.FILE_SHARING, + SOURCE_CATEGORIES.STORAGE, + SOURCE_CATEGORIES.CLOUD, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -193,6 +206,11 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.GITHUB, serviceType: 'github', + categories: [ + SOURCE_CATEGORIES.SOFTWARE, + SOURCE_CATEGORIES.VERSION_CONTROL, + SOURCE_CATEGORIES.CODE_REPOSITORY, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -227,6 +245,11 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.GITHUB_ENTERPRISE, serviceType: 'github_enterprise_server', + categories: [ + SOURCE_CATEGORIES.SOFTWARE, + SOURCE_CATEGORIES.VERSION_CONTROL, + SOURCE_CATEGORIES.CODE_REPOSITORY, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -267,6 +290,11 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.GMAIL, serviceType: 'gmail', + categories: [ + SOURCE_CATEGORIES.COMMUNICATION, + SOURCE_CATEGORIES.EMAIL, + SOURCE_CATEGORIES.GOOGLE, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -283,6 +311,13 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.GOOGLE_DRIVE, serviceType: 'google_drive', + categories: [ + SOURCE_CATEGORIES.FILE_SHARING, + SOURCE_CATEGORIES.STORAGE, + SOURCE_CATEGORIES.CLOUD, + SOURCE_CATEGORIES.PRODUCTIVITY, + SOURCE_CATEGORIES.GSUITE, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -314,6 +349,12 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.JIRA, serviceType: 'jira_cloud', + categories: [ + SOURCE_CATEGORIES.SOFTWARE, + SOURCE_CATEGORIES.BUG_TRACKING, + SOURCE_CATEGORIES.ATLASSIAN, + SOURCE_CATEGORIES.PROJECT_MANAGEMENT, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -348,6 +389,12 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.JIRA_SERVER, serviceType: 'jira_server', + categories: [ + SOURCE_CATEGORIES.SOFTWARE, + SOURCE_CATEGORIES.BUG_TRACKING, + SOURCE_CATEGORIES.ATLASSIAN, + SOURCE_CATEGORIES.PROJECT_MANAGEMENT, + ], configuration: { isPublicKey: true, hasOauthRedirect: true, @@ -396,6 +443,13 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.ONEDRIVE, serviceType: 'one_drive', + categories: [ + SOURCE_CATEGORIES.FILE_SHARING, + SOURCE_CATEGORIES.CLOUD, + SOURCE_CATEGORIES.STORAGE, + SOURCE_CATEGORIES.MICROSOFT, + SOURCE_CATEGORIES.OFFICE_365, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -423,7 +477,7 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.OUTLOOK, categories: [ - SOURCE_CATEGORIES.COMMUNICATIONS, + SOURCE_CATEGORIES.COMMUNICATION, SOURCE_CATEGORIES.PRODUCTIVITY, SOURCE_CATEGORIES.MICROSOFT, ], @@ -442,6 +496,11 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.SALESFORCE, serviceType: 'salesforce', + categories: [ + SOURCE_CATEGORIES.CRM, + SOURCE_CATEGORIES.CUSTOMER_RELATIONSHIP_MANAGEMENT, + SOURCE_CATEGORIES.ACCOUNT_MANAGEMENT, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -476,6 +535,11 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.SALESFORCE_SANDBOX, serviceType: 'salesforce_sandbox', + categories: [ + SOURCE_CATEGORIES.CRM, + SOURCE_CATEGORIES.CUSTOMER_RELATIONSHIP_MANAGEMENT, + SOURCE_CATEGORIES.ACCOUNT_MANAGEMENT, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -510,6 +574,7 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.SERVICENOW, serviceType: 'service_now', + categories: [SOURCE_CATEGORIES.WORKFLOW], configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -542,6 +607,13 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.SHAREPOINT, serviceType: 'share_point', + categories: [ + SOURCE_CATEGORIES.FILE_SHARING, + SOURCE_CATEGORIES.STORAGE, + SOURCE_CATEGORIES.CLOUD, + SOURCE_CATEGORIES.MICROSOFT, + SOURCE_CATEGORIES.OFFICE_365, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -570,6 +642,13 @@ export const staticSourceData: SourceDataItem[] = [ name: SOURCE_NAMES.SHAREPOINT_CONNECTOR_PACKAGE, serviceType: 'external', baseServiceType: 'share_point', + categories: [ + SOURCE_CATEGORIES.FILE_SHARING, + SOURCE_CATEGORIES.STORAGE, + SOURCE_CATEGORIES.CLOUD, + SOURCE_CATEGORIES.MICROSOFT, + SOURCE_CATEGORIES.OFFICE_365, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -619,6 +698,12 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.SLACK, serviceType: 'slack', + categories: [ + SOURCE_CATEGORIES.COLLABORATION, + SOURCE_CATEGORIES.COMMUNICATION, + SOURCE_CATEGORIES.INSTANT_MESSAGING, + SOURCE_CATEGORIES.CHAT, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -639,7 +724,7 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.TEAMS, categories: [ - SOURCE_CATEGORIES.COMMUNICATIONS, + SOURCE_CATEGORIES.COMMUNICATION, SOURCE_CATEGORIES.PRODUCTIVITY, SOURCE_CATEGORIES.MICROSOFT, ], @@ -658,6 +743,13 @@ export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.ZENDESK, serviceType: 'zendesk', + categories: [ + SOURCE_CATEGORIES.HELP, + SOURCE_CATEGORIES.CUSTOMER_SERVICE, + SOURCE_CATEGORIES.CUSTOMER_RELATIONSHIP_MANAGEMENT, + SOURCE_CATEGORIES.TICKETING, + SOURCE_CATEGORIES.HELPDESK, + ], configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -684,7 +776,7 @@ export const staticSourceData: SourceDataItem[] = [ }, { name: SOURCE_NAMES.ZOOM, - categories: [SOURCE_CATEGORIES.COMMUNICATIONS, SOURCE_CATEGORIES.PRODUCTIVITY], + categories: [SOURCE_CATEGORIES.COMMUNICATION, SOURCE_CATEGORIES.PRODUCTIVITY], serviceType: 'custom', baseServiceType: 'zoom', configuration: { From 6b846af084b60d0eba3bfcda3ee754b8584b4cfa Mon Sep 17 00:00:00 2001 From: Faisal Kanout Date: Mon, 23 May 2022 14:11:04 +0300 Subject: [PATCH 148/150] [Actionable Observability] Update the Rule details design and clean up (#132616) * Add rule status in the rule summary * Match design * Remove unused imports * code review --- .../rule_details/components/page_title.tsx | 13 +++- .../public/pages/rule_details/index.tsx | 68 ++++++++----------- 2 files changed, 39 insertions(+), 42 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx index 478fbf69a226c..d75be330df548 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx @@ -6,11 +6,12 @@ */ import React, { useState } from 'react'; import moment from 'moment'; -import { EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiText, EuiFlexGroup, EuiFlexItem, EuiBadge, EuiSpacer } from '@elastic/eui'; import { ExperimentalBadge } from '../../../components/shared/experimental_badge'; import { PageHeaderProps } from '../types'; import { useKibana } from '../../../utils/kibana_react'; import { LAST_UPDATED_MESSAGE, CREATED_WORD, BY_WORD, ON_WORD } from '../translations'; +import { getHealthColor } from '../../rules/config'; export function PageTitle({ rule }: PageHeaderProps) { const { triggersActionsUi } = useKibana().services; @@ -23,6 +24,16 @@ export function PageTitle({ rule }: PageHeaderProps) { return ( <> {rule.name} + + + + + {rule.executionStatus.status.charAt(0).toUpperCase() + + rule.executionStatus.status.slice(1)} + + + + diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index 5cc12452e57e1..9ca155ab7ef25 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -18,7 +18,6 @@ import { EuiButtonIcon, EuiPanel, EuiTitle, - EuiHealth, EuiPopover, EuiHorizontalRule, EuiTabbedContent, @@ -42,13 +41,8 @@ import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common'; import { DeleteModalConfirmation } from '../rules/components/delete_modal_confirmation'; import { CenterJustifiedSpinner } from '../rules/components/center_justified_spinner'; -import { getHealthColor, OBSERVABILITY_SOLUTIONS } from '../rules/config'; -import { - RuleDetailsPathParams, - EVENT_ERROR_LOG_TAB, - EVENT_LOG_LIST_TAB, - ALERT_LIST_TAB, -} from './types'; +import { OBSERVABILITY_SOLUTIONS } from '../rules/config'; +import { RuleDetailsPathParams, EVENT_LOG_LIST_TAB, ALERT_LIST_TAB } from './types'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { useFetchRule } from '../../hooks/use_fetch_rule'; @@ -188,14 +182,6 @@ export function RuleDetailsPage() { 'data-test-subj': 'ruleAlertListTab', content: Alerts, }, - { - id: EVENT_ERROR_LOG_TAB, - name: i18n.translate('xpack.observability.ruleDetails.rule.errorLogTabText', { - defaultMessage: 'Error log', - }), - 'data-test-subj': 'errorLogTab', - content: Error log, - }, ]; if (isPageLoading || isRuleLoading) return ; @@ -222,6 +208,20 @@ export function RuleDetailsPage() { /> ); + + const getRuleStatusComponent = () => + getRuleStatusDropdown({ + rule, + enableRule: async () => await enableRule({ http, id: rule.id }), + disableRule: async () => await disableRule({ http, id: rule.id }), + onRuleChanged: () => reloadRule(), + isEditable: hasEditButton, + snoozeRule: async (snoozeEndTime: string | -1) => { + await snoozeRule({ http, id: rule.id, snoozeEndTime }); + }, + unsnoozeRule: async () => await unsnoozeRule({ http, id: rule.id }), + }); + const getNotifyText = () => NOTIFY_WHEN_OPTIONS.find((option) => option.value === rule?.notifyWhen)?.inputDisplay || rule.notifyWhen; @@ -284,17 +284,7 @@ export function RuleDetailsPage() { - {getRuleStatusDropdown({ - rule, - enableRule: async () => await enableRule({ http, id: rule.id }), - disableRule: async () => await disableRule({ http, id: rule.id }), - onRuleChanged: () => reloadRule(), - isEditable: hasEditButton, - snoozeRule: async (snoozeEndTime: string | -1) => { - await snoozeRule({ http, id: rule.id, snoozeEndTime }); - }, - unsnoozeRule: async () => await unsnoozeRule({ http, id: rule.id }), - })} + {getRuleStatusComponent()} , ] @@ -304,21 +294,8 @@ export function RuleDetailsPage() { {/* Left side of Rule Summary */} - + - - - - {rule.executionStatus.status.charAt(0).toUpperCase() + - rule.executionStatus.status.slice(1)} - - - - {i18n.translate('xpack.observability.ruleDetails.lastRun', { @@ -330,6 +307,15 @@ export function RuleDetailsPage() { itemValue={moment(rule.executionStatus.lastExecutionDate).fromNow()} /> + + + + {i18n.translate('xpack.observability.ruleDetails.ruleIs', { + defaultMessage: 'Rule is', + })} + + {getRuleStatusComponent()} + From ba84602455671f0f6175bbc0fd2e8f302c60bbe6 Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Mon, 23 May 2022 13:33:20 +0200 Subject: [PATCH 149/150] [Osquery] Change prebuilt saved queries to include prebuilt flag (#132651) --- .../routes/saved_queries/edit/index.tsx | 2 +- .../saved_query/delete_saved_query_route.ts | 9 +++- .../saved_query/find_saved_query_route.ts | 9 +++- .../server/routes/saved_query/index.ts | 6 +-- .../saved_query/read_saved_query_route.ts | 7 ++- .../saved_query/update_saved_query_route.ts | 7 +++ .../server/routes/saved_query/utils.ts | 54 +++++++++++++++++++ 7 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/osquery/server/routes/saved_query/utils.ts diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/edit/index.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/edit/index.tsx index 94b1f092e1ede..cb7a95b4271e7 100644 --- a/x-pack/plugins/osquery/public/routes/saved_queries/edit/index.tsx +++ b/x-pack/plugins/osquery/public/routes/saved_queries/edit/index.tsx @@ -44,7 +44,7 @@ const EditSavedQueryPageComponent = () => { useBreadcrumbs('saved_query_edit', { savedQueryName: savedQueryDetails?.attributes?.id ?? '' }); const elasticPrebuiltQuery = useMemo( - () => savedQueryDetails?.attributes?.version, + () => savedQueryDetails?.attributes?.prebuilt, [savedQueryDetails] ); const viewMode = useMemo( diff --git a/x-pack/plugins/osquery/server/routes/saved_query/delete_saved_query_route.ts b/x-pack/plugins/osquery/server/routes/saved_query/delete_saved_query_route.ts index c2a2ad7fa8619..a27c4a0953098 100644 --- a/x-pack/plugins/osquery/server/routes/saved_query/delete_saved_query_route.ts +++ b/x-pack/plugins/osquery/server/routes/saved_query/delete_saved_query_route.ts @@ -9,8 +9,10 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '@kbn/core/server'; import { PLUGIN_ID } from '../../../common'; import { savedQuerySavedObjectType } from '../../../common/types'; +import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; +import { isSavedQueryPrebuilt } from './utils'; -export const deleteSavedQueryRoute = (router: IRouter) => { +export const deleteSavedQueryRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.delete( { path: '/internal/osquery/saved_query/{id}', @@ -25,6 +27,11 @@ export const deleteSavedQueryRoute = (router: IRouter) => { const coreContext = await context.core; const savedObjectsClient = coreContext.savedObjects.client; + const isPrebuilt = await isSavedQueryPrebuilt(osqueryContext, request.params.id); + if (isPrebuilt) { + return response.conflict({ body: `Elastic prebuilt Saved query cannot be deleted.` }); + } + await savedObjectsClient.delete(savedQuerySavedObjectType, request.params.id, { refresh: 'wait_for', }); diff --git a/x-pack/plugins/osquery/server/routes/saved_query/find_saved_query_route.ts b/x-pack/plugins/osquery/server/routes/saved_query/find_saved_query_route.ts index a2b85dbf539d9..abf62ca782daa 100644 --- a/x-pack/plugins/osquery/server/routes/saved_query/find_saved_query_route.ts +++ b/x-pack/plugins/osquery/server/routes/saved_query/find_saved_query_route.ts @@ -7,11 +7,14 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '@kbn/core/server'; + +import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; import { PLUGIN_ID } from '../../../common'; import { savedQuerySavedObjectType } from '../../../common/types'; import { convertECSMappingToObject } from '../utils'; +import { getInstalledSavedQueriesMap } from './utils'; -export const findSavedQueryRoute = (router: IRouter) => { +export const findSavedQueryRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.get( { path: '/internal/osquery/saved_query', @@ -34,6 +37,7 @@ export const findSavedQueryRoute = (router: IRouter) => { const savedQueries = await savedObjectsClient.find<{ ecs_mapping: Array<{ field: string; value: string }>; + prebuilt: boolean; }>({ type: savedQuerySavedObjectType, page: parseInt(request.query.pageIndex ?? '0', 10) + 1, @@ -43,10 +47,13 @@ export const findSavedQueryRoute = (router: IRouter) => { sortOrder: request.query.sortDirection ?? 'desc', }); + const prebuiltSavedQueriesMap = await getInstalledSavedQueriesMap(osqueryContext); const savedObjects = savedQueries.saved_objects.map((savedObject) => { // eslint-disable-next-line @typescript-eslint/naming-convention const ecs_mapping = savedObject.attributes.ecs_mapping; + savedObject.attributes.prebuilt = !!prebuiltSavedQueriesMap[savedObject.id]; + if (ecs_mapping) { // @ts-expect-error update types savedObject.attributes.ecs_mapping = convertECSMappingToObject(ecs_mapping); diff --git a/x-pack/plugins/osquery/server/routes/saved_query/index.ts b/x-pack/plugins/osquery/server/routes/saved_query/index.ts index e0bf4f622c42c..025199dcba6b6 100644 --- a/x-pack/plugins/osquery/server/routes/saved_query/index.ts +++ b/x-pack/plugins/osquery/server/routes/saved_query/index.ts @@ -16,8 +16,8 @@ import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; export const initSavedQueryRoutes = (router: IRouter, context: OsqueryAppContext) => { createSavedQueryRoute(router, context); - deleteSavedQueryRoute(router); - findSavedQueryRoute(router); - readSavedQueryRoute(router); + deleteSavedQueryRoute(router, context); + findSavedQueryRoute(router, context); + readSavedQueryRoute(router, context); updateSavedQueryRoute(router, context); }; diff --git a/x-pack/plugins/osquery/server/routes/saved_query/read_saved_query_route.ts b/x-pack/plugins/osquery/server/routes/saved_query/read_saved_query_route.ts index 1c206464d1f65..d1627d220682a 100644 --- a/x-pack/plugins/osquery/server/routes/saved_query/read_saved_query_route.ts +++ b/x-pack/plugins/osquery/server/routes/saved_query/read_saved_query_route.ts @@ -7,11 +7,13 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '@kbn/core/server'; +import { isSavedQueryPrebuilt } from './utils'; +import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; import { PLUGIN_ID } from '../../../common'; import { savedQuerySavedObjectType } from '../../../common/types'; import { convertECSMappingToObject } from '../utils'; -export const readSavedQueryRoute = (router: IRouter) => { +export const readSavedQueryRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.get( { path: '/internal/osquery/saved_query/{id}', @@ -28,6 +30,7 @@ export const readSavedQueryRoute = (router: IRouter) => { const savedQuery = await savedObjectsClient.get<{ ecs_mapping: Array<{ key: string; value: Record }>; + prebuilt: boolean; }>(savedQuerySavedObjectType, request.params.id); if (savedQuery.attributes.ecs_mapping) { @@ -37,6 +40,8 @@ export const readSavedQueryRoute = (router: IRouter) => { ); } + savedQuery.attributes.prebuilt = await isSavedQueryPrebuilt(osqueryContext, savedQuery.id); + return response.ok({ body: savedQuery, }); diff --git a/x-pack/plugins/osquery/server/routes/saved_query/update_saved_query_route.ts b/x-pack/plugins/osquery/server/routes/saved_query/update_saved_query_route.ts index 1d2bf153afd7f..e2686868b7eff 100644 --- a/x-pack/plugins/osquery/server/routes/saved_query/update_saved_query_route.ts +++ b/x-pack/plugins/osquery/server/routes/saved_query/update_saved_query_route.ts @@ -9,6 +9,7 @@ import { filter } from 'lodash'; import { schema } from '@kbn/config-schema'; import { IRouter } from '@kbn/core/server'; +import { isSavedQueryPrebuilt } from './utils'; import { PLUGIN_ID } from '../../../common'; import { savedQuerySavedObjectType } from '../../../common/types'; import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; @@ -63,6 +64,12 @@ export const updateSavedQueryRoute = (router: IRouter, osqueryContext: OsqueryAp ecs_mapping, } = request.body; + const isPrebuilt = await isSavedQueryPrebuilt(osqueryContext, request.params.id); + + if (isPrebuilt) { + return response.conflict({ body: `Elastic prebuilt Saved query cannot be updated.` }); + } + const conflictingEntries = await savedObjectsClient.find<{ id: string }>({ type: savedQuerySavedObjectType, filter: `${savedQuerySavedObjectType}.attributes.id: "${id}"`, diff --git a/x-pack/plugins/osquery/server/routes/saved_query/utils.ts b/x-pack/plugins/osquery/server/routes/saved_query/utils.ts new file mode 100644 index 0000000000000..d99d5b70f0dab --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/saved_query/utils.ts @@ -0,0 +1,54 @@ +/* + * 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 { find, reduce } from 'lodash'; +import { KibanaAssetReference } from '@kbn/fleet-plugin/common'; + +import { OSQUERY_INTEGRATION_NAME } from '../../../common'; +import { savedQuerySavedObjectType } from '../../../common/types'; +import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; + +const getInstallation = async (osqueryContext: OsqueryAppContext) => + await osqueryContext.service + .getPackageService() + ?.asInternalUser?.getInstallation(OSQUERY_INTEGRATION_NAME); + +export const getInstalledSavedQueriesMap = async (osqueryContext: OsqueryAppContext) => { + const installation = await getInstallation(osqueryContext); + if (installation) { + return reduce( + installation.installed_kibana, + // @ts-expect-error not sure why it shouts, but still it's properly typed + (acc: Record, item: KibanaAssetReference) => { + if (item.type === savedQuerySavedObjectType) { + return { ...acc, [item.id]: item }; + } + }, + {} + ); + } + + return {}; +}; + +export const isSavedQueryPrebuilt = async ( + osqueryContext: OsqueryAppContext, + savedQueryId: string +) => { + const installation = await getInstallation(osqueryContext); + + if (installation) { + const installationSavedQueries = find( + installation.installed_kibana, + (item) => item.type === savedQuerySavedObjectType && item.id === savedQueryId + ); + + return !!installationSavedQueries; + } + + return false; +}; From a807c90310d2adf5c43694a8e4ae1f982ade0e32 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Mon, 23 May 2022 13:36:00 +0200 Subject: [PATCH 150/150] [Cases] Add a key to userActionMarkdown to prevent stale state (#132681) --- .../components/user_actions/comment/user.tsx | 1 + .../components/user_actions/description.tsx | 4 +- .../user_actions/markdown_form.test.tsx | 112 +++++++++++++++++- 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx index a4e6fe6cf2887..6c4c96a95bc46 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx @@ -65,6 +65,7 @@ export const createUserAttachmentUserActionBuilder = ({ }), children: ( (commentRefs.current[comment.id] = element)} id={comment.id} content={comment.comment} diff --git a/x-pack/plugins/cases/public/components/user_actions/description.tsx b/x-pack/plugins/cases/public/components/user_actions/description.tsx index 01b0e105ecd96..eae2bd3d1258e 100644 --- a/x-pack/plugins/cases/public/components/user_actions/description.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/description.tsx @@ -43,6 +43,7 @@ export const getDescriptionUserAction = ({ handleManageMarkdownEditId, handleManageQuote, }: GetDescriptionUserActionArgs): EuiCommentProps => { + const isEditable = manageMarkdownEditIds.includes(DESCRIPTION_ID); return { username: ( , children: ( (commentRefs.current[DESCRIPTION_ID] = element)} id={DESCRIPTION_ID} content={caseData.description} - isEditable={manageMarkdownEditIds.includes(DESCRIPTION_ID)} + isEditable={isEditable} onSaveContent={(content: string) => { onUpdateField({ key: DESCRIPTION_ID, value: content }); }} diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx index 19f60d7cb8c72..ae242fc64aafa 100644 --- a/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx @@ -8,8 +8,9 @@ import React from 'react'; import { mount } from 'enzyme'; import { UserActionMarkdown } from './markdown_form'; -import { TestProviders } from '../../common/mock'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; const onChangeEditable = jest.fn(); const onSaveContent = jest.fn(); @@ -86,4 +87,113 @@ describe('UserActionMarkdown ', () => { expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); }); }); + + describe('useForm stale state bug', () => { + let appMockRenderer: AppMockRenderer; + const oldContent = defaultProps.content; + const appendContent = ' appended content'; + const newContent = defaultProps.content + appendContent; + + beforeEach(() => { + appMockRenderer = createAppMockRenderer(); + }); + + it('creates a stale state if a key is not passed to the component', async () => { + const TestComponent = () => { + const [isEditable, setIsEditable] = React.useState(true); + const [saveContent, setSaveContent] = React.useState(defaultProps.content); + return ( +
+ +
+ ); + }; + + const result = appMockRenderer.render(); + + expect(result.getByTestId('user-action-markdown-form')).toBeTruthy(); + + // append some content and save + userEvent.type(result.container.querySelector('textarea')!, appendContent); + userEvent.click(result.getByTestId('user-action-save-markdown')); + + // wait for the state to update + await waitFor(() => { + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + }); + + // toggle to non-edit state + userEvent.click(result.getByTestId('test-button')); + expect(result.getByTestId('user-action-markdown')).toBeTruthy(); + + // toggle to edit state again + userEvent.click(result.getByTestId('test-button')); + + // the text area holds a stale value + // this is the wrong behaviour. The textarea holds the old content + expect(result.container.querySelector('textarea')!.value).toEqual(oldContent); + expect(result.container.querySelector('textarea')!.value).not.toEqual(newContent); + }); + + it("doesn't create a stale state if a key is passed to the component", async () => { + const TestComponent = () => { + const [isEditable, setIsEditable] = React.useState(true); + const [saveContent, setSaveContent] = React.useState(defaultProps.content); + return ( +
+ +
+ ); + }; + const result = appMockRenderer.render(); + expect(result.getByTestId('user-action-markdown-form')).toBeTruthy(); + + // append content and save + userEvent.type(result.container.querySelector('textarea')!, appendContent); + userEvent.click(result.getByTestId('user-action-save-markdown')); + + // wait for the state to update + await waitFor(() => { + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + }); + + // toggle to non-edit state + userEvent.click(result.getByTestId('test-button')); + expect(result.getByTestId('user-action-markdown')).toBeTruthy(); + + // toggle to edit state again + userEvent.click(result.getByTestId('test-button')); + + // this is the correct behaviour. The textarea holds the new content + expect(result.container.querySelector('textarea')!.value).toEqual(newContent); + expect(result.container.querySelector('textarea')!.value).not.toEqual(oldContent); + }); + }); });